Skip to content

feat(households): every user has a household (spec 090, #380) - #426

Merged
plainprogrammer merged 45 commits into
mainfrom
090-every-user-has-a-household
Nov 14, 2026
Merged

plainprogrammer merged 45 commits into
mainfrom
090-every-user-has-a-household

Conversation

@plainprogrammer

Copy link
Copy Markdown
Owner

Closes #380.

Every user now belongs to exactly one household, an existing instance gains one for every user on upgrade, and nothing in the app looks different yet.

What changed

  • Structure (spec 090, ADR 0030, which supersedes the one-user half of ADR 0017): tables households (a name) and memberships (a user in a household as manager or member, one household per user, a household always has a manager), and two columns on accounts: household_id (NOT NULL, no-action key) and kind (personal or shared; no shared account exists yet). users.account_id stays unique. An account stays the unit of ownership and privacy.
  • A household with every user: User makes the household of one ("Ada's household", the user's first 88 characters and "'s household"), the manager membership and the account in one write, so first run, sign-up, the admin's form and the operator command all give one.
  • Three migrations, none editing a released one, each without a transaction and guarded: (a) the schema; (b) a batched (500 users) idempotent backfill in plain SQL that sets accounts.household_id only; (c) accounts.household_id NOT NULL by a table copy of accounts, which thirteen tables reference (eleven with ON DELETE CASCADE). (c) turns enforcement off before opening one transaction of its own around the stragglers step, the copy and the main.sqlite_sequence restore, and checks row counts and keys before and after. The maintainer ruled (c) over a nullable fallback on two conditions, both met: the plan review traced it against Rails 8.1.4.1's alter_table, and bin/upgrade-smoke ran from the previous release built from its tag.
  • The upgrade proof checks the households in two tiers (script/upgrade/households.rb): invariants always (every user one membership, every household a manager, every account a household and in its user's), the backfill's facts when the old snapshot had no households. expected_changes.yml stays empty.
  • Export: the household is not carried; the format stays version 4; the coverage tables list memberships, accounts.household_id and accounts.kind as left out.
  • Nothing visible: no page, route, controller or word changed; a request spec scans the main pages for "household" and for queries naming the new tables.
  • Docs: tenancy-and-accounts.md, release-and-deploy.md, data-portability.md, the multi-tenancy rule, docs/releasing.md's Upgrade note, CLAUDE.md, the specs index.

Evidence

  • bin/ci (full) passed in the push hook on 25c6e94c; bin/ci checks green on the merged tree.
  • bin/upgrade-smoke from the v0.3.0 tag (built with git archive) to this build: all 28 old tables same, households: ok (backfilled) after the upgraded and after the restored snapshot, OK: v0.3.0 … 83 s. Recorded in docs/specs/090-every-user-has-a-household/verification.md with the implementation review (Opus 5.5, SPEC-ALIGNED WITH FINDINGS, all fixed or recorded).
  • Models: spec, ADR and plan by the main session; implementers Sonnet 5.5; spec, plan and phase 6 reviews Fable 5.2 (read-only); implementation review Opus 5.5 (read-only).

For the maintainer

  • ADR 0030 is Proposed: its decisions were ruled on 2026-11-13; accepting the ADR is yours, recorded in the plan-retirement PR as for ADR 0029.
  • An instance with an account that has no user (possible only by editing the database by hand, never by the app: checked in v0.1.0, v0.2.0, v0.3.0) will not start until it is removed; the migration names the ids and docs/releasing.md has the command. Say so in the release notes of the version.
  • After the third migration an older image can't create a user; going back is a restore of the backup taken before (Upgrade note).

🤖 Generated with Claude Code

plainprogrammer and others added 30 commits November 13, 2026 19:14
Draft v0.1.0. Decisions 2 to 6 are written on the recommendations put to the
maintainer. Written by the main session on Sonnet 5.5.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Supersedes the one-user half of ADR 0017. Proposed; decisions 5 and 6 await the
maintainer's ruling. Written by the main session on Sonnet 5.5.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The maintainer ruled decisions 2, 3, 4 and 10. Review round 1 (Fable 5.2) fixed:
delete rules, the copy's risks and the nullable fallback, all three migrations
without a transaction, the harness check in two tiers, scope of shared-account
rules. Written by the main session on Sonnet 5.5.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Review round 2 (Fable 5.2) fixed: migration (c) is one transaction around its
stragglers step, the copy and the sequence restore; the fallback comparison now
states its stragglers hole and four missing facts; the README claim is corrected;
the suite's bare accounts are named allowed edits. Written by the main session
on Sonnet 5.5.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Take the table copy of accounts, not the nullable fallback, on two conditions: the
plan review traces it against Rails 8.1.4.1, and bin/upgrade-smoke runs from the
previous release built from its tag before the push.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Seven phases: (a) the schema, the models, (b) the backfill, nothing looks different,
the upgrade proof's households check, (c) the NOT NULL copy as its own last
migration phase, then documentation and evidence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Fable review traced migration (c) against Rails 8.1.4.1's alter_table and found
the copy safe. Fixed: the user's membership check no longer reads a household on an
update; (c) checks enforcement is off and that no key is broken, and has a fault test
after the DROP; the shape example compares literals; phases 1 and 2 are one batch;
the backfill uses the connection, one insert id and counts its transactions; the
upgrade smoke runs on the commit to be pushed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…380)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…d (spec 090 phase 1)

Adds the migration, its spec, the schema dump, and the export format's coverage
rows (plan steps 4.3 and 4.4, done here as step 1.4 says).

Changed from the plan's code: the migration's keeping_sequence and the spec's
sequence helper read and write main.sqlite_sequence, not the bare name. Rails'
table copy builds a TEMP table with AUTOINCREMENT, which gives the connection a
temp.sqlite_sequence that a bare name then resolves to, so the plan's restore
wrote to the temp table and the spec read the same wrong one (it failed on the
first run against a fresh test database, whose seed 34601 order puts a copy
first). The assertions are unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The second Fable review (phase 6 and the User validation) found no blocking issue.
Fixed: the check after the copy compares row counts and new broken keys against the
state before it, so a dangling row an operator left does not stop every boot; both
guards have examples; the refusal checks there is no user; the validation's lambda no
longer creates the association instance; the insert guard reads changes().

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
#380)

Checked in v0.1.0, v0.2.0 and v0.3.0: an account is inserted and deleted by the
same transaction as its user, so only a hand edit leaves one. The refusal stands and
the Upgrade notes say how to clear it. Phase 6 gains an example that changes the
count of accounts and of each of the thirteen tables in turn.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…nd (spec 090 phase 2)

Household, Membership, Account#household and #kind, and a user made with an
account in a new household and a manager membership. The membership validation
in User guards on association_cached?(:membership) first, as the coordinator's
amendment to step 2.5 says, and user_spec gains the AC-4.5 example (it failed
against the previous lambda, which let a second validation read memberships
and households, and passes with the new one).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… 090 phase 2)

Request spec for Story 1: first visitor, open sign-up, an admin's form, the
operator command, invalid sign-ups, ignored household/role/kind parameters, and
a deleted user's household.

Changed from the plan's code, in the AC-1.7 example: the instance already has a
user, so it opens sign-up (sign_up_open) before the sign-up post, and signs
Ann out (delete session_path) before signing in as the admin, because
SessionsController redirects a signed-in visitor. Assertions are unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…380)

