Skip to content

feat: support newer provider plugin-protocol features via targetVersions (RFC-04)#296

Open
so0k wants to merge 30 commits into
mainfrom
feat/provider-feature-availability
Open

feat: support newer provider plugin-protocol features via targetVersions (RFC-04)#296
so0k wants to merge 30 commits into
mainfrom
feat/provider-feature-availability

Conversation

@so0k

@so0k so0k commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Implements RFC-04 — provider feature availability: support the newer provider plugin-protocol capability families with targetVersions-aware codegen and synth-time validation. Proposal, dataset, sweep tooling and interactive report live in open-constructs/cdktn-planningRFCS/04-provider-feature-availability/ (only the merged matrix is vendored here, matching the function-availability split).

Builds directly on the #269 foundation (targetVersions + ValidateFeatureTargetSupport) and the #268 usage-registry pattern.

Guiding decision

Generate the full surface the schema offers; narrow per project at synth time via targetVersions. Generation-time filtering would fork the generated API by project configuration — impossible for prebuilt providers and hostile to caching. Constraints baked into the validations are the per-product >= ranges from the sweep dataset.

Review guide (one commit per RFC rollout item)

  1. chore: vendor provider-feature availability matrix — dataset digest + README only, no behavior change.
  2. feat(lib): TerraformEphemeralResource — new public base class synthesizing to the top-level ephemeral key (JSON + HCL renderer), refs as ephemeral.<type>.<id>.<attr>, no provisioners/connection/import/move. Constructor registers the target-version validation unconditionally (new API surface — only fires on use, no feature flag per RFC). Internal providerFeatureConstraints map sourced from the vendored matrix.
  3. feat(provider-generator): acquire newer provider-protocol schema sections (Phase 0, a bugfix on its own) — commons types for functions / ephemeral_resource_schemas / resource_identity_schemas / write_only; sanitizer walks ephemeral schemas; fetched schemas stamped with the fetching CLI; cache key now includes CLI product+minor (a schema fetched once with an old CLI no longer poisons every later generation); fetch-time warning when the fetching binary structurally cannot emit sections the targets admit.
  4. feat(provider-generator): generate ephemeral resource bindings — third schema family mirroring the data_ pipeline (EphemeralRandomPassword in ephemeral-random-password/), config extends TerraformEphemeralMetaArguments, no generateConfigForImport. Zero churn in existing snapshots (ephemeral models append after resources/data sources; class-name dedup is order-dependent).
  5. feat(provider-generator): generate provider-defined function bindingsTimeProviderFunctions.rfc3339Parse(ts)${provider::time::rfc3339_parse(...)}; namespace defaults to the registry short name with a providerLocalName override (local names change the namespace, aliases don't). Runtime flows through one public jsii chokepoint (TerraformProviderFunction.invoke) feeding a usage registry; TerraformStack validates usage against terraform >=1.8.0 / opentofu >=1.7.0 — note the deliberate asymmetry: OpenTofu language support (1.7.0) predates its schema emission (1.8.0), so generation and validation use different boundaries.
  6. feat(provider-generator): deprecate write-only attribute getters, validate usage — providers never persist write-only values (every read is null by protocol contract), so the state-backed getter is a trap: emitted @deprecated now, removal rides the next prebuilt major (JSII-breaking otherwise). Setting one (setter or constructor config) registers usage via a new protected TerraformResource.registerProviderFeatureUsage hook — generated code reaches the validation machinery only by extending base classes.
  7. feat(cli): thread targetVersions into cdktn getGetOptions.targetVersionsConstructsMakerreadSchema, driving the Phase 0 fetch-time warning; constraints.json gains diagnostic stamps (targetVersions, fetching cli) without affecting the filterAlreadyGenerated staleness logic (targets don't change codegen output, so they must not force regeneration).

Test coverage

  • Generator: snapshot tests against real terraform providers schema -json fragments from the sweep (random ephemeral, time functions incl. object returns, vault *_wo), plus a synthetic fixture for variadic/reserved-name mapping branches. Zero churn in pre-existing snapshots.
  • Core: validation matrices mirroring validations.test.ts (admit/exclude per product, hint text, the OpenTofu 1.7.0 asymmetry, registry resets).
  • Schema: emission-gap logic matrix, cache-key suffix behavior, sanitizer walk over ephemeral schemas; CLI stamps in network snapshots are stubbed (environment-dependent).

Known/deferred

Review findings addressed (from the demo-harness verification comment)

  • Fixed — targetVersions validation bypass: CdktfConfig.targetVersions (the runGetInDir path) now validates via commons validateTargetVersions (warn + ignore), and the emission-gap check treats invalid ranges as not-wanted instead of throwing — a malformed range previously crashed cdktn get via semver.intersects.
  • Fixed — ephemeral lifecycle narrowed: new TerraformEphemeralResourceLifecycle (precondition/postcondition only) replaces the full managed-resource lifecycle on the ephemeral API, before it ships as jsii surface.
  • Fixed (docs) — provider-fn self-reference cycle: generated provider-function JSDoc and TerraformProviderFunction.invoke now warn against calling a provider's functions inside that same provider's configuration block.
  • Documented — ephemeral × write-only: registration deliberately skips ephemeral resources — write-only is a state concept, ephemeral resources have no state, and no schema in the sweep (incl. vault's 16 ephemeral resources) combines the two.
  • Follow-up — nested write-only registration (Nested write-only attributes don't register targetVersions feature usage #308): write_only inside nested blocks gets the deprecated getter but skips usage registration; deep config scanning deserves its own PR (a miss degrades to the plan-time error, not silent breakage).
  • Pre-existing / by design: the AWS barrel-import OOM predates this PR (lazy-index is the existing mitigation); Fn.ephemeralasnull + sensitive on outputs is Terraform semantics (docs candidate); list resources / actions / resource identity codegen is the RFC Phase 4 deferral noted above.

Second review round addressed

  • Fixed — cross-App usage-registry leak: App construction now resets both usage registries (a new App = a new synthesis session). Within an App the process-global registry is by design — every stack resolves the same targetVersions from App context — but usage no longer leaks into later, unrelated Apps in the same process (the reviewer's jest scenario). Regression tests cover the exact sequence; the flag-gated Fn registry had the same latent leak and is reset too.
  • Fixed — emission-gap warning on cache hits: the warning moved from readProviderSchema into readSchema, running for cached and fresh schemas using the cli_name/cli_version stamps the schema carries. Regression test: two readSchema calls against a cache dir → one producer call, two warnings.
  • Docs — matrix as source of truth: both hand-maintained maps now name features-matrix.json as their source and cross-reference each other; automated drift check tracked in In-repo consistency check for the provider-feature availability matrix and its hand-maintained maps #309.

Third review round addressed (cfncompat/awscc demo findings)

  • Fixed — dynamic-typed provider-function returns coerced to string: generated wrappers for dynamic/object/map return types no longer wrap in Token.asString(...); they return the raw invoke() IResolvable, which Tokenization.isResolvable() recognizes — struct-typed attribute assignments (OutputReference.internalValue) no longer vanish silently from synth output.
  • Fixed — invoke() dropped literal null positional arguments: arguments are now validated per position (variadic flattening stays in the generated wrappers where the signature is known), and a latent third bug this exposed was fixed too: FunctionCall rendered args via Array.prototype.join, which collapses null entries to empty strings — resolved null/undefined now render as the Terraform null keyword. Regression tests pin the exact provider::cfncompat::condition_if(true, null, {"a" = 1}) rendering.

Fourth review round addressed (jsteinich's CHANGES_REQUESTED)

Seven commits on top of the reviewed head, one per finding group (inline replies on each thread carry the details):

  1. Schema fidelityFunctionParameter gains is_nullable, FunctionSignature gains deprecation_message, and both drop description_kind (verified against internal/command/jsonfunction/{function,parameter}.go and the OpenTofu equivalent: that field does not exist on function JSON; allow_null_value/allow_unknown_values are protocol-only names — the old comment was wrong). OpenTofu emits no deprecation_message at all (divergence, documented).
  2. Recursive, honest type mapping — collections recurse (list(number)number[] via Token.asNumberList, set(bool) param → Array<boolean | IResolvable>, nested compose); object/map/dynamic returns are declared IResolvable instead of any (property access on a token is now a compile error); is_nullable consumed (trailing → jsii-optional, mid-position → any, variadic → any[], docstrings say T | null); deprecation_message@deprecated; name collisions hard-fail generation with an actionable error. Structural helpers for object shapes: provider-generator: structural typing for object-shaped provider-function parameters and returns #336.
  3. App-owned usage registries — recording moved to token-resolve time keyed by the resolving stack's node.root (WeakMaps); the App-constructor resets and the "pathological" disclaimer are gone; the interleaved-App false negative is structurally impossible and pinned by regression tests for both registries.
  4. Functions move onto the provider classtime.functions.rfc3339Parse(ts) via a memoized getter fed by this.terraformResourceType (the exact required_providers key, so the namespace is correct by construction); the static class and providerLocalName parameter are gone, mooting the providerAlias naming question. Per-method self-reference JSDoc dropped (kept once on invoke; docs PR: docs(concepts): provider-defined functions + caveats cdk-terrain-docs#29).
  5. CleanupsregisterProviderFeatureUsage moved to TerraformElement (ephemeral resources reuse it); enum casts replaced by a membership guard + direct ProviderFeature indexing; hints get their own map instead of runtime label-mangling (messages byte-identical); write-only registration treats explicit null as omission (!= null, both constructor and setter paths, four pinned tests).
  6. Demo-validation fixes — exercising the generated bindings in a real app (demo PR) caught three bugs snapshots couldn't: the sibling import needed /index under nodenext; the wrapper constructor's TS parameter property is rejected by Node's native type-stripping (jsii accepts it — Node is the stricter consumer); variadic rest params need Array<T> for union element types.
  7. Edge-provider compile coverage (closes Edge-provider schema: cross-language compile coverage for ephemeral resources, provider functions, write-only attributes #311) — the edge schema now carries nine functions (one per generator branch, incl. all three nullability shapes and deprecation), an ephemeral resource, and a write_only attribute; its bindings ride the existing postbuild jsii + pacmak flow for all five languages and the test/<lang>/edge native-compile suites, independent of CI's Terraform version.

External validation (full matrix in the demo PR): terraform 1.15.7 and opentofu 1.12.3 generate byte-identical bindings modulo registry-host doc links; schema cache keys per CLI product+minor; omitted/explicit/mid-position nullable args all render the bare null keyword in position; generated bindings pass tsc + jsii + pacmak; the targetVersions validation error was demonstrated live through the resolve-time registry.

Resolves Terraform CDK issues

🤖 Generated with Claude Code

@sakul-learning

Copy link
Copy Markdown
Contributor

Draft PR #296 review notes / verification results

I built and published a small reusable demo harness against this PR head so the new provider-protocol features can be exercised with real generated AWS bindings:

https://github.com/sakul-learning/cdktn-provider-features-demo

PR head used for the run: 78299b4b9ece16c45ab26a0cd28dec08e8ba57e9.

Verification performed

Using the PR-head packages directly, not npmjs:

  • pnpm run assert:prhead-deps confirms cdktn resolves from the PR-head worktree and the CLI comes from the PR-head cdktn-cli bundle.
  • Terraform 1.15.7:
    • captured AWS provider schema
    • generated AWS bindings
    • ran schema inspection and generated-feature assertions
    • ran pnpm exec tsc --noEmit
    • ran pnpm run synth:all
    • ran pnpm run plan:all
  • OpenTofu 1.12.3:
    • reused the Terraform-generated bindings
    • ran generated-feature assertions
    • ran pnpm exec tsc --noEmit
    • ran pnpm run synth:all
    • ran pnpm run plan:all

The AWS provider schema from Terraform 1.15.7 exposed:

  • provider functions: 4
  • ephemeral resources: 10
  • list resources: 153
  • provider actions: 11
  • resource identities: 437

Generated bindings confirmed:

  • provider functions: yes
  • ephemeral resources: yes
  • list resources: no first-class generated bindings observed
  • provider actions: no first-class generated bindings observed
  • resource identities: no first-class generated bindings observed

OpenTofu 1.12.3 successfully planned the examples with the Terraform-generated bindings. A separate OpenTofu schema probe exposed provider functions, ephemeral resources, and resource identities, but reported list_resource_schemas: 0 and action_schemas: 0, so Terraform 1.15.7 is the better generator binary for this comprehensive AWS capability test.

UX / integration notes from the demo

These are non-blocking, but important feedback from trying to use the generated bindings in a real app:

  • I switched the examples from the AWS barrel import (./.gen/providers/aws) to direct generated-module imports (provider, provider-functions, and the specific ephemeral resource module). The barrel import path pulled in thousands of generated AWS classes and hit the Node/TypeScript OOM path during synth.
  • After that import change, I had to replace the remaining namespace-style references (provider.*, providerFunctions.*, and ephemeral...*) with direct class references like AwsProvider, AwsProviderFunctions, and EphemeralAwsSecretsmanagerRandomPassword.
  • The ephemeral output needs both Fn.ephemeralasnull(...) and sensitive: true so Terraform and OpenTofu accept the plan. Without ephemeralasnull, the ephemeral value is used in a non-ephemeral context; without sensitive, the output still fails because it is derived from secret ephemeral data.
  • I removed a trial use of AwsProviderFunctions.userAgent(...) inside AwsProvider configuration after OpenTofu reported a provider self-reference/cycle. The mistaken assumption was that provider-defined functions behave like pure static helpers that are safe to use while configuring that same provider. The generated call is still a provider-namespaced function (provider::aws::user_agent), so putting it inside the aws provider block asks Terraform/OpenTofu to evaluate an AWS provider function while the AWS provider is still being configured. The demo now exercises provider functions in outputs instead, after provider configuration exists.

Non-blocking findings / suggested follow-ups

  • targetVersions validation bypass in cdktn get path
    targetVersions is validated in parseConfig (packages/@cdktn/commons/src/config.ts), but cdktn get appears to use CdktfConfig.read() plus the raw targetVersions getter (packages/@cdktn/cli-core/src/lib/cdktf-config.ts, then passed through in packages/@cdktn/cli-core/src/lib/get.ts). That path appears to bypass the parseConfig validator, so malformed targetVersions can reach generation.

  • Ephemeral resources expose too much lifecycle surface
    TerraformEphemeralMetaArguments currently exposes lifecycle?: TerraformResourceLifecycle. Terraform ephemeral blocks only support lifecycle precondition / postcondition; they do not support the full managed-resource lifecycle surface such as ignore_changes, replace_triggered_by, create_before_destroy, etc. I’d introduce a narrowed lifecycle type for ephemeral resources, e.g. only precondition and postcondition, and use that in TerraformEphemeralMetaArguments.

  • Nested write-only attributes do not register feature usage
    The constructor-time write-only registration only iterates top-level assignable attributes in resource.configStruct.assignableAttributes. Provider schemas can put write_only inside nested blocks, not only on top-level resource config attributes, so nested write-only usage can skip registerProviderFeatureUsage("writeOnlyAttributes").

  • Generated ephemeral resources skip write-only feature usage registration
    The generator currently limits write-only registration to classes whose parent is TerraformResource; generated ephemeral resources do not get that registration path. Maybe current provider schemas do not combine ephemeral resources with write-only attributes, but since this PR is adding generalized newer-protocol support, I’d either cover the ephemeral path or explicitly document/filter why that combination cannot happen.

Overall: the core provider-function and ephemeral-resource generated bindings worked in real synth/plan flows for Terraform and OpenTofu once the UX issues above were accounted for. The largest current generated-coverage gap is that Terraform exposes AWS list resources, actions, and resource identities, but I did not see first-class generated bindings for those schema sections yet.

so0k added a commit that referenced this pull request Jul 2, 2026
… path

CdktfConfig.targetVersions (used by runGetInDir) returned the raw
cdktf.json value, bypassing the parseConfig validation the main get
handler goes through — and a malformed range then made
semver.intersects throw inside the fetch-time emission check, crashing
cdktn get with a raw stack trace.

Two layers: the getter now validates via commons validateTargetVersions
(warn + ignore invalid targets; generation proceeds, only the warning/
stamp lose them), and checkSchemaEmissionGapFamilies treats an invalid
target range as not-wanted instead of throwing — a best-effort
diagnostics path must never break get.

Found in draft-PR verification (#296 review notes).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
so0k added a commit that referenced this pull request Jul 2, 2026
Terraform ephemeral blocks support only precondition/postcondition in
lifecycle — createBeforeDestroy, preventDestroy, ignoreChanges and
replaceTriggeredBy are state-oriented concepts that do not apply to
stateless ephemeral resources. New TerraformEphemeralResourceLifecycle
replaces the full TerraformResourceLifecycle on the ephemeral API;
narrowing later would be jsii-breaking, so it lands before first
release.

Found in draft-PR verification (#296 review notes).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
so0k added a commit that referenced this pull request Jul 2, 2026
…generated JSDoc

Calling a provider-defined function inside the configuration block of
the same provider asks Terraform/OpenTofu to evaluate the function
while that provider is still being configured — a self-referential
cycle. Generated method JSDoc and TerraformProviderFunction.invoke now
carry the caveat.

Also documents why write-only usage registration deliberately skips
ephemeral resources: write-only is a state concept, ephemeral resources
have no state, and no provider schema in the RFC-04 sweep combines the
two.

From draft-PR verification (#296 review notes).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
so0k added a commit that referenced this pull request Jul 5, 2026
… path

CdktfConfig.targetVersions (used by runGetInDir) returned the raw
cdktf.json value, bypassing the parseConfig validation the main get
handler goes through — and a malformed range then made
semver.intersects throw inside the fetch-time emission check, crashing
cdktn get with a raw stack trace.

Two layers: the getter now validates via commons validateTargetVersions
(warn + ignore invalid targets; generation proceeds, only the warning/
stamp lose them), and checkSchemaEmissionGapFamilies treats an invalid
target range as not-wanted instead of throwing — a best-effort
diagnostics path must never break get.

Found in draft-PR verification (#296 review notes).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
so0k added a commit that referenced this pull request Jul 5, 2026
Terraform ephemeral blocks support only precondition/postcondition in
lifecycle — createBeforeDestroy, preventDestroy, ignoreChanges and
replaceTriggeredBy are state-oriented concepts that do not apply to
stateless ephemeral resources. New TerraformEphemeralResourceLifecycle
replaces the full TerraformResourceLifecycle on the ephemeral API;
narrowing later would be jsii-breaking, so it lands before first
release.

Found in draft-PR verification (#296 review notes).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
so0k added a commit that referenced this pull request Jul 5, 2026
…generated JSDoc

Calling a provider-defined function inside the configuration block of
the same provider asks Terraform/OpenTofu to evaluate the function
while that provider is still being configured — a self-referential
cycle. Generated method JSDoc and TerraformProviderFunction.invoke now
carry the caveat.

Also documents why write-only usage registration deliberately skips
ephemeral resources: write-only is a state concept, ephemeral resources
have no state, and no provider schema in the RFC-04 sweep combines the
two.

From draft-PR verification (#296 review notes).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@so0k
so0k force-pushed the feat/provider-feature-availability branch from ac13f35 to 71148c5 Compare July 5, 2026 12:55
so0k added a commit that referenced this pull request Jul 7, 2026
… path

CdktfConfig.targetVersions (used by runGetInDir) returned the raw
cdktf.json value, bypassing the parseConfig validation the main get
handler goes through — and a malformed range then made
semver.intersects throw inside the fetch-time emission check, crashing
cdktn get with a raw stack trace.

Two layers: the getter now validates via commons validateTargetVersions
(warn + ignore invalid targets; generation proceeds, only the warning/
stamp lose them), and checkSchemaEmissionGapFamilies treats an invalid
target range as not-wanted instead of throwing — a best-effort
diagnostics path must never break get.

Found in draft-PR verification (#296 review notes).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
so0k added a commit that referenced this pull request Jul 7, 2026
Terraform ephemeral blocks support only precondition/postcondition in
lifecycle — createBeforeDestroy, preventDestroy, ignoreChanges and
replaceTriggeredBy are state-oriented concepts that do not apply to
stateless ephemeral resources. New TerraformEphemeralResourceLifecycle
replaces the full TerraformResourceLifecycle on the ephemeral API;
narrowing later would be jsii-breaking, so it lands before first
release.

Found in draft-PR verification (#296 review notes).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
so0k added a commit that referenced this pull request Jul 7, 2026
…generated JSDoc

Calling a provider-defined function inside the configuration block of
the same provider asks Terraform/OpenTofu to evaluate the function
while that provider is still being configured — a self-referential
cycle. Generated method JSDoc and TerraformProviderFunction.invoke now
carry the caveat.

Also documents why write-only usage registration deliberately skips
ephemeral resources: write-only is a state concept, ephemeral resources
have no state, and no provider schema in the RFC-04 sweep combines the
two.

From draft-PR verification (#296 review notes).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@so0k
so0k force-pushed the feat/provider-feature-availability branch from 71148c5 to 244dd68 Compare July 7, 2026 02:56
@so0k
so0k marked this pull request as ready for review July 7, 2026 12:30
@so0k
so0k requested a review from a team as a code owner July 7, 2026 12:30
so0k added a commit that referenced this pull request Jul 7, 2026
Once any provider function was invoked in a long-lived process, later
unrelated apps in the same process (sequential apps in one jest file
being the common case) saw the process-global usedProviderFunctions set
and could fail validation despite never using provider functions — the
new validator registers unconditionally on every stack. The Fn registry
shares the design and the same cross-App leak, but its validator is
feature-flag-gated.

The registries stay process-global (tokens are scope-free; per-stack
attribution is impossible by design) — but App construction now resets
both, scoping usage to the current synthesis session. Within one App
the global registry remains by design: every stack resolves the same
targetVersions from App context, so a sibling-stack validation reports
the same error the using stack would.

Regression tests per review: app1 uses a provider function under
satisfying targets, then app2 with default targets and no usage synths
clean in the same process; analogous flag-gated Fn-registry case.

From PR #296 review.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
so0k added a commit that referenced this pull request Jul 7, 2026
…s too

The fetch-time emission-gap warning lived inside readProviderSchema, so
cachedAccess bypassed it entirely on a hit: a schema cached by an old
CLI was reused silently even when the project's targetVersions admit
the newer sections that fetch could never have contained.

The warning moves to readSchema and now runs after every provider
schema resolves — cached or fresh — using the cli_name/cli_version
stamps the schema itself carries (falling back to probing the current
binary when stamps are absent). Regression test drives readSchema twice
against a temp cache dir: one producer call, two warnings.

From PR #296 review.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
so0k added a commit that referenced this pull request Jul 7, 2026
providerFeatureConstraints (usage boundaries, packages/cdktn) and
SCHEMA_EMISSION_BOUNDARIES (fetching-CLI emission boundaries,
@cdktn/provider-schema) now name features-matrix.json as their source,
cross-reference each other, and spell out the deliberate OpenTofu
provider-functions exception (language support 1.7.0 vs schema emission
1.8.0). The matrix README gains an in-repo consumers section.

An automated drift check between the maps and the matrix is tracked in
#309. From PR #296 review.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment on lines +126 to +129
// Only managed resources extend TerraformResource (and therefore have
// registerProviderFeatureUsage available); data sources, providers, and
// ephemeral resources do not, so their write-only attributes (if any)
// only get the deprecated getter.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

deliberately not exposed there because the combination doesn't occur - verified empirically across every provider in the 1.15.6 sweep dump — zero ephemeral attributes carry write_only.

@vincenthsh

Copy link
Copy Markdown
Contributor

While extending cdktn-provider-features-demo to port real aws-cdk-lib L2 construct logic onto generated awscc bindings + the cfncompat provider's intrinsic-function polyfills, I hit two cdktn-side bugs (not cfncompat bugs) worth flagging here. Full context/examples: examples/l2-kinesis-stream, README "UX notes" section.

1. Provider-function bindings that return a documented "dynamic" type are force-coerced to string, silently dropping struct-typed results

Some provider::*::* functions (e.g. cfncompat's condition_if, matching Fn::If) are documented as returning a dynamic type — either branch of Fn::If can be a string or an object. But the generated TS wrapper unconditionally routes the result through Token.asString(...):

public static conditionIf(condition: any, valueIfTrue: any, valueIfFalse: any, providerLocalName?: string): any {
  return cdktn.Token.asString(cdktn.TerraformProviderFunction.invoke(providerLocalName ?? "cfncompat", "condition_if", [condition, valueIfTrue, valueIfFalse])) as any;
}

That's fine for the common string-return case, but breaks when the result is assigned to a struct-typed resource attribute: Tokenization.isResolvable() doesn't recognize a Token.asString-wrapped value, so the struct's generated setter (e.g. KinesisStreamStreamEncryptionOutputReference.set internalValue) treats it as a plain object with no known keys and the whole attribute silently disappears from synth output — no warning, no error.

Repro:

new KinesisStream(this, "stream", {
  streamEncryption: CfncompatProviderFunctions.conditionIf(
    someCondition, {}, { encryptionType: "KMS", keyId: "alias/aws/kinesis" },
  ),
});

terraform plan shows no stream_encryption key at all in the planned resource.

Workaround: call cdktn.TerraformProviderFunction.invoke("cfncompat", "condition_if", [...]) directly, bypassing the generated wrapper — its return type (IResolvable, no Token.asString) is handled correctly by struct setters.

Suggested fix: only wrap a provider function's result in Token.asString(...) when its schema-declared return type is actually string-shaped; leave functions documented/typed as dynamic (condition_if, find_in_map, select, ...) as a raw IResolvable.

2. TerraformProviderFunction.invoke(...) silently drops literal null positional arguments from any call

TerraformProviderFunction.invoke's runtime validates its whole args array with a single variadic(anyValue) validator — the same one used for genuinely variadic argument lists (e.g. condition_and/condition_or's conditions). variadic's underlying listOf() unconditionally filters out null/undefined entries, which is correct for a variadic list, but wrong for a fixed-arity call: passing null for one positional argument (e.g. condition_if(condition, value_if_true, value_if_false)'s value_if_true) drops it from the argument list entirely instead of preserving its slot.

Repro:

TerraformProviderFunction.invoke("cfncompat", "condition_if", [someCondition, null, { a: 1 }])

terraform plan fails:

Error: Not enough function arguments
while calling provider::cfncompat::condition_if(condition, value_if_true, value_if_false)
Function "provider::cfncompat::condition_if" expects 3 argument(s). Missing value for "value_if_false".

(Terraform actually complains about the third argument going missing, since null — meant for the second slot — got filtered and everything shifted.)

Workaround: avoid passing null; substitute an empty struct ({}) or other schema-appropriate sentinel where possible. Not a great substitute for CloudFormation's Aws.NO_VALUE (property omission) semantics, but the closest expressible result today.

Suggested fix: TerraformProviderFunction.invoke should validate fixed-arity provider functions per-position (preserving null/undefined in their slot) rather than treating the whole args array as one variadic list; the "flatten variadic trailing args" behavior should only apply when a function is genuinely variadic.

so0k added a commit that referenced this pull request Jul 7, 2026
… path

CdktfConfig.targetVersions (used by runGetInDir) returned the raw
cdktf.json value, bypassing the parseConfig validation the main get
handler goes through — and a malformed range then made
semver.intersects throw inside the fetch-time emission check, crashing
cdktn get with a raw stack trace.

Two layers: the getter now validates via commons validateTargetVersions
(warn + ignore invalid targets; generation proceeds, only the warning/
stamp lose them), and checkSchemaEmissionGapFamilies treats an invalid
target range as not-wanted instead of throwing — a best-effort
diagnostics path must never break get.

Found in draft-PR verification (#296 review notes).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@so0k
so0k force-pushed the feat/provider-feature-availability branch from 1818644 to 30b1724 Compare July 13, 2026 00:27
sakul-learning

This comment was marked as resolved.

Comment thread packages/@cdktn/commons/src/provider-schema.ts Outdated
Comment thread packages/@cdktn/commons/src/provider-schema.ts Outdated
Comment thread packages/@cdktn/commons/src/provider-schema.ts
Comment thread packages/@cdktn/commons/src/provider-schema.ts Outdated
Comment thread packages/@cdktn/commons/src/provider-schema.ts Outdated
// `Tokenization.isResolvable()` downstream - see the mapReturnType
// docstring above). `IResolvable` is assignable to `any`, so no cast is
// needed either.
return {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Similar to the parameters case, this isn't that useful to consumers.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Returns are honest as of 80945aa: list/set(string|number) unwrap via Token.asList/asNumberList; everything that can't go through a lossless Token helper (object/map/dynamic/other collections) is declared cdktn.IResolvable instead of any — so property access on a token (result.hours) is now a compile error instead of a silent undefined. This also surfaced in the demo migration, where the stricter types caught two real call sites.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We do have some token supported map types which could be supported similar to the list types

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Could be useful to add a return type to the documentation similar to what is being done for the parameter types.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

You're right, we under-used our own toolbox — 08433c6 wires map(string|number|bool) returns through Token.asStringMap/asNumberMap/asBooleanMap (and map(*) through asAnyMap), so typed map results now feed typed map parameters directly. Composition pinned by test.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added in 08433c6 — every generated method now carries @returns {<type>}.

Comment thread packages/cdktn/src/terraform-resource.ts Outdated
Comment thread packages/cdktn/src/terraform-resource.ts Outdated
Comment thread packages/cdktn/src/terraform-resource.ts Outdated
Comment thread packages/@cdktn/provider-generator/src/get/generator/emitter/resource-emitter.ts Outdated
so0k added 4 commits July 18, 2026 15:21
The FunctionParameter/FunctionSignature types in commons diverged from
what `providers schema -json` actually emits (verified against
hashicorp/terraform internal/command/jsonfunction and the OpenTofu
equivalent):

- parameters carry `is_nullable` (from the protocol's AllowNullValue) -
  now modeled; the comment claiming `allow_null_value` /
  `allow_unknown_values` could appear in the JSON was wrong (those are
  plugin-protocol names; `allow_unknown_values` has no JSON
  representation at all).
- function signatures carry `deprecation_message` (Terraform only;
  OpenTofu doesn't emit it - divergence noted in a comment).
- neither functions nor their parameters have `description_kind` in the
  JSON (that field exists only on attribute/block schemas) - removed.

Also spell out why list_resource_schemas / action_schemas /
state_store_schemas stay untyped (real CLI sections, preserved verbatim
through parse/sanitize/cache, consumption deferred per RFC-04 Phase 4).

Consumption of the new fields lands in the follow-up commit.
…pping

Addresses the type-fidelity review findings on the provider-function
generator:

- Parameter mapping is now recursive: bool becomes
  `boolean | IResolvable` (input-position union, matching the resource
  attribute convention), list/set recurse on their element type
  (list(number) -> number[], set(bool) -> Array<boolean | IResolvable>,
  list(list(string)) -> string[][]), and map of a primitive gets a real
  indexed-signature type. Unrepresentable elements widen to any[] but
  the docstring keeps the honest element shape.
- Return mapping: list/set(string) -> Token.asList, list/set(number) ->
  Token.asNumberList; every other non-primitive shape (object, map,
  dynamic, other collections) is declared `IResolvable` instead of
  `any` - the raw invoke() result was already returned, but `any`
  invited property access on a token (result.hours) that silently
  yields undefined; IResolvable surfaces that at compile time.
- `is_nullable` is consumed: trailing-compatible nullable fixed params
  become jsii-optional (omission renders the Terraform null keyword),
  mid-position nullable params widen to any, nullable variadics widen
  to any[]; docstrings state `T | null` in all three cases.
- `deprecation_message` is surfaced as an `@deprecated` JSDoc tag.
- Generation now hard-fails with an actionable error when two function
  names or two parameter names sanitize to the same identifier, instead
  of silently shadowing/dropping one.

Fixture coverage: number/bool/nested collections, object returns, typed
and untyped maps, deprecation, all three nullability shapes, and unit
tests pinning both collision errors. Snapshot churn is confined to
provider-function outputs.
The Fn.* and provider-function usage registries were process-global
Sets reset by App's constructor. That approximation had a real false
negative (flagged in review): construct App A, use a gated function,
construct App B before synthesizing A - B's constructor reset wiped
A's recorded usage, so unsupported target versions passed validation
silently.

Usage is now recorded at token-RESOLVE time in FunctionCall.resolve():
IResolveContext.scope is always the resolving TerraformStack, so
records are keyed by its node.root - the owning App - in WeakMaps.
Validations read only their own root's records. Ownership is
structural: interleaved Apps cannot cross-contaminate, the
App-constructor resets (and the "known limitation" disclaimer) are
gone, and App.synth()'s ordering guarantee (prepareStack's preparing
resolve for all stacks runs before any stack's validations) means
recorded usage is always visible to the validations that read it.

Semantics sharpened as a side effect: a function token that never
lands in any rendered output is no longer validated - only usage that
actually reaches synthesized configuration matters.

Regression tests pin the interleaved-App scenario for both registries.
…lass

Provider-defined function bindings become instance methods reachable
from the generated provider construct:

  const time = new TimeProvider(this, "time");
  time.functions.rfc3339Parse(input);

replacing the static class + trailing `providerLocalName?` parameter.
The `functions` getter (emitted only for providers that declare
functions) constructs the wrapper with `this.terraformResourceType` -
the exact value terraform-provider.ts keys required_providers off, so
the namespace is correct by construction, including cdktf.json `name`
constraint overrides. The wrapper's constructor still takes a local
name, remaining usable directly for exotic setups.

This resolves the review discussion around the parameter's naming
(`providerLocalName` vs `providerAlias`) by eliminating it: users no
longer supply a namespace at all, and provider config aliases -
which never selected function namespaces - can't be confused into
that role. It also nudges users toward having the provider declared
before calling its functions, which required_providers rendering
needs anyway.

The per-method self-reference-cycle JSDoc is dropped (review
consensus: too narrow an edge case to repeat on every method); the
warning remains once on TerraformProviderFunction.invoke and moves to
the docs.

Generation hard-fails with an actionable error if a provider's own
config schema would emit a `functions` attribute getter that the new
getter would collide with (no provider in the sweep does).
…xing, deliberate write-only null rule

Four review cleanups on the provider-feature validation machinery:

- registerProviderFeatureUsage (and its per-instance dedup set) moves
  from TerraformResource up to TerraformElement, so any element
  subclass is covered without moving it again; TerraformEphemeralResource
  now uses the hook instead of hand-building the same validation.
- The Record<string, ...> casts around the feature maps are gone: a
  hasOwnProperty membership guard (for jsii callers passing arbitrary
  strings at runtime) followed by direct ProviderFeature-keyed
  indexing keeps the compile-time relationship the enum exists for.
- The validation hint is no longer derived by capitalizing the label
  at runtime; a providerFeatureHints map sits next to
  providerFeatureConstraints/providerFeatureLabels as its own
  deliberate source of truth. Rendered messages are byte-identical.
- Write-only usage registration treats explicit null as omission
  (Terraform semantics: null == omitted): the generated constructor
  guard becomes `!= null` and the setter path gets the same guard.
  Tests pin all four cases (constructor/setter x null/value).
…der nodenext and Node type-stripping

Three defects surfaced by exercising the generated bindings in a real
app (cdktn-provider-features-demo) instead of only snapshotting them:

- The provider class's sibling import of the functions wrapper lacked
  /index, so nodenext/ESM resolution looked for
  ../provider-functions.js and failed; now emits
  '../provider-functions/index<ext>'.
- The wrapper constructor used a TS parameter property, which Node's
  native type-stripping rejects with ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX
  (it requires a synthesized assignment, not type erasure) - breaking
  any project that executes .ts directly. Now a declared field plus a
  body assignment. (jsii accepted the parameter property; Node's
  stripper is the stricter consumer.)
- Variadic rest parameters rendered as `T[]`, which for a union
  element type binds as `boolean | (IResolvable[])`; now `Array<T>`.

Validated end-to-end against the demo harness: tsc --noEmit clean,
all 9 examples synthesize, provider::<name>::<fn>() expressions in
output, and the generated package still passes tsc + jsii + pacmak.
…s, ephemeral resources, write-only

Closes #311. Extends the edge-provider schema - whose bindings are
jsii-compiled and pacmak-codegenned for all five languages at postbuild
and native-compiled by the test/<lang>/edge integration suites - with
the three new-protocol surfaces from this PR:

- nine provider-defined functions, one per generator branch: plain
  string, nullable trailing (jsii-optional), nullable mid-position
  (widens to any), nullable variadic, bool variadic (union-element
  rest param), deprecation_message, object return, list(number)
  return, map(string) parameter;
- an ephemeral resource (required/optional/computed attributes plus a
  nested block) extending TerraformEphemeralResource;
- a write_only attribute on an existing managed resource, so the
  deprecated getter and both != null registration guards land in
  compiled bindings.

This is the compile-slice of the CI-coverage gap: every workflow's
Terraform tops out at 1.6.5 (pre-1.8), so no CI job can fetch a schema
containing these sections - the edge fixture is binary-independent and
rides existing jobs. The runtime slice (real cdktn get on terraform
1.15.x + opentofu 1.12.x) is tracked in #337.

Verified locally: jsii + pacmak --code-only clean for all five targets;
typescript/go/csharp edge integration tests pass against the regenerated
bindings (java needs mvn, unavailable in this environment; python edge
remains describe.skip'd - both pre-existing gaps noted in #311).
@so0k
so0k requested a review from jsteinich July 18, 2026 12:28
@sakul-learning

Copy link
Copy Markdown
Contributor

I found two remaining correctness gaps at 58cd428338d826cb7425bbf9320252599d762306.

1. provider-functions/index.ts: sanitized function names can collide with generated wrapper members

packages/@cdktn/provider-generator/src/get/generator/models/provider-function-model.ts rejects two provider function names that sanitize to the same method name, but it does not reserve names already emitted by packages/@cdktn/provider-generator/src/get/generator/emitter/provider-functions-emitter.ts on the generated <Provider>ProviderFunctions class: constructor and the private providerLocalName field.

For a schema whose functions map contains constructor, sanitizeMethodName() leaves the name unchanged and ProviderFunctionsEmitter.emitMethod() emits a second constructor:

export class ExampleProviderFunctions {
  private readonly providerLocalName: string;

  constructor(providerLocalName: string) {
    this.providerLocalName = providerLocalName;
  }

  public constructor(...): SomeType {
    // ...
  }
}

TypeScript parses the second declaration as another constructor, not an ordinary method; its return type and implementation shape are invalid. A provider_local_name function instead sanitizes to providerLocalName and emits a method with the same name as the wrapper's private readonly providerLocalName field. Schemas containing either name therefore produce invalid TypeScript rather than the generator diagnostic that assertNoMethodNameCollisions() provides for function-to-function collisions.

Could buildProviderFunctionsModel() reject Terraform function names that sanitize to the wrapper's currently emitted reserved names—at least constructor and providerLocalName—and report both the provider and original Terraform function name? Generator tests/fixtures for each name should assert generation fails before output is written.

2. attributes-emitter.ts / terraform-element.ts: clearing a write-only value does not clear its target-version validation

The generated setter in packages/@cdktn/provider-generator/src/get/generator/emitter/attributes-emitter.ts now avoids registering WRITE_ONLY_ATTRIBUTES when its incoming value is null or undefined; the generated constructor applies the analogous guard. However, after a non-null constructor value or setter call registers the feature, packages/cdktn/src/terraform-element.ts retains both the feature in _registeredProviderFeatures and its ValidateFeatureTargetSupport validation for the lifetime of the element.

Assigning null later does not undo that registration, and a generated reset...() method only clears the stored attribute value. Thus, with targetVersions below the write-only floor (Terraform/OpenTofu <1.11.0), synthesis still fails even after the configured write-only value has been cleared—where null is treated as Terraform omission and reset stores undefined.

For example:

resource.secretWo = "temporary"; // installs write-only target validation
resource.secretWo = null;        // Terraform treats the final null as omission
// or: resource.resetSecretWo()

app.synth(); // still rejects an older target

The same stale validation remains when construction begins with a non-null optional write-only value and the generated reset method is called before app.synth().

Could registration be reconciled from current values at validation/synthesis time, or otherwise be removed when no write-only attribute on that resource remains configured? An optional write-only attribute fixture is needed because the current required secret_key_wo fixture cannot generate a reset method. Generated-binding/runtime tests should cover:

  • non-null setter value → null before synth;
  • non-null setter value → reset before synth;
  • non-null constructor value → reset before synth;
  • in each case, the synthesized attribute state and the absence of write-only target validation for an older targetVersions range.

Current coverage establishes the initial null/undefined guards and repeated-registration deduplication, but it does not cover clearing a previously registered write-only value.

so0k added 2 commits July 18, 2026 21:58
…nction collision checks

A provider function named "constructor" emitted a second class
constructor (invalid TypeScript), and "provider_local_name" camelCased
into a method colliding with the wrapper's private providerLocalName
field - both produced broken output instead of the diagnostic that
function-to-function collisions already get.

buildProviderFunctionsModel now rejects any function whose sanitized
method name lands on a member the emitter itself puts on the wrapper
class (constructor, providerLocalName), with an actionable error naming
the provider, the Terraform function name, and the generated
identifier, thrown at the model level - before any output is written.
Parameter names are deliberately not checked against providerLocalName:
they live in method scope and merely shadow the field.
… not mutation history

Registration of WRITE_ONLY_ATTRIBUTES usage was event-based and sticky:
the first non-null constructor/setter value armed the target-version
validation forever, so clearing the value (explicit null or reset)
before synth still failed projects targeting < 1.11 - an error claiming
something false about the synthesized artifact. A Lazy/IResolvable
producer that resolved to nothing also counted as usage, penalizing
exactly the conditional/mixin patterns targetVersions encourages.

Registration now happens at token-resolve time, mirroring the
function-usage registry: generated synthesizeAttributes()/
synthesizeHclAttributes() wrap write-only values in
TerraformResource.markWriteOnlyAttribute(), a transparent IResolvable
that defers to the real resolver and registers usage only when the
RESOLVED result is non-null. undefined/null pass through unwrapped, so
omission filtering is untouched. The mutation-time guards in the
generated constructor and setters are deleted entirely - validation
describes what renders, structurally.

Consequences, all pinned by tests: set-then-null, set-then-reset, and
constructor-then-reset synth cleanly on old targets with the attribute
absent; a value present at synth still fails; re-setting after a clear
re-arms; a Lazy producer resolving to undefined does not register while
the same producer resolving to a value does; dual resolve passes
(preparing + final) dedup to one validation.

The write-only fixture gains an optional attribute so the reset path is
exercised (the required one cannot generate a reset method).
@so0k

so0k commented Jul 18, 2026

Copy link
Copy Markdown
Contributor Author

Both findings addressed and pushed (58cd42833..60d933cc6) — thanks, these were sharp catches.

1. Wrapper-member collisions → 045ecb2f1. buildProviderFunctionsModel now rejects any function whose sanitized method name lands on a member the emitter itself puts on the wrapper class (constructor, providerLocalName), with the actionable error you asked for (provider + original Terraform name + generated identifier), thrown at the model level so it fires before any output is written. Both names are pinned by tests. One deliberate scoping note (documented in the assertion): parameter names are not checked against providerLocalName — parameters live in method scope and merely shadow the field, so there's no invalid output to abort over.

2. Sticky write-only validation → 60d933cc6. We went further than reconciling at validation time: registration now happens at token-resolve time, mirroring the function-usage registry this PR already uses. Generated synthesizeAttributes()/synthesizeHclAttributes() wrap write-only values in a transparent markWriteOnlyAttribute() IResolvable that defers to the real resolver and registers usage only when the resolved result is non-null; undefined/null pass through unwrapped so omission filtering is untouched, and the mutation-time guards in the generated constructor/setters are deleted entirely. Validation describes what renders — structurally, not by bookkeeping.

Your full matrix is pinned in packages/cdktn/test/write-only.test.ts (plus the generator fixture gained the optional write-only attribute so the reset path exists): setter→null, setter→reset, and constructor→reset all synth cleanly on <1.11 targets with the attribute absent from output; a value present at synth still fails; re-set after clear re-arms; and — the case that motivated going resolve-time — a Lazy/IResolvable producer resolving to undefined registers nothing, while the same producer resolving to a value does. Dual resolve passes (preparing + final) dedup to a single validation, relying on the same prepareStack-before-validations ordering invariant the function registry documents and tests.

@eduardomourar

Copy link
Copy Markdown

Just an idea. Maybe use the JSDoc @experimental tag where applicable. That way we can introduce breaking changes while developing the functionality to its production-ready API.

so0k added 2 commits July 19, 2026 02:18
…ed to undefined

In HCL synthesis, attributes are {value, isBlock, type, storageClassType}
descriptors. A token value (e.g. a write-only marker wrapping a Lazy that
produces undefined) survives the generated pre-resolution filter; generic
resolution then strips only the undefined `value` key, and
renderAttributes() fell through to renderFuzzyJsonExpression() on the
metadata-only shell - emitting a bogus assignment built from internal
descriptor fields instead of omitting the attribute.

renderAttributes() now recognizes the orphaned-descriptor shape (the
exact metadata key combination the generator emits, with both `value`
and `dynamic` absent - only `value` can ever be token-typed, so the
shell is unambiguous) and omits the attribute entirely. Explicit null
values keep rendering as null assignments.

Not specific to write-only: any attribute whose token resolves to
undefined in HCL mode hit this path (the #313 HCL-defects family) -
the write-only marker contract just made it load-bearing. Regression
tests land with the companion lifecycle commit (shared write-only test
harness).
…ynthesis pass

Round-6 review findings: resolve-time discovery only worked through
App.synth() (which happens to prepare stacks before validating), and
registrations were lifetime-sticky once a value had rendered.

Usage now has explicit epoch semantics - it describes the current
synthesis pass, discovered by a prepare step every validation-enabled
entry point establishes:

- Element registrations are mode-tagged: structural (the element's
  existence is the usage - ephemeral resources; never reset) vs
  resolve-discovered (write-only; deactivated at the start of each
  preparing resolve, re-armed only when resolution rediscovers a real
  value). constructs has no removeValidation, so the installed
  validation is a thin gate that reports nothing while inactive.
- ISynthesisSession gains optional `stacksPrepared`; App.synth() sets
  it (it already prepares all stacks first), and
  StackSynthesizer.synthesize() prepares the stack itself - including
  the per-root function-usage epoch reset - when reached without it.
- Testing.synth()/synthHcl() with validations now run the discovery
  half of prepareStack() (split out as an internal method so no
  backend is injected into existing snapshots) plus the function-epoch
  reset before validating. Previously these entry points validated
  before any resolve ran, silently passing configured write-only
  values and gated functions on unsupported targets.
- The function-usage registries adopt the same epoch: recorded usage
  clears at the start of each synthesis session, so render-once,
  remove, re-synth no longer reports stale usage - for Fn.*, provider
  functions, and write-only alike.

Regressions pinned: old-target failures now surface through
Testing.synth, Testing.synthHcl, and direct synthesizer use (for both
write-only and provider functions); synth-fail -> reset -> re-synth
passes; a Lazy producer flipping value -> undefined across passes
passes; multi-stack Apps keep sibling usage intact; structural
(ephemeral) registrations survive epochs; HCL output omits write-only
attributes whose token resolves to undefined (regressions for the
companion renderer fix).

Doc comments on the marker and registries now state exactly this
invariant instead of the stronger claims the review called out.

BREAKING CHANGE (testing semantics): Testing.synth/synthHcl with
runValidations=true now run the preparing resolve before validations,
so validations see late-bound (token/Lazy) usage - previously they ran
against pre-resolve state and could miss it. Consumers who added
custom prepare wrappers around Testing APIs can remove them.
@so0k

so0k commented Jul 18, 2026

Copy link
Copy Markdown
Contributor Author

Round 6 (private delta review): validation lifecycle now has explicit epoch semantics — 📝 release-notes material below

Two commits (60d933cc6..0eb1a1a4f) resolving the three lifecycle/HCL findings against the resolve-time write-only design:

fc5cd9dd3 — HCL renderer omits orphaned attribute descriptors. A token resolving to undefined in HCL mode left a metadata-only {isBlock, type, storageClassType} shell that rendered as a bogus fuzzy-JSON assignment. renderAttributes() now recognizes and omits that shape (unambiguous — only value can ever be token-typed in a generated descriptor). This fixes the general pre-existing HCL hole (#313 family), not just write-only markers; explicit null keeps rendering as a null assignment.

0eb1a1a4f — usage represents the current synthesis pass, everywhere. Feature registrations are now mode-tagged (structural — e.g. ephemeral resources, never reset — vs resolve-discovered — write-only, deactivated each preparing resolve and re-armed only when a real value renders), and both function-usage registries adopt the same per-session epoch. Every validation-enabled entry point now establishes the discovery prepare: App.synth() (as before), StackSynthesizer.synthesize() via the new optional ISynthesisSession.stacksPrepared flag, and Testing.synth/synthHcl. Pinned regressions: old-target failures surface through all entry points (write-only and gated functions); synth-fail → reset → re-synth passes; Lazy flipping value→undefined across passes passes; multi-stack sibling usage survives; structural registrations survive epochs.

📝 For the release notes (behavior change in Testing)

Testing.synth(stack, true) and Testing.synthHcl(stack, true) now run the preparing resolve before validations. Previously these entry points validated before any resolve pass, so validations could not see late-bound usage (write-only values behind tokens, Fn.*/provider-function calls) — they silently passed configurations the same stack would fail under app.synth(). Consumers who worked around this with custom prepare wrappers in their testing harnesses can delete them: validations now see exactly what the pass renders, and only that (usage is per-synthesis, so cleared attributes and removed function calls no longer trip stale validations on re-synth). No backend is injected by this change — Testing runs only the discovery half of prepareStack(), so existing snapshots are unaffected.

Suites at 0eb1a1a4f: cdktn core 516/517 (sole failure remains the pre-existing docker toPlanSuccessfully), provider-generator 99/99 with 101 snapshots, git diff --check clean on branch-touched files.

@sakul-learning

Copy link
Copy Markdown
Contributor

Re-evaluated at 0eb1a1a4f780f3b008aa02f9e2cccb87688a9320: the remaining concerns I found are confined to HCL synthesis/validation. I found no unresolved blocker in the default JSON synthesis path or in this PR's provider-protocol feature behavior. From this review, I consider PR #296 unblocked / approved; the items below are HCL follow-ups, not requested changes to this PR. The pre-existing HCL renderer defects remain tracked in #313.

For the HCL follow-up, two behaviors should be retained explicitly:

  1. This PR introduces a new HCL-only drop risk in packages/cdktn/src/hcl/render.ts: isOrphanedAttributeDescriptor() omits any object passed through renderAttributes() that lacks own value/dynamic keys and has own isBlock, type, and storageClassType keys. The predicate does not require an exact own-key set or a private descriptor marker. Consequently, a legitimate custom synthesizeHclAttributes() value with those metadata-like keys plus application fields can be omitted wholesale rather than rendered. This is a PR-introduced HCL-only regression, not one of fix(lib): HCL synth corrupts or drops core blocks (terraform{} extras, top-level overrides, lifecycle conditions) #313's pre-existing defects. A follow-up should use a non-colliding descriptor marker (or an equivalent internal representation) and pin a regression case for a custom object with those keys plus user data.

  2. This PR's HCL validation discovery still resolves the JSON representation: TerraformStack._runPreparingResolve() resolves toTerraform() even when Testing.synthHcl(..., true) or an HCL StackSynthesizer will emit toHclTerraform(). Thus, when JSON and HCL representations differ, target-feature validation can report JSON-only usage that HCL does not render, or miss HCL-only usage that it does render. The underlying JSON/HCL synthesis divergence predates feat: support newer provider plugin-protocol features via targetVersions (RFC-04) #296 and is exemplified by #313; the PR-introduced worsening is that the new resolve-time validation lifecycle uses the JSON-only discovery pass for validation-enabled HCL synthesis. A follow-up should make discovery renderer-aware and test both JSON-only and HCL-only feature values.

These are concrete HCL follow-up acceptance cases, but I am not requesting changes to #296 for them. At this head, the configured validation test, cdktn build, and full cdktn test target all passed locally (40 focused tests; 42 suites / 517 full tests).

}
if (element === "bool") {
return {
tsType: "Array<boolean | cdktn.IResolvable>",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Other places in the codebase use Array<boolean | cdktn.IResolvable> | cdktn.IResolvable which allows for the entire list to be a token.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Adopted in 08433c6list/set(bool) is now Array<boolean | cdktn.IResolvable> | cdktn.IResolvable, and the same whole-collection union applies to nested collections and the any[] fallback (each branch now cites its attribute-type-model counterpart). string[]/number[] stay plain, matching resource inputs (encoded list tokens already satisfy them). This is also what makes function-result → function-parameter composition typecheck — pinned by composition tests (set(bool) result into set(bool) param).

const element = type[1];

if (element === "string") {
return { tsType: "string[]", docstringType: "Array<string>" };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Perhaps distinguish between list and set in the doc string

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in 08433c6 — set-typed params/returns are labeled [set] in the docstring so the unordered/deduped semantics are visible.

return {
tsType: child.tsType.includes("|")
? `Array<${child.tsType}>`
: `${child.tsType}[]`,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This check isn't really accurate for what compatible types are actually generated as in other parts of the codebase for nested collection types. Perhaps there's a simple way to tie into the normal generation.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed the heuristic was wrong — replaced in 08433c6 with explicit per-branch mappings, each commented with the attribute-type-model.ts branch it mirrors (nested collections follow the non-simple-element rule: string[][] | cdktn.IResolvable). We mirrored rather than physically reused: the resource machinery is entangled with struct/storage-class generation that function expressions don't have, but the type matrix is now convention-identical and tested as such.

* leaves `undefined` in the invoke args array, which `FunctionCall`
* already renders as the Terraform `null` keyword.
* - A nullable parameter followed by a required one keeps its position
* (jsii can't express "optional but not trailing"), but widens its type

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Is this actually true? Optional vs nullable can be different concepts. I'm used to this being true for actually optional parameters, but not true for nullable parameters, but different languages could have different rules.
Just want to double check as this is complexity that would be nice if we didn't need it.

@so0k so0k Jul 19, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fair question — verified against jsii's model rather than assumed: Java and C# cannot omit parameters (jsii TypeScript-restrictions doc), so a jsii-optional parameter there is satisfied by passing null (cf. the @nullable discussion aws/aws-cdk#6026) — i.e. optional≈nullable is exactly jsii's cross-language behavior. TS/Python omit; Java/C# pass null; both render the Terraform null keyword positionally (pinned by test in 08433c6). The trailing param additionally accepts Token.nullValue() now, so the two concepts also converge in the typed API.


return {
...model,
tsType: "any",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is pretty painful from a useability perspective.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 08433c6 — mid-position nullables are no longer any: they're T | cdktn.IResolvable, with Token.nullValue() as the explicit-null spelling (it resolves to the bare null keyword — proving that surfaced two pre-existing expression-rendering bugs, fixed in a2ea87a: a resolvable resolving to null rendered as "" and a resolvable resolving to a raw array rendered via Array.toString).

for (const fn of functions) {
const existingTerraformName = seenBy.get(fn.methodName);
if (existingTerraformName !== undefined) {
throw new Error(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Errors for these two cases is probably fine to start with, but has the potential to become a challenge to support long term. Perhaps a follow-up issue to dedupe them.

@so0k so0k Jul 19, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed — tracked as a follow-up in #340 (deterministic dedupe strategy to replace the hard-fail long-term, plus evaluating jsii's per-language reserved-word lists). Meanwhile the prebuilt sweep below shows no real provider can currently trip the errors.


// Parameter names that collide with a reserved word/identifier in one of the
// jsii target languages; mirrors tools/generate-function-bindings mapParameter.
const RESERVED_PARAMETER_NAMES: { [name: string]: string } = {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I was thinking of the jsii reserved list, but I'm not sure we actually use that anywhere now that I look for it.

`Provider-defined functions of the ${model.providerName} provider.`,
);
comment.end();
this.code.openBlock(`export class ${model.className}`);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I was thinking the functions themselves would be available rather than a separate class access.
The updated approach does eliminate the need for the extra parameter, but it still obscures them a bit. Perhaps that is ok for the reduced name collision risk.

The name collision generation failure is still not ideal, but might be ok for now (should at least check pre-built providers)

Comment thread packages/cdktn/src/terraform-element.ts Outdated
* the lifetime of the element, gated on/off via the registration's
* `active` flag instead of being re-added or removed.
*/
private readonly _registeredProviderFeatures = new Map<

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We should consider just defining the type and only instantiating it if a registration is necessary. I would expect most elements to not actually use this which means we could save a decent amount of memory in a large stack.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I was thinking the functions themselves would be available rather than a separate class access.
The updated approach does eliminate the need for the extra parameter, but it still obscures them a bit. Perhaps that is ok for the reduced name collision risk.

The name collision generation failure is still not ideal, but might be ok for now (should at least check pre-built providers)

Agree we should do a data-sweep across pre-built provider schema if any function names post normalization cause collisions and will produce errors when this is merged. There's also grafana and mongodb providers requested to be added to pre-built providers so we could at least include those in our local datasweep assuming we will eventually (preferably sooner than later) add them to the pre-built list as well.

Ref:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in a2ea87a — the map is allocated on first registration; elements that never register (the vast majority) no longer pay for it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

* the validations that read it afterwards.
*/
const usedFunctions = new Set<string>();
const usedFunctionsByRoot = new WeakMap<IConstruct, Set<string>>();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This has gone through a couple iterations, but ultimately, it still seems like this isn't place in the correct scope for its actual usage.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Third time was the charm — you were right that the scope was still wrong, and it was more than hygiene: targetVersions can be overridden per stack, so root-keyed usage let a stack targeting old versions fail on a sibling's rendered function. As of a2ea87a the state lives on the TerraformStack itself (the construct whose constructor registers the validations and whose context resolves targetVersions): recorded via context.scope at resolve time, cleared by the stack's own preparing resolve, read by the stack's own validations. The module-global registry file is deleted outright, along with every session-level reset — per-stack epochs need no coordination. Sibling-stacks-with-different-targetVersions tests pin the fix, including the shared-token-attributed-to-both case.

so0k added 2 commits July 19, 2026 17:26
… nullable ergonomics

Third type-fidelity pass, aligning provider-function signatures with
the established resource-input conventions branch for branch (each
mapping now cites its attribute-type-model counterpart; the string-
based parens heuristic is gone):

- list/set(bool), nested collections, and any[]-fallback collections
  gain `| IResolvable` so a whole-collection token is accepted -
  which is what makes function-result -> function-parameter
  composition typecheck (a set(bool) result is an IResolvable).
  string[]/number[] stay plain, as resource inputs do (encoded list
  tokens already satisfy them).
- map(string|number|bool) returns use the existing Token.asStringMap/
  asNumberMap/asBooleanMap helpers (map(*) -> asAnyMap) instead of
  falling back to raw IResolvable - typed map results now feed typed
  map parameters directly.
- Nullable parameters drop both `any` widenings: mid-position and
  variadic nullables become `T | IResolvable` (pass
  Token.nullValue() for an explicit null), trailing nullables stay
  jsii-optional and gain the same union. jsii's cross-language model
  makes optional~nullable sound: TS/Python omit, Java/C# pass null,
  both render the Terraform null keyword.
- Docs: [list]/[set] distinction in docstrings, @returns lines, and
  the variadic comment says "array parameter" (it never was a rest
  parameter).

Fixture adds list(bool)/list(object)/all four map-return shapes and
two composition pairs; runtime tests pin Token.nullValue() rendering
and whole-collection-token inputs.
…collections correctly

Function-usage state moves from module-global WeakMaps keyed by the
App root onto the owning TerraformStack itself - the scope where the
validations are registered, and whose context chain resolves
targetVersions. Root-keyed recording had a real false positive:
targetVersions can be overridden per stack, so a stack targeting old
versions failed on a sibling's rendered usage. Now each stack records
(via context.scope at resolve time), clears its own sets in
_runPreparingResolve (the per-stack epoch), and validates only what it
rendered - errors carry accurate stack attribution. usage-registry.ts
is deleted along with every session-level reset (App.synth, Testing,
direct-synthesizer): per-stack clearing needs no coordination.

Tests: sibling stacks with independent targetVersions (compatible
sibling using a gated function no longer fails the incompatible one;
the inverse fails only the using stack; a shared token rendered in
both stacks is attributed to both), for provider functions and Fn.*.

Also fixes two pre-existing expression-rendering bugs surfaced while
proving Token.nullValue() behavior instead of assuming it: a
resolvable resolving to null rendered as "" (dropping the argument)
instead of the null keyword, and a resolvable resolving to a raw
array/object rendered via default stringification (true,false - which
Terraform would parse as two arguments) instead of [true, false].
Both paths now collapse/format resolved values correctly.

Per review suggestion, TerraformElement's provider-feature
registration map is now allocated lazily on first registration -
most elements never register, so large stacks stop paying for empty
Maps.
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.

Edge-provider schema: cross-language compile coverage for ephemeral resources, provider functions, write-only attributes

5 participants