## Context

See `proposal.md` — Why.

Three facts about the current system shape everything below.

**The host already half-exists, at the wrong level.** `wedding.host_contact_phone` is a
nullable column rendered as a bare `tel:` link at the bottom of `InvitationShell.tsx`, with no
name beside it. It is also read by the RSVP-closed message (`InvitationInteractive`,
`OpenRsvpInteractive`) and by the cancellation page. So this change is not adding a host — it
is moving one down a level and giving it a name.

**Events are already the unit that owns things.** Design D11/D12 of the earlier changes made a
guest belong to exactly one event and removed the one-event-per-type constraint. Host
identity belongs on `event` for the same reason a guest list does: two ceremonies of one
wedding are run by different people.

**The host is a parent, and the database has never stored one.** `wedding` holds `bride_name`
and `groom_name`; the hosts these fields name are the couple's parents — one name or two — and
no column anywhere holds them. That single fact drives D5: there is no existing value a
migration can honestly derive a host name from.

**The invitation page is a server component with a PII boundary.** `InvitationShell` renders
server-side into the initial HTML, and `web/lib/view-models.ts` exists so no API object is
ever spread into a client component. The host block must respect that boundary — which it
does trivially, because the host is not guest data.

## Goals / Non-Goals

**Goals:**

- Host identity stored per event, required at the database level rather than by convention.
- A host block at the foot of the invitation that reads like the foot of a printed card.
- An upgrade that leaves no event without host details, since the page now expects them.
- One phone-normalisation path, shared with guests, so a host number is as dialable as theirs.

**Non-Goals:**

- **No Bangla variants of the host names.** `title_en`/`title_bn` are paired because the
  event *title* is translated; a person's name is not. The Bangla locale chain is a separate
  piece of outstanding work and this change must not pre-empt its decisions.
- **No host email, and no change to `Reply-To`.** `wedding.host_email` keeps owning that.
- **No change to the cancellation page or the RSVP-closed message.** Both keep reading the
  wedding-wide phone. See D7 for why, and Open Questions for the loose end this leaves.
- **No admin screen for the wedding-wide `host_contact_phone`.** It stays exactly as it is.
- **Nothing on the card design.** The card is fixed per event and carries no guest data; the
  host block sits on the page around it, not inside the shadow root.

## Decisions

### D1 — Three columns on `event`, not a `host` table

`host_name_1 TEXT NOT NULL`, `host_name_2 TEXT NULL`, `host_phone VARCHAR(20) NOT NULL`.

*Alternative considered:* a `host` table with a foreign key, so one host record could be
shared by several events. Rejected — it buys reuse nobody asked for and costs a join on the
hottest read path in the system (the invitation page), plus a second question at event
creation ("existing host or new?") on a form that is already long. Two events sharing a host
means typing the name twice, which is the correct amount of friction for something typed once
per event ever.

*Alternative considered:* a JSONB `host` blob, matching `invitation_messages`. Rejected —
JSONB earned its place there because the shape grows with locales. Here the shape is fixed at
three scalars, one of them `NOT NULL`, and a database-level constraint is precisely the thing
JSONB gives up.

### D2 — `host_name_1` / `host_name_2`, not `primary` / `secondary`

The numeric suffix matches how the fields are described and how they are ordered on screen,
and it survives the case where the two hosts are equal partners — "secondary host" is a
statement about a person that the data does not actually make. The spec uses "primary" and
"secondary" as prose for the requiredness distinction; the columns use the numbers.

### D3 — Required means `NOT NULL`, enforced in three places

Pydantic (`Field(min_length=1)`) rejects the request, the column rejects the row, and the
form disables submission. The database constraint is the one that matters: without it, one
future code path that forgets the check leaves an event the invitation page cannot render a
host block for, and the failure shows up on a guest's screen rather than in a test.

`host_name_2` stores `NULL` for absent, never `""`. Empty string and null both meaning
"no second host" is two representations of one state, and the render logic would have to test
for both forever. The API coerces a blank submission to `NULL` on the way in.

### D4 — Host phone reuses `app/services/phone.normalize_phone`

The strict form, not `try_normalize_phone`. A bad guest row in a 300-row CSV must not abort
the batch, which is why the lenient variant exists; a bad host phone is one admin typing one
number into one form, and silently storing `None` there would violate `NOT NULL` and surface
as a 500 instead of a field error. `InvalidPhoneNumberError` is caught in the router and
re-raised as a 422 naming the field, matching how `_clean_messages` already turns a service
error into a useful 422.

### D5 — Migration is add-nullable → backfill → set-NOT-NULL, in one revision

A single Alembic revision with three `op` steps rather than three revisions, because a
half-applied sequence is what leaves the table in a state neither the old nor the new code can
use. Backfill runs as SQL inside the revision:

- `host_phone` ← `wedding.host_contact_phone`. This one is a genuine derivation: the
  wedding-wide number was always the family's contact number, which is what a host phone is.
- `host_name_1` ← the literal placeholder `Set host name`. `host_name_2` ← `NULL`.

**The names are placeholders because nothing true exists to copy.** The host is a parent, and
`wedding` holds only `bride_name` and `groom_name`. Writing those would put the couple's names
under "hosted by" on every existing invitation — not an approximation of the truth but a
statement the domain says is false. A placeholder is worse-looking and better: it is wrong in
a way an admin notices, rather than wrong in a way a guest believes.

*Alternative considered:* prompt for the real parent names at migration time and write them to
every event. Rejected — an Alembic revision that blocks on input cannot run in CI or in an
automated deploy, and hard-coding one wedding's parents into a revision file bakes today's
single-wedding data into schema history.

**Where `wedding.host_contact_phone` is NULL** the phone has no true value either, and
`NOT NULL` still has to be satisfiable. The revision writes the literal sentinel
`+880000000000`. It is deliberately not a blank, not a fake-plausible number, and not a valid
Bangladeshi mobile: an admin who opens the editor sees an obviously wrong number, and anyone
who dials it reaches nothing rather than reaching a stranger.

Both placeholders are chosen to fail loudly. Task 6.5 covers the release note that tells
operators what to look for.

*Alternative considered:* leave the columns nullable and let the page skip the block when
empty. Rejected — the whole point is that a guest can always see who invited them, and a
nullable column makes "no host" a permanent supported state rather than a migration artefact.

**Downgrade** drops the three columns. That loses host data with no way back, so the revision's
docstring says so plainly.

### D6 — The separator is the literal word `And`, capitalised

Specified by the customer in exactly that form. It is not the typographic default — running
text would use lowercase "and" or an ampersand — so it is recorded here as a decision rather
than left to look like a bug. It is a single constant in one render helper; changing it to
"&" or "and" later is a one-line edit.

The composition helper lives on the **web** side, not the API: it is presentation, and the
API already returns the two names as separate fields that a future Bangla renderer would want
to join differently. The separator itself is a `Dictionary` key in `web/lib/i18n.ts`, not a
bare constant — see D11, which applies the same rule to the label.

### D7 — The host block replaces the wedding-wide phone on `InvitationShell` only

`InvitationShell` is shared by `/i/{token}` and `/e/{slug}`, so one edit covers both invitation
surfaces. The trailing `couple.hostContactPhone` paragraph is deleted and the host block takes
its place at the end of the below-the-fold region.

`CoupleView.hostContactPhone` **stays** in the view model and stays populated, because the
cancellation page and the RSVP-closed message still read it. Those two are messages of the
form "you can no longer do this here, ring someone" — a different job from "here is who
invited you" — and rewiring them is a scope increase the customer explicitly declined when
choosing between the reconciliation options. See Open Questions.

### D8 — Host fields go on `EventView`, and that is PII-safe

`toOpenEventView` must never carry guest data because `/e/{slug}` is reachable by anyone with
the printed QR. The host is the person whose number is *printed on the invitation for guests
to call* — publishing it on the public event page is its purpose, not a leak. The fields are
listed explicitly in `EventView` like every other field, so the boundary rule is followed
rather than excepted.

### D9 — The editor section sits between Settings and Card design

Placement is specified by the customer. It also happens to be the correct seam: everything
above it is staged behind the modal's Save, everything below it (card upload, publish, delete)
acts immediately. Putting host details *after* Card design would place a staged field below
an immediate one, and the modal's existing "Save above applies to the settings only" note
would become false.

Consequence: the host fields are part of the same `PATCH /admin/events/{id}` payload as the
rest of Settings, and need no endpoint of their own.

### D10 — Create form gains one row, not a second step

`EventsView`'s create modal already runs Ceremony/Starts at → Name/Name in Bangla → Venue/
Address → Invitation messages. The host fields become one more two-column row plus the phone,
placed after Venue/Address and before Invitation messages, mirroring the editor's ordering
(details of the event, then who is hosting it, then what guests are told).

### D11 — The block is labelled `Invited By`, and the label is dictionary copy

Specified by the customer in exactly that capitalisation. Without a label the block is two
lines of unexplained text at the foot of the page — a name and a number that a guest has to
infer the purpose of, which is the same flaw the wedding-wide phone had.

The label and the `And` separator are **guest-facing copy**, so both go in `web/lib/i18n.ts`
alongside `dear`, `dressCode` and `getDirections`, with an `en` and a `bn` value like every
other key. Hardcoding them into `InvitationShell` would make the host block the one part of
the invitation that cannot be translated, and the Bangla locale chain is outstanding work that
would then have to come back and unpick it.

This does **not** contradict the Non-Goal above. That rules out Bangla variants of the host
*names* — data typed by an admin, and a person's name is not translated. The label and the
joining word are chrome, which every other piece of invitation chrome already translates.

*Alternative considered:* a label reading "Hosted by". Rejected — the customer specified
"Invited By", and on a wedding invitation the two are not quite synonyms: the hosts are who
the invitation comes *from*, which is what a guest reads at the foot of a printed card.

## Risks / Trade-offs

**Every existing event carries a placeholder host name until someone edits it.** → Accepted
deliberately: the alternative was blocking the upgrade on manual data entry, or writing the
couple's names as a falsehood. The fields are ordinary editable fields and the release note
calls it out. This repo is pre-launch with a demo dataset, so the real-world blast radius is
small — that would not be true after launch.

**A placeholder host name or `+880000000000` could reach a guest.** → A guest whose event was
never corrected sees plainly broken content rather than a wrong-but-plausible name and a
number that connects them to a stranger. Tasks 6.2 and 6.3 are what catch it before guests do;
there is no way to make an un-set required field both invisible and honest.

**`POST /admin/events` gains required fields — a breaking API change.** → The only caller is
this repo's own web app, updated in the same change, plus `make seed` and `make walkthrough`,
both of which are listed as tasks. The generated client makes a missed caller a typecheck
failure rather than a runtime one, provided `make client` is run (task 2.5).

**Two phone numbers may still appear on one page.** → After the deadline passes, `/i/{token}`
shows the RSVP-closed message with the wedding-wide number *and* the host block with the
event's. Both are labelled by context, and the state is temporary and late in the event's
life. Recorded as an open question rather than silently widened scope.

**Host names are unbounded `TEXT` rendered into a fixed-width block.** → Same treatment the
guest name already gets in the header: wrap, never truncate. Two long names joined by "And"
is the worst case and is a spec scenario.

## Migration Plan

1. Deploy the Alembic revision (`make migrate`). It is additive-then-constraining and safe to
   run before the new API image is live: old code ignores the new columns, and the backfill
   ensures the `NOT NULL` steps cannot fail on existing rows.
2. Deploy API and web together — the web build depends on the regenerated client, and the API
   now requires the fields on create.
3. Verify with `make walkthrough`, which creates an event and opens a guest link end to end.
4. Review every pre-existing event in the admin and enter the real host details. Every one of
   them shows `Set host name` until this is done, and any still showing `+880000000000` was
   never given a real number either.

**Rollback:** downgrade drops the three columns and loses the host data entered since the
upgrade. Because the web app renders the host block unconditionally, roll back web and API
together — new web against a downgraded API renders a block with nothing in it.

## Open Questions

- **Should the RSVP-closed message and the cancellation page move to the event's host phone
  too?** Deferred deliberately. It changes no spec written here, no data model and no task —
  it is a two-line swap in two components — and the customer chose the narrower reconciliation
  when asked. Worth revisiting once the host block has been seen on a real invitation.
