Spec 0027 — admission outcomes and editable outcome mail
Status: operator-approved for local red-first implementation on an isolated mono-web worktree; no deployment or mono-web main landing authority. The operator corrected the model: a substitute is not an admission outcome or a role. Assistant B substitutes for absent assistant A on one dated school run. The operator approved the two-outcome policy and clean cutover below. The operator has approved the earlier Admitted/Rejected message drafts as default copy, the two-step decide/announce workflow, and the safety rule that editor-authored code must never execute. Global defaults with per-department overrides and explicitly delegated department-coordinator/global-administrator template editors are operator-approved. Contact spec 0028 is independent and approved. This is read-only design research against clean, unpublished mono-web 8152c389f1176ede38b70417bab1332b0a311c21 (unpublished). mw/ means a path relative to that mono-web checkout. No SQL, API, SDK, UI, provider or production code changed for this revision. The operator-owned mono-web main remains read-only.
Source inventory at 8152c389 (unpublished): two old meanings of Substitute
| Surface | What the source currently says, with exact location | Consequence of the correction |
|---|---|---|
| Admission domain | mw/packages/domain/src/admissions/outcome.ts:16-23 defines `Admitted | Substitute |
Applied migration 0075 | mw/packages/database/migrations/0075-substitute-admission-outcome-coverage-records.sql:7-33 created append-only application-outcome revisions with a three-value CHECK and consecutive-revision trigger. Separately, :35-86 created school_service_coverage_records: (coverage_id, absence_id) identifies one absence's coverage, covering_person_id identifies the covering person, coverer_kind is `Assistant | Substitute, a unique partial index permits one current record per absence, and withdrawal preserves history. :88-113converted **acknowledged coverage of an absence** into these records and rewired service closures;:160-278derives attendance and reserves dated service intervals;:280-301removed offer/dispatch and substitute-pool tables.mw/packages/database/src/migrations.ts:599-604andmw/packages/database/migrations/checksums.json:76` freeze this applied migration. |
| Live SQL and adjacent placement API | mw/packages/database/src/admissions/outcome.ts:62-77,155-185 reads the highest outcome revision and appends another under an application-row lock. mw/packages/domain/src/placements/schema.ts:230-246,273-299 exposes CoverageCovererKind and covererKind on coverage/coverer resources. mw/packages/database/src/placements/coverage.ts:177-213 ranks actively placed persons as Assistant and account-linked applicants whose latest outcome is Substitute as Substitute; :423-472 chooses a covering person for one absence, checks interval conflicts, writes coverage and audits its kind. mw/packages/http-api/src/placements.ts:48-56,219-245,257-284 and mw/apps/backend/src/placements/http.ts:388-400 expose those public shapes. | An admitted assistant without a semester placement must remain an eligible coverer when linked to a Person. Eligibility cannot read the retired Substitute outcome. Remove the false kind from public resources, SQL projections, new audit events and UI, while keeping per-absence coverage commands and exclusion locks. |
| Outcome HTTP and problems | mw/packages/http-api/src/admission-outcomes.ts:31-45 exports an entry/ETag and board union `ReadOnly { substitutes } | Decide { entries }. Its four operations are list scopes, read board, conditional read one, and recordOutcome POST /api/admission-outcomes/:applicationId:record (:85-169). Its read union has 17 ordered codes and its command union adds idempotency.unavailable (:47-76); **no code is Substitute-specific**. mw/apps/backend/src/admission/outcome-http.ts:202-243,272-347materializes the on-call ReadOnly board and the handwritten command/response receipt. Thecoverage.coverer-ineligible problem *detail*, however, still says “placed assistant or substitute on call” (mw/packages/http-api/src/http-semantics.ts:411-413`). |
| SDK | mw/packages/sdk/native-api-operations.json:3-25,28-85 indexes four admissionOutcomes and ten admissions operations. mw/packages/sdk/scripts/generate-operations.ts:24-56, mw/packages/sdk/src/effect-client.ts:63-76 and mw/packages/sdk/src/promise.ts:36-37 derive methods/types from ExternalNativeApi; the index is not a second enum definition. | Regenerate complete OpenAPI/SDK after changing outcome and coverage schemas; migrate both Admissions and Placements callers. Preserve external operation IDs except the deliberately versioned decision command described below. |
| Board and other browser UI | mw/apps/dashboard/app/routes/dashboard.vikarer._index.tsx:41-63,90-165 loads the board and calls the generated recordOutcome; :184-192,352-374,435-455 offers “Vikar,” filters Substitute and says an unplaced admitted applicant is on call. mw/apps/dashboard/app/foldkit/dashboard/navigation.ts:87-88, mw/apps/dashboard/app/routes/dashboard.tsx:202-203 and mw/apps/dashboard/e2e/native-identity-browser.spec.ts:49-51 link to /dashboard/vikarer. mw/apps/dashboard/app/routes/dashboard.assistenter._index.tsx:933-950,988-989,1261-1263 displays coverage `Assistant | Substitute`. |
| Named journeys, local and hosted | mw/justfile:139-158 names just e2e substitutes; mw/apps/dashboard/package.json:41, mw/tools/acceptance/substitute-outcome-check.ts:1-3,624-629 and mw/apps/dashboard/e2e/run-real-native-substitutes.mjs:168-172 run the browser spec. mw/apps/dashboard/e2e/native-substitutes.spec.ts:57-60,137-206,238-315 checks outcome, double-click deduplication, stale 412/replay, mobile, denial, on-call and axe; its on-call expectations become invalid, not the rest of the assertions. mw/packages/database/src/placements/postgres.test.ts:573-578,926-945, mw/apps/dashboard/e2e/native-placement.spec.ts:551-575,1271-1272, mw/tools/e2e/placement-check.ts:480-532,2713-2723 and mw/tools/e2e/golden-school-service.mjs:67-69,171-181,325-356 assert the current outcome→coverer coupling. The hosted suite is listed in mw/.github/workflows/tests.yml. | Retarget and rename the complete substitutes suite to admission outcomes, retain all still-valid API/browser/PostgreSQL/keyboard/mobile/axe assertions, and replace on-call checks with two-outcome/coordinator announce/mail assertions. Keep the independent placement coverage/golden journeys and update their eligible-coverer assertions. Do not simply delete or unhost a journey. |
| Intended system and model | mw/docs/system.md:182-188 already separates admission and school placement; :194-205 nevertheless says returning assistants require placement, and that Substitute is admitted without one. :64-66,334-343 calls recorded coverage a dated absence/cover fact. mw/docs/model/contexts.cml:56,61-63,1292-1306,1582-1595 simultaneously describes the coverage fact, the obsolete three-value outcome and two-step announce/decide capabilities. mw/docs/architecture.md:154-157 and mw/STATE.md:169-175,263 repeat the earlier model as historical/current implementation evidence. | In the future source fix update system.md and contexts.cml to two outcomes, independent placement and B-for-A on one dated run; update architecture/current-state when implementation lands. Do not silently rewrite historical changelogs or applied migration text. The operator correction overrides their old model. |
| Alloy | mw/docs/model/authority.als:169-171,199-236,323-325 models organizational and placement authority but has no Substitute, recommendation, admission-outcome or message predicate (search over the file found none). | No existing Alloy check proves either old or proposed outcome policy. Extending it is recommended below; report a run only after the model is actually extended and its mutants execute. |
What already exists for a dated school run. SchoolServiceCommitment holds one confirmed-roster assignment snapshot, school, date and time interval (mw/packages/domain/src/placements/schema.ts:181-197; mw/packages/database/migrations/0058-dated-school-service-commitment.sql:3-21; scheduling writer mw/packages/database/src/placements/postgres.ts:295-375). SchoolServiceAbsence identifies the absent scheduled person and commitment (schema.ts:214-228; writer mw/packages/database/src/placements/coverage.ts:376-401). RecordCoverage chooses another Person for that absence under an interval availability check (schema.ts:330-357; coverage.ts:423-472); the covering interval is reserved and an occurrence's attendance is confirmed-roster people minus absences plus current coverers (mw/packages/database/migrations/0075-substitute-admission-outcome-coverage-records.sql:205-278; mw/packages/domain/src/placements/policy.ts:222-245). A closure links absent person, exact occurrence and covering person (mw/packages/database/migrations/0075-substitute-admission-outcome-coverage-records.sql:97-156). Days served is a different aggregate drawn from completed attendance (mw/packages/domain/src/placements/days-served.ts:6-8,77-109; mw/packages/database/src/placements/certificates.ts:126-152). Semester placement remains recurring school/day/block data (mw/packages/domain/src/placements/schema.ts:33-50; mw/packages/database/migrations/0032-native-volunteer-placement.sql:18-29). There is no separate named Run/Replacement command that rewrites the immutable roster snapshot; current coverage is recorded for one commitment/absence, can reserve B's time, and people arrange cover outside the system (schema.ts:233-245,377-416; mw/docs/system.md:334-343). Richer prospective replacement/offer scheduling is a future separate spec, but removing the false admission/coverer role coupling in the existing dated coverage paths is part of this cutover.
Existing editable content and mail — no reusable mail-template store
| Source | Present authoring and persisted shape |
|---|---|
| TeamApplications | Both transactional subjects and text bodies are assembled in code (mw/packages/domain/src/team-application/notification.ts:59-108). The private JSONB envelope stores rendered text/subject; the worker strict-decodes it and supplies MAIL_SENDER (mw/packages/database/src/team-application/outbox.ts:120-134,276-300; mw/apps/backend/src/config.ts:237-258,286-308). There is no staff edit/preview endpoint for these messages. |
| Password reset | The Better Auth callback enqueues an identity-only outbox row (mw/packages/database/src/password-recovery.ts:77-83,157-158; mw/packages/database/migrations/0034-password-recovery.sql:71-87). Its drain builds fixed Tilbakestill passordet ditt subject and text at delivery (mw/packages/database/src/password-recovery.ts:381-483); its fingerprint prevents changing an already-attempted payload. It has no editable copy. |
| Recruitment invitations | Their typed outbox request has a per-invitation message field (mw/packages/domain/src/recruitment/effects.ts:27-46), but the external RECRUITMENT_NOTIFICATION_URL gateway has no configured sender or editable shared template (mw/apps/backend/src/recruitment/delivery.ts:18-24,91-121). Do not copy this gateway for outcomes. |
| Content and admin UI | /dashboard/artikler is a Foldkit workspace (mw/apps/dashboard/app/routes/dashboard.artikler._index.tsx:9-12) but its body editor is a raw-HTML textarea, not a WYSIWYG or Markdown editor (mw/apps/dashboard/app/foldkit/content/view.ts:143-155; model :35-53). Articles persist body_html text for editable drafts and immutable published versions (mw/packages/domain/src/content/schema.ts:79-105,144-190; mw/packages/database/migrations/0020-content-publication.sql:4-45); a parse5 sanitizer runs on save/publish (mw/packages/domain/src/content/sanitize.ts:11-70,73-168; mw/packages/database/src/content/postgres.ts:716-719,920-923,1109-1112). Public output inserts the sanitized HTML (mw/apps/homepage/src/routes/nyhet.$slug.tsx:33-38). Its revision/ETag/audit and Foldkit workspace conventions are useful patterns, but storing its HTML or reusing its textarea for mail violates the closed-document rule; Content articles are not a mail template mechanism. foldkit/@foldkit/ui are pinned 0.163.0 (mw/package.json:35-45). |
A read-only search of mw/packages/database/migrations/ for _template/message_template found no mail-template table; the inspected Contact/Recruitment/TeamApplications/Identity/Content paths above exposed no editable transactional-mail-template API. This is a scoped absence claim, not proof no template exists in an unseen provider. Reuse the in-repo versioned-content and transactional-outbox patterns, not another independently stored raw HTML copy.
Approved two-outcome policy and clean cutover
- Admission is not scheduling.
AdmissionOutcome = Admitted | Rejected. For an interviewed applicant,Ja → Admittedregardless of placement andNei → Rejected; neither can be manually overridden.Kanskjeand a returning assistant without an interview get an explicit RekrutteringAdmittedorRejecteddecision. An Admitted assistant without placement stays an assistant and can be eligible to cover a dated service; it is not a separate on-call category. School placement remains its own decision (mw/docs/system.md:182-205shows the existing contradiction; the operator correction supersedes:194-205andmw/docs/model/contexts.cml:61-63,1292-1306). Account/Person linkage needed for a coverer is separate from admission. - Two authorized steps. Keep the earlier approved CML authority split: Rekruttering holds
admissions.outcomes.decideby board-leader delegation only forKanskje/returning; Skolekoordinering announces withadmissions.outcomes.announceandcoordinator(department), transitioningApplicationSubmitted → OutcomeAnnouncedonce. No department-wideallocation closedfact. Announce derives Ja/Nei, requires a decision for Kanskje/returning, checks the effective admission/placement facts within the same serializable transaction, then stores one immutable final outcome and one mail envelope.Rejectedrequires no active same-department/semester placement; Reject and a concurrent placement create contend on the same scope lock. A later placement may change for an Admitted assistant without rewriting the announced admission or mailing a correction. A later placement for a final Rejected person must fail rather than make theRejectedinvariant false; an announced admission never changes (mw/docs/model/contexts.cml:1292-1306;mw/docs/system.md:190-207for the parts not superseded). - Existing
Substituterows. Do not rewrite/delete the append-only history or silently cast reads forever. In the next free new migration after the accepted 0080 baseline, append oneAdmittedsemantic-upgrade revision for each currentlySubstituteapplication, preserving each older literal as internal historical evidence. Give migration-origin revisions explicit provenance and no invented human decider; do not attribute a new decision to the person who decided the old row. New inserts (including direct SQL) rejectSubstitute; only preserved old revisions may retain the legacy label. Public reads/types expose latestAdmitted | Rejectedonly. Historical outbox/audit/coverage facts stay intact, and the migration must not announce or send mail. Prove this against previous-schema seeds on PGlite and both supported PostgreSQL majors (mw/packages/database/migrations/0075-substitute-admission-outcome-coverage-records.sql:7-33;mw/packages/database/src/admissions/outcome.ts:164-185;mw/packages/database/src/migrations.ts:626-631). An in-place UPDATE would require disabling 0075's append-only trigger and erase the original decision; not recommended. - Existing coverage rows and candidates. Preserve the per-absence
coverage_id, absent/covering Persons, withdrawal and closure/attendance references. Drop the spurious persistedcoverer_kind, itsAssistant | Substitutepublic discriminator and kind-based audit of new events; old frozen audit events keep their provenance. Select eligible active placed assistants and linked current Admitted assistants for the department/semester, with no double listing and the existing interval/absent-person conflicts. A historiccoverer_kind='Substitute'then means no standing role; the same B-for-A coverage identity survives (mw/packages/database/migrations/0075-substitute-admission-outcome-coverage-records.sql:35-86,97-156;mw/packages/database/src/placements/coverage.ts:177-213,423-472). Replace the obsolete eligibility problem detail in the registry without reordering codes (mw/packages/http-api/src/http-semantics.ts:411-413). - UI/journey. Replace
/dashboard/vikarerwith/dashboard/opptaksutfallas the admitted/rejected decision and coordinator-announce board; update navigation, dashboard breadcrumbs and identity path checks, remove the old route rather than keep a misleading alias. DeleteReadOnly.substitutesand the unauthorised member list. Retain Decider's scoped entries and add a minimal coordinator-only Announce projection (ID, safe display, current ETag, decision/finality); don't grant every department member the submitted email. The recipient stays server-only. Keep/dashboard/assistenterfor actual dated coverage, now with assistant-only labels. Rename/retargetjust e2e substitutestojust e2e admission-outcomes, update its just/package/CI runner registration and preserve its old denial, replay, stale conflict, concurrent, browser, keyboard, mobile, axe and PostgreSQL assertions while replacing false on-call expectations with announce/real mailbox checks (mw/apps/dashboard/e2e/native-substitutes.spec.ts:57-60,137-206,238-315;mw/tools/acceptance/substitute-outcome-check.ts:1-3,624-629). - Source correction. Revise
mw/docs/system.md:182-207,334-343andmw/docs/model/contexts.cml:56,61-63,1292-1306,1582-1595with the future approved implementation: two values, Ja admitted even without school, a final coordinator announcement, and assistant B covering A for a dated service. Update the then-current architecture/STATE as needed; do not alter applied historical migrations or changelogs. Keep richer offer/reassignment workflow outside this Admissions repair.
Replay identity consequence. The existing HTTP receipt table can retain a pre-cutover successful JSON response containing Substitute for 24 hours (mw/packages/database/migrations/0029-native-http-semantics.sql:1-12,22-59; mw/apps/backend/src/http-api/receipt-transaction.ts:165-205). It cannot be tombstoned early under its current tombstoned_at >= full_expires_at constraint (0029...sql:53-60). Recommend replacing the old qualified admissionOutcomes.recordOutcome with admissionOutcomes.decideOutcome and changing the route suffix from :record to :decide at the policy cutover, with no compatibility alias. This versions both the SDK operation and HTTP receipt identity; old capsules age naturally but cannot replay into the new closed response schema. The four-operation group count remains four before adding announce/templates. Carry the new strong If-Match/Idempotency-Key, credential and status requirements into the replacement command (mw/apps/backend/src/admission/outcome-http.ts:272-347).
Approved default copy and editable templates
The operator approved two Norwegian bokmål message families as default copy. At announce the server selects the effective template revision, renders the current outcome and placement into subject, HTML and plain text, and writes those exact bytes into one immutable outbox envelope with a deterministic delivery ID and response receipt. A later template/layout edit does not alter queued mail, earlier receipts, or an exact replay. The recipient is the submitted application's email (mw/packages/domain/src/admissions/outcome.ts:36-49); the sender is existing MAIL_SENDER (mw/apps/backend/src/config.ts:242-243,375-386). No new sender env var. Use in-repo MailDeliveryRequest + claim-fenced outbox/worker, not Recruitment's senderless external gateway. Enable delivery only with a new default-disabled ADMISSIONS_OUTCOME_DELIVERY_MODE=disabled|http, using the existing mail transport and sender (mw/apps/backend/src/config.ts:237-258,286-308; mw/packages/database/src/team-application/outbox.ts:260-313). Provider-accepted MailDeliveryOutcome { providerReference } is “sent”; terminal permanent rejection is visible Quarantined, not recipient delivery. No delivery/bounce receipt may change a final outcome (mw/packages/domain/src/mail.ts:19-43; mw/packages/database/src/team-application/outbox.ts:284-313).
Admitted with active school placement — approved default text:
Emne: Opptaksresultat – {departmentName}, {semesterName}: Tatt opp
Hei {firstName},
Utfall: Tatt opp. Du er tatt opp som assistent i Vektorprogrammet i {departmentName} for {semesterName}.
Skole: {schoolName}.
Vennlig hilsen
VektorprogrammetAdmitted without active school placement — approved variant of the Admitted text: use the same subject and copy, replacing only Skole: {schoolName}. with Skoleplassering kommer.. The word choice comes from the operator correction; this is not a third admission outcome. schoolName is unavailable/forbidden in this variant.
Rejected — approved default text:
Emne: Opptaksresultat – {departmentName}, {semesterName}: Avslag
Hei {firstName},
Utfall: Avslag. Du fikk dessverre ikke plass i Vektorprogrammet i {departmentName} for {semesterName}.
Vennlig hilsen
VektorprogrammetOne closed document; never stored HTML or executable MDX. Persist three template variants for two outcomes: AdmittedWithSchool, AdmittedPlacementPending, Rejected. Each revision has a subject token sequence and a strict, versioned, ProseMirror/Tiptap-compatible JSON document for the body. Its Schema admits only paragraphs, headings, bold/italic, lists, https:/mailto: links, atomic placeholder nodes, and an explicitly allowlisted Button/action node. Unknown node names, marks, attributes, protocols, HTML fragments, event handlers, arbitrary CSS, image/source tags, expression/ESM/JSX nodes and component names fail on save, never at send. Placeholders are atomic tokens from {firstName}, {departmentName}, {semesterName} and (only for AdmittedWithSchool) {schoolName}. Validate the per-variant required set across subject/body; unknown, missing and wrong-variant placeholders fail Schema decode. The recipient email is never a template token. Edits append revisions with actor/time/audit and strong If-Match/idempotency. Seed the approved copy as versioned global defaults, not compiled literals for future sends. A scoped override, if the operator chooses it, supersedes a global default without copying that default into each department. Preview a proposed revision before save through the same server renderer, with explicit sample context and no persistence or provider call.
First editor candidate: React Email's own editor. Pin @react-email/editor@1.7.11, MIT, the npm stable latest (distinct experimental/canary tags); upstream tag @react-email/editor@1.7.11 resolves to commit b0b46687f38a048982f0b08a476e7ddabd603045. It is Tiptap/ProseMirror, not Lexical; official getting started and EmailEditor API support TipTap JSON via getJSON() as well as HTML export. The editor's extensions may replace the defaults; use its lower-level EditorProvider with only our node/mark extensions and custom, atom: true placeholder and closed Button/action extensions (official custom extensions; Tiptap Node API). Do not use the broad 35+ default StarterKit, its free-form built-in Button href/class/alignment, HTML import, email HTML export or paste sanitizer as a trusted storage/send boundary (pinned Button source; pinned editor source). The browser emits JSON, the backend strictly Schema-decodes it, and the Bun send worker never imports the editor. This satisfies closed-format/no-code-execution only with that app-owned validator and renderer; package defaults alone do not.
The editor is a WYSIWYG view. Official docs give no Markdown-source mode or complete Norwegian locale. React Router 8 embedding is an integration inference from its React 18/19 peer range, not a tested upstream guarantee. Use Norwegian app-owned toolbar/labels and verify actual keyboard/focus/axe behavior; official slash keyboard, link shortcut and focus scope are partial evidence, not WCAG certification. If a Markdown-like writing view is added later, parse it into the same closed JSON model; never persist a second raw Markdown representation. The operator asked for rich-text and/or Markdown-like authoring: the restricted WYSIWYG satisfies the initial UI if those tests pass. Fall back to validated Markdown/mdast + allowlisted directives if the restricted JSON/editor integration cannot pass the safety/browser gates; don't weaken the schema.
Why no executable MDX. @mdx-js/mdx@3.1.1 (MIT; also in mw/bun.lock) compiles author content to JavaScript. Its official syntax permits ESM import/export, JS expressions and JSX; its security guide calls MDX a programming language. The official processor options offer format: "md" | "mdx" and remarkPlugins, rehypePlugins, recmaPlugins, not a switch that forbids ESM/expressions and allowlists JSX. jsx: false compiles JSX away rather than rejecting it; providerImportSource is not an allowlist. A fail-closed AST validator could block mdxjsEsm, mdxFlowExpression, mdxTextExpression, arbitrary JSX/attributes, but then persistence and rendering must still use the validated data. Choose the smaller JSON/React Email-editor route; never call MDX evaluate/run, compile saved author text at send, or execute an author-defined component. A Markdown fallback uses remark-parse@11.0.0 (lockfile mw/bun.lock:2586) and optionally remark-directive@4.0.0 (MIT; official source); reject HTML nodes and unknown directives at save. Directives are data mapped by code to fixed components, not arbitrary HTML.
Code-owned rendering and explicit transport change. Pin react-email@6.11.0, MIT, upstream tag commit 15419ff1f4cd0e32ed2c15a4a9182cc47a200a60 for the backend layout/render package. It re-exports the renderer @react-email/render@2.1.0 (MIT); freeze the resolved version in Bun's lock. A pure, application-owned visitor turns only validated nodes into React Email elements inside a fixed <Html>/<Body>/<Container> layout, inserts user values as escaped React text nodes, and uses documented render(element) then toPlainText(html) to get both outputs. Preview and announce use the same visitor, layout, library versions and input context: assert {subject,html,text} bytes match exactly. The output HTML/text is snapshotted in the envelope, never saved as the editable template. The worker may only forward that snapshot, not re-render a later template revision.
Today MailDeliveryRequest is text-only {deliveryId,sender,recipient,replyTo?,subject,text} (mw/packages/domain/src/mail.ts:8-17); mw/apps/backend/src/mail/http.ts:36-58 forwards this JSON with a delivery-ID idempotency key to a configured external endpoint whose HTML support is not known from this repo (mw/apps/backend/src/delivery/http.ts:56-69). The in-tree Cloudflare structural binding also has only text (mw/apps/backend/src/mail/cloudflare.ts:9-16,105-121). Adding HTML is therefore a transport contract change, not a claim the provider already handles multipart: add optional, bounded html to shared MailDeliveryRequest, HTTP JSON transport and Cloudflare mapper/validation; require text in all mail. Make html required in the Admissions-outcome envelope and prove the configured endpoint/loopback sink accepts both and produces a text alternative before accepting the journey. TeamApplications' two existing text-only messages remain valid (mw/packages/domain/src/team-application/notification.ts:26-32,59-108; outbox mw/packages/database/src/team-application/outbox.ts:276-298); reset mail remains text-required and fingerprint-stable for already-attempted rows (mw/packages/database/src/password-recovery.ts:62-71,451-483). Do not silently add HTML to old reset attempts: their payload fingerprint would change and quarantine them. CloudflareMailLive is source/test-only, while production workers currently select HttpMailLive (mw/apps/backend/src/main.ts:249-255,267-273). HTML support at the out-of-repo provider needs its own evidence; a loopback test is not deployed-provider proof. A Bun 1.3.10 + React 19.2.8 worker render probe is required before implementation acceptance; official React Email peers accept React 19, Bun's SSR guide supports React 19 Web Streams, but React Email does not document this exact worker combination. Do not assert a Bun render pass until observed (Bun SSR guide; mw/apps/dashboard/package.json:81-82; mw/apps/backend/package.json; mw/package.json). The send path must not import TipTap, a Node-only editor API or author-source compiler.
Handwritten API, SDK and 0026 inventory — approved target
Preserve PersonSecurity, canonical application-department resolution, transactional authorization, private responses, strong ETags and the single NativeProblemRegistry. The four existing outcome operations become listScopes, readOutcomes (Decide and minimal Announce projections, no ReadOnly substitute list), readOutcome, and decideOutcome (POST /api/admission-outcomes/:applicationId:decide, strong If-Match, Idempotency-Key, AdmissionOutcomeCommand with only Admitted/Rejected; Rekruttering only Kanskje/returning). Add announceOutcome (POST /api/admission-outcomes/:applicationId:announce, strong If-Match, Idempotency-Key, {} JSON command, admissions.outcomes.announce, coordinator(department), 200 private no-store resource with new ETag). The response and board show ApplicationSubmitted | OutcomeAnnounced explicitly rather than treating a nonnull decision as final (mw/packages/http-api/src/admission-outcomes.ts:31-45,78-169; mw/apps/backend/src/admission/outcome-http.ts:272-347; mw/docs/model/contexts.cml:1292-1306 for the authority parts not superseded).
Add three endpoints in the same admissionOutcomes group for template authoring, not a new generic mail editor group:
| Operation | Proposed wire and access | Observable result |
|---|---|---|
readOutcomeTemplates | GET /api/admission-outcomes/templates?departmentId=... (omit ID for global default), PersonSecurity; only authorized coordinator for that department or global administrator; private no-store. | Read the effective three variants, provenance (global/department), revision and strong ETag. No arbitrary applicant contact data. |
previewOutcomeTemplate | POST /api/admission-outcomes/templates/:kind:preview with typed draft JSON and explicit synthetic/sample placeholder values; same editor authority, no write/receipt/worker; private no-store. | Strictly validate the draft with the same Schema and renderer as save/announce; return exact rendered {subject, html, text} or typed validation error. This extra endpoint makes server-side preview/send byte identity testable without importing backend rendering code into the browser. |
saveOutcomeTemplate | PUT /api/admission-outcomes/templates/:kind?departmentId=..., PersonSecurity, editor authority at Transaction time; required strong If-Match and Idempotency-Key, versioned typed subject/document payload. | Append one auditable revision, never mutate an old template, 200 with new ETag. Preview must have occurred before save in the editor UI, but server validates independently. |
Global defaults omit departmentId; a per-department override requires its canonical departmentId. The three operation IDs and count stay fixed. A global default is never a fallback that masks an invalid selected override. Use canonical department selection and fresh authorization for command/replay, not route/UI role strings. When red tests demonstrate a genuinely new failure (undecided announcement, Rejected with placement, bad template node/placeholder, stale template revision), append canonical codes after existing idempotency.unavailable in the ordered 18-member command problem union, derive status/body/header from NativeProblemRegistry, and extend the 17-member read union only if a read can emit the new code. Never reorder old codes (mw/packages/http-api/src/admission-outcomes.ts:47-76; mw/packages/http-api/src/http-semantics.ts:411-413,451-685).
Counts: frozen source has 107 external + 1 excluded internal; outcome group 4, other Admissions 10, so 14 Admissions operations today. The approved decideOutcome replaces recordOutcome (no net addition), while announce adds 1 and template read/preview/save add 3: outcome group 8, Admissions migration 18, full external index 111, one internal still excluded. The earlier 15-operation estimate excluded editable templates; a count of 17 would omit the required server-side preview. Regenerate complete OpenAPI/SDK and all changed schema/caller bytes against the clean post-0027 handwritten transport before spec 0026 migrates its 18 operations. The separately approved one-operation Contact migration replaces an existing operation and does not alter these totals. These are approved future counts, not current source evidence (mw/packages/http-api/src/admission-outcomes.ts:159-169; mw/packages/http-api/src/admissions.ts:332-343; mw/packages/sdk/native-api-operations.json:3-85; docs/specs/0026-admissions-migration.md).
Falsifiers and future acceptance (not exercised)
- Red-first real PostgreSQL at
8152c389 (unpublished), separate commit. Ja+unplaced must be Admitted, not Substitute; Ja+placed Admitted; Nei Rejected, with a placed Nei blocked until placement is removed; Kanskje and returning require Rekruttering's explicit two-value decision; no Ja/Nei override or unplaced-Admitted denial. Reject an undecided announce, a placed Rejected outcome, a stale If-Match and a non-coordinator announcement. Race Rejected announcement against placement creation in both orders on separate PostgreSQL connections and assert the same scope lock; later Admitted placement changes never rewrite/mailer-correct the final outcome; later Rejected placement creation cannot break its invariant. One terminal announcement, no second envelope on replay, rollback or denial, and no oldSubstituteresponse after the cutover. - Prove semantic migration, not a rewritten history. Seed previous-schema rows with current/historical
Substitute, Admitted and Rejected outcomes plus acknowledged/withdrawn coverage records and a prior HTTP receipt. Upgrade using the then-next free migration number; assert exactly one explicitly provenance-marked Admitted revision per current Substitute, unchanged earlier history/coverage IDs/closure FKs/attendance/interval reservations, no fabricated decider or new announcement, no direct SQL insertion of a new Substitute value, and old receipt identity cannot replay stale three-value JSON. Run PGlite and supported PostgreSQL 17/18 upgrade proofs before claiming acceptance (mw/packages/database/migrations/0075-substitute-admission-outcome-coverage-records.sql:7-33,35-54,88-113;mw/packages/database/migrations/0029-native-http-semantics.sql:41-60). - Template/data/code separation. Saving
<script>, anonerror=attribute, ajavascript:link, unknown node/mark/attribute/component, wrong-variant/missing/unknown placeholder, executable MDX or author HTML must fail before persistence. A placeholder value containing<img onerror=...>must appear escaped as text in both preview and sent HTML. Invalid or ambiguous link/button targets never reach an external request. Round-trip closed JSON through the restricted editor and server Schema, verify Norwegian labels, keyboard operation, mobile and axe with browser tests. An authorized editor can preview before saving; a different department coordinator/member/global-board-seat without the chosen authority cannot edit. Preview and sent{subject,html,text}for the same template revision and values are byte-for-byte equal; a later template/layout edit leaves the already queued envelope, retry and replay byte-for-byte unchanged. HTML and required plain text both reach the local mail sink. Thereact-emailBun render integration, Cloudflare adapter/HTTP endpoint contract and old text-only TeamApplications/reset requests receive explicit tests; no remote-provider acceptance is inferred. - Preserve complete named journeys. Retarget the existing real
just e2e substitutestojust e2e admission-outcomes, extend it with coordinator announcement, template preview/save and token-protected loopback HTML+text mailbox, and keep original security, stale/conflict, replay, concurrency, mobile, keyboard and axe checks. Update and runjust e2e placementandjust golden school-serviceso dated coverage stays real withoutcovererKind: Substitute; run focused backend/domain/HTTP/SDK/upgrade suites and full serialjust check --concurrency=1,just test --concurrency=1on one clean committed mono-web revision under the sharedjust measureheavy lock. Each result reports exact revision, exit, wall time and peak RSS; no aggregate pass is inferred from focused passes (mw/AGENTS.md#verification-and-resources). - Alloy recommendation: yes, if authority.als is extended. Add only the corrected laws: coordinator vs delegated Rekruttering, Ja/Nei mapping, explicit Kanskje/returning decision, Admitted independent of placement, Rejected excluded by a placement, terminal immutability and race coherence. Keep SchoolServiceCommitment/coverage outside the outcome submodel. Add negative mutants for wrong actor, erroneous Substitute admission/role, unplaced-Admitted denial, placed-Rejected acceptance, undecided announcement and post-final rewrite; run
just model checkonly after actually extendingmw/docs/model/authority.als. Existing model has none of these predicates, so its current green result would not be policy proof (mw/docs/model/authority.als:169-171,199-236,323-325).
Boundary and execution order: The operator authorized local red-first real-PostgreSQL tests in a separate mono-web worktree at the accepted 8152c389 (unpublished) source. Commit the test-only red result and report it; only then make distinct policy, SQL, outbox, handwritten HTTP, generated SDK and browser fix commits. When the Contact compiler pack arrives, finish Contact and its full gates first. Rebase the 0027 implementation onto that clean Contact-accepted revision before the final PG17/18 and PGlite upgrade proofs, Alloy mutants, browser and full serial acceptance on one clean lineage. Only after 0027 acceptance may spec 0026 migrate the repaired 18 operations. This approval does not authorize production/provider/credential changes, deployment, mono-web main landing or an effx transport cutover.
Operator-approved template decisions
- Scope — approved: global defaults plus per-department overrides. The authored global default is the fallback only when a department has no valid selected override. A coordinator edits a versioned template for its own application department, without copying each default into every department (
mw/docs/model/contexts.cml:23-32,1292-1295;mw/packages/domain/src/organization/authority.ts:136-146). - Editors — approved: explicit departmental delegation and separate global administration. Only the department's explicitly delegated Skolekoordinering coordinator edits that department's override. Only a separately granted global administrator edits global defaults. Board membership and the coarse dashboard role confer neither authority. Resolve the named capability or explicit delegation during the committing transaction (
mw/docs/model/contexts.cml:23-25,46-50,1292-1295;mw/packages/domain/src/organization/authority.ts:151-175,178-202).
The accepted source has no general coordinator(department) editor role derived from a team name (mw/docs/model/contexts.cml:46-50). Implement the approved grants as named capabilities or explicit delegations, not display-text inference, and re-evaluate them inside the write transaction and on replay. The operator approved this two-outcome cutover and both template choices; no product-policy choice remains open. The two default message families are approved; the unplaced-Admitted variant retains that copy and replaces only the school line as directed.