Skip to content
2 changes: 1 addition & 1 deletion docs/_generated/integ-last-run.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ drift-revert-arrays 2026-08-01T09:36:05Z PASS 110 verify.sh 0801 regression swee
drift-revert-vpc 2026-07-30T16:18:56Z PASS 480 verify.sh post-rebase final for PR #1307 (#1299/#1300): 6/6 reverted, 21 del 0 err 0 orphans
dsql 2026-07-30T18:11:24Z PASS 540 verify.sh 4-phase incl. destroy --remove-protection CC flip (#1312); orph clean
dynamodb-autoscaling 2026-07-21T14:33:57Z PASS 77 verify.sh rc ok, orph clean
dynamodb-globaltable 2026-08-09T14:33:56Z PASS 182 verify.sh #1423 PARTIAL removal asserted live (write reset absent, read kept 50); 3 del/0 err, 0 orphans
dynamodb-globaltable 2026-08-09T16:45:32Z PASS 253 verify.sh re-run after reviewer fix (both-sides-on-demand gate); 3 deleted 0 errors, 0 orphans
dynamodb-gsi-update 2026-07-20T08:09:23Z PASS 579 verify.sh rc ok, orph clean
dynamodb-ondemand 2026-08-01T10:07:21Z PASS 81 verify.sh 0801 regression sweep; ondemand+policy+kinesis backfills ok, 0 orphans
dynamodb-sse 2026-08-01T10:07:21Z PASS 40 verify.sh 0801 regression sweep; SSE mapping ok, 0 orphans
Expand Down
3 changes: 3 additions & 0 deletions docs/changelog-cdkd.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ The CLAUDE.md `## Known Limitations` section retains the load-bearing summary

---

**Recently Implemented** (2026-08-10):
- ✅ **`AWS::DynamoDB::GlobalTable`: the TABLE-level on-demand write ceiling resets when the template drops it; the two REPLICA overrides are proven un-resettable and deliberately left alone (issue [#1434](https://github.com/go-to-k/cdkd/issues/1434))** — `src/provisioning/providers/dynamodb-globaltable-provider.ts` + `tests/unit/provisioning/dynamodb-globaltable-provider-gsi-throughput.test.ts`. **The class:** removing `WriteOnDemandThroughputSettings` from a template never set `flatChanged`, so no `UpdateTable` went out and the old ceiling stayed live in AWS while cdkd reported success — the absent-field-reset silent drop (#1160), the table-level sibling of the per-GSI case #1423 closed. **The fix** merges per member rather than branching on "the whole block disappeared" (the shape the #1433 review caught: a block can survive with its member dropped, which is the likelier user edit) and is SUPPRESSED while the billing mode is flipping, since dropping the on-demand block on the way to PROVISIONED is the natural template edit rather than a clear request, and step 4's flip owns that call. The `oldBilling` / `newBilling` pair is now resolved ONCE above step 3 and reused by step 4, so the two sites cannot disagree about what "flipping" means. **The issue asked for three fields; only ONE turned out to be implementable, and the live probes are what settled it** (us-east-1 source + us-west-2 replica, one global table per probe, both torn down — transcripts on the issue). Table-level `-1` behaves exactly as the issue assumed: `{MaxWriteRequestUnits: -1}` was accepted and `DescribeTable` then returned `{MaxReadRequestUnits: 100}`, i.e. the dropped member cleared and the untouched sibling preserved, with the reset reading back as ABSENCE never as -1 — verified independently rather than inherited from #1423, since reset semantics are field-specific. The two REPLICA overrides have NO reset mechanism at all: `OnDemandThroughputOverride: {MaxReadRequestUnits: -1}` is accepted but **stored literally as -1** on readback (writing it would be strictly worse than the current no-op — a nonsense value instead of a stale-but-valid one); the documented "empty override means inherit the source table" form `{}` is accepted and then **hangs the table in `UPDATING` for over an hour** with the override unchanged and every later call returning `ResourceInUseException` (it left a table AWS refused to delete for having "acted as a source region for new replica(s) ... in the last 24 hours" — treat `{}` as hazardous in any future probe); an entry carrying only `IndexName` is rejected outright (`ValidationException: There are no actions specified in the Replica Update Action`). `ProvisionedThroughputOverride` is the same story by construction — the registry schema declares `ReadCapacityUnits` / `MaxReadRequestUnits` `"minimum": 1`, and live `-1` / `0` both fail validation while `{}` returns `InternalServerError`. So no in-band sentinel exists, and shipping any of them would trade a silent no-op for a live defect. **Tests:** 6 units covering the full removal, the PARTIAL removal, value-changed (never the sentinel), stringly-typed coercion, billing-flip suppression, and the no-change redeploy; revert-proofed — neutering the reset fails exactly the first two while the other four stay green, since they pin behavior the change does not alter. **Related finding filed, not fixed here:** the probes surfaced that the table-level on-demand READ ceiling (`Replicas[local].ReadOnDemandThroughputSettings`, i.e. the canonical `Billing.onDemand({maxReadRequestUnits})`) is never wired AT ALL — dropped on the way in rather than merely un-reset, and the repo's own verbatim-`cdk synth` fixture already carried the value with no assertion over it (issue [#1436](https://github.com/go-to-k/cdkd/issues/1436)).

**Recently Implemented** (2026-08-09):
- ✅ **The two `handledProperties` wiring gaps the #1404 critic caught are closed by DECLARING the drop, not by faking a wire (issues [#1411](https://github.com/go-to-k/cdkd/issues/1411) / [#1412](https://github.com/go-to-k/cdkd/issues/1412))** — `src/provisioning/providers/ec2-provider.ts`, `src/provisioning/providers/logs-loggroup-provider.ts`, `scripts/gen-handled-property-wiring.ts` (both `HANDLED_WIRING_ALLOW_LIST` entries removed), `src/provisioning/property-coverage.generated.ts` + `docs/_generated/handled-property-wiring.{json,md}` regenerated, `.claude/rules/code-layout.md`, and the `tests/integration/vpc-nat-gateway/` fixture. **Both issues proposed "wire it into create"; for #1411 that turned out to be IMPOSSIBLE, and re-deriving it changed the fix.** `AWS::EC2::NatGateway.MaxDrainDurationSeconds` is not a `CreateNatGatewayRequest` member (`@aws-sdk/client-ec2` `models/models_1.d.ts`), and EC2 ships no `ModifyNatGateway*` operation at all — the ONLY two SDK inputs carrying the field are `DisassociateNatGatewayAddressRequest` (`models_5`) and `UnassignPrivateNatGatewayAddressRequest` (`models_7`), i.e. it is a per-call drain timeout for RELEASING secondary addresses, which is why the CFn registry schema lists it under `writeOnlyProperties`. cdkd's `updateNatGateway` rejects every NatGateway property change, so it never issues those two calls and has nowhere to deliver the value. Modelling it as a replacement trigger (the fallback the issue suggested) was rejected too: the registry schema does NOT list it under `createOnlyProperties`, so recreating a gateway on a drain-timeout change would diverge from CloudFormation and needlessly break the data plane. **`AWS::Logs::LogGroup.ResourcePolicyDocument`** took option 2 of #1412 for the reason the issue itself gives — it maps to the separate `AWS::Logs::ResourcePolicy` type whose `logs:PutResourcePolicy` is ACCOUNT-scoped, not per-log-group, so owning it from a log group's lifecycle would require inventing an ownership answer (which policy name to claim, what to do on delete when the account-wide policy may be shared, how to resolve two log groups declaring conflicting documents); managing the sibling resource remains the real feature. **Both are now `unhandledByDesign` with rationales.** Correcting a premise carried in both issues: neither provider sets `disableCcApiFallback`, so this does NOT hard-reject such templates via the #614 viability guard — it fires the #614 AUTO-ROUTE, provisioning the resource through Cloud Control API where AWS's own handler applies the value. That is strictly better than both the silent drop and a reject. Note on existing stacks: routing stickiness is `cc-api` -> `cc-api` ONLY (`provider-registry.ts` short-circuits on that value alone), so a resource already recorded `provisionedBy: 'sdk'` whose template sets one of these properties DOES re-evaluate and flip to Cloud Control on its next UPDATE — `deploy-engine.ts` does that deliberately. That is the intended outcome (the value starts being applied instead of dropped) and costs no physical-ID churn, since Cloud Control updates in place. **Tests:** 10 new units across two files (each asserts the property is absent from `handledProperties`, present in `unhandledByDesign` with a rationale, reported by `findSilentDropProperties`, and that `ProviderRegistry.getProviderFor` routes to CC for a template setting it and to SDK for one that does not), plus a guard that `CreateNatGateway` never carries the field and a fence that neither retired property may return to `handledProperties`; `tests/unit/scripts/gen-handled-property-wiring.test.ts` re-pointed its real-code stale-entry probes onto a still-live allow-list entry (`IAMAccessKeyProvider#Serial`). Revert-proofed: re-adding both properties to `handledProperties` fails 6 of 10 and makes `audit:handled-property-wiring:check` fail naming both. The critic now reports **0 gaps, 2 allow-listed** (down from 4). **Integ:** the `vpc-nat-gateway` fixture gains a SECOND, L1-only private NAT gateway setting the property, so the L2 gateway stays on the SDK path and the two together assert heterogeneous routing in one stack. The value is `writeOnlyProperties` and no EC2 API returns it, so a read-back-and-compare assertion is structurally impossible; `verify.sh` asserts the routing consequence instead (`provisionedBy == 'cc-api'` on the drain gateway, `'sdk'` on the plain one, a vacuity guard grepping the literal out of the stack file so the two cannot drift, both gateways live and `available` on AWS, and both gone after destroy).
- ✅ **`AWS::CodeBuild::Project`: seven nested `Source` / `Environment` / `Cache` sub-keys wired, plus the `SourceIdentifier` create failure (issue [#1386](https://github.com/go-to-k/cdkd/issues/1386))** — `src/provisioning/providers/codebuild-provider.ts`, unit tests, and the `tests/integration/ci-cd/` fixture. **The class:** `mapSource` / `mapProperties` build FRESH SDK objects, so every CFn sub-key they do not name is dropped by the SDK serializer with the deploy still reporting success — the #1373 nested-key class, in a provider not yet under that critic's `NESTED_KEY_TARGETS`. **Wired (create AND update — both share `mapProperties`, and the drop is WORSE on update because `UpdateProject` is read-modify-write, so an unnamed sub-key was actively WIPED off a live project):** `Source.Auth.{Type,Resource}` -> `auth`, `Source.GitSubmodulesConfig.FetchSubmodules` -> `gitSubmodulesConfig` and `Source.BuildStatusConfig.{Context,TargetUrl}` -> `buildStatusConfig` (both synthesized by the CDK L2, so daily-pattern surfaces), `Environment.Fleet.FleetArn` -> `fleet`, `Environment.DockerServer.{ComputeType,SecurityGroupIds}` -> `dockerServer`, `Cache.CacheNamespace` -> `cacheNamespace`. **Bonus fix found while wiring:** `Source.SourceIdentifier` was never mapped at all, and AWS REQUIRES it on every `SecondarySources` entry — so any template using secondary sources failed `CreateProject` outright (a loud failure, not a silent drop, which is why #1386's static sweep missed it). `SecondarySources` route through the same `mapSource` helper, so all four Source sub-keys cover them automatically (pinned by a test). **Read side:** `readCurrentState` reverse-maps each newly-wired sub-key emit-when-present, so the drift baseline stays symmetric with the AWS-current snapshot — except `Source.Auth`, deliberately excluded because `BatchGetProjects` echoes it back partially and emitting a partial shape would fire phantom drift on every project that sets it (documented in-code). **Not mapped:** `Environment.HostKernel` exists in the CFn registry schema but has NO member anywhere in the installed `@aws-sdk/client-codebuild` `dist-types` tree, so there is nothing to map it onto until an SDK bump; it is a NESTED key, so it can live in neither `unhandledByDesign` (top-level-only) nor `NESTED_KEY_TARGETS` (CodeBuild is not a target yet — tracked on #1393), and an in-code comment naming both is the record. `handledProperties` is unchanged (all seven sit under the already-declared top-levels `Source` / `Environment` / `Cache`), so `property-coverage.generated.ts` correctly needed no regeneration. **Verified against the authoritative schemas, not the issue text:** every SDK member name and nesting read off `@aws-sdk/client-codebuild`'s `models_0.d.ts`, every CFn spelling off the live `cloudformation:DescribeType` registry schema (the repo's captured `AWS-CodeBuild-Project.json` fixture stores top-level names only). **Live-probed before the fixture was written** — a CODEPIPELINE-typed source REJECTS both nested Source sub-blocks ("Git submodules config is not supported for CodePipeline source" / "Source type CODEPIPELINE does not support BuildStatusConfig"), so the pipeline-fed project cannot carry them; the fixture adds a SECOND standalone L1 `CfnProject` on a PUBLIC GitHub source, the cheapest shape that accepts both and needs no source credential. `verify.sh` asserts all four covered values off a SINGLE `batch-get-projects` blob (so a throttle cannot make some assertions pass while others silently vanish) and gone-checks the new project after destroy. `Environment.Fleet.FleetArn` (paid reserved-capacity fleet), `Environment.DockerServer` (billable docker server) and `Source.Auth` (needs a connected source credential) are unit-pinned but deliberately not in the fixture. **Tests:** 7 new provider units, binding-proofed by reverting the real provider (5 of the 7 fail without the fix; the 2 that pass both ways are the intentional "omits the members when unset" regression guards).
Expand Down
68 changes: 60 additions & 8 deletions src/provisioning/providers/dynamodb-globaltable-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -776,6 +776,32 @@ export class DynamoDBGlobalTableProvider implements ResourceProvider {
);
}

// Resolved BEFORE the flat-field block below (step 4 re-reads them for
// the flip itself). The on-demand reset in that block has to know the
// billing mode on BOTH sides — see `onDemandCeilingLive` below.
const oldBilling =
(previousProperties['BillingMode'] as string | undefined) ?? 'PAY_PER_REQUEST';
const newBilling = (properties['BillingMode'] as string | undefined) ?? 'PAY_PER_REQUEST';
// The table-level on-demand ceiling is only a live, resettable setting
// when the table is on-demand on BOTH sides of this deploy.
//
// A "not flipping" test is NOT enough, and getting that wrong introduces
// a failure where none existed: PROVISIONED -> PROVISIONED is also "not
// flipping", so a provisioned template that carried
// `WriteOnDemandThroughputSettings` (legal in the CFn schema; reachable
// from hand-authored L1 and from state written before this fix) and then
// drops it would send `OnDemandThroughput` on a PROVISIONED table and
// earn a ValidationException — where the pre-fix behavior was a correct
// no-op, since the ceiling was never live there in the first place.
//
// Requiring PAY_PER_REQUEST on both sides subsumes the flip suppression
// too: a flip makes the two sides differ, so at least one is not
// PAY_PER_REQUEST. Dropping the block while moving to PROVISIONED is the
// natural template edit, not a clear request, and step 4's flip owns
// that call.
const onDemandCeilingLive =
oldBilling === 'PAY_PER_REQUEST' && newBilling === 'PAY_PER_REQUEST';

// 3. Non-conflicting flat fields in one combined UpdateTable.
// AWS allows combining these in a single call because they don't
// conflict with each other or with each other's modes.
Expand Down Expand Up @@ -876,10 +902,36 @@ export class DynamoDBGlobalTableProvider implements ResourceProvider {
const wodts = properties['WriteOnDemandThroughputSettings'] as
| Record<string, unknown>
| undefined;
if (wodts?.['MaxWriteRequestUnits'] !== undefined) {
flatUpdate.OnDemandThroughput = {
MaxWriteRequestUnits: Number(wodts['MaxWriteRequestUnits']),
};
const previousWodts = previousProperties['WriteOnDemandThroughputSettings'] as
| Record<string, unknown>
| undefined;
// Gate on the RAW presence of the key, not on whether it coerces:
// "present but unparseable" (an unresolved intrinsic) must keep
// reaching AWS as it always did, so the request fails loudly. Routing
// it into the reset branch below would silently CLEAR the ceiling the
// template was trying to set — a worse outcome than the noisy failure,
// and the exact silent-wrong-action class this fix exists to remove.
const rawMaxWrite = wodts?.['MaxWriteRequestUnits'];
if (rawMaxWrite !== undefined) {
flatUpdate.OnDemandThroughput = { MaxWriteRequestUnits: Number(rawMaxWrite) };
flatChanged = true;
} else if (onDemandCeilingLive && previousWodts?.['MaxWriteRequestUnits'] !== undefined) {
// The template DROPPED the table-level on-demand write ceiling.
// Omitting the field left the old ceiling live in AWS forever while
// cdkd reported success — the absent-field-reset silent-drop class
// (#1160), the table-level sibling of the per-GSI case #1423 fixed.
//
// `-1` is the reset sentinel. Unlike the per-GSI action (where it
// had to be discovered by probe) AWS documents it for the
// table-level `OnDemandThroughput`, and it was LIVE-VERIFIED anyway
// (issue #1434, us-east-1): `UpdateTable` with
// `OnDemandThroughput: {MaxWriteRequestUnits: -1}` was accepted and
// `DescribeTable` afterwards returned `{MaxReadRequestUnits: 100}` —
// the dropped member cleared, the untouched read sibling preserved.
// A follow-up `{MaxReadRequestUnits: -1}` removed the block entirely.
// So the reset reads back as ABSENCE, never as -1, and any drift /
// read-back comparison must expect that.
flatUpdate.OnDemandThroughput = { MaxWriteRequestUnits: ON_DEMAND_LIMIT_RESET };
flatChanged = true;
}
}
Expand All @@ -892,10 +944,10 @@ export class DynamoDBGlobalTableProvider implements ResourceProvider {
// Defaults must match `create()` (line 183: `PAY_PER_REQUEST`) so
// a template with no explicit `BillingMode` doesn't false-fire
// a PROVISIONED → PAY_PER_REQUEST diff on every update of a
// PAY_PER_REQUEST table.
const oldBilling =
(previousProperties['BillingMode'] as string | undefined) ?? 'PAY_PER_REQUEST';
const newBilling = (properties['BillingMode'] as string | undefined) ?? 'PAY_PER_REQUEST';
// PAY_PER_REQUEST table. Both are resolved just above step 3, which
// needs the same pair to suppress its on-demand reset during a flip —
// ONE binding, so the two sites cannot disagree about what "flipping"
// means.
// GSIs whose ProvisionedThroughput was already applied as part of the
// BillingMode flip below — step 6 must not re-issue an Update for them.
const gsiHandledByBillingFlip = new Set<string>();
Expand Down
Loading