effx
Specifications

Spec 0028 — Contact one-operation migration, Gate 4

Status: operator-approved for local Contact implementation against mono-web 8152c389 (unpublished); no production cutover authority. This design is intentionally independent of the separately approved Admissions policy repair in spec 0027, queued until Contact finishes. Its clean, unlanded mono-web source baseline is 8152c389f1176ede38b70417bab1332b0a311c21 (unpublished) (unpublished). mw/ denotes mono-web repository-relative paths; unprefixed paths belong to effx. The operator-owned mono-web main remains read-only. The separate effx Contact design branch began at live main ce82917; if S5 or other authorized work advances main before this design lands, rebase this branch on that main. This approval authorizes the local Contact backend/homepage source cutover and its disposable checks, but no real provider call, token change, production action or mono-web branch landing (docs/specs/0004-monoweb-migration-plan.md:16-33,72-90,316-327; mw/AGENTS.md#verification-and-resources).

Outcome and why Contact is one distinct context

An anonymous visitor uses the existing /kontakt homepage form, chooses an active department and receives success only after the configured delivery transport accepts a message. The visitor never receives the backend Contact credential and never calls the native Contact operation directly. Replace exactly one handwritten external endpoint declaration and its raw backend binding with an effx-generated declaration and typed raw binding, in the existing contact group. Preserve its OpenAPI title Public contact, description, operation ID contact.submitContactMessage, method/path, custom middleware, ObjectCapability AccessSpec, bodyless 201, closed problems and SDK method. Contact owns a public React Router/Worker journey and no Foldkit program. Do not fold Contact into Admissions, create a contacts group, invent a second operation, or convert acceptance into inbox delivery (mw/packages/http-api/src/contact.ts:27-106; mw/apps/backend/src/contact/http.ts:60-91; mw/apps/homepage/src/routes/{_home.kontakt,_home.kontakt._index,_home.kontakt.$department}.tsx; docs/specs/0004-monoweb-migration-plan.md:32,77,324-325).

The one current operation is:

Qualified operationMethod, request and credentialExact existing AccessSpecSuccess and errors
contact.submitContactMessagePOST /api/contact-messages; JSON ContactMessage {departmentId,name,email,subject,message}; required branded canonical x-vektor-contact-ip; server-only x-vektor-contact-backend API-key security through ContactSsrSecurity.External; accepted ObjectCapability contact.submit, principal CapabilityHolder, capability One contact.submit, no requirements, resolver contact.department-recipient, Reveal, SnapshotRead. Not None/Anonymous, Better Auth cookie or OAuth Person.201, no body: the current success is precisely HttpApiSchema.WithHeaders(Schema.Void.pipe(HttpApiSchema.status(201)), {"cache-control": "no-store", vary: "Origin"}); no fabricated JSON body/content type, no ETag or Location. ContactProblem is the current ordered eight-code closed union.

The eight ordered codes are request.malformed, header.malformed, request.too-large, validation.failed, rate-limit.exceeded, contact.unavailable, media-type.unsupported, internal.error. NativeProblemRegistry owns code/status/body and Retry-After; middleware separately supplies the missing/invalid credential 401 and ContactSSR challenge. Preserve the distinct 400/413/415/422/429/500/503 behavior, the existing Retry-After: 3600 on 429, RFC 9457 no-store problem responses and CORS/Origin policy. The Contact payload field bounds and trimmed/control-free text come from the existing domain schemas (CONTACT_LIMITS), not a second copied list. ContactVisitorIp accepts only the canonical single IPv4/IPv6 identity; not a list, network, zone, port or spoofed forwarded address (mw/packages/http-api/src/contact.ts:29-97; mw/packages/http-api/src/http-semantics.ts:643-712,926-934,1392-1548; mw/packages/domain/src/contact/schema.ts:5-74; mw/apps/backend/src/contact/http.ts:25-35,69-88).

Bodyless 201 is not a generic create response

The native Contact success uses Schema.Void.pipe(HttpApiSchema.status(201)), not the ordinary 201 JSON create helper. TeamApplications previously proved a genuine Effect HttpApiSchema.NoContent/Schema.Void 204 projection in a disposable compiler fixture; that probe does not prove Contact's distinct 201. After approval, exercise one real Contact-schema NoContent-style 201 declaration against the installed pinned compiler and compare the rendered 201 OpenAPI response subtree to this handwritten source: status, absence of response content, required no-store/Vary headers, and no invented Location or body. The first-party backend response is new Response(null, {status: 201, headers: ...}); the existing homepage Effect SDK result maps success to a form acknowledgment, not to a payload. An actual compiler diagnostic requires a minimal real fixture and a report to the effx compiler owner; never hand-edit emitted code, replace Void with {}, or keep a second handwritten Contact router as a permanent compatibility shim (mw/packages/http-api/src/contact.ts:57-68; mw/apps/backend/src/contact/http.ts:74-89; mw/apps/homepage/src/lib/contact-message.server.ts:116-149; docs/specs/0023-team-applications-migration.md:49-57).

Observed compiler prerequisite (operator-approved, not a wire-contract concession). The first truthful seven-group Contact declaration on mono-web 8152c389 (unpublished) failed effx check --strict-access with EFFX2501: Command access cannot decide in a read snapshot: Contact is a Command because it consumes quota and awaits delivery, but its existing ObjectCapability/CapabilityHolder AccessSpec declares SnapshotRead and no mutable authority requirement (mw/packages/http-api/src/contact.effx.ts; mw/packages/http-api/src/contact-effx-adapters.ts; effx/packages/compiler/src/extensions/access-contract.ts:151-153; docs/specs/0024-declaration-density.md:66-67). A temporary Transaction probe proved that genuine bodyless 201, custom security and ordered problems generate, but would change access bytes and is not the solution. The operator approved a separate ADR-backed explicit diagnosed Command/SnapshotRead opt-in limited to this no-mutable-fact capability case; default EFFX2501 still rejects ordinary Command/SnapshotRead and invalid opt-ins get a diagnostic. Re-vendor only its clean, source-identified pack after S5; set the one declaration flag and require unchanged Contact AccessSpec, complete pre-Contact OpenAPI/SDK bytes and six prior group projections. No fake Transaction annotation, bypass, runtime fallback or presumed compiler pass. This is a compiler prerequisite to satisfy the already-approved Contact spec, not permission to change its authority or endpoint.

Two-hop authority and business ordering stay application-owned

visitor /kontakt React form
  → trusted homepage Worker ingress: distinct CONTACT_INGRESS_TOKEN + socket visitor address
  → React Router action: decoded bounded ContactMessage, selected active department
  → server-side generated SDK: CONTACT_BACKEND_TOKEN + canonical CONTACT_IP_HEADER;
    same configured backend origin, manual redirects only, no token forwarded to a new origin
  → native ContactSsrSecurity middleware: backend token comparison / challenge
  → Contact raw handler: strict JSON and typed IP
  → PostgreSQL fixed-hour visitor-IP quota commits
  → Organization resolves active department + deliverable mailbox
  → existing ContactDelivery/HttpClient Layer awaits delivery transport acceptance
  → 201 with empty body, no-store and Vary: Origin; no inbox-delivery claim
  • Different credentials on the two hops. The homepage ingress accepts only its controlled x-vektor-contact-ingress token and a separately configured, nonidentical x-vektor-contact-backend token; both must satisfy the existing minimum length. The trusted ingress derives and Schema-decodes the canonical visitor address from the socket edge, not browser-controlled X-Forwarded-For or an arbitrary x-vektor-contact-ip. API_URL is one exact HTTP(S) origin with no URL credentials. The server-side SDK refuses a cross-origin URL and a 3xx/opaque redirect before the backend secret can follow it. Backend ContactSsrSecurity validates the backend token in constant time when configured; its security scheme remains contactBackend with the exact header name. A missing/partial backend configuration can only answer contact.unavailable without quota/delivery; it does not create a permissive successful anonymous Contact operation (mw/apps/homepage/src/lib/{contact-context.server,contact-message.server}.ts:52-111,82-149; mw/packages/domain/src/contact/schema.ts:47-74; mw/packages/http-api/src/contact.ts:29-44; mw/apps/backend/src/http-api/transport.ts:271-296; mw/apps/backend/src/contact/{config,http}.ts:11-48,65-84).
  • Quota commits before delivery. Only a valid, decoded authenticated Contact command reaches submitContact; it consumes the canonical visitor-IP quota before Organization recipient lookup or provider I/O. PostgreSQL records at most five admitted attempts in a fixed one-hour window, without sliding expiry; concurrent contenders are serialized by its INSERT ... ON CONFLICT ... WHERE attempts < 5. Expired rows may be deleted separately. An invalid/inactive/non-mailbox department or rejected/ambiguous/unavailable delivery does not refund an admitted attempt. Body/media/header or security rejection before service execution does not count as an admitted message. Persist the quota row only; do not store message content or add an outbox, response receipt, automatic retry or new rate-limit system merely for effx parity. There is no Idempotency-Key on this operation, so do not claim exactly-once behavior after a lost 201 (mw/packages/domain/src/contact/service.ts:31-61; mw/packages/database/src/contact/postgres.ts:10-30; mw/packages/database/migrations/0030-native-contact-quota.sql:1-7; mw/apps/backend/src/contact/http.ts:69-88; mw/packages/http-api/src/contact.ts:57-97).
  • Delivery is an owned foreign boundary. ContactDelivery still selects the current active department's valid mailbox as to, submitted visitor email as replyTo, and the configured sender; its existing JSON envelope prefixes the subject and includes the submitted name/email/message. deliverJson waits for the configured loopback/provider HTTP transport acceptance and maps rejection/timeout to contact.unavailable after the quota charge. HTTP 201 means transport accepted the submission, not inbox delivery or a durable notification. Keep this Layer and its config at the backend composition root, not in effx-generated code; do not introduce an unrelated provider or credential (mw/packages/domain/src/contact/{schema,service}.ts; mw/apps/backend/src/contact/{http,config}.ts:37-58; mw/apps/backend/src/delivery/http.ts; mw/packages/http-api/src/contact.ts:92-96).

ContactSsrSecurity runs at ingress; generated access metadata alone cannot authenticate anyone. Bind a real, deferred, fail-closed generated guard to the existing verified middleware/Contact authority seam, preserving the current token check and quota-before-recipient ordering. The operation is an effx Operation.command because it consumes quota and attempts delivery, despite the existing AccessSpec's explicit SnapshotRead decision time; POST and AccessSpec metadata do not prove purity or create a transaction. If the compiler cannot express the exact custom ObjectCapability/CapabilityHolder/middleware/error combination, stop with the actual strict diagnostic and minimal real fixture rather than weakening it to None, an eager outer guard, or a no-op. The domain submitContact, SQL adapter, Web response, Worker action and host Layers remain authored by mono-web (mw/packages/http-api/src/contact.ts:29-97; mw/apps/backend/src/{contact/http,http-api/transport}.ts; mw/packages/database/src/contact/postgres.ts; docs/decisions/0006-query-is-a-semantic-claim.md).

Unmerged proposals are read-only evidence

fix/delivery-review-0926 at 31c82272 (unpublished) and experiment/domain-scope-0926 at efc6cdb3 (unpublished) are unmerged and do not replace the accepted 8152c389 (unpublished) contract. The first changes Contact backend config, quota/tests and homepage real-contact runner among wider delivery policy changes; the second reshapes domain Contact schema/service/tests. Their branch notes or diff are not a green accepted migration result. In particular a rolling/new quota, provider policy or schema rewrite cannot be silently folded into this fixed-hour/no-retry one-endpoint port; a genuine accepted-source defect needs a committed focused red HTTP/PostgreSQL/browser regression before a separate minimal repair and explicit policy authority. Neither branch is checked out or merged by this design (mw/packages/database/src/contact/postgres.ts; mw/apps/homepage/e2e/run-native-contact.mjs; docs/research/monoweb-branches.md:57-78; mw/AGENTS.md#delegation-and-landing).

Accepted runner and implementation gates — after approval, not yet run

The existing named journey does exist: just e2e contact dispatches the homepage's e2e:contact:native script to mw/apps/homepage/e2e/run-native-contact.mjs. Its first-party runner starts disposable PostgreSQL, a native backend, a built local homepage Worker, a token-protected loopback delivery sink and Chromium; it validates correct/wrong-hop credentials, canonical IP/spoofed headers, invalid input/recipient, fixed expiry, atomic 5/7 quota, separate visitor, provider rejection/timeout, redirect isolation, browser form/pending/retained draft, axe and owned cleanup. That is a local transport/application journey, not a provider inbox receipt. The current runner asserts 201 and delivery record count, but its API checks do not explicitly assert the 201 body and response Content-Type absence; add those wire assertions to this existing suite rather than inventing a compiler-only echo (mw/justfile:139-149; mw/apps/homepage/package.json:25; mw/apps/homepage/e2e/run-native-contact.mjs:1-16,402-467,469-703).

After approval, perform these gates serially under devenv shell, with each heavy job through just measure --class <class> -- ... and one machine-wide heavy holder. No check is claimed by this spec (mw/AGENTS.md#verification-and-resources):

  1. Pin source and package. In the isolated mono-web integration worktree capture clean exact HEAD, generated whole OpenAPI/SDK hashes, the sole Contact ID, six accepted groups' contract/handler fingerprints, and effx/mono vendored manifests; run frozen Bun install. At design time both manifests pin f3c6bf8ae6205b9b60a449d01faed63744beb765. If S5 lands and the exact compiler pack changes generated Contact or prior-six source, re-vendor that clean S5 pack as part of this slice. Before declaring Contact, regenerate the accepted groups on the then-current clean pre-Contact source. On unchanged 8152c389 (unpublished) require complete OpenAPI SHA-256 81e7b872178803a3c8bf6ccf86b96b5185ba560e9661b47e98d1d0d4e827b749, complete SDK index SHA-256 c4abcca1878171c84fd3c531c3fe5c93f92329818c59d4b73a8bf796bca8aad3, 107 external plus one excluded internal, and prior-six IR hash 48da4481368fad6dd09b793f66fccbd07211d8b141a51a18dcd4571004ef09ce if S5 preserves the IR. If spec 0027 policy repair lands first, freeze a new post-0027, 108-external whole OpenAPI/SDK reference at its clean source revision and demand byte parity against that instead; the older 107-operation whole-file hashes cannot compare equal after an intentionally added Admissions endpoint. Preserve the six accepted groups against their own current post-S5 source hashes. S5 may intentionally change generated TypeScript bytes; compare prior-six files to the post-S5 six-group baseline, not blindly to f3 artifacts. If the pack is unchanged, report no-op instead of manufacturing a re-vendor (mw/tools/effx-dist/manifest.json; mw/justfile:29-34; docs/specs/0019-lift.md:3-11; docs/research/organization-slice-evidence.md).
  2. Genuine bodyless-201 proof and one IR. Probe Schema.Void.pipe(HttpApiSchema.status(201)) plus current ContactSsrSecurity, header schema, annotated AccessSpec and eight ordered problems using the actual source exports. Compare the whole rendered 201 response subtree to the handwritten contract; zero JSON content, no content type, correct no-store/Vary, 201 rather than default 200/204. A failed probe is a compiler diagnostic, not license to add an empty object. Add one contact.effx.ts and app-owned adapter to the existing two six-group project configs; just effx-native, strict contract/backend effx check --strict-access, matching IR hashes, and generate the complete API/SDK. Require the exact frozen pre-Contact external operation inventory (107 on 8152c389 (unpublished), 108 after the approved spec 0027 source repair lands), one contact.submitContactMessage in the unchanged contact group, one separately excluded internal receipt, and complete OpenAPI/SDK files byte-for-byte equal to accepted 8152c389 (unpublished) reference (or the byte-identical post-S5 six-group baseline). Replace root handwritten ContactApi, old SubmitContactMessageEndpoint and backend handwritten HttpApiBuilder.group in one cutover; update homepage ContactMessagePayload/ContactMessageHeaders, SDK/tests and every old-symbol caller to canonical generated exports. No aliases/dual handlers/hand edits (mw/packages/http-api/src/{api,index,contact}.ts; mw/apps/backend/src/contact/http.ts; mw/apps/homepage/src/lib/{api-types,contact-message.server}.ts; mw/packages/sdk/native-api-operations.json).
  3. Focused source and security tests. Run mw/packages/http-api/test/native-api.test.ts plus one-operation declaration/adapter tests on the rendered response/security/problem schemas; generated SDK client/index tests; mw/apps/backend/src/contact/http.test.ts, mw/packages/domain/src/contact/contact.test.ts, and mw/apps/homepage/test/{contact-ingress,contact-message}.test.ts. Prove 401 before quota for configured missing/wrong/wrong-hop token, branded IP/header/media/body failures, 201 empty/no-store/Vary, 422 invalid recipient consuming an admitted attempt, 429 after the fifth fixed-hour attempt with Retry-After, 503 delivery rejection/timeout consuming quota, no automatic retry, and no cross-origin backend-token forwarding. Use existing disposable PostgreSQL tests/runner for quota concurrency, not a fake in-memory counter. A test cannot claim inbox delivery from HTTP acknowledgment (mw/apps/homepage/e2e/run-native-contact.mjs:402-703; mw/packages/database/src/contact/postgres.ts:10-30; mw/AGENTS.md#verification-and-resources).
  4. The named whole journey. Run just e2e contact against the new generated Contact contract/raw handler and the real generated SDK through local Worker → SSR action → backend → PostgreSQL and acknowledged loopback sink. Keep all original malformed, denial, 5/7 race, fixed-window, no-refund, unavailable, manual redirect, browser draft/keyboard/axe and cleanup assertions; add the strict 201 body/Content-Type/cache wire checks, not another service-only fixture. No new Foldkit program or provider claim (mw/apps/homepage/e2e/run-native-contact.mjs; mw/apps/homepage/src/routes/_home.kontakt*.tsx; mw/justfile:139-149).
  5. Adjacent and aggregate on one clean commit. Run prior accepted Profile, Schools, SocialEvents, Content, TeamApplications golden (normal plus its two deliberate fault/teardown modes), Organization, and receipt-owner named journeys separately; also just e2e applicant because Contact and the public application share the built homepage Worker and generated SDK. Run full serial just check --concurrency=1 and just test --concurrency=1 after complete focused browser verification. All jobs must reference one clean committed mono-web revision under the one machine-wide heavy lock; capture each exit/wall/peak RSS from just measure --report/per-job ledger, preserve red/fail causes, and never count an intentional injected exit 1/143 as a passing normal golden (mw/AGENTS.md#verification-and-resources; docs/research/organization-slice-evidence.md).
  6. Source accounting and custody. Compare maintained wc -l of the same 13 existing Contact-scoped files at 8152c389 (unpublished) versus the accepted commit: mw/packages/http-api/src/contact.ts; mw/apps/backend/src/contact/{http,config}.ts; mw/packages/domain/src/contact/{schema,service}.ts; mw/packages/database/src/contact/postgres.ts; mw/apps/homepage/src/lib/{contact-context.server,contact-message.server,contact-message}.ts; mw/apps/homepage/src/components/kontakt-tabs.tsx; and mw/apps/homepage/src/routes/{_home.kontakt,_home.kontakt._index,_home.kontakt.$department}.tsx. The baseline is 902 physical lines. Include new authored .effx.ts/adapter and every affected caller in maintained after-state; count generated Contact contract/handler lines and whole OpenAPI/SDK bytes separately. Source lines are descriptive, not a reduction target. Keep private runner artifacts out of the public repository and report only mw/-relative redacted evidence after final acceptance. No production credentials, hosted provider, deployed environment, operator-owned mono-web main, Admissions HOLD, or branch cutover may be changed by this design (mw/AGENTS.md#verification-and-resources; docs/specs/0004-monoweb-migration-plan.md:305-336).

Definition of done after approval: the original visitor Contact Worker/SSR browser journey executes the one effx-generated contact.submitContactMessage/typed raw binding and generated SDK with exact custom two-hop security, branded visitor IP, persisted five-attempt quota, original failure/proxy confinement, 201 with no response body, current provider-neutral transport acceptance only, the unchanged complete pre-Contact OpenAPI/SDK bytes (107 external on 8152c389 (unpublished) or 108 after the approved spec 0027 repair) and one excluded internal endpoint, plus all adjacent and full checks at the same clean revision. Contact delivery is not durable inbox proof. Local implementation is operator-approved; acceptance still requires all gates on one clean revision.

On this page