Skip to content

feat(forms): add durable delivery receipts and retries#2222

Closed
AaronBakerDev wants to merge 3 commits into
emdash-cms:mainfrom
AaronBakerDev:codex/forms-delivery-receipts
Closed

feat(forms): add durable delivery receipts and retries#2222
AaronBakerDev wants to merge 3 commits into
emdash-cms:mainfrom
AaronBakerDev:codex/forms-delivery-receipts

Conversation

@AaronBakerDev

@AaronBakerDev AaronBakerDev commented Jul 23, 2026

Copy link
Copy Markdown

What does this PR do?

Adds a durable Forms delivery outbox so persisted submissions have stable submission and receipt IDs, bounded delivery leases, retry/backoff state, terminal outcomes, and sanitized receipt/health readback. Email and webhook delivery use the same stored plan, while webhook requests carry a stable idempotency key. The plugin self-heals its delivery task for already-active installations before accepting a new submission.

EmDash plugin storage does not expose compare-and-set. Claims therefore use a durable lease, unique token, and read-back verification. The implementation documents the residual cross-isolate duplicate window; strict exactly-once effects still require provider-side idempotency.

Closes #

Type of change

  • Bug fix
  • Feature (requires maintainer-approved Discussion)
  • Refactor (no behavior change)
  • Translation
  • Documentation
  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes
  • pnpm lint passes
  • pnpm test passes (or targeted tests for my change)
  • pnpm format has been run
  • I have added/updated tests for my changes (if applicable)
  • User-visible strings in the admin UI are wrapped for translation (if applicable). Do not include messages.po changes except in translation PRs — a workflow extracts catalogs on merge to main.
  • I have added a changeset (if this PR changes a published package)
  • New features link to an approved Discussion: https://github.com/emdash-cms/emdash/discussions/...

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: OpenAI Codex (GPT-5)

Screenshots / test output

  • Forms tests: 39/39 passed.
  • Forms package typecheck passed.
  • Type-aware lint and formatting passed.
  • Includes a minor changeset for @emdash-cms/plugin-forms.

@changeset-bot

changeset-bot Bot commented Jul 23, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 6ec9b7e

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 5 packages
Name Type
@emdash-cms/plugin-forms Minor
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-demo-site Patch
@emdash-cms/do-demo-site Patch
@emdash-cms/do-solo-demo-site Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

github-actions Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

All contributors have signed the CLA ✍️ ✅
Posted by the CLA Assistant Lite bot.

@github-actions github-actions Bot added review/needs-review No maintainer or bot review yet area/plugins size/XL labels Jul 23, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Scope check

This PR changes 1,350 lines across 14 files. Large PRs are harder to review and more likely to be closed without review.

If this scope is intentional, no action needed. A maintainer will review it. If not, please consider splitting this into smaller PRs.

See CONTRIBUTING.md for contribution guidelines.

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the right change for the stated problem. Moving form delivery from synchronous fire-and-forget sends to a versioned outbox with durable receipts, bounded leases, backoff, and provider-side idempotency headers fits EmDash’s plugin model and the documented storage constraints. The implementation is careful about sanitizing the public receipt/health responses and the test coverage is broad.

I did not run the test suite, linter, or build; the reported test/typecheck/lint results are unverified.

There are several AGENTS.md convention violations and one contract issue that should be addressed before merge:

  • packages/plugins/forms/src/outbox.ts uses a module-scope let as a singleton (deliveryRun), which AGENTS.md forbids because Vite can duplicate modules across SSR chunks. It should live on globalThis keyed by Symbol.for.
  • The CAS comment in outbox.ts and the self-heal comment in submit.ts are justification/narrative comments; AGENTS.md says comments should not explain rejected alternatives or justify a decision.
  • The submit handler now calls ctx.cron.list() on every public submission, adding a query to the logged-out hot path. AGENTS.md treats this as presumed wrong; the self-heal goal is valid but the per-submit cost needs to be avoided (e.g. via a KV flag, an upgrade hook, or scheduling idempotently without a read).
  • Honey-pot submissions return submissionId/receiptId but never persist a submission, so the returned receipt ID is unresolvable. This contradicts the PR’s stated contract that IDs are returned only after the delivery plan is durably stored.

None of these are security/data-loss blockers, so I’m leaving this as comment rather than request_changes, but the AGENTS.md items are real findings under the repo’s own rubric.


Findings

  • [needs fixing] packages/plugins/forms/src/outbox.ts:369

    Module-scope mutable singletons are prohibited by AGENTS.md because Vite can duplicate modules across SSR chunks, so a plain let may become more than one variable. deliveryRun is used to serialize handleDelivery calls and should live on globalThis behind a Symbol.for key instead.

    const deliveryRunKey = Symbol.for("emdash:forms:deliveryRun");
    
    function getDeliveryRun(): Promise<void> {
    	return ((globalThis as Record<symbol, unknown>)[deliveryRunKey] as Promise<void> | undefined) ?? Promise.resolve();
    }
    
    function setDeliveryRun(run: Promise<void>): void {
    	(globalThis as Record<symbol, Promise<void>>)[deliveryRunKey] = run;
    }
    
  • [needs fixing] packages/plugins/forms/src/outbox.ts:236-241

    This comment explains the rejected approach (compare-and-set), narrates the duplicate-window trade-off, and justifies the design with "therefore required". AGENTS.md says comments should not justify decisions, narrate rejected alternatives, or address reviewers. Trim it to a single invariant if the residual concurrency risk is non-obvious, or delete it.

  • [needs fixing] packages/plugins/forms/src/handlers/submit.ts:112-115

    This comment justifies why the cron scheduler is checked inside the submit handler. AGENTS.md prohibits comments that explain a decision rather than a non-obvious invariant. The code already states what it does; the surrounding block could be extracted to a named helper like ensureDeliveryCron() so the code explains itself, and the comment can be removed.

  • [needs fixing] packages/plugins/forms/src/handlers/submit.ts:119

    Calling ctx.cron.list() on every public form submission adds a new query to the logged-out hot path. AGENTS.md is explicit that any increase in logged-out query counts is presumed wrong and needs a really good reason. The self-heal goal is legitimate, but this should not be paid on every submit: consider using a cached/process-level guard, a KV flag set once after activation, or an EmDash upgrade hook so the list call is not on the request path.

  • [needs fixing] packages/plugins/forms/src/handlers/submit.ts:93-100

    Honey-pot submissions return submissionId and receiptId but never persist a submission, so the receipt ID cannot be resolved later. This contradicts the PR’s stated contract that IDs are returned only after the delivery plan is durably stored. Either omit the IDs from the decoy response or generate the IDs after the honey-pot check.

    	if (settings.spamProtection === "honeypot") {
    		if (input.data._hp) {
    			// Honeypot triggered — return success silently
    			return { success: true, message: settings.confirmationMessage };
    		}
    	}
    

@github-actions github-actions Bot added review/needs-rereview Author pushed changes since the last review and removed review/needs-review No maintainer or bot review yet labels Jul 23, 2026
@AaronBakerDev

Copy link
Copy Markdown
Author

I have read the CLA Document and I hereby sign the CLA

@AaronBakerDev

Copy link
Copy Markdown
Author

recheck

@AaronBakerDev

Copy link
Copy Markdown
Author

Closing at the author's request. This upstream submission was opened without the repository owner's explicit approval.

@AaronBakerDev
AaronBakerDev deleted the codex/forms-delivery-receipts branch July 24, 2026 00:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/plugins cla: signed review/needs-rereview Author pushed changes since the last review size/XL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant