1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
# The birthday editor: a row that writes the model

2026-09-07. Task uw's last stage, deferred to this doc by
docs/plans/2026-09-05-web-card-editor.md's staging: "Birthday, last:
it writes the model rather than the card, so it needs a store write
that bumps the change log from the model side." Everything above is
that sentence unpacked.

## Why this row is not the others

The editor's safety so far is addressing: a row names the line it
edits by digest, and the save splices that line's bytes. A birthday
has no line to name. It is database state — a partial date has no
vCard 3.0 spelling, so it lives in the birthdays table and composes
into the served card on read (docs/plans/2026-08-31-partial-birthdays.md).
The birthday row's save is an upsert, not a splice, and its
prefill is the model (`Contact#birthday`), not a reading off the card.

That is also why the row can offer what no client renders. The
grammar's six shapes (RFC 6350 section 4.3.1) are all editable — a
year alone, a month alone, year-and-month, a day alone — because the
model holds them all and `Birthday#to_s` renders each. What `to_line`
cannot serve reaches no device and the row still round-trips it; that
is the documented fate of those shapes, not a new decision.

## The store write

`Store#rewrite` gains the birthday: `rewrite(id, vcard, birthday:)`,
required and without a default — `Contact.for`'s own rule, that an
optional nil lets a caller quietly hold no birthday off a contact
that has one, is why the parameter's meaning change must be noticed
at every call site. The web save always passes one: the parsed row
when the form posted it, the contact's current model when it did not
(a POST from anything but this form — the phones' own `is_a?(Hash)`
posture, where a group the request never carried touches nothing).

One transaction, one change-log entry, as before — but the entry's
etag is now the composed hash of the card *and* the birthday being
written, because a birthday edit moves the served card and a sync
token must see it. The entry's action stays `edit`: to a syncing
client the member changed at a new etag, which is all an edit says,
and migration 003 already admits it. The card upsert runs on a
birthday-only save too — identical bytes, but `updated_at` moves and
"recently updated" is the honester for it, the same trade the ctag's
comment records for rewrites that store identical bytes.

`write_birthday` is reused from `put` unchanged: an upsert for a
birthday, a delete for nil. Nil is all three fields blank — blank
equals absent, the form's uniform rule, so a birthday is removed the
way a phone row is.

## The one hazard: a card that carries its own BDAY

No stored card carries a *modeled* BDAY, but a card can carry lines
the model refused — the reduced forms, a foreign sentinel, a BDAY
sharing bytes with something else (Store#put's case analysis). For
such a contact the model is nil and the served card still shows the
card's own spelling, which is what the details page renders and the
raw vCard section shows.

A birthday submitted against that state would compose a second BDAY
beside the card's own, and the next client rewrite of that card lands
in put's cardinality-broken arm — reported, but a mess this editor
made. So the save refuses: a non-blank birthday row against a stored
card carrying BDAY lines is a toast and nothing written, the
blank-name backstop's shape. The whole save refuses, not just the
row — one submit, one etag check, one write, the plan doc's own
arithmetic.

The alternative — extract the card's BDAY lines and migrate the
birthday into the model — was designed down to its error and set
aside. For unmodelable spellings the extraction drops data or keeps
the line and recreates the double-BDAY; keeping them safe means
importing put's carried-and-lost machinery into the editor, which is
client-submission semantics, the exact thing `rewrite` exists to
avoid. The probe task (kxyvsxzx, "Probe the birthday shapes against
iOS Contacts") is what dissolves this class of states or makes it
worth serving; until then the refusal is the honest edge.

## The row

Three controls in the value column on one line — month as a select
of names (validated by construction, and names rather than numbers
is the friendlier read), day and year as number inputs with native
min and max. Order month, day, year, matching `Birthday#to_s`'s own
"December 10, 1985". A component is not an attribute and earns no
type column of its own; the caption is `birthday`, the row's one
label, and the controls carry their own aria-labels.

A shape the grammar refuses — a day with no month, digits where a
number should be — is refused with a toast and nothing written. The
browser's own input constraints make that a hand-crafted POST's
backstop, the name toast's sibling: `Birthday.new`'s ArgumentError
is the grammar's own refusal, caught at the one boundary where a
re-render is the fallback. The checks keep apply_edit's order — the
request's validity first (name, then the row's shape), the
conditionals on stored state after (the etag snapshot, then the
card's own BDAY).

## Non-goals

- Promoting the four unrendered shapes onto the wire, or any change
  to what `to_line` serves. That is the probe's question.
- The change-log display on the contact's page distinguishing a
  birthday edit from a card edit. The log records which side wrote;
  within the editor's side, which row, is not a fact anyone has
  asked for.
- Editing an unmodelable BDAY spelling in place. The refusal above
  is the scope; the raw card stays the truth for those.