Phase 1's implementer found that a table copy builds a TEMP table with AUTOINCREMENT,
so a bare sqlite_sequence resolves to temp.sqlite_sequence. Phase 6 had the same bug.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…90 phase 3)

Migration (b): every user with no membership gets a household named for
them, a manager membership and their account's household_id, in batches of
500 users, each batch its own transaction. Idempotent, plain SQL, sets
accounts.household_id only.

Changes from the plan's code, none to an assertion:
- spec: the timing example reports through RSpec.configuration.reporter
  (rubocop RSpec/Output forbids puts; the repo's timing specs use the
  reporter) and times with Process.clock_gettime, because Benchmark is not
  loaded under Ruby 4.0.
- spec: the session example gets :aggregate_failures (it has two
  expectations; RSpec/MultipleExpectations).
- migration: trailing "Safe:" comments on the interpolated SQL lines, as
  BackfillSessionsLastUsedAt does.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…the round trip (spec 090 phase 4)

households_invisible_spec passed on its first run (nothing visible was added;
it is a guard). household_untouched_spec: its first run failed on a spec bug
(target was a lazy let, created after the first household_facts call, so
Household.pluck differed); target is now let!. No assertion was changed.
Coverage (4.3, 4.4) was done in phase 1.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…d, restored and next snapshots (spec 090 phase 5)

