fix(codegen): emit z.record for freeform-JSON properties in input schemas (#457) #3

Merged
stack72 merged 1 commit from fix/issue-457-json-type-input-schema into main 2026-05-27 20:05:47 +00:00
Owner

Summary

Fixes swamp-club issue #457: @swamp/aws/iam/role-policy PolicyDocument schema says z.string() but AWS CloudControl expects JSONObject.

  • Root cause: codegen/shared/zodGenerator.ts had three fallback paths that emitted z.string() for freeform-JSON CloudFormation properties. CloudControl expects JSONObject on writes, so string input passed swamp validation but failed at the AWS API.
  • Fix: All three paths now emit z.record(z.string(), z.unknown()), making input schemas accept YAML objects — which is what CloudControl expects.
  • Prior context: Issue #143 / commit 8026920b6 fixed the resource/state schema side but explicitly left inputs as z.string(). That decision was incorrect — CloudControl rejects strings on write.

What changed

codegen/shared/zodGenerator.ts (3 one-line changes):

  1. generateObjectZod bare-object fallback (line 540) — PRIMARY fix for PolicyDocument
  2. generateZodForProperty json case (line 196) — secondary fix for type: ["string", "object"] properties
  3. generateSimplifiedZodForProperty bare-object fallback (line 390) — resource schema consistency

codegen/shared/zodGenerator_test.ts:

  • Updated test 14 to assert z.record for json-type input schemas
  • Added test 15 for bare type: "object" with no properties

Regenerated models (390 files):

  • ~96 AWS services (182 properties across ~70 unique services)
  • ~39 GCP services (bare-object properties in BigQuery, Container, etc.)

Why this is correct

  • CloudFormation schemas define PolicyDocument as bare type: "object" (confirmed by fetching cf-schema.json and inspecting AWS::IAM::RolePolicy)
  • CloudControl API rejects String for these fields — only JSONObject is accepted
  • After this fix: YAML objects pass validation (5/5) and match CloudControl's expectation
  • After this fix: JSON strings are correctly rejected at validation (expected record, received string) before reaching AWS

Verified

  • deno check main.ts and deno test pass (15/15 tests)
  • Filtered generation (generate:aws iam) shows correct diff
  • Full AWS regeneration (255 services): 168 models changed, all z.string()z.record()
  • Full GCP regeneration (260 services): 77 models changed
  • Second run of both AWS and GCP: 0 changes (idempotent)
  • End-to-end test in a scratch swamp repo: object PolicyDocument validates 5/5, string PolicyDocument correctly rejected

Breaking change

Operators with string-valued freeform-JSON properties (e.g., PolicyDocument: '{"Version":...}') must switch to YAML objects. This is the correct behavior — string input never worked at CloudControl.

Test plan

  • zodGenerator unit tests pass (15/15)
  • Codegen type-check, lint, fmt pass
  • Filtered regeneration produces intended diff
  • Full regeneration shows no unintended collateral
  • Idempotency verified (second run = zero diff) for both AWS and GCP
  • End-to-end validation in scratch swamp repo
## Summary Fixes swamp-club issue #457: `@swamp/aws/iam/role-policy` PolicyDocument schema says `z.string()` but AWS CloudControl expects JSONObject. - **Root cause**: `codegen/shared/zodGenerator.ts` had three fallback paths that emitted `z.string()` for freeform-JSON CloudFormation properties. CloudControl expects JSONObject on writes, so string input passed swamp validation but failed at the AWS API. - **Fix**: All three paths now emit `z.record(z.string(), z.unknown())`, making input schemas accept YAML objects — which is what CloudControl expects. - **Prior context**: Issue #143 / commit `8026920b6` fixed the resource/state schema side but explicitly left inputs as `z.string()`. That decision was incorrect — CloudControl rejects strings on write. ## What changed **`codegen/shared/zodGenerator.ts`** (3 one-line changes): 1. `generateObjectZod` bare-object fallback (line 540) — PRIMARY fix for PolicyDocument 2. `generateZodForProperty` json case (line 196) — secondary fix for `type: ["string", "object"]` properties 3. `generateSimplifiedZodForProperty` bare-object fallback (line 390) — resource schema consistency **`codegen/shared/zodGenerator_test.ts`**: - Updated test 14 to assert `z.record` for json-type input schemas - Added test 15 for bare `type: "object"` with no properties **Regenerated models** (390 files): - ~96 AWS services (182 properties across ~70 unique services) - ~39 GCP services (bare-object properties in BigQuery, Container, etc.) ## Why this is correct - CloudFormation schemas define PolicyDocument as bare `type: "object"` (confirmed by fetching `cf-schema.json` and inspecting `AWS::IAM::RolePolicy`) - CloudControl API rejects `String` for these fields — only `JSONObject` is accepted - After this fix: YAML objects pass validation (5/5) and match CloudControl's expectation - After this fix: JSON strings are correctly rejected at validation (`expected record, received string`) before reaching AWS ## Verified - `deno check main.ts` and `deno test` pass (15/15 tests) - Filtered generation (`generate:aws iam`) shows correct diff - Full AWS regeneration (255 services): 168 models changed, all `z.string()` → `z.record()` - Full GCP regeneration (260 services): 77 models changed - Second run of both AWS and GCP: 0 changes (idempotent) - End-to-end test in a scratch swamp repo: object PolicyDocument validates 5/5, string PolicyDocument correctly rejected ## Breaking change Operators with string-valued freeform-JSON properties (e.g., `PolicyDocument: '{"Version":...}'`) must switch to YAML objects. This is the correct behavior — string input never worked at CloudControl. ## Test plan - [x] zodGenerator unit tests pass (15/15) - [x] Codegen type-check, lint, fmt pass - [x] Filtered regeneration produces intended diff - [x] Full regeneration shows no unintended collateral - [x] Idempotency verified (second run = zero diff) for both AWS and GCP - [x] End-to-end validation in scratch swamp repo
stack72 self-assigned this 2026-05-27 16:11:50 +00:00
fix(codegen): emit z.record for freeform-JSON properties in input schemas (#457)
All checks were successful
CI / cve/mini-shai-hulud - lockfile up to date (pull_request) Has been skipped
CI / cve/mini-shai-hulud - fmt (pull_request) Has been skipped
CI / model/digitalocean - fmt (pull_request) Successful in 26s
CI / model/digitalocean - check (pull_request) Successful in 36s
CI / model/digitalocean - lint (pull_request) Successful in 23s
CI / model/hetzner-cloud - check (pull_request) Successful in 25s
CI / model/hetzner-cloud - fmt (pull_request) Successful in 21s
CI / model/hetzner-cloud - lint (pull_request) Successful in 21s
CI / model/digitalocean - lockfile up to date (pull_request) Successful in 27s
CI / model/hetzner-cloud - lockfile up to date (pull_request) Successful in 29s
CI / aws models - fmt (pull_request) Successful in 51s
CI / Dependency Audit (pull_request) Successful in 4m37s
CI / aws models - lint (pull_request) Successful in 1m5s
CI / aws models - lockfiles up to date (pull_request) Successful in 56s
CI / gcp models - fmt (pull_request) Successful in 1m55s
CI / gcp models - lint (pull_request) Successful in 2m35s
CI / gcp models - lockfiles up to date (pull_request) Successful in 43s
CI / cloudflare models - check (pull_request) Successful in 8m18s
CI / cloudflare models - fmt (pull_request) Successful in 33s
CI / cloudflare models - lint (pull_request) Successful in 34s
CI / cloudflare models - lockfiles up to date (pull_request) Successful in 33s
CI / codegen - check (pull_request) Successful in 46s
CI / codegen - fmt (pull_request) Successful in 31s
CI / codegen - lint (pull_request) Successful in 30s
CI / codegen - lockfile up to date (pull_request) Successful in 34s
CI / CI Security Review (pull_request) Has been skipped
CI / aws models - check (pull_request) Successful in 31m26s
CI / gcp models - check (pull_request) Successful in 31m19s
CI / Adversarial Code Review (pull_request) Successful in 4m24s
CI / Claude Code Review (pull_request) Successful in 6m15s
bace55bd48
CloudFormation schemas define properties like PolicyDocument as bare
type: "object" (no sub-properties) or type: ["string", "object"]
(normalized to internal "json" type). The Zod generator mapped both to
z.string() in input schemas, but AWS CloudControl expects JSONObject —
so string input passed swamp validation but failed at the API with
"expected type: JSONObject, found: String".

The prior fix (8026920b6, issue #143) corrected the resource/state
schema side but explicitly kept z.string() for inputs, reasoning that
"users supply JSON as strings." That reasoning was incorrect —
CloudControl rejects strings on write. This change completes the fix
by emitting z.record(z.string(), z.unknown()) for all three fallback
paths:

- generateZodForProperty json case (input schemas, 43 properties)
- generateObjectZod bare-object fallback (input schemas, 139 properties)
- generateSimplifiedZodForProperty bare-object fallback (resource schemas)

Operators must now provide PolicyDocument (and similar properties) as
YAML objects rather than JSON strings. This matches what CloudControl
expects and lets operators write readable YAML.

Blast radius: ~96 AWS services, ~39 GCP services.

Co-Authored-By: Blake Irvin <blakeirvin@me.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author
Owner

Adversarial Review

Critical / High

None found.

Medium

  1. codegen/shared/zodGenerator.ts:196 -- json type now rejects string inputs across all providers

    The case json in generateZodForProperty unconditionally emits z.record(z.string(), z.unknown()). This assumes every provider json-typed property expects a parsed object at the input boundary. If any provider normalizer produces type json for fields where users legitimately supply a JSON string (e.g. a stringified policy document), the generated input schema will reject it at runtime with a Zod validation error.

    The resource schema already handles this ambiguity with z.union([z.string(), z.record(...)]) (line 324), but the input schema does not get the same treatment.

    Breaking example: A user calls an extension with PolicyDocument as a JSON string instead of a parsed object. The old schema accepted it; the new schema rejects it.

    Mitigation: If CloudControl definitively expects objects (not strings) for all json fields, this is correct and intentional -- just worth confirming that no existing callers rely on passing JSON strings. If there is any ambiguity, the input schema could use the same union pattern as the resource schema.

Low

  1. codegen/shared/zodGenerator.ts:196 -- z.record(z.string(), z.unknown()) rejects JSON arrays

    z.record only accepts plain objects, not arrays. If a json-typed CloudFormation property could legitimately be a JSON array at the top level, it would be rejected. For known CF schema fields like PolicyDocument, Configuration, etc., these are always objects, so this is unlikely in practice.

  2. codegen/shared/zodGenerator_test.ts:425-428 -- test assertion is weaker than intended

    The test asserts result.inputSchemaBody.includes("PolicyDocument: z.string()") is false to confirm no bare z.string(). However, this would not catch a regression where the output includes z.string().describe(...). The positive match on the expected expression (line 421-424) is the load-bearing assertion; the negative one is redundant.

Verdict

PASS -- This is a well-scoped bug fix. The three changed sites consistently replace an incorrect z.string() fallback with z.record(z.string(), z.unknown()) for freeform-JSON and bare-object properties. The logic is correct, the tests cover the changed paths, and the snapshot tests are unaffected. The medium finding is a semantic question about provider contract assumptions, not a code defect.

## Adversarial Review ### Critical / High None found. ### Medium 1. **codegen/shared/zodGenerator.ts:196 -- json type now rejects string inputs across all providers** The case json in generateZodForProperty unconditionally emits z.record(z.string(), z.unknown()). This assumes every provider json-typed property expects a parsed object at the input boundary. If any provider normalizer produces type json for fields where users legitimately supply a JSON string (e.g. a stringified policy document), the generated input schema will reject it at runtime with a Zod validation error. The resource schema already handles this ambiguity with z.union([z.string(), z.record(...)]) (line 324), but the input schema does not get the same treatment. Breaking example: A user calls an extension with PolicyDocument as a JSON string instead of a parsed object. The old schema accepted it; the new schema rejects it. Mitigation: If CloudControl definitively expects objects (not strings) for all json fields, this is correct and intentional -- just worth confirming that no existing callers rely on passing JSON strings. If there is any ambiguity, the input schema could use the same union pattern as the resource schema. ### Low 1. **codegen/shared/zodGenerator.ts:196 -- z.record(z.string(), z.unknown()) rejects JSON arrays** z.record only accepts plain objects, not arrays. If a json-typed CloudFormation property could legitimately be a JSON array at the top level, it would be rejected. For known CF schema fields like PolicyDocument, Configuration, etc., these are always objects, so this is unlikely in practice. 2. **codegen/shared/zodGenerator_test.ts:425-428 -- test assertion is weaker than intended** The test asserts result.inputSchemaBody.includes("PolicyDocument: z.string()") is false to confirm no bare z.string(). However, this would not catch a regression where the output includes z.string().describe(...). The positive match on the expected expression (line 421-424) is the load-bearing assertion; the negative one is redundant. ### Verdict PASS -- This is a well-scoped bug fix. The three changed sites consistently replace an incorrect z.string() fallback with z.record(z.string(), z.unknown()) for freeform-JSON and bare-object properties. The logic is correct, the tests cover the changed paths, and the snapshot tests are unaffected. The medium finding is a semantic question about provider contract assumptions, not a code defect.
Author
Owner

Code Review

Blocking Issues

None.

Suggestions

  1. Dead branch in generateObjectBody indent logic (zodGenerator.ts:570): The indent ternary depth > 0 ? 1 : 2 has an unreachable 4-space branch. generateObjectBody is always invoked with depth + 1 from generateObjectZod, so depth is always >= 1 at that call site and the ternary always resolves to 1 (2 spaces). The 4-space case is dead code. Replace with a literal 2-space string for clarity.

  2. Double cast in test 14 (zodGenerator_test.ts:407): The cast as unknown as CfProperty is required because the "json" type is an internal normalized type not present in the CfProperty type union. A short inline comment noting that "json" comes from normalizeProperty rather than the CloudFormation spec would help future readers understand the cast.

  3. Indentation asymmetry between input and resource schemas: The input schema path in generateObjectBody uses 2-space indentation for nested object children, while the resource schema path in generateSimplifiedZodForProperty hardcodes 4 spaces (visible in the test-8 snapshot). Not a correctness issue, but the asymmetry means generated model files have mixed indentation style.

## Code Review ### Blocking Issues None. ### Suggestions 1. Dead branch in generateObjectBody indent logic (zodGenerator.ts:570): The indent ternary `depth > 0 ? 1 : 2` has an unreachable 4-space branch. generateObjectBody is always invoked with depth + 1 from generateObjectZod, so depth is always >= 1 at that call site and the ternary always resolves to 1 (2 spaces). The 4-space case is dead code. Replace with a literal 2-space string for clarity. 2. Double cast in test 14 (zodGenerator_test.ts:407): The cast `as unknown as CfProperty` is required because the "json" type is an internal normalized type not present in the CfProperty type union. A short inline comment noting that "json" comes from normalizeProperty rather than the CloudFormation spec would help future readers understand the cast. 3. Indentation asymmetry between input and resource schemas: The input schema path in generateObjectBody uses 2-space indentation for nested object children, while the resource schema path in generateSimplifiedZodForProperty hardcodes 4 spaces (visible in the test-8 snapshot). Not a correctness issue, but the asymmetry means generated model files have mixed indentation style.
stack72 deleted branch fix/issue-457-json-type-input-schema 2026-05-27 20:05:47 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
swamp-club/swamp-extensions!3
No description provided.