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 operation | Method, request and credential | Exact existing AccessSpec | Success and errors |
|---|---|---|---|
contact.submitContactMessage | POST /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-ingresstoken and a separately configured, nonidenticalx-vektor-contact-backendtoken; both must satisfy the existing minimum length. The trusted ingress derives and Schema-decodes the canonical visitor address from the socket edge, not browser-controlledX-Forwarded-Foror an arbitraryx-vektor-contact-ip.API_URLis 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. BackendContactSsrSecurityvalidates the backend token in constant time when configured; its security scheme remainscontactBackendwith the exact header name. A missing/partial backend configuration can only answercontact.unavailablewithout 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 itsINSERT ... 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.
ContactDeliverystill selects the current active department's valid mailbox asto, submitted visitor email asreplyTo, and the configured sender; its existing JSON envelope prefixes the subject and includes the submitted name/email/message.deliverJsonwaits for the configured loopback/provider HTTP transport acceptance and maps rejection/timeout tocontact.unavailableafter 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):
- 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 unchanged8152c389 (unpublished)require complete OpenAPI SHA-25681e7b872178803a3c8bf6ccf86b96b5185ba560e9661b47e98d1d0d4e827b749, complete SDK index SHA-256c4abcca1878171c84fd3c531c3fe5c93f92329818c59d4b73a8bf796bca8aad3, 107 external plus one excluded internal, and prior-six IR hash48da4481368fad6dd09b793f66fccbd07211d8b141a51a18dcd4571004ef09ceif 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). - Genuine bodyless-201 proof and one IR. Probe
Schema.Void.pipe(HttpApiSchema.status(201))plus currentContactSsrSecurity, 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 JSONcontent, 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 onecontact.effx.tsand app-owned adapter to the existing two six-group project configs;just effx-native, strict contract/backendeffx check --strict-access, matching IR hashes, and generate the complete API/SDK. Require the exact frozen pre-Contact external operation inventory (107 on8152c389 (unpublished), 108 after the approved spec 0027 source repair lands), onecontact.submitContactMessagein the unchangedcontactgroup, one separately excluded internal receipt, and complete OpenAPI/SDK files byte-for-byte equal to accepted8152c389 (unpublished)reference (or the byte-identical post-S5 six-group baseline). Replace root handwrittenContactApi, oldSubmitContactMessageEndpointand backend handwrittenHttpApiBuilder.groupin one cutover; update homepageContactMessagePayload/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). - Focused source and security tests. Run
mw/packages/http-api/test/native-api.test.tsplus 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, andmw/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). - The named whole journey. Run
just e2e contactagainst 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). - 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 applicantbecause Contact and the public application share the built homepage Worker and generated SDK. Run full serialjust check --concurrency=1andjust test --concurrency=1after 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 fromjust 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). - Source accounting and custody. Compare maintained
wc -lof the same 13 existing Contact-scoped files at8152c389 (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; andmw/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 onlymw/-relative redacted evidence after final acceptance. No production credentials, hosted provider, deployed environment, operator-owned mono-webmain, 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.