lib: cut the comments that re-derive a plan down to citing it
AGENTS.md's rule, applied to the long blocks: "Where the reasoning is
already written down, cite it rather than re-derive it... Two copies of
an argument drift apart, and the plan holds the whole of it."
Every cut below was checked against the document it cites, and only the
half that document already argues was removed. The local fact — what
this file does that no plan says — stays.
- Contact's class comment and #initialize: the birthday and the
inheritance sitting beside the stored card, and why `birthday:` and
`inherited:` are required and the card is `stored:`, are
2026-09-05-contact-takes-its-structure.md, "The contracts".
- Store's class comment: the four irreducible facts are argued in
2026-08-25-sqlite-schema.md, 2026-08-31-partial-birthdays.md and
2026-08-24-vcard-storage-and-groups.md; the transaction-mode and
fan-out paragraphs in 2026-09-09-group-edits-propagate.md.
- Store#put's UTF-8 paragraph: AGENTS.md, "Those tables are STRICT".
- Store#subtract_inherited's four shapes: #classify holds them, and
both group plans argue them.
- ContactsEdit#birthday_picker: nearly every line was
2026-09-15-a-picker-beside-the-birthday.md, down to the reason there
is no guard on a partial birthday.
- ContactsEdit's class comment and #add_property_dialog: the save's
rules are the editor plans', the add affordance is docs/DESIGN.md's.
What stays is this screen's own — telling the person the
blank-removes rule before the save enforces it.
- ProxyAuth, Birthday, Parser, SHAREABLE_NAMES, #unedited?,
#kept_types?: same treatment, smaller blocks.
Left alone: the long blocks that cite nothing, because they are the
only copy of their argument — Layout's case for loading Alpine,
ContactDialog's for the Popover API, CardDiff's for a multiset over an
edit script, Profile's, and the RFC inventories in web/dav.rb.
Prose comment lines in lib go 3,183 → 3,074, and lib 8,725 → 8,545.
The share barely moves (36.5% → 36.0%): only 562 of those lines sat in
a block that cited a document at all, which is the ceiling on this.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WWFQaLD58uLFedh3Dvfb2C
diff --git a/lib/pro_tacts/admin/contacts_edit.rb b/lib/pro_tacts/admin/contacts_edit.rb
index b54af9e..b0d79f7 100644
--- a/lib/pro_tacts/admin/contacts_edit.rb
+++ b/lib/pro_tacts/admin/contacts_edit.rb
@@ -9,42 +9,30 @@ require "pro_tacts/admin/name_pair"
module ProTacts
module Admin
- # GET /contacts/:id/edit — the editor
- # (docs/plans/2026-09-05-web-card-editor.md): an explicit mode
- # rather than an always-editable page, one form over the
- # properties the save knows how to address. The cardinality-1 set
- # — name, nickname, note — saves through VCard#replace under each
- # field; the phone, email, and address rows save through
- # VCard#substitute, each named by its line's digest; the birthday
- # row saves to the model, no card line existing to address
- # (docs/plans/2026-09-07-web-birthday-editor.md).
+ # GET /contacts/:id/edit — the editor. What the save can address,
+ # how each kind of row is written back, and the
+ # blank-equals-absent rule are
+ # docs/plans/2026-09-05-web-card-editor.md and
+ # docs/plans/2026-09-07-web-birthday-editor.md; the fields
+ # prefill from the accessors' unescaped readings so write and read
+ # agree on what an empty value means.
#
- # The fields prefill from the accessors' unescaped readings, and
- # blank equals absent on the way back (CardForm.contact_card), so
- # write and read agree on what an empty value means. Blank's meaning is
- # stated before the save, not only enforced by it: a standing row
- # whose blanking deletes — a phone, email, or address line, the
- # nickname or note property, a held birthday — wears
- # `data-blank-removes`, its single-value box says "removed on
- # save" in its blank, and admin.css strikes the caption in
- # Gloss's danger color once every value in the row reads blank —
- # destructive is what that color means there, carried by the
- # caption rather than the boxes' borders, a blank row being a
- # valid save and Gloss's danger border the aria-invalid
- # contract. The birthday row renders only over a held birthday —
- # absence is added by the dialog like any other property — and
- # earns a remove control of its own, three separate blanks
- # being a rule nobody can guess: one click empties the row
- # (Alpine's own verb, a mutation markup cannot do) and the blank
- # rule does the rest. The state itself is CSS over :placeholder-shown, not
- # Alpine — revealing a state over standing elements is markup's
- # job (see Layout). The rows the add dialog reveals wear none of
- # it, their blank a no-op rather than a removal; the nickname
- # and note rows, which render whether or not the card carries
- # the property, wear it only over something to lose (each row's
- # own comment says which). The etag rides
- # along hidden — the snapshot guard's half, the POST's refusal
- # being the other. `autofocus` on the first field: this screen's
+ # What is this screen's own is telling the person that rule before
+ # the save enforces it. A standing row whose blanking deletes
+ # wears `data-blank-removes`, its single-value box says "removed
+ # on save" in its blank, and admin.css strikes the caption in
+ # Gloss's danger color once every value in the row reads blank.
+ # The caption carries the color rather than the boxes' borders,
+ # because a blank row is a valid save and Gloss's danger border is
+ # the aria-invalid contract. The state is CSS over
+ # :placeholder-shown rather than Alpine — revealing a state over
+ # standing elements is markup's job (see Layout).
+ #
+ # Two kinds of row wear none of it: the ones the add dialog
+ # reveals, whose blank is a no-op rather than a removal, and a
+ # nickname or note row over a card that carries neither, which has
+ # nothing to lose. The etag rides along hidden, the snapshot
+ # guard's half. `autofocus` on the first field: this screen's
# entry point is the name, not the header search.
class ContactsEdit < Phlex::HTML
# The property types the add dialog offers, in the order it
@@ -219,13 +207,10 @@ module ProTacts
# one per type named in the dialog, in the order they were
# asked for. Nothing stands here until something is added and
# nothing lingers after: an empty row waiting to be used is the
- # scaffold docs/DESIGN.md refuses, and a trailing one left over
- # from a row already filled is the same scaffold arriving late.
- # A blank added row is a no-op rather than a removal
- # (CardForm.edited_values), so these rows wear no removal state —
- # the class comment's exemption.
- # That verb — make a row, now, without a round trip — is what
- # CSS could not do and what Alpine is here for (see Layout).
+ # scaffold docs/DESIGN.md refuses ("Records are cards, not
+ # forms"). That verb — make a row, now, without a round trip —
+ # is what CSS could not do and what Alpine is here for (see
+ # Layout).
#
# The row is a div rather than the standing rows' label because
# one template serves every kind and the address kind holds six
@@ -368,18 +353,12 @@ module ProTacts
# The calendar affordance: a date input the row never shows and
# never submits, and the button that opens its native picker.
- # The three fields stay the value — a date input holds only the
- # complete shape, and four of the six this row edits have no
- # spelling in one — so the picker writes into them and they are
- # what posts (docs/plans/2026-09-15-a-picker-beside-the-birthday.md).
- #
- # The input carries no `name`, so nothing of it reaches the
- # save; `tabindex` and `aria-hidden` keep it out of both orders,
- # the button being the control a person actually meets. It is
- # hidden by being taken out of flow at zero width rather than by
- # `display: none` (admin.css), because showPicker() on an
- # element that is not rendered is not a thing the HTML standard
- # promises to honor.
+ # Why the picker writes the three fields instead of being one,
+ # why the input is hidden out of flow rather than by
+ # `display: none`, why it carries no name and sits outside both
+ # the tab order and the accessibility tree, and why opening
+ # syncs from the boxes with no guard for a partial birthday are
+ # all docs/plans/2026-09-15-a-picker-beside-the-birthday.md.
#
# aria-hidden is spelled out rather than left bare: ARIA's
# true/false attributes are enumerated, not HTML booleans, and
@@ -388,16 +367,6 @@ module ProTacts
# harmless doing it — a decorative svg inside a labelled button
# contributes no name either way — but this is an input, and
# an input that reaches the tree unlabelled is not harmless.
- #
- # Opening syncs the picker to the fields rather than the fields
- # being prefilled once at render: a date typed into the boxes is
- # then where the calendar opens. A blank component makes a
- # string no date input accepts, and the assignment of an invalid
- # value leaves the input empty (HTML section 4.10.5.1.7) — so a
- # partial birthday opens the picker on its own default with no
- # guard here. Picking writes all three fields, which is the
- # trade this control is: the year is the one a no-year birthday
- # then blanks by hand.
#: () -> void
def birthday_picker
sync = "const r = $el.closest('.date-row'), n = r.querySelectorAll('input[type=number]'), " \
@@ -444,25 +413,12 @@ module ProTacts
# Where a property a contact does not have yet gets named —
# docs/DESIGN.md's "a quiet add affordance opens a native dialog
- # that names the attribute types available." It replaces the
- # blank add-row that used to sit in the stack, because that row
- # was an empty attribute with a caption on it and the card is
- # supposed to render the record's real weight. One row per type
- # would have been three of them by the time emails and addresses
- # land; one dialog is one row of chrome no matter how many types
- # it names.
- #
- # The dialog names a type and nothing else. It holds no value,
- # so there is nothing staged out of sight, and Cancel is a plain
- # hide with nothing to undo — Add is the only thing that changes
+ # that names the attribute types available." It holds no value,
+ # so there is nothing staged out of sight and Cancel is a plain
+ # hide with nothing to undo; Add is the only thing that changes
# the form, and what it changes is visible in the card the
# moment the popover closes.
#
- # The options are the kinds of row the save can insert, the
- # birthday only over a contact that holds none (#addable_types)
- # — and its radio leaves the list the moment its row stands,
- # so the dialog never names what cannot be added again.
- #
# The radios share a `name` so they are one native group —
# arrow-key navigation and "1 of n" come from that, not from
# `x-model`, which only tells Alpine which one is picked. The
diff --git a/lib/pro_tacts/birthday.rb b/lib/pro_tacts/birthday.rb
index c39905b..d1f61de 100644
--- a/lib/pro_tacts/birthday.rb
+++ b/lib/pro_tacts/birthday.rb
@@ -18,9 +18,9 @@ module ProTacts
# Every well-shaped BDAY a client sends is read into this model, and
# only the two shapes that survive a round trip through both Apple
# clients go back out (#to_line); the other four are kept and shown
- # but never sent. Store moves the line out of the card and composes it
- # back in. The reasoning is docs/plans/2026-09-11-every-birthday-in-the-model.md;
- # why a partial date cannot live in a vCard 3.0 card is
+ # but never sent. Why, and why a partial date cannot live in a vCard
+ # 3.0 card at all, are
+ # docs/plans/2026-09-11-every-birthday-in-the-model.md and
# docs/plans/2026-08-31-partial-birthdays.md.
#
# The signature lives in sig/pro_tacts/birthday.rbs: a Data class has
diff --git a/lib/pro_tacts/contact.rb b/lib/pro_tacts/contact.rb
index 764f771..026eaad 100644
--- a/lib/pro_tacts/contact.rb
+++ b/lib/pro_tacts/contact.rb
@@ -10,44 +10,35 @@ module ProTacts
# One contact: its id, the card served for it, and the etag over that
# card's bytes — the one model of a contact
# (docs/plans/2026-09-01-contact-is-the-model.md). The id is the
- # vCard's UID and the name in its href (see
- # docs/plans/2026-01-12-carddav-architecture.md).
+ # vCard's UID and the name in its href
+ # (docs/plans/2026-01-12-carddav-architecture.md).
#
- # A VCard rather than the bytes, because a contact that held bytes
- # would have to make a card out of them to answer anything, and every
- # caller handing one over already had a card in hand to make the bytes
- # from. One card is built per contact and every read is asked of it.
+ # What it holds is the *stored* card — a VCard rather than its bytes,
+ # since every caller handing one over already had a card in hand and
+ # a contact holding bytes would have to make one to answer anything —
+ # with the birthday and the lines its groups lend beside it rather
+ # than parsed back out of the card they compose into; #vcard composes
+ # the served one from the three.
+ # Why each sits beside the card instead of in it, and why they are
+ # independent rather than two spellings of one fact, is
+ # docs/plans/2026-09-05-contact-takes-its-structure.md,
+ # docs/plans/2026-08-31-partial-birthdays.md, and
+ # docs/plans/2026-08-24-vcard-storage-and-groups.md. An unmodeled
+ # BDAY spelling stays the card's own: #properties shows it, and
+ # #birthday reads nil over it.
#
- # The card this holds is the stored one, and the birthday sits beside
- # it rather than being parsed back out of the card it was composed
- # into (docs/plans/2026-09-05-contact-takes-its-structure.md): Store's
- # write either moves a BDAY line into the model or leaves it in the
- # card, never both, so the two facts are independent and #vcard
- # composes the served card from them. An unmodeled BDAY spelling is
- # the card's own — #properties shows it, and #birthday reads nil over
- # it. The lines a contact inherits from its groups sit beside the
- # birthday the same way: no stored card carries them, so they are
- # store facts composed in at read
- # (docs/plans/2026-08-24-vcard-storage-and-groups.md).
- #
- # The etag hashes the card that goes out, so it changes exactly when
- # what the client downloads changes. It is derived here and stored
- # nowhere: a contact read back from the store hashes its card the same
- # way one about to be written does, which is why there is one
- # constructor rather than one that computes and one that trusts. It is
- # in the entity-tag's quoted form (RFC 7232 section 2.3), which is what
- # both the ETag header and getetag properties carry.
- #
- # The structured accessors are that idea applied to the card's
- # contents: everything else derives from the bytes, and the card defers
- # the walk that reads them until something asks. The CardDAV paths never
- # ask — they answer in bytes — so the serving paths pay for no parse
- # they do not use, and that laziness is VCard's to keep rather than
- # this class's to arrange.
+ # The etag hashes the composed card, so it changes exactly when what
+ # the client downloads changes, and is derived here and stored
+ # nowhere — one constructor, rather than one that computes it and one
+ # that takes it on trust. Quoted, the entity-tag's own form (RFC 7232
+ # section 2.3), which both the ETag header and getetag carry.
#
# The accessors are deliberately shallow, like the parser: a card can
# carry properties no accessor knows, and CardDAV still serves every
- # one. #properties is the read for those.
+ # one — #properties is the read for those. Their walk is lazy and
+ # that is load-bearing rather than style, for the reason the first
+ # plan gives under "Contact stops being a Data class": the paths that
+ # answer in bytes never ask.
class Contact
# @rbs @id: String
# @rbs @stored: VCard
@@ -66,15 +57,12 @@ module ProTacts
# ADR's seven components (section 3.2.1: post office box, extended
# address, street, locality, region, postal code, country — nil
# where the card left the position blank or stopped short of it).
- # A phone also carries a `label`, the `X-ABLabel` sharing its
- # property group — macOS's spelling of a custom label, a different
- # fact from the types and carried beside them
- # (docs/plans/2026-09-18-phone-labels.md, "Reading").
- # Each also carries `line`, the parsed line the value was read
- # from: the address a save names that row by
- # (docs/plans/2026-09-05-web-card-editor.md) — the accessors fold
- # lines into typed values, and without provenance no form could
- # say "edit this phone."
+ # A phone's `label` is the `X-ABLabel` sharing its property group,
+ # a different fact from its types
+ # (docs/plans/2026-09-18-phone-labels.md, "Reading"), and every
+ # shape's `line` is the parsed line it was read from, the address
+ # a save names that row by
+ # (docs/plans/2026-09-05-web-card-editor.md).
# Data classes, whose members the inline syntax cannot read; the
# signatures live in sig/pro_tacts/contact.rbs.
# @rbs skip
@@ -111,20 +99,14 @@ module ProTacts
end
# A contact from its id, its stored card, its birthday, and the
- # content lines it inherits from its groups. An etag that came from
- # anywhere but the card in hand is an etag that can be wrong, and a
- # birthday or an inheritance from anywhere but the store is wrong
- # the same way — which is why `birthday:` and `inherited:` are
- # required and carry no defaults, since an optional nil would let a
- # caller quietly get no birthday off a card that has one, and an
- # omitted inheritance would serve a member its group's lines no
- # longer reach. Named `stored:` rather than `vcard:` because what
- # #vcard returns is composed, and an argument that is not what the
- # reader hands back is a trap.
+ # content lines it inherits from its groups. Why `birthday:` and
+ # `inherited:` are required and carry no default, and why the card
+ # is `stored:` rather than `vcard:`, is
+ # docs/plans/2026-09-05-contact-takes-its-structure.md, "The
+ # contracts".
#
- # The id check is here rather than behind a factory: an id outside
- # ID_FORMAT cannot be served, and a guard a caller can walk around
- # by reaching for `new` is not a guard.
+ # The id check is here rather than behind a factory: a guard a
+ # caller can walk around by reaching for `new` is not a guard.
#: (id: String, stored: VCard, birthday: Birthday?, inherited: Array[Inherited]) -> void
def initialize(id:, stored:, birthday:, inherited:)
raise ArgumentError, "invalid contact id: #{id}" unless id.match?(ID_FORMAT)
diff --git a/lib/pro_tacts/proxy_auth.rb b/lib/pro_tacts/proxy_auth.rb
index aefbea5..e62b38c 100644
--- a/lib/pro_tacts/proxy_auth.rb
+++ b/lib/pro_tacts/proxy_auth.rb
@@ -1,14 +1,12 @@
require "pro_tacts"
module ProTacts
- # Who is asking: the login on Remote-User, which the proxy in front of
- # this app writes (docs/plans/2026-09-15-identity-from-one-header.md).
- #
- # The Caddy site's `header_up` sets Remote-User, overwriting whatever
- # arrived, so a client cannot supply its own. That makes the header
- # trustworthy, but only behind such a proxy: reached directly, the app
- # trusts whatever it is handed. The app must not be listening anywhere
- # but localhost.
+ # Who is asking: the login on Remote-User, which the proxy in front
+ # of this app writes, and which is trustworthy only behind such a
+ # proxy — reached directly the app trusts whatever it is handed, so
+ # it must not be listening anywhere but localhost
+ # (docs/plans/2026-09-15-identity-from-one-header.md, "Which
+ # header").
#
# A request that names nobody is refused at the top of Web's route
# (Web#unauthorized). That covers the two cases Tailscale documents as
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index 7b8f34a..1aa9511 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -18,20 +18,14 @@ module ProTacts
# card exactly as it was submitted.
#
# Four kinds of state live here and they are not equally precious.
- # The cards are the truth about contact data, and the reason they are
- # stored as the card rather than a parse of it is
- # docs/plans/2026-08-24-vcard-storage-and-groups.md. The change log is
- # the truth about history, and is why this is one database rather than
- # a directory of files: "what changed since token X" cannot be
- # recovered from current state, so a card, its etag, and its
- # change-log entry have to land or fail together. The birthdays are
- # the third thing that cannot be rebuilt — a partial date has no
- # vCard 3.0 spelling, so it lives beside its card rather than in it
- # (docs/plans/2026-08-31-partial-birthdays.md). The groups are the
- # fourth — membership and the lines a group contributes to its
- # members' served cards, facts no stored card carries and so nothing
- # can re-derive. Everything else is an index derived from the cards,
- # and #rebuild_index will make it again from nothing.
+ # Only the cards, the change log, the birthdays, and the groups
+ # cannot be rebuilt, and each is argued where it was decided: the
+ # first two in docs/plans/2026-08-25-sqlite-schema.md, "What the
+ # tables are for", the birthdays in
+ # docs/plans/2026-08-31-partial-birthdays.md, the groups in
+ # docs/plans/2026-08-24-vcard-storage-and-groups.md. Everything else
+ # is an index derived from the cards, and #rebuild_index will make
+ # it again from nothing.
#
# Every Contact this store hands out is composed, never the stored
# card alone: a birthday and the lines a contact inherits from its
@@ -40,12 +34,6 @@ module ProTacts
# sees, and the etag the change log records, describe the card a
# client downloads, not the bytes on disk.
#
- # Sequel's transactions join one already open rather than failing on
- # SQLite's lack of nesting, which is what lets the group fan-out this
- # design needs — one member's card rewriting its group and every other
- # member's card — call #put from inside a larger write and still be a
- # single atomic unit.
- #
# The signature lives in sig/pro_tacts/store.rbs, for the Change Data
# class the inline syntax cannot read.
class Store
@@ -173,15 +161,11 @@ module ProTacts
# no value types — and this is the one caller that compares values.
STRUCTURED_VALUES = %w[ADR].freeze #: Array[String]
- # The property names a group may lend, which is a second copy of
- # the CHECK in db/migrations/004_groups.rb and says so here. The
- # constraint stays the authority; this is the pre-check a
- # propagated edit passes first, because the line a member's client
- # hands back is not always one the group can hold — an address
- # edited in a type Contacts cannot model comes back as an
- # `item1.ADR` with its label beside it (see #kept_types?), which
- # the CHECK refuses. Without this the refusal would be an
- # exception on an ordinary sync
+ # The property names a group may lend, a second copy of the CHECK
+ # in db/migrations/004_groups.rb and saying so here. The constraint
+ # stays the authority; this is the pre-check a propagated edit
+ # passes first, so that a line the group cannot hold is refused
+ # rather than raising on an ordinary sync
# (docs/plans/2026-09-09-group-edits-propagate.md, "The line a
# group cannot hold").
SHAREABLE_NAMES = %w[ADR NOTE].freeze #: Array[String]
@@ -236,14 +220,9 @@ module ProTacts
# pragma: Sequel turns them on for every SQLite connection it
# opens, which is what makes the index's cascades fire.
@database.run("PRAGMA journal_mode = WAL")
- # BEGIN IMMEDIATE for every transaction, so that the writes which
- # read before they write serialize instead of one of them dying.
- # A deferred transaction — SQLite's default — takes no write lock
- # until its first write, and the busy handler declines to retry
- # that upgrade because retrying could deadlock, so the second of
- # two concurrent group edits gets SQLITE_BUSY straight back
- # however long BUSY_TIMEOUT is. Taking the lock at BEGIN is what
- # puts the wait back under the timeout
+ # BEGIN IMMEDIATE for every transaction, so the writes that read
+ # before they write serialize instead of one of them dying on
+ # SQLITE_BUSY however long BUSY_TIMEOUT is
# (docs/plans/2026-09-09-group-edits-propagate.md, "Two edits,
# one shared value"). Readers are unaffected: WAL is on above.
@database.transaction_mode = :immediate
@@ -441,31 +420,25 @@ module ProTacts
# this card hashed to now, and the index rows read off the card. One
# transaction, because the log entry cannot be rebuilt from anything.
#
- # What is stored is the card minus its birthday, which moves into
- # the birthdays table — vCard 3.0 cannot carry a partial date, so
- # no stored card carries the model's BDAY and every read composes
- # it back in (docs/plans/2026-08-31-partial-birthdays.md).
+ # What is stored is the card minus its birthday and minus what its
+ # groups lend it, both composed back in on read
+ # (docs/plans/2026-08-31-partial-birthdays.md,
+ # docs/plans/2026-08-24-vcard-storage-and-groups.md).
#
# A card rather than its bytes, so the reading a caller already
# made is the one #split_birthday decides from: a PUT has asked
# whether the bytes are a card at all and whose UID they carry
- # before it gets here (Web#write_card), and taking the card it
- # asked those of leaves the walk behind them made once.
+ # before it gets here (Web#write_card).
#
- # The strings are UTF-8 by contract, and the adapter holds the store
- # to it: the sqlite3 gem encodes every bound value to UTF-8, so a
- # binary-flagged byte above 7 bits raises at the bind, and bytes
- # that are not UTF-8 at all stop at the insert, which SQLite refuses
- # to store as text. The body — the one binary input — is relabelled
- # and judged in the same breath where it is read, in write_card, so
- # nothing that is not text gets this far; VCard's own raise, at the
- # construction the caller makes, is the assertion under that, and
- # the bind is the third line.
+ # The strings are UTF-8 by contract and the bind is the third line
+ # holding them to it, under Web#write_card's relabel-and-judge and
+ # VCard's own raise. AGENTS.md, "Those tables are STRICT", has the
+ # whole of it.
#
# `client` marks a client's write: a card it creates joins `sync:*`,
- # created on first use, or it would drop out of the collection it
- # was written to (docs/plans/2026-09-15-client-creates-join-everyone.md).
- # A rewrite of a card that exists joins nothing.
+ # created on first use
+ # (docs/plans/2026-09-15-client-creates-join-everyone.md). A
+ # rewrite of a card that exists joins nothing.
#: (String id, VCard vcard, ?client: bool) -> Contact
def put(id, vcard, client: false)
existing = birthday_of(id)
@@ -898,31 +871,19 @@ module ProTacts
# The block's group writes, bracketed by a read of every member
# they could move: what each served before and what each serves
- # after, and a `group` entry wherever the two differ. A group edit
- # moves bytes on cards nobody wrote to, and a client is told a card
- # changed only by the log — a sync token silently skips whatever
- # the log missed (see the class comment).
- #
- # An entry only where the bytes actually moved, which is narrower
- # than one per member: a rename moves nothing and a group that
- # lends nothing lends nothing to a new member either, and an entry
- # for either would spend every client's next poll re-fetching a
- # card that reads the same. `except` is the member doing the
- # writing, whose own entry already carries this composition
- # (docs/plans/2026-09-09-group-edits-propagate.md, "The fan-out").
+ # after, and a `group` entry wherever the two differ. Why an entry
+ # only where the bytes moved, why `except` is the writing member,
+ # and why this runs inside the caller's own transaction are
+ # docs/plans/2026-09-09-group-edits-propagate.md, "The fan-out".
#
- # `moved` is the exception: cards logged whatever their bytes did,
- # because a `sync:` membership moves a card into or out of a book
- # without touching what it serves, and the log is the only way a
- # client's sync token hears of it
+ # `moved` is the exception the plan does not cover: cards logged
+ # whatever their bytes did, because a `sync:` membership moves a
+ # card between books without touching what it serves
# (docs/plans/2026-09-12-per-user-books.md, "The change log").
#
# Card ids rather than groups, because the cards a write can move
# are not always members yet: a card joining a group is read before
# the row that makes it one exists.
- #
- # Inside whatever transaction the caller has open: Sequel joins one
- # rather than nesting, which is what lets this run from #put.
#: (Array[String] cards, ?except: String?, ?moved: Array[String]) { () -> void } -> void
def fan_out(cards, except: nil, moved: [])
ids = cards - [except].compact
@@ -1134,20 +1095,10 @@ module ProTacts
# minus inherited and a client that PUTs back what it downloaded
# stores what it started with
# (docs/plans/2026-08-24-vcard-storage-and-groups.md, "Groups
- # compose into cards"). Without this a member's rewrite would
- # materialize the group's lines into its own card, and a later edit
- # to the group would reach nobody.
- #
- # Each lent line is classified against the submission, and three of
- # the four shapes act. The line coming back saying what the group
- # lends is the group's, untouched, and it goes. One line of that
- # name left over is an edit to the shared value: it goes too, and
- # the group's row takes its bytes, so the member does not end up
- # carrying its own copy beside the one composition puts back. None
- # left over is a deletion, and the group's row goes with it
- # (docs/plans/2026-09-09-group-edits-propagate.md). More than one
- # left over is a line this cannot attribute at all, and reports
- # rather than guesses.
+ # compose into cards"). Which of four shapes a lent line came back
+ # as, and what each asks of its group, is #classify — argued in
+ # that plan's "Classifying what came back" and in
+ # docs/plans/2026-09-09-group-edits-propagate.md.
#
# Two passes, because one lent line must not be attributed a line
# another lent line would have matched exactly. A member of two
@@ -1259,22 +1210,16 @@ module ProTacts
# Whether a submitted line still says what the group lends, which
# is a question about what it says and not about its bytes: macOS
- # re-serializes every card it touches, so `ADR;TYPE=home` comes
- # back `ADR;type=HOME;type=pref` on an address nobody edited
+ # re-serializes every card it touches
# (docs/apple-contacts.md, "The client rewrites every card it
- # touches"). Comparing bytes reads every such line as an edit.
- #
- # What a line says is its value and its types: a member who
- # relabels a lent address from home to work edited the shared line
- # as surely as one who changed a digit of it, and an edit is the
- # group's to take (the plan's "Edits propagate to the group"). So a
- # type that moved is not subtracted, and stays in the member's card
- # where the propagation will find it.
+ # touches"), and comparing bytes reads every such line as an edit.
#
- # Every other parameter goes uncompared, being the half the client
- # rewrites without being asked: it drops the ones it does not model
- # — a `NOTE;LANGUAGE=en` comes back bare — and fills in defaults on
- # the ones it does.
+ # What a line says is its value and its types, because a relabel is
+ # an edit the group takes like any other
+ # (docs/plans/2026-08-24-vcard-storage-and-groups.md, "Edits
+ # propagate to the group"). Every other parameter goes uncompared,
+ # being the half the client rewrites unasked — it drops the ones it
+ # does not model and fills in defaults on the ones it does.
#
# A line that will not read has no value to compare and falls back
# to its bytes, which still recognize the line nobody touched.
@@ -1287,19 +1232,16 @@ module ProTacts
end
# Whether a submitted line carries the types the group lent it,
- # across the three rewrites they survive: the values come back
- # uppercased and `pref` filled in, so neither side's case counts
- # and neither counts `pref` (types_of drops it). An untyped line
- # comes back untyped, so no types compares to no types and a
- # member who labels one has edited it.
+ # across the three rewrites they survive. Two are cosmetic and
+ # Property#types already absorbs them: the values come back
+ # uppercased, and `pref` comes back filled in.
#
- # The third: a type Contacts has no field for comes back moved,
- # not kept — into a property group, `TYPE` parameter gone, the type
- # the value of an `X-ABLabel` beside it — on an address nobody
- # touched (docs/apple-contacts.md, "An address type the client
- # cannot model becomes a custom label"). So that label counts as
- # one of the line's types, and `ADR;TYPE=dom` coming back as
- # `item1.ADR` with `item1.X-ABLabel:dom` is unedited.
+ # The third moves the type off the line: a type Contacts has no
+ # field for comes back as an `X-ABLabel` in a property group
+ # (docs/apple-contacts.md, "An address type the client cannot
+ # model becomes a custom label"), so that label counts as one of
+ # the line's types and `ADR;TYPE=dom` coming back as `item1.ADR`
+ # with `item1.X-ABLabel:dom` is unedited.
#: (VCard::Parser::Line line, VCard::Parser::Line lent, Array[VCard::Parser::Line] pool) -> bool
def kept_types?(line, lent, pool)
label = label_of(line, pool)&.property
diff --git a/lib/pro_tacts/vcard/parser.rb b/lib/pro_tacts/vcard/parser.rb
index 22f7e1e..0c8d6b4 100644
--- a/lib/pro_tacts/vcard/parser.rb
+++ b/lib/pro_tacts/vcard/parser.rb
@@ -8,13 +8,13 @@ module ProTacts
class VCard
# Reads a card's bytes into Lines.
#
- # Deliberately shallow: a property is a name, its parameters, and the
- # text of its value, and nothing here knows what any of them mean.
- # That is not a shortcut. The stored card is authoritative and this
- # is only a projection of it, so a property this code has never heard
- # of has to travel through untouched, which is what RFC 6352 section
- # 6.3.2.2 requires of a CardDAV server. See
- # docs/plans/2026-08-24-vcard-storage-and-groups.md.
+ # Deliberately shallow: a property is a name, its parameters, and
+ # the text of its value, and nothing here knows what any of them
+ # mean. That is not a shortcut — a property this code has never
+ # heard of has to travel through untouched (RFC 6352 section
+ # 6.3.2.2, and docs/plans/2026-08-24-vcard-storage-and-groups.md
+ # for why the stored card is the authority and this only a
+ # projection of it).
#
# The whole reading half of VCard lives here — unfolding, the split
# into logical lines, the reads over them, and the values they come