Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
039ac0d
docs(specs): draft spec 090, every user has a household (#380)
plainprogrammer Nov 14, 2026
ffd5bb2
docs(adr): 0030, a household is a group of accounts (#380)
plainprogrammer Nov 14, 2026
360ecec
docs(specs): spec 090 v0.2.0, rulings and review round 1 (#380)
plainprogrammer Nov 14, 2026
dde4166
docs(specs): spec 090 v0.3.0, review round 2 (#380)
plainprogrammer Nov 14, 2026
df8c097
docs(specs): spec 090 v0.3.2, the maintainer rules migration (c) (#380)
plainprogrammer Nov 14, 2026
d8bac52
docs(specs): plan for spec 090, every user has a household (#380)
plainprogrammer Nov 14, 2026
8580458
docs(specs): plan 090, plan review round 1 fixes (#380)
plainprogrammer Nov 14, 2026
4ffe332
docs(specs): record Gate B for plan 090 and the condition on phase 6 …
plainprogrammer Nov 14, 2026
3c01b6a
feat(db): households, memberships and the account's household and kin…
plainprogrammer Nov 14, 2026
f822679
docs(specs): plan 090, phase 6 review fixes (#380)
plainprogrammer Nov 14, 2026
f001fc7
docs(specs): plan 090, no released path leaves an account with no use…
plainprogrammer Nov 14, 2026
4203d90
feat(models): household and membership, an account's household and ki…
plainprogrammer Nov 14, 2026
b4505ca
feat(auth): every way of making a user makes a household of one (spec…
plainprogrammer Nov 14, 2026
0204408
docs(specs): plan 090, phase 6 reads and writes main.sqlite_sequence …
plainprogrammer Nov 14, 2026
327983a
feat(db): backfill a household of one for every existing user (spec 0…
plainprogrammer Nov 14, 2026
3300ae7
test(households): nothing looks different, the format's coverage and …
plainprogrammer Nov 14, 2026
3c145da
test(upgrade): the proof checks every user's household on the upgrade…
plainprogrammer Nov 14, 2026
45bf33b
feat(db): accounts.household_id is NOT NULL, by a copy that loses no …
plainprogrammer Nov 14, 2026
40f3553
test(db): the earlier households specs follow the final shape (spec 0…
plainprogrammer Nov 14, 2026
cc696ca
docs(architecture): tenancy and accounts gains the household (spec 090)
plainprogrammer Nov 14, 2026
da7aab2
docs(rules): households and memberships are instance-level (spec 090)
plainprogrammer Nov 14, 2026
bdd8ec1
docs(architecture): the upgrade proof checks households in two tiers …
plainprogrammer Nov 14, 2026
0f00e86
docs(releasing): the upgrade note for the household migrations (spec …
plainprogrammer Nov 14, 2026
a1d0e7d
docs(claude): every user belongs to a household (spec 090)
plainprogrammer Nov 14, 2026
fc68460
docs(adr): ADR 0030 is accepted with spec 090
plainprogrammer Nov 14, 2026
dd4e551
docs(specs): spec 090 is approved and supersedes spec 004 FR-3 in par…
plainprogrammer Nov 14, 2026
7187f2f
fix(migrations): guard the households migration's down like the third…
plainprogrammer Nov 14, 2026
051d55e
test(households): check the invariants after an apply and the new tab…
plainprogrammer Nov 14, 2026
26f210a
docs(households): say which specs hold the household name equal, and …
plainprogrammer Nov 14, 2026
2c251ca
docs(households): the upgrade note, the export format and the ADR say…
plainprogrammer Nov 14, 2026
30c1949
test(migrations): compare accounts by named columns, as a schema-load…
plainprogrammer Nov 14, 2026
b13fb16
Merge origin/main into 090-every-user-has-a-household
plainprogrammer Nov 14, 2026
62a437c
docs(specs): verification of spec 090 with the implementation review …
plainprogrammer Nov 14, 2026
90379d9
docs(adr): ADR 0030 stays Proposed; the maintainer accepts it (#380)
plainprogrammer Nov 14, 2026
77bb2dc
chore(memory): capture session learnings (spec 090)
plainprogrammer Nov 14, 2026
3dc5765
docs(architecture): distil spec 090 into tenancy and accounts, releas…
plainprogrammer Nov 14, 2026
469449d
docs(specs): mark spec 090 frozen by the tenancy, release and data po…
plainprogrammer Nov 14, 2026
1363ae2
docs(specs): record the distillation of spec 090 in its verification
plainprogrammer Nov 14, 2026
0a09808
Merge origin/main into 090-every-user-has-a-household
plainprogrammer Nov 14, 2026
25c6e94
docs(specs): record the upgrade smoke from v0.3.0 for spec 090 (#380)
plainprogrammer Nov 14, 2026
60951ee
Merge origin/main into 090-every-user-has-a-household
plainprogrammer Nov 14, 2026
aaf84e6
docs(architecture): name the pull request of spec 090 in the distilla…
plainprogrammer Nov 14, 2026
2ad3f42
docs(specs): record the second upgrade smoke on the merged commit (sp…
plainprogrammer Nov 14, 2026
386b994
Merge origin/main into 090-every-user-has-a-household
plainprogrammer Nov 14, 2026
64c75d1
docs(specs): record that the third merge was docs only, so no smoke r…
plainprogrammer Nov 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .claude/memory/MEMORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,4 +90,5 @@
- [No git stash or sqlite3 in briefs](no-git-stash-in-briefs.md) — forbid git stash/reset/checkout and the sqlite3 command (it prompts the maintainer) in every subagent brief (except restoring a mutation-tested file); a stash/pop on a clean tree applied an unrelated old stash in spec 080
- [Unset `config.x` answers nil](rails-config-x-unset.md) — an unset `Rails.configuration.x` key is an empty OrderedOptions; assert a boot reading's class, not only nil attributes
- [Mutation proof in place of failing-first](mutation-proof-when-behaviour-already-built.md) — only where an earlier task already built the behaviour; each mutation and its failure go in the commit body (coordinator, spec 085 Gate B)
- [Re-review a changed data-loss step](rereview-changed-data-loss-steps.md) — after a plan changes a step that can lose data (a table copy, a delete), run the read-only review again on that step before its batch (spec 090 phase 6)
- [Harness changes the model mid-run](harness-changes-the-model-mid-run.md) — a worker's main session can change model without being asked; state the model per commit at gates, answer from what the harness reports
12 changes: 12 additions & 0 deletions .claude/memory/rereview-changed-data-loss-steps.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
name: rereview-changed-data-loss-steps
description: when a plan step that can lose a collector's data changes after its review, run the read-only review again on the changed step before its batch (spec 090's phase 6)
metadata:
type: feedback
---

A plan review traced migration (c) as first written; the plan then changed it (guards, a fault test, the corrected `down`). The coordinator asked for a second read-only Fable review limited to the changed step before its batch ran, and it found a boot loop on dangling rows that the first review of the unchanged step could not have.

**Why:** a trace is of the code it read; a fix to that code is new code. On a step that deletes data on a failure, the cost of one more read-only review is small.

**How to apply:** after any change to a data-loss step (a table copy, a delete, a backfill) that follows its review, dispatch the review again, limited to the step and what changed, and send its verdict to the coordinator before the implementer batch starts. See [[plan-reviews-run-the-code]] for how plan reviews trace.
1 change: 1 addition & 0 deletions .claude/rules/multi-tenancy.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
- ✓ Scope every query through the tenant (`Current.account.collections.find(params[:id])`); bare `Model.find(params[:id])` on tenant data is a bug.
- ✓ Pass the account (or a record owning it) into every job and restore `Current.account` before the job body runs.
- ✓ Add a request spec per tenant-scoped resource proving another account's record returns 404.
- ✓ `households` and `memberships` are instance-level, not tenant-owned: read them through the user or the account, never through `Current.account`'s collections, and name none in a request.
- ✓ Shared catalog data (e.g. Scryfall cards) is global; user-owned data (copies, conditions, prices paid, notes) is always tenant-scoped.
- ✗ Never use `unscoped` or cross-tenant queries without an explanatory comment.
- ✗ Don't cache tenant data under keys that omit the account.
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co

## Project Status

Collector is a self-hostable, multi-tenant web app for tracking collectibles, starting with Magic: The Gathering cards. The Rails app skeleton exists (stack, RSpec, `bin/ci`, Compose and Kamal deployment). Feature 002 added a collectible-agnostic catalog core (`app/models/catalog/`) with an MTG extension (`app/models/mtg/`), Scryfall bulk-data ingestion via a background refresh, and a card search at `/catalog/entries`, which has needed sign-in since accounts (spec 004) unless the operator opens it with `COLLECTOR_PUBLIC_CATALOG` (spec 023). Accounts (row-level tenancy through `Current.account`) and collections of lots (spec 004, list view spec 006) exist, and the card scanner (specs 005, 007–009) adds scanned cards to the collection through its confirm flow. Admins start and watch catalog refreshes and art index builds at `/admin/catalog`, and see, retry and discard background jobs at `/admin/jobs` (spec 015). A collector's data has a documented export format, and a collector downloads, previews and applies an export file from the account menu or More (`CollectionExport`, `CollectionImport`, `CollectionImport::Apply`; [`docs/architecture/data-portability.md`](docs/architecture/data-portability.md)). Every page's footer ends with Collector's licence line, AGPL-3.0 with a link to the source of the running build, and shows the running version and short SHA, which the image's build passes as `COLLECTOR_VERSION`, `COLLECTOR_REVISION` and `COLLECTOR_SOURCE_URL` and `Collector::BuildFacts` reads once at boot; `bin/image-smoke` fails an image without them (spec 084). The line ends with a link to `/licences`, which anyone can open and which lists the licences of the third-party software and assets in the image: the gems from the running bundle, the OCR engine, the importmap JavaScript, the fonts and the icons from `config/third_party_licences.yml`, kept honest by a guard spec (`Collector::ThirdPartyLicences`, `LicencesController`, spec 087), and `bin/ci checks` fails a gem or a manifest entry under a licence that `config/licence_policy.yml` doesn't allow or name as an exception (`Collector::ThirdPartyLicences::Policy` and `Check`, `bin/rails licences:check`, spec 091, [ADR 0031](docs/adr/0031-a-dependency-licence-is-a-decision.md)). A collector adds cards from a set by collector number at `/collection/number_entry` ("Add by number", spec 026): one line per number, quantity and finish, each added at once to a per-account list with Undo (`NumberEntry`, `NumberEntry::Line`), which Done clears; the list is working state, left out of the export. A collector manages the places their collection is kept in at `/locations` (`Location`, spec 029) and records which location a lot is in when adding or editing it, or moves some or all of a lot's copies with Move copies (`Lot::Move`, spec 032; a location that holds copies can't be removed; export format version 3) and moves every copy of many lots at once from bulk mode with Move to… (`Lot::BulkMove`, spec 041), opens a location's lots in bulk mode from its removal refusal page with one button (`Locations::SelectionsController`, spec 059), and the collection's grid tiles and table rows show it (spec 033) and filter by it (spec 037). A collector creates, renames and removes decks at `/decks` (`Deck`, `Deck::Creation`, `Catalog::Formats`, spec 062, [ADR 0028](docs/adr/0028-decks-are-locations-with-zones.md)): a deck is a location of kind `deck` whose zones are child locations that hold its copies, the collectible extension registers the formats, a lot in a zone is shown as "Deck › Zone", the export format (version 4) carries decks, a collector changes a deck's format at `/decks/:id/format/edit` (`Deck::FormatChange`, spec 064), opens a deck's lots in bulk mode from its removal refusal page (`Decks::SelectionsController`, spec 067), and opens a deck at `/decks/:id` to read the cards they own in it, zone by zone, with their cost and legality (`Deck::Page`, the seam `Catalog::DeckCards`, spec 066), and the deck's shape, its mana curve, card types and coloured mana symbols, in a panel beside the zones (`Catalog::DeckShape`, `MTG::DeckShape`, spec 069), and records the cards a deck's zone needs and they don't own as proxies, shown beside the owned copies and counted in a deck's cards but never as owned (`Deck::Proxy`, spec 068): a change of format moves them into Considering and the export carries them (format version 4 has `proxies`). A collector swaps a proxy for a free copy they own (Use my copy) or a new copy (Add a new copy) from the row's menu, and the proxy shrinks or goes in the same write (`Deck::Proxy::UseCopies`, `Deck::Proxy::AddCopy`, `Lot.free`, `Lot::MoveIntoZone`, spec 070). A collector adds a card to a deck from its card page, their free copies or a proxy (`Deck::Addition`, `Lot::FreeCopies`, spec 071). The card page shows how many copies are free to use and which decks hold the rest, in its stats and an "In decks" table (`Catalog::CardOverview`, `Deck::CardUses`, spec 073). A collector checks a deck on demand from its page, its size, copy limits and the cards with owned copies and proxies in one zone, and nothing about the check is stored (`Catalog::DeckChecks`, `MTG::Collecting.deck_rules`, `Decks::ChecksController`, spec 079). An operator backs up a running instance and restores it onto a fresh volume with two rake tasks, `backup:create[DIRECTORY]` and `backup:restore[FILE]` (`Collector::Backup`, `lib/collector/backup/`, spec 030, ADR 0024). Market prices will live in a fifth SQLite database, `prices` (`PricesRecord`, `db/prices_migrate/`, `db/prices_structure.sql`; spec 081, ADR 0029; [prices](docs/architecture/prices.md)), whose five tables exist, with a price source contract and Scryfall's prices through it (`Prices::Sources`, `MTG::Scryfall::PriceQuotes`, `PriceSource.sync!`; spec 085, #364) a daily refresh that records each price change once (`Prices::Refresh`, `Prices::RefreshJob`, `PriceRefreshRun`, `CurrentPrice`, `PriceChange`; spec 089, #365) and no reader yet (#366 to #369 are still to come), and the backup holds it beside the primary (backup format version 2); a server start refuses a volume that holds the prices database and no primary (`Collector::StartCheck`, `start:check`, spec 086). CI proves an upgrade from the previous release's published image (`bin/upgrade-smoke`, `script/upgrade/`, spec 031, #42; [release and deploy](docs/architecture/release-and-deploy.md)). License: AGPL-3.0.
Collector is a self-hostable, multi-tenant web app for tracking collectibles, starting with Magic: The Gathering cards. The Rails app skeleton exists (stack, RSpec, `bin/ci`, Compose and Kamal deployment). Feature 002 added a collectible-agnostic catalog core (`app/models/catalog/`) with an MTG extension (`app/models/mtg/`), Scryfall bulk-data ingestion via a background refresh, and a card search at `/catalog/entries`, which has needed sign-in since accounts (spec 004) unless the operator opens it with `COLLECTOR_PUBLIC_CATALOG` (spec 023). Accounts (row-level tenancy through `Current.account`) and collections of lots (spec 004, list view spec 006) exist, every user belongs to a household (spec 090, ADR 0030): a household of one is made with the user and an existing instance gains one on upgrade, nothing visible yet; the shared collection and joining are the households lane (#381 to #389), and the card scanner (specs 005, 007–009) adds scanned cards to the collection through its confirm flow. Admins start and watch catalog refreshes and art index builds at `/admin/catalog`, and see, retry and discard background jobs at `/admin/jobs` (spec 015). A collector's data has a documented export format, and a collector downloads, previews and applies an export file from the account menu or More (`CollectionExport`, `CollectionImport`, `CollectionImport::Apply`; [`docs/architecture/data-portability.md`](docs/architecture/data-portability.md)). Every page's footer ends with Collector's licence line, AGPL-3.0 with a link to the source of the running build, and shows the running version and short SHA, which the image's build passes as `COLLECTOR_VERSION`, `COLLECTOR_REVISION` and `COLLECTOR_SOURCE_URL` and `Collector::BuildFacts` reads once at boot; `bin/image-smoke` fails an image without them (spec 084). The line ends with a link to `/licences`, which anyone can open and which lists the licences of the third-party software and assets in the image: the gems from the running bundle, the OCR engine, the importmap JavaScript, the fonts and the icons from `config/third_party_licences.yml`, kept honest by a guard spec (`Collector::ThirdPartyLicences`, `LicencesController`, spec 087), and `bin/ci checks` fails a gem or a manifest entry under a licence that `config/licence_policy.yml` doesn't allow or name as an exception (`Collector::ThirdPartyLicences::Policy` and `Check`, `bin/rails licences:check`, spec 091, [ADR 0031](docs/adr/0031-a-dependency-licence-is-a-decision.md)). A collector adds cards from a set by collector number at `/collection/number_entry` ("Add by number", spec 026): one line per number, quantity and finish, each added at once to a per-account list with Undo (`NumberEntry`, `NumberEntry::Line`), which Done clears; the list is working state, left out of the export. A collector manages the places their collection is kept in at `/locations` (`Location`, spec 029) and records which location a lot is in when adding or editing it, or moves some or all of a lot's copies with Move copies (`Lot::Move`, spec 032; a location that holds copies can't be removed; export format version 3) and moves every copy of many lots at once from bulk mode with Move to… (`Lot::BulkMove`, spec 041), opens a location's lots in bulk mode from its removal refusal page with one button (`Locations::SelectionsController`, spec 059), and the collection's grid tiles and table rows show it (spec 033) and filter by it (spec 037). A collector creates, renames and removes decks at `/decks` (`Deck`, `Deck::Creation`, `Catalog::Formats`, spec 062, [ADR 0028](docs/adr/0028-decks-are-locations-with-zones.md)): a deck is a location of kind `deck` whose zones are child locations that hold its copies, the collectible extension registers the formats, a lot in a zone is shown as "Deck › Zone", the export format (version 4) carries decks, a collector changes a deck's format at `/decks/:id/format/edit` (`Deck::FormatChange`, spec 064), opens a deck's lots in bulk mode from its removal refusal page (`Decks::SelectionsController`, spec 067), and opens a deck at `/decks/:id` to read the cards they own in it, zone by zone, with their cost and legality (`Deck::Page`, the seam `Catalog::DeckCards`, spec 066), and the deck's shape, its mana curve, card types and coloured mana symbols, in a panel beside the zones (`Catalog::DeckShape`, `MTG::DeckShape`, spec 069), and records the cards a deck's zone needs and they don't own as proxies, shown beside the owned copies and counted in a deck's cards but never as owned (`Deck::Proxy`, spec 068): a change of format moves them into Considering and the export carries them (format version 4 has `proxies`). A collector swaps a proxy for a free copy they own (Use my copy) or a new copy (Add a new copy) from the row's menu, and the proxy shrinks or goes in the same write (`Deck::Proxy::UseCopies`, `Deck::Proxy::AddCopy`, `Lot.free`, `Lot::MoveIntoZone`, spec 070). A collector adds a card to a deck from its card page, their free copies or a proxy (`Deck::Addition`, `Lot::FreeCopies`, spec 071). The card page shows how many copies are free to use and which decks hold the rest, in its stats and an "In decks" table (`Catalog::CardOverview`, `Deck::CardUses`, spec 073). A collector checks a deck on demand from its page, its size, copy limits and the cards with owned copies and proxies in one zone, and nothing about the check is stored (`Catalog::DeckChecks`, `MTG::Collecting.deck_rules`, `Decks::ChecksController`, spec 079). An operator backs up a running instance and restores it onto a fresh volume with two rake tasks, `backup:create[DIRECTORY]` and `backup:restore[FILE]` (`Collector::Backup`, `lib/collector/backup/`, spec 030, ADR 0024). Market prices will live in a fifth SQLite database, `prices` (`PricesRecord`, `db/prices_migrate/`, `db/prices_structure.sql`; spec 081, ADR 0029; [prices](docs/architecture/prices.md)), whose five tables exist, with a price source contract and Scryfall's prices through it (`Prices::Sources`, `MTG::Scryfall::PriceQuotes`, `PriceSource.sync!`; spec 085, #364) a daily refresh that records each price change once (`Prices::Refresh`, `Prices::RefreshJob`, `PriceRefreshRun`, `CurrentPrice`, `PriceChange`; spec 089, #365) and no reader yet (#366 to #369 are still to come), and the backup holds it beside the primary (backup format version 2); a server start refuses a volume that holds the prices database and no primary (`Collector::StartCheck`, `start:check`, spec 086). CI proves an upgrade from the previous release's published image (`bin/upgrade-smoke`, `script/upgrade/`, spec 031, #42; [release and deploy](docs/architecture/release-and-deploy.md)). License: AGPL-3.0.

Mission and principles: `.claude/memory/foundation.md`. Goal and spec rules: `docs/north-star.md`. Order of work: `docs/roadmap.md`. Decisions: `docs/adr/` (index in its README). Current rules per subsystem: `docs/architecture/` (area docs). Detailed coding rules: `.claude/rules/`. Feature specs: `docs/specs/` (index: `docs/specs/README.md`).

Expand Down
4 changes: 4 additions & 0 deletions app/models/account.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
# The tenant: owns every piece of collection data. One per user (spec 004 FR-3).
class Account < ApplicationRecord
has_one :user, dependent: nil
# The household this account is in: a user's own account (personal) is in that user's household, and a shared account (none
# exists yet, #384) is the household's. Required, and `NOT NULL` once spec 090's third migration has run.
belongs_to :household
# Order matters: the foreign key from a lot to its location refuses deleting a location a lot is in, so the lots go first.
# spec/models/account_spec.rb guards it (spec 032 AC-6.6).
has_many :lots, dependent: :delete_all
Expand All @@ -16,6 +19,7 @@ class Account < ApplicationRecord
# The currency every price of the account is shown in; a new account starts at the instance default (spec 038 decision 1–2).
attribute :currency, :string, default: -> { Rails.configuration.x.default_currency.code }
validates :currency, inclusion: { in: Collector::Currency::SYMBOLS.keys, message: "Choose one of the listed currencies." }
enum :kind, { personal: "personal", shared: "shared" }, default: :personal, validate: true

def copies_count = lots.sum(:quantity)

Expand Down
29 changes: 29 additions & 0 deletions app/models/household.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# A named group of accounts: today always one user's, from a later issue also a family's (spec 090, ADR 0030). It is the
# household's, not the collector's, so it is never scoped through `Current.account` and no request names one.
class Household < ApplicationRecord
NAME_LIMIT = 100
# The name of a household of one is the user's name followed by this: 12 characters, so the user's name is cut to 88.
SUFFIX = "'s household".freeze

has_many :memberships, dependent: nil # the foreign key has no action: a household that holds a member can't be deleted
has_many :users, through: :memberships
has_many :accounts, dependent: nil # likewise, so a delete can never reach an account and, through its cascades, a collection

validates :name, presence: true, length: { maximum: NAME_LIMIT }

before_destroy :refuse_while_held, prepend: true

# The name a household of one gets (spec 090 decision 2): the user's first 88 characters and "'s household", at most 100.
# `script/upgrade/households.rb` and the backfill migration build the same name in SQL with `substr`:
# `spec/migrations/backfill_households_spec.rb` holds the backfill's SQL equal to this method, and
# `spec/script/upgrade/households_spec.rb` builds its fixture names with it, against the check's SQL.
#
# @param user_name [String] the user's name
# @return [String]
def self.name_for(user_name) = "#{user_name.to_s.slice(0, NAME_LIMIT - SUFFIX.length)}#{SUFFIX}"

private
def refuse_while_held
throw :abort if memberships.exists? || accounts.exists?
end
end
Loading
Loading