feat(mrf): MRF v4 minimal e2e — plumber native-envelope snapshot + reconstruction + gates (S4)#9777
Draft
kevin9foong wants to merge 7 commits into
Draft
Conversation
|
Tick the box to add this pull request to the merge queue (same as
|
kevin9foong
force-pushed
the
feat/9743-mrf-step-token-write-guard
branch
2 times, most recently
from
July 22, 2026 11:03
8d971f5 to
18ac15c
Compare
kevin9foong
force-pushed
the
feat/9744-mrf-v4-minimal-e2e
branch
from
July 22, 2026 11:04
d37b483 to
e814dc0
Compare
kevin9foong
force-pushed
the
feat/9743-mrf-step-token-write-guard
branch
from
July 22, 2026 11:14
1418a29 to
46e25af
Compare
kevin9foong
force-pushed
the
feat/9744-mrf-v4-minimal-e2e
branch
from
July 22, 2026 11:23
e814dc0 to
4ba04eb
Compare
Add the go-forward v4 submission-history S3 bucket as a compulsory startup var, mirroring ATTACHMENT_S3_BUCKET exactly (convict compulsoryVarsSchema with default:null so the app throws on boot if unset; surfaced on config.aws.submissionHistoryV4S3Bucket via the existing spread; no public bucket-URL wiring since it is server-side only). Wires the var through .env.example, the backend test env, docker-compose, the ECS task definition secrets, and init-localstack (local bucket create). SSM contract with infra slice S2a (#9753): /<ENVIRONMENT_SITE_NAME>/SUBMISSION_HISTORY_V4_S3_BUCKET. Part of PRD #9740 (MRF v4 Webhooks). Slice S4 (#9744). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… writer/reader
Deep, mostly-pure module owning the per-step snapshot object:
- submission-snapshot.schema.ts: the zod SubmissionSnapshot discriminated
union (v4/v1) with a `_v` envelope stamp as the single source of truth
(TS types inferred), plus the ONE fail-loud parser (parseSnapshot) — a
missing/malformed body or unknown `_v` all raise the SAME data-integrity
error carrying the stable code MRF_WEBHOOK_SNAPSHOT_DATA_INTEGRITY (the
string a Datadog monitor keys on).
- submission-snapshot.store.ts: the single key-builder
({formId}/{submissionId}/{submissionIndex}/{token}.json), a create-if-absent
writer (IfNoneMatch:'*') that retries with a fresh UUID on a 412 collision
(bounded; never a silent overwrite), and a point-read reader that surfaces
the same fail-loud error with no live-row fallback.
Behavioural + steering gates: single key-builder/parser, same-token collision,
and a graceful-skip Localstack smoke test.
Part of PRD #9740. Slice S4 (#9744).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
buildV4Snapshot freezes the native MRF envelope VERBATIM — the row's encryptedContent + THAT step's wrapped submission secret key (encryptedSubmissionSecretKey) + native verifiedContent when present — with no form-key re-encryption. Both content and the wrapped read key must be frozen because the row overwrites the keypair every step, so a superseded step is undecryptable without its own frozen key. Pure (no I/O). Round-trip test proves the frozen content decrypts back with the frozen wrapped key. Part of PRD #9740. Slice S4 (#9744). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The single pure place any webhook decision branches on the consumer:
(webhookType, webhookFormat, submissionIndex, submittedStepsLength) ->
{ contentShape, includeReadKey, includeStepToken }. Plumber is forced v4
(submission version 3); the wrapped read key ships on every v4 delivery
(all steps); the step token is reserved for plumber's latest step (computed
but not yet shipped — that is S11). Zapier is treated as generic by callers.
Part of PRD #9740. Slice S4 (#9744).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
reconstructMrfWebhookData((liveData, snapshot?, submissionIndex?, policy)) is the single seam that makes an initial send and a later retry byte-identical. With a submissionIndex the snapshot MUST exist (a missing one fails loud, never a silent live-row fallback); content/verifiedContent/read-key come from the FROZEN snapshot, submittedSteps is the slice(0, submissionIndex+1) prefix, and the submission version is DERIVED from contentFormat (v4->3). Without a submissionIndex it returns today's live-row payload unchanged. The read key is gated by policy.includeReadKey; step-token fields never appear in output. Part of PRD #9740. Slice S4 (#9744). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
V4 is the baseline content shape; the encryptSubmission gate now only downgrades to V3 (adaptV4ToV3, mrfVersion 1) per consumer class: - no-webhook form -> V4 (unchanged from today) - plumber webhook -> V3 today, V4 once `mrf-step-write-token` is on - generic (incl. zapier) -> V3/v1 unless the form opts into v4 (`enable-mrf-webhooks` && webhookFormat==='v4' && `mrf-step-write-token`); never ships an unguarded read key. Consumer class is derived from getWebhookType(url). Adds an optional `webhookFormat` to the FormWebhook settings type (type-only; the admin setting/UI is S9). Flag-off is byte-identical to today (plumber V3). Part of PRD #9740. Slice S4 (#9744). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tial send Wires the S4 modules into the MRF submission flow, all behind the two dormant flags: - Adds `snapshotToken` to submittedStepSchema (+ shared SubmittedStep type): the per-attempt UUID recorded on the winning step entry, the only link between the row and its S3 object. It is a key leaf only — never in a payload. - create/updateMultiRespondentFormSubmission enforce S3-first ordering (PUT snapshot -> commit step -> enqueue): when V4 && hasWebhookUrl && isRetryEnabled, freeze + write the v4 snapshot and record the winning token on the step BEFORE save; a failed PUT aborts the save (never commit-first). A lost __v race surfaces as 409; the loser's object is a benign orphan (no inline delete). - The send path (sendMrfInitialWebhookIfEligible) applies the send gate (plumber always; generic iff `enable-mrf-webhooks`), reconstructs the v4 payload from the recorded snapshot + policy, guards the serialized size against WEBHOOK_MAX_CONTENT_LENGTH, and fails loud (metric, no fallback) on a snapshot data-integrity error. createInitialWebhookSender takes an optional pre-built WebhookView so legacy paths stay byte-identical. Part of PRD #9740. Slice S4 (#9744). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
kevin9foong
force-pushed
the
feat/9743-mrf-step-token-write-guard
branch
from
July 22, 2026 11:43
46e25af to
ffe0223
Compare
kevin9foong
force-pushed
the
feat/9744-mrf-v4-minimal-e2e
branch
from
July 22, 2026 11:43
4ba04eb to
dc6b8b9
Compare
kevin9foong
marked this pull request as draft
July 22, 2026 11:51
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.
Problem
MRF webhooks today only ship to plumber, and as a V3-shaped payload read straight off the live submission row. Two things block the v4 rollout: (1) an MRF row is mutated in place every step, so a delayed webhook retry would re-read the row and deliver the latest step's bytes, not the step that actually fired; and (2) there is no machinery to snapshot a step, reconstruct its payload, or decide per-consumer what shape/keys to ship. This slice lays the minimal end-to-end v4 path — the tracer bullet the rest of the epic builds on — for the privileged plumber consumer only, on initial send.
Closes #9744. Part of PRD #9740 (MRF v4 Webhooks — Option A). Stacked on #9743 (step-token write-guard).
Solution
On initial send to a plumber v4 form, we now freeze an immutable native-envelope snapshot to a dedicated S3 bucket before the step commits, then reconstruct the webhook payload from
live row + snapshot. The v4 payload is the MRF envelope verbatim (content under the submission public key + the wrapped submission secret key as a read credential) — no re-encryption to the form key. Everything is dormant behind the V4 gate —answer-object-encryption(master) plus the consumer-class flags below (mrf-step-write-token,enable-mrf-webhooks); with V4 off, production is byte-identical to today.The correctness-critical logic lands as small, isolated, unit-tested modules (reviewable one commit at a time):
{formId}/{submissionId}/{submissionIndex}/{token}.json, through a single key-builder and a single fail-loud parser (missing / malformed / unknown_vall raise the same alarmableMRF_WEBHOOK_SNAPSHOT_DATA_INTEGRITY). Create-if-absent (IfNoneMatch:'*') with fresh-UUID collision retry.(liveRow, snapshot?) → WebhookData; with asubmissionIndexthe snapshot must exist (fail loud, never a live-row fallback), version is derived (v4→3), read key comes from the frozen snapshot; without one it returns today's live-row payload unchanged. This one function is what makes initial-send and retry byte-identical.S3-first ordering (atomicity by ordering, not a shared txn)
sequenceDiagram participant R as Respondent participant BE as Backend participant S3 as V4 snapshot bucket participant DB as Mongo row participant WH as Plumber webhook R->>BE: PUT next-step submission BE->>S3: PUT snapshot, create-if-absent Note over BE,S3: fresh-UUID retry on collision. A failed PUT aborts the save. BE->>DB: save, recording the winning snapshotToken on the step Note over DB: lost version race becomes 409. Loser object is a benign orphan, no wipe. BE->>WH: reconstruct from row and snapshot, then send v4 payload Note over BE,WH: missing or malformed snapshot fails loud, never a live-row fallbackV4 gate — delivery and content shape
One diagram for both decisions a webhook goes through: whether it is delivered (the send gate) and what shape it carries (the encryption gate).
flowchart TD A[MRF submission] --> M{"answer-object-encryption ON? (master)"} %% AOE off — no V4 anywhere; shape is always V3 M -->|no| B0{"Webhook type?"} B0 -->|plumber| SV3["Delivered · V3 (mrfVersion 1)"] B0 -->|generic or zapier| G0{"enable-mrf-webhooks ON?"} G0 -->|yes| SV3 G0 -->|no| NOSEND["Not delivered"] %% AOE on — V4 possible, per consumer class M -->|yes| B{"Webhook type?"} B -->|none| NW["V4 stored · no webhook (mrfVersion 2)"] B -->|plumber| P{"mrf-step-write-token ON?"} P -->|yes| SV4["Delivered · V4 native envelope (mrfVersion 2)"] P -->|no| SV3 B -->|generic or zapier| G{"enable-mrf-webhooks AND webhookFormat = v4 AND mrf-step-write-token?"} G -->|yes| SV4 G -->|no| G0answer-object-encryptionis the master switch (PRD #9740 §D13): no form ships V4 while it is off, and post the V4-BE revert (#9775) stored responses are V3-shaped, so a V4 form upgrades viaadaptV3ToV4(mrfVersion 2) while every other path stays V3 (mrfVersion 1). Plumber is never gated byenable-mrf-webhooksin either dimension — it is always delivered to, and its shape depends only on AOE +mrf-step-write-token.enable-mrf-webhooksgates the generic/zapier branch alone: both whether it is delivered and (withwebhookFormat = v4) whether it is V4.Alternatives considered
{submissionIndex}key. Rejected — two concurrent writers to the same step would clobber each other, so reconstruction could return a rejected writer's bytes. A per-attempt UUID recorded on the winningsubmittedStepsentry means the loser's object is a harmless orphan and reconstruction always reads the winner.mrf-step-write-tokenflip also turns on the next-step write-guard (S3, S3 — MRF step-token write-guard (primitive, lifecycle, magic-link token, PUT gate, grace migration) #9743).Breaking Changes
No — backwards compatible. With V4 off, plumber still receives V3 and no-webhook forms still get V4 — byte-identical to today, no snapshot written, no backfill. Generic/zapier MRF forms receive no MRF webhook today, so gating generic delivery behind
enable-mrf-webhooks(default off) silences nothing that exists.Tests
Start from a 3-step workflow MRF form whose webhook URL is a plumber URL (
https://plumber.gov.sg/webhooks/...). Requires the V4 bucket provisioned/localstack-created (see #9753 /init-localstack.sh).TC1: Plumber v4 initial send writes a snapshot and delivers the native envelope
answer-object-encryptionandmrf-step-write-tokenON. Submit step 1.SUBMISSION_HISTORY_V4_S3_BUCKETat{formId}/{submissionId}/0/{token}.jsonand the row'ssubmittedSteps[0].snapshotTokenmatches that token.encryptedContentequals the row's, carriesencryptedSubmissionSecretKey,version === 3, and has nocontentFormat/ no step-token fields.encryptedSubmissionSecretKey.TC2: Flag-off parity (byte-identical to today)
mrf-step-write-tokenOFF, run a full plumber MRF fill/advance.mrfVersion 1), NO snapshot is written, and the body matches today's delivery.TC3: Fail-loud on a missing/corrupt snapshot
mrf.snapshot.data_integrity_errormetric is emitted (the string a Datadog monitor keys on).TC4: Concurrent same-step submit → single winner
submittedStepsentry is appended; the loser gets HTTP 409 (not a 5xx); the two attempts wrote to distinct S3 keys; and the delivered payload is the winner's bytes.TC5: Payload-size guard
WEBHOOK_MAX_CONTENT_LENGTH(1 MB).mrf.webhook.payload_too_largemetric is emitted. Also confirm a realistic multi-step MRF stays within the limit.TC6: Send gate for generic
enable-mrf-webhooksOFF, submit a step → confirm NO webhook fires.enable-mrf-webhooksON → confirm the webhook fires.TC7: Config is compulsory
SUBMISSION_HISTORY_V4_S3_BUCKETand boot the backend → confirm it throws a clear startup validation error.