docs: say what is true after the ChangeId move and the shipped features
The README opened by saying the three features the design is shaped
around do not exist yet. All three have shipped: groups lend their
members attributes, each login syncs its own book, and `rake db:dump`
writes the store out. Status covered two of them and never mentioned
the first, so it gains a paragraph on what a group lends and what a
member's edit does to it.
AGENTS.md's layout map had drifted to about half of lib/: no admin/,
which is a third of the code, and no birthday, birthday_line,
card_diff, change_id, dev_login, proxy_auth or warnings. It also still
said vcard.rb folds, which it stopped doing when fold went.
docs/DESIGN.md called this a single-user admin with "no second user to
design around", written before per-user books. The admin is in fact
unscoped -- contacts_by_recency and all_groups take no login, so every
screen shows the whole store to whoever is looking -- while the sync is
per-login. That is a decision a new screen needs to know, so it says so
rather than leaving the reader to infer it from a stale sentence.
Two citations went stale under the ChangeId refactor and one was
ambiguous all along:
- db/migrations/005 pointed at Store#next_group_id, which the refactor
deleted, and called the alphabet "reverse-hex", which it no longer
goes through. The shape its CHECK pins is unchanged; only the comment
moved. (A comment is not the schema the never-edit-a-migration rule
is about, and Sequel tracks migrations by filename.)
- Store::GROUP_ID_ATTEMPTS reasoned from a width that now lives at the
call site, so it names where to look.
- Birthday's constructor and its test credited "#to_i", which reads as
Birthday's own; the producer is String's, in from_value and in
CardForm.birthday_component. Qualified, the way profile.rb writes
String#scan.
Every Klass#method and #method citation in lib/, test/, db/ and tasks/
was checked against the definitions in the tree; all of them resolve.
steep is clean and every Ruby file parses. The suite still needs Ruby
4, which this container does not have.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UXvcTFuXPr8ecpyD99h33M
diff --git a/AGENTS.md b/AGENTS.md
index 6106d7d..76cb21c 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -49,16 +49,25 @@ lib/pro_tacts/
├── web/contacts.rb # The card browser and its editor
├── web/groups.rb # The group screens and their editor
├── web/setup.rb # The device setup screen and its profile
+├── admin/ # The Phlex views those screens render, plus
+│ # card_form.rb, which reads a form back into a card
├── store.rb # Sequel over SQLite: cards, change log, derived index
├── contact.rb # the one model of a contact: id, vCard, etag,
│ # and the structured accessors over the card
-├── vcard.rb # vCard 3.0 escaping and folding, and what a card is
+├── birthday.rb # A partial date, the one fact no card can hold
+├── birthday_line.rb # What a submitted BDAY line is, for Store#put
+├── card_diff.rb # The lines a write added and removed
+├── vcard.rb # vCard 3.0 escaping, and what a card is
├── vcard/parser.rb # One card's bytes into lines
+├── proxy_auth.rb # The login, off the header the proxy writes
+├── dev_login.rb # That header, filled in for `rake dev` (dev.ru only)
+├── change_id.rb # Ids in the letters jj spells a change id with
├── config.rb # Every environment read in the app
├── profile.rb # carddav.mobileconfig generation
├── exchange_log.rb # Failed DAV exchanges, whole, under an id Sentry
│ # carries
-└── refusal_alerts.rb # DAV refusals as Sentry warnings, one per route
+├── refusal_alerts.rb # DAV refusals as Sentry warnings, one per route
+└── warnings.rb # $VERBOSE off for a block, for the phlex requires
lib/roda/plugins/dav_verbs.rb # PROPFIND and REPORT routing verbs
lib/sequel/extensions/sole.rb # `first`, minus the ambiguity
db/migrations/ # Sequel migrations, run on every store open
diff --git a/README.md b/README.md
index 5ac9621..de5f0fd 100644
--- a/README.md
+++ b/README.md
@@ -9,14 +9,9 @@ A CardDAV server for my family.
- Apple devices only (it might happen to work on other CardDAV clients, but
only accidentally)
-## Features it is being built for
-
-None of these exist yet; they are what the design is shaped around. What
-does work is under Status below.
-
-- Groups with attributes (e.g., an address shared by all members)
-- Selective sync (choose which contacts to sync rather than all-or-nothing)
-- A readable dump of the database, for history kept in version control (`rake db:dump`)
+The three features the design was shaped around — groups with shared
+attributes, selective sync, and a readable dump for version control —
+are all serving now; Status covers each.
## Status
@@ -47,6 +42,14 @@ cards, birthdays, and groups out as plain files, into `data/dump`
unless `DUMP` names another directory
(`docs/plans/2026-09-12-database-dump.md`).
+A group holds attributes its members' cards carry: an address or a note
+written once and composed into every member's card on the way out, with
+membership never exposed to the client. A member editing a shared line
+in Contacts writes it back for everyone, and a member deleting one takes
+the row away — which is why the lines a group may hold are only the two
+a household actually shares (`docs/plans/2026-08-24-vcard-storage-and-groups.md`,
+`docs/plans/2026-09-09-group-edits-propagate.md`).
+
macOS Contacts displays them over Tailscale serve as of 2026-08-14, so
later work has a known-good baseline to change. See
`docs/plans/2026-08-12-one-card-on-macos.md` for what that milestone
diff --git a/db/migrations/005_group_identity.rb b/db/migrations/005_group_identity.rb
index 182455b..0af6e0a 100644
--- a/db/migrations/005_group_identity.rb
+++ b/db/migrations/005_group_identity.rb
@@ -9,9 +9,9 @@
# fallback into the read).
#
# Which makes the id something a person reads, so it is minted rather
-# than chosen: four reverse-hex letters, k through z, the alphabet jj
-# renders a change id in so that an id can never be mistaken for a hash
-# (Store#next_group_id). GLOB rather than LIKE, because GLOB takes the
+# than chosen: four letters from k to z, the alphabet jj renders a
+# change id in so that an id can never be mistaken for a hash
+# (ProTacts::ChangeId). GLOB rather than LIKE, because GLOB takes the
# character ranges this shape is written as and because its case
# sensitivity is its own rather than a per-connection pragma's — 004
# records the second reason at length. `glob(X, Y)` is `Y GLOB X`:
diff --git a/docs/DESIGN.md b/docs/DESIGN.md
index bbc83f4..81bdb2c 100644
--- a/docs/DESIGN.md
+++ b/docs/DESIGN.md
@@ -1,9 +1,15 @@
# Pro-tacts — Design Notes
-Pro-tacts is a single-user admin for a personal contact store: the place where
-one person curates the contacts and groups that get synced out to their own
-devices. It is a tool, not a product. Nobody is being onboarded, nothing is
-being sold, and there is no second user to design around.
+Pro-tacts is an unscoped admin for a family contact store: the place where
+the contacts and groups that get synced out are curated. It is a tool, not a
+product. Nobody is being onboarded and nothing is being sold.
+
+Unscoped is the design decision, not an omission. Which cards a device syncs
+is per-login — the `sync:` groups of
+`docs/plans/2026-09-12-per-user-books.md` — but every screen here shows the
+whole store to whoever is looking, because the two or three people on the
+tailnet are curating one book together. A new screen scoped to its viewer
+would be the first, and would need an argument.
This document describes the system the interface is built on — the rules that
should hold as screens get added or reworked. It deliberately says nothing
diff --git a/lib/pro_tacts/birthday.rb b/lib/pro_tacts/birthday.rb
index 42b92f6..c39905b 100644
--- a/lib/pro_tacts/birthday.rb
+++ b/lib/pro_tacts/birthday.rb
@@ -59,10 +59,11 @@ module ProTacts
# The only constructor, so no birthday exists in a shape the grammar
# rejects: one `in` clause per shape RFC 6350 section 4.3.1 admits,
# each component's range standing where the component stands. A range
- # admits any Comparable in its bounds, but the two producers — SQLite's
- # integer columns and #to_i — only yield Integers, and a String misses
- # to the else. Components are not calendar-validated: a nonsense but
- # well-shaped date round-trips rather than 500s a write.
+ # admits any Comparable in its bounds, but the two producers —
+ # SQLite's integer columns and String#to_i — only yield Integers,
+ # and a String misses to the else. Components are not
+ # calendar-validated: a nonsense but well-shaped date round-trips
+ # rather than 500s a write.
def self.new(year: nil, month: nil, day: nil)
case [year, month, day]
in [0..9999, 1..12, 1..31]
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index 696bbde..7408741 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -134,11 +134,11 @@ module ProTacts
# ordinary, and SQLITE_BUSY straight back to the client is not.
BUSY_TIMEOUT = 5_000 #: Integer
- # How many ids a create draws before giving up. Four letters is one
- # of 65,536 and an address book holds groups by the dozen, so a
- # first collision is already unlucky and a run of eight is a broken
- # generator rather than a run of bad draws — better to say so than
- # to spin.
+ # How many ids a create draws before giving up. The four letters
+ # #create_group asks ChangeId for are one of 65,536, and an address
+ # book holds groups by the dozen, so a first collision is already
+ # unlucky and a run of eight is a broken generator rather than a run
+ # of bad draws — better to say so than to spin.
GROUP_ID_ATTEMPTS = 8 #: Integer
# The property names whose value is structured rather than free
diff --git a/test/pro_tacts/test_birthday.rb b/test/pro_tacts/test_birthday.rb
index 28f2584..ad1b713 100644
--- a/test/pro_tacts/test_birthday.rb
+++ b/test/pro_tacts/test_birthday.rb
@@ -33,8 +33,9 @@ class BirthdayTest < Minitest::Test
# The shape pattern matches by range, and a range admits any
# Comparable within its bounds — so a String component is refused as a
# shape problem. A non-Integer numeric would pass; no producer can
- # make one (SQLite integer columns and #to_i are Integers), which the
- # constructor's comment records as the boundary of the check.
+ # make one (SQLite integer columns and String#to_i are Integers),
+ # which the constructor's comment records as the boundary of the
+ # check.
def test_components_that_are_not_integers_are_refused
assert_raises(ArgumentError) { ProTacts::Birthday.new(year: "1985", month: 4, day: 12) }
assert_raises(ArgumentError) { ProTacts::Birthday.new(year: 1985, month: "4", day: 12) }