Upgrade::Households (script/upgrade/households.rb) reads a snapshot with SQLite
directly in two tiers: invariants true on every snapshot from this release on
(one membership per user, a manager per household, every account in a household,
a personal account in its user's household), and the backfill's own facts
(count, role, kind, name), checked only when the old snapshot had no households
table. The harness runs it in `compare` (the upgraded and the restored
snapshots, failing with AC-6.1) and in `next_release`. Prints `households: ok`
or `households: ok (backfilled)`. expected_changes.yml stays `changes: []` and
seed_exceptions.yml is unchanged.

Changes from the plan: in households_spec the "joined household" fixture also
deletes household 2 (user 2 leaves its household of one when it joins household
1; otherwise tier 1 rightly names a household with no manager); the assertion
is unchanged. The harness examples are in a new describe block with its own
run_with. The seeds_spec example passed on its first run (a guard: the seed's
users are made through the models, which make the households).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…row (spec 090 phase 6)

Migration (c) of spec 090 decision 5: accounts.household_id becomes NOT NULL through Rails' table copy,
run outside Rails' own transaction with foreign keys already off, so the thirteen tables that reference
accounts (eleven ON DELETE CASCADE) keep every row. A stragglers step, the copy and the sequence's restore
commit together or not at all; counts and new broken keys are checked after the copy.

Re-trace of activerecord-8.1.4.1 (sqlite3_adapter.rb, sqlite3/database_statements.rb), step 6.3:
1. disable_referential_integrity runs PRAGMA foreign_keys = OFF before our transaction opens (it is a no-op after BEGIN).
2. alter_table's own disable_referential_integrity (restores to our OFF) and transaction (joins ours) neither re-enable enforcement nor commit early.
3. copy_table copies columns, the foreign key to households, the check constraints, and copy_table_indexes the plain, unique and partial indexes (the spec compares the shapes).
4. move_table creates and drops tables by name; no child is renamed, so the children's keys are untouched.
5. db/schema.rb: lots and users reference accounts with NO ACTION, the other eleven with CASCADE.
6. Rails runs no foreign_key_check after a copy (only fixtures call check_all_foreign_keys_valid!); the migration's own state check does.

db/schema.rb differs by the version and `t.integer "household_id", null: false` only.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…90 phase 6)

accounts.household_id is NOT NULL now, so the specs that put a household-less account in the database take
migration (c) down first and put it back in an around ensure (backfill_households_spec), the versions list of
create_households_and_memberships_spec covers all three migrations, and the raw account inserts of the
backup writer, the snapshot spec and the backfill's batch example make a household first.

One change beyond the plan: the backfill's batch example runs `described_class.name.constantize` instead of
`described_class`. The Migrator reloads a migration's file with `load`, which makes a new class under the
same name once any example has taken (c) down; `stub_const("BackfillHouseholds::BATCH", 2)` then stubs the
new class while described_class is the old one, so under some seeds (38210) the example saw one batch
instead of three. The timing example takes (c) down before inserting its household-less accounts, so its
assertion of 10,000 households is unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Scope, glossary (household, membership, personal and shared account, the second meaning of member), the instance-level tables, the invariants, the cross-account reads, the order of a user's delete, the accepted limit and the sources, each with its spec and ADR.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…(spec 090)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…090)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…t (index)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…, and prove it with rows in every referencing table (spec 090)

The first migration's down copies accounts twice in one transaction. It now refuses when foreign keys are enforced and rolls back when a copy changed a row count, as the third migration does. Its spec fills a row in all thirteen tables that reference accounts (the helper moves to spec/support), goes down and up through all three migrations, and runs down inside an outer transaction. The AC-3.4 example compares the full shape of accounts, households and memberships before and after, and the AC-3.7 re-run now runs the migration itself.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…les' queries on every invisible page (spec 090)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…drop a history phrase (spec 090)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… exactly what holds (spec 090)

An old container's delete of a user after the third migration leaves an empty household, the export format gives the reason households are not in the file, and the ADR says foreign keys are switched off before the copy's own transaction opens.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
plainprogrammer and others added 15 commits November 13, 2026 21:39
…ed table orders them differently (spec 090)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Conflicts: CLAUDE.md (kept main's paragraph and the households clause) and the
specs index (both rows, 090 before 091).
…380)

Verdict SPEC-ALIGNED WITH FINDINGS; every finding fixed or recorded. The smoke run and the
full CI are to be recorded when they run.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The maintainer ruled its decisions on 2026-11-13; accepting the ADR is theirs, recorded
in the plan-retirement pull request as for ADR 0029. Spec 090 v0.3.3 says so.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Two lessons (a table copy of a referenced table; association(:name) creates the
association) and one feedback memory (re-review a changed data-loss step).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…e and deploy and data portability

The area docs were written with the feature (phase 7); this adds their distillation log
rows and freezes the spec's index row with its plan marked retirement owed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…rtability area docs (spec 027 D4)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Conflicts: the distillation log of release and deploy and the specs index (main's
updated spec 091 rows kept beside the spec 090 rows).
households: ok (backfilled) after the upgraded and the restored snapshots; 28 tables compared,
OK: v0.3.0 upgraded to 0a09808.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Conflicts: the memory index, CLAUDE.md (main's paragraph and the households clause) and
the specs index (spec 089's row before spec 090's). Brings in spec 089's prices migrations.
…tion logs

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ec 090)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Conflict: the specs index (main's retired-plan row of spec 089 kept beside the spec 090 row).
…e-run (spec 090)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@plainprogrammer
plainprogrammer marked this pull request as ready for review November 14, 2026 04:39
@plainprogrammer plainprogrammer self-assigned this Nov 14, 2026
@plainprogrammer
plainprogrammer merged commit 6828788 into main Nov 14, 2026
9 checks passed
@plainprogrammer
plainprogrammer deleted the 090-every-user-has-a-household branch November 14, 2026 04:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Every user has a household

1 participant