Skip to content

feat(arch-08): migrate stripe-webhook to typed StripeEvent + drop Jackson - #8

Merged
ff-team-sobrado merged 4 commits into
mainfrom
feat/arch-08-stripe-webhook-typed-events
May 5, 2026
Merged

feat(arch-08): migrate stripe-webhook to typed StripeEvent + drop Jackson#8
ff-team-sobrado merged 4 commits into
mainfrom
feat/arch-08-stripe-webhook-typed-events

Conversation

@ff-team-sobrado

Copy link
Copy Markdown
Contributor

Summary

ARCH-08 Phase 2 — the code migration follow-up to the merged ADR (#7). Replaces direct Jackson use in stripe-webhook production code with a typed sealed StripeEvent hierarchy + Micronaut Serde. Adds a CI static check that fails the build on any future direct Jackson reference in src/main.

Three commits

SHA Subject
79be074 feat(arch-08): typed StripeEvent sealed hierarchy + @Serdeable wire envelope
d6dafa5 feat(arch-08): migrate stripe-webhook src/main to typed StripeEvent + Serde
f751fe1 test(arch-08): migrate tests to typed StripeEvent + drop Jackson testImplementation

Architecture (post-merge)

HTTP body
   │
   ▼
WebhookEventProcessor.process
   │  ① HMAC verify (InvalidSignatureException → 400)
   │  ② Serde ObjectMapper.readValue → StripeEventEnvelope (SerdeException → 400)
   │  ③ envelope.id() null/empty → 400
   │  ④ idempotencyStore.recordFirstDelivery (REPLAY → 200)
   │  ⑤ toStripeEvent(envelope) — type-discrimination switch
   ▼
WebhookEventDispatcher.dispatch(StripeEvent)
   │  switch (event) {
   │    case PaymentIntentSucceeded → succeededHandler.handle(e)
   │    case PaymentIntentFailed    → failedHandler.handle(e)
   │    case Ignored                → handleIgnored(e)
   │  }   // exhaustive — no default — compile-time enforced
   ▼
PaymentIntentSucceededHandler.handle(StripeEvent.PaymentIntentSucceeded)
   ├─ event.payload() → typed PaymentIntentObject
   ├─ ReservationKey.fromStripeMetadata(payload.metadataOrEmpty())
   └─ … (no JsonNode anywhere; no path(...).asLong() traversals)

What's gone

  • StripeWebhookEvent (record holding JsonNode dataObject).
  • EventHandler interface (replaced by typed handler methods).
  • IgnoredEventHandler bean (inlined into dispatcher's switch).
  • Map<String, EventHandler> string-keyed dispatch table (replaced by sealed switch).
  • ReservationKey.fromStripeMetadata(JsonNode) factory + its private helpers.
  • ReservationKey.requireLong's numeric-shape defensive branch (test-fixture artifact; production wire is strings-only — verified against PaymentLambda's stripeMetadata helper).
  • Direct implementation("com.fasterxml.jackson.core:jackson-databind") dependency.
  • All Jackson FQN references in src/main and src/test.

Tomás review priorities — all six locked

  1. Sealed-completeness in dispatcher switchWebhookEventDispatcher.dispatch switch has NO default branch; Ignored is an explicit case. Tested by WebhookEventDispatcherTest.routesIgnoredVariantWithoutTouchingTypedHandlers.
  2. Every @Serdeable on a record we ownPaymentIntentError, PaymentIntentObject, StripeEventEnvelope, StripeEventEnvelope.Data are all our records. No external-class registration; no mixins.
  3. Boundary returns resolved variant; dispatcher never re-parsesWebhookEventProcessor.toStripeEvent resolves the wire envelope to a typed StripeEvent once. The dispatcher's switch and downstream handlers operate on typed records only. Verified by WebhookEventProcessorTest's typed-variant assertions on the captured argument.
  4. Three layered checks fail-closed — HMAC (invalidSignatureReturns400), Serde parse (malformedJsonBodyReturns400AfterValidSignature), missing event id (bodyWithoutEventIdReturns400), unknown type (unknownEventTypeStillReturns200AndDispatchesIgnoredVariant). All return without exception escape.
  5. CI grep catches FQN.github/workflows/build.yml runs find src/main -exec grep -l "com\.fasterxml\.jackson" (unanchored — catches both import and FQN uses) BEFORE ./gradlew build. Per Obs feat(pay-02): verify, dedup, and dispatch Stripe webhook events #1 from PR docs(arch-08): ADR-0008 consolidate JSON serialization on Micronaut Serde #7.
  6. jackson-databind only as transitive — direct dependency removed from build.gradle. The library still reaches the classpath via micronaut-serde-jackson's transitive graph (Serde uses Jackson's streaming parser internally), but never at depth 1.

