fix(pay-05): correct UserInvestmentStore PK shape + InvestmentPayment GSI-name default - #11
Merged
Merged
Conversation
… GSI-name default
Bundles two related PAY-05 Phase 2b corrections + a structural-prevention
test class, all surfaced by Tomás's Option (c) drill in DEV on 2026-05-12.
## Bug 1 — UserInvestmentStore PK shape (handler-side companion to MR !51)
Pre-fix the store built the put item with `userId` HASH +
`investmentId#investmentVersion` RANGE, assuming a composite base-table
key analogous to InvestmentReservation. Verified deployed schema via
`aws dynamodb describe-table`:
KeySchema: id (S, HASH) ← Amplify Gen 2 default UUID identifier
only — no sort key on the base table
The `investmentId#investmentVersion` attribute is in
AttributeDefinitions, but as the GSI sort key for
`UserInvestmentByUserAndInvestment`, not as a base-table key. Pre-fix
the put was missing the actual `id` HASH key, so the surrounding
TransactWriteItems cancelled with a ValidationException-class failure
at operation 3 in PaymentIntentSucceededHandler. Bug surface:
- Stripe `payment_intent.succeeded` arrives, signature-verifies
- Audit-log writer fires (PAY-08; post-verify, correct)
- InvestmentReservation load + status guard pass
- InvestmentPayment load + status guard pass
- TransactWriteItems with 4 ops: payment update, reservation update,
UserInvestment put (THIS), AuditLog put
- Op 3 cancels (missing HASH key) → atomic rollback → no DDB writes,
no SES email, but 200 to Stripe (TransactionCanceledException is
in the handler's exception catch per Tomás §10 R4)
- Reconciliation drift: Stripe has the charge, Sobrado has nothing.
Fix:
- Generate `id` (random UUID, same pattern as AuditLogStore).
- Drop `investmentId#investmentVersion` from PK construction. KEEP it
as a non-key item attribute so the `UserInvestmentByUserAndInvestment`
GSI remains queryable (Amplify writes the composite alongside the
scalar fields).
- Change conditionExpression from `attribute_not_exists(userId)` to
`attribute_not_exists(id)`. With a fresh UUID this is structurally
a tautology; the load-bearing duplicate-prevention is the
surrounding reservation-status guard in the TWI envelope (a replay
finds the reservation already `executed`, the conditional check
fails on the reservation Update, and the entire TWI rolls back —
no duplicate UserInvestment row is possible).
Empirical anchor:
software.amazon.awssdk.services.dynamodb.model.DynamoDbException:
The table does not have the specified index: investmentPaymentByReservation
(That message was Bug #2 — the GSI-name mismatch — fixed via
infrastructure MR !51. This MR is the handler-side companion fix on the
UserInvestment surface that would have been the NEXT failure when
re-drilled with the GSI-name fix in place.)
## Bug 2 (handler-default mirror) — InvestmentPaymentProperties
Pre-fix:
static final String DEFAULT_BY_RESERVATION_INDEX_NAME = "investmentPaymentByReservation";
Post-fix:
static final String DEFAULT_BY_RESERVATION_INDEX_NAME = "gsi-InvestmentReservation.payments";
Matches the Amplify Gen 2 auto-generated GSI naming convention
(`gsi-<ParentModel>.<relationshipField>`) for `hasMany` ↔ `belongsTo`
relations. Dormant tech-debt: the env var set by IaC (MR !51) overrides
this default in prd, so the broken default never actually fired at
runtime — but keeping the source-side default in sync with the
deployed-reality canonical name prevents future drift if a Lambda ever
runs without the env var explicitly set.
## Bug 0 (audit-discovered) — javadoc drift
Updated javadoc on `UserInvestmentStore`, `InvestmentPaymentStore`, and
`InvestmentPaymentProperties` to reflect the deployed reality. Cited
HYG-19 axis-4 + MR !51 + the drill anchor.
## Structural-prevention layer — new UserInvestmentStoreTest
Added a dedicated store-level test class with 6 tests asserting the
put-item shape against the deployed schema:
- putItemHasIdHashKeyAsRandomUUID
- putItemDoesNotIncludeCompositeAttributeAsBaseTableKey (regression
test for THIS bug class)
- putItemIncludesAllDomainAttributesAsScalars
- putItemHasAttributeNotExistsConditionOnIdHashKey
- putTargetsCorrectTable
- differentInvocationsProduceDistinctIds
This closes the structural gap that let the bug slip through PR review:
no store-level test asserted the put-item shape against the deployed
table's KeySchema. The handler tests mocked the store entirely (verifying
"the store's mock was called" but not "what the store actually produces").
HYG-19 axis-4 static-prevention layer literally points at this: a store-
test asserting put-item.keys ⊆ deployed-PK-attribute-set would have
caught BOTH bugs (GSI-name AND UserInvestment PK) at PR-time, not at
drill-time.
Broader-pattern follow-up flagged separately: all PAY-05 Phase 2b
stores (UserInvestmentStore + InvestmentReservationStore + AuditLogStore)
should have parallel store-level shape-assertion tests. Filing a
companion HYG-N ticket post-merge.
## Audit findings (no other bugs surfaced)
Cross-checked every PAY-05 Phase 2b DDB operation against deployed
schemas. Only the UserInvestment put was structurally wrong. Full
audit table in HYG-40 (Tomás filing); my findings in tomas/team-lead
DM thread 2026-05-12.
## Tests
- 82/82 tests pass (was 76; +6 new UserInvestmentStoreTests).
- `./gradlew --rerun-tasks test` clean run.
## HYG-19 axis-4 canonical anchor (post-cascade)
This bug pair (MR !51 + this MR) is THE canonical empirical anchor for
HYG-19 axis 4 ("new env vars consumed via @ConfigurationProperties or
@value" AND "handler-assumed shape silently passes startup, fails at
first runtime use of the DDB operation"). Will bank in HYG-19 Notion
ticket once PAY-08b cutover stabilizes.
## Coordination
- Tomás: security pair-review on the diff shape. No security surface
delta — same Lambda, same IAM, same KMS. Only the constructed put-item
shape and a properties-bean constant change.
- Miguel: Owner-tier approval per the GitHub-side identity-collapse
constraint (`feedback_github_bot_identity_collapse_routes_to_owner`).
- Tomás re-drills Row 2 with seeded fixtures (still in DEV DDB) post-merge.
## Refs
- Infrastructure MR !51 (companion GSI-name fix)
- HYG-19 Notion: https://www.notion.so/35ed485396648140bc0cd6a195c819eb
- HYG-40 Notion: (Tomás filing — PAY-05 Phase 2b structural audit
+ ongoing-drill discipline)
- Drill empirical anchor: Tomás DM 2026-05-12T19:03:45Z
- AuditLogStore — canonical UUID-id pattern reference
- WebhookEvent table — single-HASH-key reference pattern
🤖 Generated with [Claude Code](https://claude.com/claude-code)
miguelaferreira
approved these changes
May 12, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Companion to infrastructure MR !51 (gitlab). Tomás's PAY-05 Phase 2b Option (c) drill in DEV on 2026-05-12 caught TWO empirically-confirmed deploy-blocking bugs in the PAY-05 Phase 2b cascade:
GSI-name mismatch — IaC env-var set
INVESTMENT_PAYMENT_BY_RESERVATION_INDEX_NAME = "investmentPaymentByReservation"; Amplify Gen 2 actually generatedgsi-InvestmentReservation.payments. Fixed via MR !51 (IaC env-var bump).UserInvestmentStore PK-shape mismatch — handler built put item with
userIdHASH +investmentId#investmentVersionRANGE; deployed schema isidHASH only. TWI cancels at op 3 → 200 to Stripe + zero writes + reconciliation drift. Fixed in this PR.After bug 1 was fixed via MR !51, a re-drill of Row 2 would have hit bug 2 immediately. This PR is the handler-side companion fix needed to unblock the cutover. Plus a small dormant-tech-debt correction on the handler-default mirror.
What changes
UserInvestmentStore.javaidUUID HASH key; drop composite from PK construction (keep as non-key item attribute forUserInvestmentByUserAndInvestmentGSI sort key); conditionattribute_not_exists(id); rewrite javadoc to reflect deployed schemaInvestmentPaymentProperties.javaDEFAULT_BY_RESERVATION_INDEX_NAMEfrom"investmentPaymentByReservation"→"gsi-InvestmentReservation.payments"(matches MR !51's IaC env-var canonical value)InvestmentPaymentStore.javaUserInvestmentStoreTest.java(NEW)Verified deployed schema (the source of truth)
$ aws dynamodb describe-table --table-name UserInvestment-2mm6zfbtrngy5jqblv7nna6b2e-NONE --profile ff-dev { "Table": { "KeySchema": [{"AttributeName": "id", "KeyType": "HASH"}], "AttributeDefinitions": [ {"AttributeName": "id", "AttributeType": "S"}, {"AttributeName": "investmentId", "AttributeType": "S"}, {"AttributeName": "investmentId#investmentVersion", "AttributeType": "S"}, {"AttributeName": "investmentVersion", "AttributeType": "N"}, {"AttributeName": "userId", "AttributeType": "S"} ], "GlobalSecondaryIndexes": [ {"IndexName": "gsi-Investment.userInvestments", "KeySchema": [{"AttributeName": "investmentId", "KeyType": "HASH"}, {"AttributeName": "investmentVersion", "KeyType": "RANGE"}]}, {"IndexName": "UserInvestmentByUserAndInvestment", "KeySchema": [{"AttributeName": "userId", "KeyType": "HASH"}, {"AttributeName": "investmentId#investmentVersion", "KeyType": "RANGE"}]} ] } }PK is
id(S) HASH only — Amplify Gen 2 default UUID identifier. The compositeinvestmentId#investmentVersionattribute IS in AttributeDefinitions, but as the GSI sort key forUserInvestmentByUserAndInvestment, NOT as a base-table key.Audit findings (no other bugs surfaced)
Per team-lead's "audit before fix" directive: cross-checked every PAY-05 Phase 2b DDB operation against the deployed schemas of all 5 tables (InvestmentReservation, InvestmentPayment, UserInvestment, AuditLog, WebhookEvent). Only UserInvestmentStore was structurally wrong. All other stores (InvestmentReservationStore, InvestmentPaymentStore.findByReservationAndIntent/buildStatusUpdate, AuditLogStore, WebhookIdempotencyStore) verified correct.
Full audit table in Rui→team-lead DM thread 2026-05-12; will copy verbatim into HYG-40 once Tomás files it.
Test plan
idHASH key + composite attribute preservation + condition expression + table name + UUID-per-invocation distinctness./gradlew --rerun-tasks testclean run@Inject/@Singletonmodifier changes — handler-side store mutation onlybuild.gradledeps /--initialize-at-build-time/ native-image buildArgs changesexecuted; UserInvestment row visible viaUserInvestmentByUserAndInvestmentGSI queryWhy this is one PR (not split)
Three changes; all share the same empirical anchor (Tomás's 2026-05-12 drill); all touch the same
dynamodb/package; all needed for PAY-08b cutover to unblock cleanly. Splitting into three PRs would add Miguel-click overhead (3× Owner approval) for no review-quality benefit — the diffs are independent at the file level but causally bound at the cycle level. Bundling matches the "team drives to merge with drilling" discipline (feedback_team_drives_to_merge_with_drilling).Sequencing
rc0.0.11-<sha>→ manual retag tov0.0.12-ddviaaws ecr put-image(HYG-12 precedent shape; happens on GHCR pull-through-cache cycle).stripe_webhook_image_tagv0.0.11-dd → v0.0.12-dd.Refs
🤖 Generated with Claude Code