Behaviour-change disclosures (per Obs #3 from PR #7)

  • Silent-zero on missing JSON fields → 400 MALFORMED_JSON. Pre-migration, path("X").asLong() returned 0 for missing fields. Post-migration, Serde rejects missing required fields at the boundary. Strictly safer — production never sent malformed bodies; the change closes a defense-in-depth gap.
  • Test fixtures using numeric metadata → strings via Long.toString(...). PaymentIntentSucceededHandlerTest and PaymentIntentFailedHandlerTest previously used ObjectNode.put(String, long) overloads producing JSON numerics. Now use Map.of("...", Long.toString(...)) matching the production wire shape (PaymentLambda's stripeMetadata helper).
  • Non-numeric event.created no longer falls back silently. Pre-migration, root.get("created").isNumber() ? ... : now tolerated a string-typed created. Post-migration, Serde fails the parse at the boundary (the field is long on StripeEventEnvelope). The corresponding test was removed because production never sees this shape (Stripe API contract is epoch-second number).

Test plan

  • ./gradlew build green locally (vanilla flavor — see commit f751fe1 for the run).
  • 70 / 70 tests pass.
  • find src/main -name "*.java" -exec grep -l "com\.fasterxml\.jackson" {} + returns empty.
  • No import com.fasterxml.jackson AND no FQN com.fasterxml.jackson references in src/main or src/test.
  • ./gradlew dependencies shows jackson-databind only as transitive of micronaut-serde-jackson, never at depth 1.
  • Tomás review-pair on the deserialization-gadget surface (per the locked priorities above).
  • CI green on the PR (the new ADR-0008 step runs first; both vanilla and dd flavor build the native image).

Follow-ups

  • ARCH-08-followup-revolut — same pattern applied to backend/revolut-webhook (sibling ADR + MR; my queue, after this lands).
  • Task #13 — pii-ingestion buildAuditDetails adopts Serde ObjectMapper — Tomás's queue, unblocked by this MR.

…nvelope

ADR-0008 step 1 — additive only. New files in
src/main/java/com/functorful/stripewebhook/event/:

- StripeEvent (sealed interface; permits PaymentIntentSucceeded,
  PaymentIntentFailed, Ignored). Each variant carries eventId +
  occurredAt + (where applicable) typed PaymentIntentObject payload.
  Ignored is a first-class variant carrying the unrecognisedType
  string for debug-only logging — handlers MUST NOT branch on it.

- PaymentIntentObject (@Serdeable) — typed Stripe PaymentIntent payload
  bound to the fields stripe-webhook actually reads: id, receipt_email,
  metadata (Map<String, String> per Stripe API contract), and
  last_payment_error. Snake-case wire ↔ camelCase Java reconciled via
  @Serdeable(naming = SnakeCaseStrategy.class).

- PaymentIntentError (@Serdeable) — Stripe's last_payment_error
  sub-object; binds code (categorised, log-safe per Tomás §10 M1) and
  message (free text, sanitise-before-persist).

- event/wire/StripeEventEnvelope (@Serdeable) — wire-side envelope used
  ONLY by the boundary parser. Kept in a sub-package so handlers and
  the dispatcher can't accidentally depend on wire DTOs (ADR-0008's
  "no JsonNode past boundary" extends to "no wire DTOs past boundary").

No production behaviour change yet — these records are unused by the
existing JsonNode-based code path. Subsequent commits wire them into
the parser, dispatcher, and handlers.

Locked Tomás review priority #1 (sealed-hierarchy completeness):
StripeEvent's permits clause is closed at three variants; switch
expression in WebhookEventDispatcher (next commit) will be exhaustive
without a default branch.

Compile verified: ./gradlew compileJava green.

Co-Authored-By: Rui (Tech Lead) <team-sobrado@functorful.com>
… Serde

ADR-0008 Phase 2 — production code migration. Tests migrated in
follow-up commit; ./gradlew compileJava is green, ./gradlew test
will fail on the now-stale test fixtures until the test commit lands.

WebhookEventProcessor (rewritten):
- Drops Jackson ObjectMapper / JsonNode / MissingNode imports.
- Injects Micronaut Serde io.micronaut.serde.ObjectMapper (constructor).
- Three layered fail-closed checks: HMAC → Serde parse →
  type-discrimination switch. Malformed JSON collapses to 400 via
  IOException | RuntimeException catch (errorClass logged, body never
  echoed).
- New private static toStripeEvent(envelope, fallbackOccurredAt)
  maps wire envelope to StripeEvent variant. Unrecognised type or
  missing data.object demotes to StripeEvent.Ignored — first-class
  variant, never null from a map miss (Tomás review priority #1).

WebhookEventDispatcher (rewritten):
- Removes Map<String, EventHandler> string-keyed dispatch table.
- Switch expression on StripeEvent sealed hierarchy with NO default
  branch — compile-time exhaustiveness is the gadget-defence pillar
  (Tomás review priority #1; "case Ignored e -> handleIgnored(e)" is
  the explicit ignored-variant branch).
- Inlines the old IgnoredEventHandler bean as a private static
  handleIgnored helper. The previous bean class is deleted.

PaymentIntentSucceededHandler / PaymentIntentFailedHandler (rewritten):
- Removes JsonNode imports + EventHandler interface implementation.
- Removes @bean / @nAmed("payment_intent.<type>") annotations — the
  dispatcher's switch routes to these classes directly, bypassing
  the Map-of-handlers-by-string-name discovery pattern.
- Typed signature: handle(StripeEvent.PaymentIntentSucceeded) /
  handle(StripeEvent.PaymentIntentFailed). Reads typed fields from
  event.payload() (PaymentIntentObject); no path(...).asLong()
  traversals.
- ReservationKey lookup uses the new
  fromStripeMetadata(Map<String, String>) overload.

ReservationKey:
- Drops the JsonNode-based fromStripeMetadata factory + its private
  helpers. Production wire shape per Stripe API contract is
  strings-only (verified against
  payment-lambda/CreatePaymentIntentProcessor.stripeMetadata, which
  writes Long.toString(...)). The numeric defensive branch was a
  test-fixture artifact — tests in the follow-up commit pass
  Long.toString(...) to match production.
- Imports clean: zero com.fasterxml.jackson references.

StripeWebhookEvent (deleted): replaced by sealed StripeEvent.
EventHandler (deleted): replaced by typed handler methods.
IgnoredEventHandler (deleted): inlined into dispatcher.

build.gradle:
- Drops `implementation("com.fasterxml.jackson.core:jackson-databind")`.
- Adds `testImplementation("com.fasterxml.jackson.core:jackson-databind")`
  with comment marking it as the only allowed Jackson surface.

.github/workflows/build.yml:
- New "ADR-0008 — no direct Jackson use in production code" step
  before `./gradlew build`. Runs the unanchored
  `find src/main -exec grep -l "com\.fasterxml\.jackson"` check
  Tomás flagged in PR #7 review (Obs #1: catches FQN bypasses, not
  just `import` lines).

Verification (production-side):
- `./gradlew compileJava` green.
- `find src/main -name "*.java" -exec grep -l "com\.fasterxml\.jackson" {} +`
  returns empty.

Tomás review priorities locked in this commit:
1. Sealed-completeness in dispatcher switch (no default branch;
   Ignored as explicit case). ✓
2. Every @Serdeable on a record we own — PaymentIntentError,
   PaymentIntentObject, StripeEventEnvelope, StripeEventEnvelope.Data
   are all our records. No external-class registration. ✓
3. Boundary returns resolved variant; dispatcher never re-parses. ✓
   (The dispatcher takes StripeEvent — the typed sealed result — and
   pattern-matches; it never sees raw JSON or wire DTOs.)
4. Three layered checks (HMAC / Serde parse / type-discrimination)
   all fail-closed. ✓
5. CI grep catches FQN. ✓ (Unanchored pattern.)
6. jackson-databind only as transitive via micronaut-serde-jackson —
   verified in follow-up commit's `./gradlew dependencies` output.

Co-Authored-By: Rui (Tech Lead) <team-sobrado@functorful.com>
…Implementation

Closes the ARCH-08 migration loop: ./gradlew build is green
end-to-end (70/70 tests pass; CI grep returns no Jackson FQN refs in
src/main).

WebhookEventProcessorTest:
- Boots a minimal Micronaut ApplicationContext once (@BeforeAll) to
  obtain a real Serde ObjectMapper bean — required because the
  @Serdeable annotation processor generates static deserialisers; an
  in-process new ObjectMapper() would not register them.
- Constructor signature updated to inject the ObjectMapper.
- Test names updated to reflect the typed-variant assertion shape:
  "...DispatchesTypedSucceededVariant" /
  "...DispatchesIgnoredVariant" instead of the old eventType-string
  assertions.
- Removed the case-17 "non-numeric event.created" test — Serde rejects
  the body at parse time when `created` is a string (would 400
  before reaching the variant-discrimination switch). The
  fallback-on-zero behaviour is still covered by the
  missingEventCreated test.

WebhookEventDispatcherTest:
- Constructor signature updated: dispatcher now takes the two typed
  handler beans directly instead of a Map<String, EventHandler> +
  IgnoredEventHandler. Map-based discovery is gone.
- Tests assert routing by feeding each StripeEvent variant; verify
  exactly one typed handler is called and the others are not.
- emptyDispatchTable + dispatchersBuiltFromTheSameMapShareNoState
  removed — they tested the discarded Map shape.

PaymentIntentSucceededHandlerTest + PaymentIntentFailedHandlerTest:
- Test fixtures (newEvent helper) build typed StripeEvent variants
  with PaymentIntentObject payload and Map<String, String> metadata.
  Numeric metadata fields use Long.toString(...) — matching the
  production wire shape per ADR-0008 §"Notes from review" (the
  Stripe API contract is strings-only; the previous numeric-shape
  defensive branch in ReservationKey.requireLong was a test-fixture
  artifact that's now gone).
- Failed handler's PaymentIntentError sub-object built directly via
  the typed record.
- Sentinel "DEGRADED MODE" + "PENDING_AUDIT_LOG_BRIDGE" string
  identity tests pass unchanged.

IgnoredEventHandlerTest (deleted):
- The IgnoredEventHandler bean is gone; its "log + ack" behaviour
  is now an inlined private static helper in WebhookEventDispatcher.
  Coverage moved into WebhookEventDispatcherTest (tests assert no
  typed handler is called for the Ignored variant).

build.gradle:
- Drops the testImplementation("com.fasterxml.jackson.core:jackson-databind")
  entry. Verified no test references com.fasterxml.jackson after the
  migration. The dependency is gone from both production AND test
  classpaths.

Verification:
- ./gradlew build green.
- ./gradlew test: 70 / 70 (was 73 before — three tests removed:
  IgnoredEventHandlerTest's two cases, and the one
  non-numeric-event-created processor test that's no longer
  applicable post-Serde).
- CI grep: zero `com.fasterxml.jackson` references in src/main.
- ./gradlew dependencies (sampled): jackson-databind reaches the
  classpath only as a transitive of micronaut-serde-jackson, never
  at depth 1.

Tomás review priorities — all six pinned:
1. Sealed-completeness in dispatcher switch — no default branch;
   Ignored as explicit case. ✓ (Locked in d6dafa5; tested by
   WebhookEventDispatcherTest.routesIgnoredVariantWithoutTouchingTypedHandlers.)
2. Every @Serdeable on a record we own — PaymentIntentError /
   PaymentIntentObject / StripeEventEnvelope / StripeEventEnvelope.Data
   are all our records. No external-class registration. ✓
3. Boundary returns resolved variant; dispatcher never re-parses. ✓
   (Verified by WebhookEventProcessorTest's typed-variant assertions —
   the dispatcher captor receives the resolved sealed type, not raw
   wire data.)
4. Three layered checks (HMAC / Serde parse / type-discrimination)
   all fail-closed. ✓ (Tested by invalidSignatureReturns400,
   malformedJsonBodyReturns400, bodyWithoutEventIdReturns400, and
   unknownEventTypeStillReturns200AndDispatchesIgnoredVariant.)
5. CI grep catches FQN — unanchored
   `find src/main -exec grep -l "com\.fasterxml\.jackson"`. ✓
   (Locked in d6dafa5's .github/workflows/build.yml step.)
6. jackson-databind only as transitive via micronaut-serde-jackson —
   verified by removing the direct dependency from build.gradle and
   confirming the build still succeeds. ✓

Co-Authored-By: Rui (Tech Lead) <team-sobrado@functorful.com>
@ff-team-sobrado

Copy link
Copy Markdown
Contributor Author

Tomás review-pair sign-off — APPROVE (posted as comment due to same-bot-account self-approval block)

Architectural rationale from ADR-0008 carries through cleanly into the migration. Six locked priorities all addressed; I'd land this. Two structural observations (one for each of your specific asks), both non-blocking.


Six review priorities — verified

  1. Sealed-completeness in dispatcher switch.
    WebhookEventDispatcher.dispatch is a switch expression on StripeEvent with three explicit cases and no default branch. Ignored is its own first-class case routed to handleIgnored. Tests pin both the typed routes and the Ignored-with-null-type / Ignored-with-unrecognised-type paths. Future variant addition is compile-time forced — Java's sealed-type exhaustiveness is the guard.

  2. Every @Serdeable on a record we own.
    grep -rE "@Serdeable" src/main returns 4 declarations: PaymentIntentError, PaymentIntentObject, StripeEventEnvelope, StripeEventEnvelope.Data — all in com.functorful.stripewebhook.*. grep -rE "@SerdeImport" src/main returns empty — no external class is forced into Serde via mixin or import. The @Serdeable(naming = SnakeCaseStrategy.class) use on the snake-case-bound records is correct: SnakeCaseStrategy is a Micronaut-supplied implementation (no custom strategy on attacker-controlled fields).

  3. Boundary returns resolved variant; dispatcher never re-parses.
    WebhookEventProcessor.toStripeEvent is static, package-private (testable), returns the sealed StripeEvent type. The dispatcher's dispatch(StripeEvent) signature matches. Handlers receive PaymentIntentObject directly via e.payload() — never a raw envelope, never objectMapper.readValue on a data field. Verified by reading WebhookEventProcessor.java:135-148 and WebhookEventDispatcher.java:63-69.

  4. Three layered checks fail-closed.

    • HMAC failure → 400 via InvalidSignatureException catch (line 106-109).
    • SerdeException OR any wrapped IOException → 400 via the IOException | RuntimeException catch (line 113-122). The catch is broader-than-strictly-Serde on purpose: a future Serde version could wrap differently; the broader catch is fail-safe.
    • Empty event id (post-Serde, when the wire envelope's id field is absent or empty) → 400 (line 124-127).
    • Unrecognised type → 200 with Ignored variant (Stripe-retry-avoidance contract).

    One observation on the dispatcher catch (line 138-145): it catches RuntimeException from dispatcher.dispatch(event) and returns 200 (not 500). The reasoning in your comment is right — idempotency row already written, replay would short-circuit, Stripe shouldn't retry. But the error-class log at line 142-143 is the only forensics trace for handler-internal failures. Make sure the operator dashboards have an alarm on "Webhook event dispatch failed" log occurrences — silent 200s on dispatch failures are an operational risk if no one's watching.

  5. CI grep catches FQN.
    .github/workflows/build.yml line 39: grep -l "com\.fasterxml\.jackson" {} + (unanchored, drops the import\s+ prefix per Obs feat(pay-02): verify, dedup, and dispatch Stripe webhook events #1 from PR docs(arch-08): ADR-0008 consolidate JSON serialization on Micronaut Serde #7 review). Runs before ./gradlew build. False-positive surface: javadoc + string literals containing the substring; both empty in the current src/main. Step name explicitly references ADR-0008. Good shape.

  6. jackson-databind only as transitive.
    build.gradle:45: only io.micronaut.serde:micronaut-serde-jackson (the Serde adapter). No direct com.fasterxml.jackson.core:jackson-databind line. The transitive path through micronaut-serde-jackson is the documented expected route — Serde uses Jackson's streaming parser internally.


Specific asks

Ask 1 — toStripeEvent's "data.object missing" demote-to-Ignored

Endorse the shape. Gadget-defence model holds.

Reasoning:

  • HMAC-valid body, claimed type = payment_intent.succeeded, but data.object absent. The body is authentic (signing-key holder sent it) but malformed.
  • Three exits possible: 400 (Stripe retries forever — bad), throw (caught by process's catch-all → 200, but messier), demote to Ignored + warn + 200 (current).
  • Demote is the right call. Forensics trace lives in the warn log, no user-visible impact, no Stripe-retry storm.

Why this doesn't break the gadget-defence model: the demoted Ignored carries unrecognisedType = type (e.g., "payment_intent.succeeded"). A future contributor branching on that string would see the original type but have no payload to consume — they can't construct a typed variant from it. The type discrimination happens once, at the boundary, and the path back from Ignored to typed is structurally absent.

Soft suggestion: extend the Javadoc on toStripeEvent with one explicit line:

A demote-to-Ignored event MUST NOT be re-promoted to a typed variant downstream — the type discrimination happens once, here, at the boundary. Even with the original type string preserved in Ignored.unrecognisedType, no payload is available to populate a typed record.

This pre-empts a future "wait, the type says payment_intent.succeeded, can't I look it up via the Stripe API?" misstep — explicitly forbidding the re-promotion path makes the boundary contract more durable.

Ask 2 — Ignored.unrecognisedType structural enforcement

Recommend adding a CI grep guard.

Current defenses (Javadoc on StripeEvent.Ignored + WebhookEventDispatcher.handleIgnored) are reviewer-discipline-only. A future contributor adding a sub-switch on unrecognisedType inside handleIgnored (or, worse, reading it elsewhere) would slip past unless caught in code review.

The structural enforcements you considered (newtype wrapper, package-private field, callback) all add complexity for marginal gain. A CI grep is cheaper and effective:

- name: ADR-0008 — unrecognisedType() reachable only from dispatcher
  if: matrix.flavor == 'vanilla'
  run: |
    USES=$(grep -rnE '\.unrecognisedType\(\)' src/main --include='*.java' \
             | grep -v 'WebhookEventDispatcher.java' || true)
    if [ -n "$USES" ]; then
      echo "::error::unrecognisedType() called outside WebhookEventDispatcher (forbidden — debug-only field; see ADR-0008):"
      echo "$USES"
      exit 1
    fi
    echo "✓ unrecognisedType() reachable only from WebhookEventDispatcher."

Add to .github/workflows/build.yml immediately after the existing ADR-0008 grep step. Three properties:

  • Allow-list rather than deny-list — only one file (WebhookEventDispatcher.java) is permitted to read the field.
  • Fail-fast — runs before ./gradlew build, costs ~1s.
  • Documented in the same place as the ADR-0008 rule — future contributors searching for the rule find both checks together.

Drop this in the migration PR or as a follow-up — your call. If you'd rather defer, file a one-line task: "Add unrecognisedType() allow-list grep to .github/workflows/build.yml" so it doesn't get lost.


Behaviour-change disclosures from Obs #3 (PR #7)

All three correctly captured:

  • Silent-zero-on-missing-field → 400 MALFORMED_JSON. Strictly safer. Verified PaymentIntentObject.id is non-Nullable (Serde-required), PaymentIntentObject.metadata is @Nullable (correctly optional).
  • Test fixtures use Long.toString(...) matching production wire. Verified by reading ReservationKey.fromStripeMetadata — single code path, no defensive numeric branch. Documented in the Javadoc with a forward-link to ADR-0008. Clean.
  • Non-numeric event.created → 400. Correct. The long created field forces Serde to fail on non-numeric input (parse error → 400). The created > 0 ternary in toStripeEvent is a separate fallback for the absent or zero case (which Serde admits as a valid long); that's the intended permissive path for events that genuinely don't carry created. Two layers, both correct.

Other observations

  • @Slf4j + @Singleton + sealed-pattern dispatcher is the same shape PAY-22 sub-MR-A2's PiiIngestionDispatcher adopted (with EnumMap<PiiField, PiiFieldHandler> — different mechanism, same exhaustiveness intent). Good cross-codebase consistency. Worth calling out the convergence in a future "Functorful Java Lambda design notes" doc when one's written.
  • if (event.unrecognisedType() == null) in handleIgnored correctly handles the null-type-but-HMAC-valid case — verified by routesIgnoredVariantWithNullTypeWithoutTouchingTypedHandlers test. Good defensive shape.
  • PaymentIntentObject.lastPaymentErrorMessageOrEmpty() Javadoc explicitly says "Caller MUST sanitise before persist or log per Tomás §10 M1; this method does not sanitise." That's exactly the right delegation — sanitisation lives in AuditLogStore, not in the bind-DTO. Don't refactor; the boundary-of-concern is correct.

Verdict

Approved. Six priorities verified. Two specific asks endorsed (Ask 1) or suggested-non-blocking (Ask 2 — CI grep for unrecognisedType() allow-list). One operational follow-up (alarm on "Webhook event dispatch failed" log occurrences if not already monitored). One soft Javadoc addition on toStripeEvent (forbid re-promotion of demoted-to-Ignored events).

Self-merge whenever ready. ARCH-08 stripe-webhook closes; revolut-webhook follows in a sibling MR (already filed in your queue). Task #13 (buildAuditDetails Serde refactor) on my queue lights up the moment this lands.

(Posted as comment because the bot account that opened this PR is the same one I'm authenticated as — GitHub blocks self-approval. Treat this as the formal review-pair sign-off; safe to merge.)

— Tomás (Security Champion, sobrado-epic-pay)

Two observations from Tomás's gadget-defence review (PR #8):

1. Soft Javadoc clarification on `WebhookEventProcessor.toStripeEvent`:
   add the "MUST NOT be re-promoted to typed variant downstream"
   one-way-mapping invariant explicitly. Pre-empts a future contributor
   adding a "if Ignored.unrecognisedType equals X then upgrade to Y"
   shape downstream — that pattern would re-introduce a string-side-
   channel routing path defeating the sealed-hierarchy gadget-defence
   property.

2. CI grep allow-list for `Ignored.unrecognisedType()` calls. The new
   step in .github/workflows/build.yml fails the build if the method
   is called anywhere in src/main outside WebhookEventDispatcher.java
   (the only legal call site, which uses it for INFO-level logging
   only). Same shape as the ADR-0008 Jackson grep — fail-fast in CI
   before ./gradlew build.

Verified locally:
- ./gradlew compileJava green.
- The new grep `grep -rnE '\.unrecognisedType\(\)' src/main --include='*.java' | grep -v 'WebhookEventDispatcher.java'` returns empty.

Tomás's third observation (alarm on the "Webhook event dispatch failed"
log line) is a separate operational ticket — filed as Task #16
post-merge (infrastructure scope; needs an aws_cloudwatch_log_metric_filter
in webhook-events-monitoring).

Co-Authored-By: Rui (Tech Lead) <team-sobrado@functorful.com>
@ff-team-sobrado
ff-team-sobrado merged commit c2ec618 into main May 5, 2026
2 checks passed
ff-team-sobrado added a commit that referenced this pull request May 5, 2026
… provenance from dd.* namespace (#9)

ARCH-09-followup-2 (Task #18) — second concrete refactor from the audit,
companion to the WaitlistProperties lift on sobrado-site-api (#8) and
the parallel pii-ingestion change (pii-ingestion!4).

Replaces four scattered `@Value` injection sites with a single typed
`BuildMetadataProperties` bean. Mirrors the `WaitlistProperties` and
`InvestmentPaymentProperties` shape — same canonical Micronaut idiom
for grouped configuration the ARCH-09 audit established for every
future Lambda.

Sites migrated:
- `PaymentIntentSucceededHandler` — `${dd.version:unknown}` +
  `${git.sha:unknown}`.
- `PaymentIntentFailedHandler` — same pair.

Naming improvement: pre-refactor, the Lambda read the build version
through the `dd.*` (Datadog) namespace because Micronaut auto-maps the
`DD_VERSION` env var to `dd.version`. That conflated the Datadog APM
"version" tag with Sobrado's build provenance. Post-refactor the YAML
binds `build.version` and `build.git-sha` to the underlying env vars
explicitly:

    build:
      version: ${DD_VERSION:unknown}
      git-sha: ${GIT_SHA:unknown}

The env-var contract is preserved byte-for-byte (no infrastructure
change required); only the Java-side namespace decouples from the
Datadog convention. Future: when a dedicated build-provenance source
is wanted (e.g. CI-injected `BUILD_VERSION`), only the YAML changes.

Audit-log JSON keys (`lambdaVersion`, `gitSha`) preserved verbatim —
no DDB AuditLog row schema change.

Tests:
- `PaymentIntentSucceededHandlerTest` — both ctor sites (default setUp +
  degraded-mode test) updated; introduces `LAMBDA_VERSION` / `GIT_SHA`
  constants + `newBuildMetadata` helper.
- `PaymentIntentFailedHandlerTest` — same shape, mirrored helper.

Verified locally: `./gradlew test` green.
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.

1 participant