fkeenv / fkeenv/tabby

Wayfinder map: Tabby

Open
#1 0 comments 0 reactions 0 assignees View on GitHub
wayfinder:map
Dominant language
Vue
Stars
0
Forks
0
PR merge metrics
No merged PRs in 30d

Description

## Destination

A **build-ready spec** for the Tabby MVP — domain model, claim-flow semantics, database schema, route and permission map, and the UX states of the two screens that carry the product — complete enough to hand to `/to-tickets` and build in separate sessions.

**This map decides; it does not build.** Implementation tickets come later, from the finished spec.

## Notes

**Domain.** Cost-splitting for one-off shared events, with self-service claiming as the differentiator. The vocabulary is settled and lives in [`CONTEXT.md`](https://github.com/fkeenv/tabby/blob/main/CONTEXT.md) — read it before working any ticket, and challenge it rather than working around it. Three decisions have ADRs: [balances are derived, never stored](https://github.com/fkeenv/tabby/blob/main/docs/adr/0001-balances-are-derived-never-stored.md), [money is allocated in whole minor units by largest remainder](https://github.com/fkeenv/tabby/blob/main/docs/adr/0002-money-is-allocated-in-whole-minor-units-by-largest-remainder.md), and [the Claim Link has no identity boundary](https://github.com/fkeenv/tabby/blob/main/docs/adr/0003-the-claim-link-has-no-identity-boundary.md).

**Stack.** Laravel 13, Inertia v3, Vue 3, Tailwind v4, Fortify (login / 2FA / passkeys already wired), Wayfinder, Pest 5. No domain code exists yet — `app/Models/User.php` is the only model. The project rules in `CLAUDE.md` apply.

**Skills every session should consult.** `grilling` and `domain-modeling` by default; `prototype` for prototype tickets; `research` for research tickets. For Laravel work additionally: `laravel-best-practices`, `inertia-vue-development`, `testing-best-practices`.

**Standing preferences.** Prototypes are throwaway and live on scratch branches, never `main`. Mobile-first — the claim screen is used standing up in a restaurant. Honour system throughout: there is no verification anywhere, by design.

**Settled while charting** — the frame every ticket inherits:

- Groups are **event-scoped** and **never lock**; balances stay live forever.
- **Authenticated Organizer, anonymous claimers.** Only the Organizer mutates the expense ledger.
- Claiming is **share-based**: a Line Item's cost divides by total Shares claimed. Quantity is a completeness warning, not a cap.
- **Participants are created by self-registration** on the Claim Link, and repaired (rename / merge / delete) by the Organizer.
- **One payer per Expense.** Line Items optional; Adjustments optional, allocated evenly or pro-rata per Adjustment.
- Balances present as **minimised Suggested Transfers**, with the raw netting one tap away.
- The Claim Link may **view, claim, record a payment, and add a Participant** — never create, edit, or delete Expenses. It carries **no identity boundary**: any holder may act as any Participant.

## Decisions so far

- [Claim screen prototype](https://github.com/fkeenv/tabby/issues/2): the claim screen is a **Participant rail over a grid of Line Item tiles**, built for one phone passed around the table; tap a tile to take a Share, tap the badge's `−` to give one back. Over-claiming opens a **confirmation dialog** rather than being blocked — a hard cap was prototyped and rejected because it makes a shared bottle unclaimable. Prototype on [`prototype/claim-screen`](https://github.com/fkeenv/tabby/tree/prototype/claim-screen).
- [Money rounding, remainders, and currency](https://github.com/fkeenv/tabby/issues/3): money is **integer minor units** in `*_minor` columns, one **2-decimal currency per Group** frozen once it holds an Expense. One allocator serves Line Items, pro-rata Adjustments, and even Adjustments: **largest remainder, ties broken by Participant-id order rotated by the allocating record's id**, rounding **per allocation unit** so every figure on screen sums to the one beside it. Rejected: exact rationals rounded once at the end, which is more accurate but shows a visible arithmetic error. Even Adjustments divide among Participants who claimed **anything in the Group**, so a lurker who never claims owes nothing and a late claimer does re-split history. Negative Adjustments allowed; PHP is authoritative with a TS twin kept honest by shared golden vectors. Recorded as [ADR-0002](https://github.com/fkeenv/tabby/blob/main/docs/adr/0002-money-is-allocated-in-whole-minor-units-by-largest-remainder.md).
- [Balance and suggested-transfer prototype](https://github.com/fkeenv/tabby/issues/5): balances are **two screens**. Any Claim Link holder gets the **Participant screen** — one amount, one button, and a *"Why them?"* disclosure that leads with what you claimed before why this person, expanding into a payers-left / receivers-right **flow diagram** for the whole group. The authenticated Organizer gets a separate **ledger**: claimed / fronted / paid / net per Participant, the Unclaimed row, and a raw ↔ suggested toggle. The **minimised transfer set is the model, not a convenience** — a raw-first variant with opt-in simplification was built to contest this and rejected, so a Participant cannot flip to the un-minimised view. Unclaimed appears only on the ledger, by design. Prototype on [`prototype/balances`](https://github.com/fkeenv/tabby/tree/prototype/balances).

- [Claim link mechanics](https://github.com/fkeenv/tabby/issues/6): the link is `/g/{token}` — **128-bit base62, 22 chars, plaintext and unique-indexed** — and a Group has exactly **two addresses**, the token for anonymous holders and `/groups/{group}` behind `auth` for the Organizer. Rotation lives in a **`claim_tokens` table with `revoked_at`**, retained so a stale link gets a **410 naming the Organizer** rather than the 404 that tells an innocent person they imagined the group; Participants, Claims and Payments survive it untouched. Identity is a new term, **Acting Participant**: a device-scoped UI default in a separate 30-day `tabby_claim` cookie, **never in the URL** (a per-Participant token appended to the link was rejected — forwarding it makes a stranger claim as you silently) and **never the Laravel session**, whose 120-minute lifetime must not be stretched to 30 days on the Organizer's behalf. Impersonation extends everywhere without exception, including Payments — which record their typist in `recorded_by_participant_id` — and the Organizer's own Participant; "signed in as" is banned from the Claim Link. The authenticated Organizer opening their own token URL gets the Claim Link view, unredirected. Copy button and `navigator.share`, no QR and no new dependency. Recorded as [ADR-0003](https://github.com/fkeenv/tabby/blob/main/docs/adr/0003-the-claim-link-has-no-identity-boundary.md).

- [Group lifecycle and data retention](https://github.com/fkeenv/tabby/issues/7): a Group has **two independent lifecycle axes** — **Settled** (derived, every Balance zero, reversible by a late Claim) and **Archived** (an Organizer's `archived_at`, which hides the Group from the default dashboard and touches nothing else; the Claim Link keeps working). Both are now in `CONTEXT.md`. The dashboard is **one list sorted by last activity**, Archived behind a toggle, each card leading with the **Organizer's own net Balance** — tabs were rejected for making Settled read as a destination. Deletion is **soft, restorable for 30 days** (the `tabby_claim` cookie's window, reused so the product has one number), then hard-purged; the stranded link's **410 is derived from `deleted_at`, never by writing `revoked_at`**, so restore is one flip and rotation history survives. **No auto-expiry ever** — the Groups that go quiet are the ones where somebody still owes somebody. Account deletion **cascades** through the same window, other people's records included, because Participants have no accounts and there is nobody to transfer a Group to; orphaning was rejected as immortal ownerless data. Lifecycle events now have names — **Settled / Unsettled** derived, **Archived / Unarchived / Deleted / Restored / Purged** stored — which unblocks the notification ticket. Also corrects this map's premise: Participants are not PII-free, they hold a stranger-typed name; what is true is that they hold **no contact channel and no account**.

- [Mutating expenses after Claims exist](https://github.com/fkeenv/tabby/issues/10): corrections land **in place** and an append-only **Activity** stream is the protection. **Claims are never invalidated** by an edit beneath them — a Claim asserts consumption, and a corrected price changes what it costs, not whether it happened — so a quantity dropped below the claimed Shares just reads the same amber `5/3` a table can produce by hand. Deletion is **soft** with Claims preserved and excluded; refusing to delete anything claimed was rejected because a duplicate Expense is exactly what needs deleting. **Warn, never block**, throughout: claimer counts on the edit form, a named count on delete, and a warning when a payer change strands money with someone who has already been paid — which also closes the prototype's open item by carrying over-claimed rows back to the Organizer, the only person who can fix them. **Activity** is one everyone-visible screen at `/g/{token}/activity` recording ledger mutations and Payments individually and Claims rolled up (impersonation makes them worth seeing, tap-level makes them unreadable); it outlives what it describes and purges with the Group. Per-record revision history was rejected and is now out of scope. Finding out something moved is **device-scoped** — a last-viewed timestamp beside the Acting Participant in `tabby_claim`, since ADR-0003 leaves nobody to attribute a read to — except the **zero crossing**, which gets its own banner because it is the one change that contradicts what the app previously told you. New term **Activity** in `CONTEXT.md`, with **Claim** and **Expense** sharpened; **Event** was unusable as a name because a Group *is* the shared event. Recorded as [ADR-0004](https://github.com/fkeenv/tabby/blob/main/docs/adr/0004-the-ledger-is-corrected-in-place-and-activity-is-the-record.md).

- [Who absorbs the Unclaimed residual](https://github.com/fkeenv/tabby/issues/13): **the payer carries it**, charged in the **balance derivation** and never in the allocator — so `Σ allocations + Unclaimed = total` and ADR-0002's golden vectors are untouched, Unclaimed survives as a named quantity, and reversing this is one function rather than a migration. The ticket's own framing was rejected: "payer carries" was written off as *silently* charging the payer, but the payer is out of pocket either way, so the defect is the silence and the silence is what we fixed. Splitting among claimers lost on prototype numbers (Eve pays $36.80 toward a dinner she did not attend, and claiming becomes decoration); leaving the books unbalanced lost because the nets then sum to the residual and **Settled becomes permanently unreachable**. Amends the balance prototype's ruling that the Participant screen never shows Unclaimed: the payer sees it named in their *"Why them?"* disclosure and everyone sees a soft group-level nudge into the claim screen, because the residual is resolved by the people who ate the fries, not by the Organizer reading a ledger. The nudge is **derived, not dismissible** — no write-off action, because ADR-0003 already lets anyone claim as anyone, so the payer claiming it themselves moves no money but zeroes the residual. Pro-rata Adjustments gain a **synthetic Unclaimed weight** so tax on an unclaimed item stops landing on claimers (ADR-0002 amended in place). Recorded as [ADR-0005](https://github.com/fkeenv/tabby/blob/main/docs/adr/0005-the-payer-carries-the-unclaimed-residual.md), with `CONTEXT.md`'s **Balance** entry corrected — it derived from Claims and Payments alone, which was never the whole truth for a payer.

- [Notification triggers](https://github.com/fkeenv/tabby/issues/9): **four emails, all fired by derived transitions** — Group Settled, Group Unsettled, fully claimed (the residual reaching zero), and a **7-day pre-purge warning**. The ticket's six candidates were re-cut rather than filtered, on two principles: **email transitions, not events** (Activity already covers anyone who opens the app, so email is for what you would not otherwise find out), and **never email someone about their own action**, which removes Archived / Unarchived / Deleted / Restored outright. Contests [Group lifecycle](https://github.com/fkeenv/tabby/issues/7)'s starting opinion that *Purged* wants an email — unactionable by construction, so the warning moves to while restoring is still possible, and it deliberately does **not** fire on the account-deletion cascade. Dropped: first Claim, and per-Payment email (a Payment is an unverified note that changes no state; five payers would send five emails saying what Settled says once). **No digest**: notifications are queued with a ~15-minute delay and the transition **re-checked before sending**, which handles the real edge case — a Group near zero flapping between Settled and Unsettled — with one mechanism. Per-type opt-out at `settings/notifications`, except the **pre-purge warning, which is not opt-outable**. Emails link the authenticated `/groups/{group}` and **never carry the Claim Link token** (added to ADR-0003's hygiene list); they carry the Organizer's own net position, name only the Participant who caused an Unsettled, and no per-Participant breakdown. No production mail provider chosen — a deployment decision. No ADR: every part is a policy in one method or a settings row. **Surfaced a gap: the project has no scheduler at all**, so [Group lifecycle](https://github.com/fkeenv/tabby/issues/7)'s day-30 purge has no engine either; flagged on [Data model, schema, and route and permission map](https://github.com/fkeenv/tabby/issues/11).

- [Roster repair: rename, merge, and delete a Participant](https://github.com/fkeenv/tabby/issues/14): one line organises the whole answer — **a Claim is an assertion, a paid Expense and a recorded Payment are records of money moving**; assertions can be voided, money moving cannot. **Merge writes `merged_into_id` and nothing else**, with every read resolving to the canonical survivor exactly as the `tabby_claim` cookie already does; bulk re-pointing was rejected because it makes merge irreversible, destroys the provenance Activity wants, and cuts against ADR-0001's derive-don't-store grain. Shares **add** on a collision (any other rule guesses at a fact only the person knows), made safe by the existing amber over-claim signal and a confirmation that names collisions first; a Payment between the merged pair is kept and nets to zero on its own. **Delete releases Claims into Unclaimed** — never re-splitting among remaining claimers, which would silently raise other people's bills — but is **refused when the Participant is a payer or party to a Payment**, which is referential rather than the hostage-taking [Group lifecycle](https://github.com/fkeenv/tabby/issues/7) rejected. Closes a gap nothing owned: **the Organizer can soft-delete a Payment**, without which that refusal is a dead end. **Rename becomes the Claim Link's fifth power** — forbidding a stranger to fix a name while letting them conjure a Participant under any name is incoherent — while merge and delete stay the Organizer's. All three repairs land in Activity. The Unclaimed residual needed no handling at all, answering [Who absorbs the Unclaimed residual](https://github.com/fkeenv/tabby/issues/13)'s pointer: it is derived from the payer, so resolving the payer carries it for free. No new ADR; **ADR-0003 amended** to five powers with the rejected Organizer-only-rename recorded, and `CONTEXT.md`'s **Claim Link** and **Participant** entries corrected.

- [Data model, schema, and route and permission map](https://github.com/fkeenv/tabby/issues/11): the settled model transcribed into **nine tables, two disjoint route families, one derivation seam, and one shared fixture**. Money is `*_minor` integers throughout; three columns are absent *by decision* — no `settled_at` (ADR-0001), no `category` ([Reporting and categories scope](https://github.com/fkeenv/tabby/issues/8)), no version columns (ADR-0004) — and deletion is soft across the ledger with **one exception, a Claim taken back, which hard-deletes** because unmaking an assertion is not deleting a record of money. Claims carry a **unique `(line_item_id, participant_id)`**, which turns [Roster repair](https://github.com/fkeenv/tabby/issues/14)'s "Shares add on a collision" into one `increment`. The boundary ADR-0003 asked for is **structural, not conditional**: `routes/groups.php` behind `auth` and id-addressed, `routes/claim.php` token-addressed with eight routes and no expense-mutating route to gate — a shared family with a `can:mutate-ledger` gate was rejected as the authentication theatre the ADR exists to prevent. `DeriveGroupLedger` **does not cache**: ADR-0001 offers "cheap or cached with explicit invalidation" and every ledger mutation invalidates every balance in the Group, so the entry would be written and dropped in the same breath; the budget is enforced with `expectsDatabaseQueryCount()` instead. That leaves the **dashboard** as the one real read-path cost, because a largest-remainder net is not expressible as a SQL aggregate — so it **batch-loads and paginates**, bounding the work by the page. Wayfinder binds a **`ClaimToken` model keyed by `token`**, never `Group::getRouteKeyName()`, which would put the token in every Organizer URL. The fixture is **Nonna Rosa — Saturday lifted verbatim from both prototypes**, with ids pinned because ADR-0002 seeds allocation off them. Also wires the scheduler this project never had. Six transcription choices are named on the ticket for the spec to contest, the softest being the once-per-hour write window that makes rolled-up Claim Activity possible without editing an append-only row. Surfaced two tickets: [Organizer expense entry](https://github.com/fkeenv/tabby/issues/16), a screen nobody has designed, and [Abuse and rate limiting](https://github.com/fkeenv/tabby/issues/17), graduated from the fog.

- [Group export: what is in the file](https://github.com/fkeenv/tabby/issues/15): an **Organizer-only zip of ten CSVs** whose primary job is the durable archive that makes day-30 hard purge safe — raw ledger plus stamped `balances` / `suggested_transfers` (`generated_at` on `group`) plus `activity`. Money is **decimal major units** with the Group's currency named once; the participants sheet keeps merge tombstones and soft-deletes while money sheets use **canonical resolved names**; soft-deleted ledger rows are included with `deleted_at`, taken-back Claims stay absent. **No Claim Link token** in the file. Lives at `GET /groups/{group}/export`, always from Group overflow/settings and called out on soft-delete confirm, and stays available through the **30-day soft-delete window**. Re-import is out of scope. No ADR.

- [Organizer expense entry: recording and correcting an Expense on a phone](https://github.com/fkeenv/tabby/issues/16): **Total-first (variant B)** — create leads with description, one amount, and payer; the amount is one implicit Line Item; breaking into lines and tax/tip are opt-in expands. When itemised, fields are **quantity × unit price**. Nested create stands; shell+drill-in UX rejected (per-resource routes remain for API scope, not as the primary flow). ADR-0004 warn-never-block stays on the same screen for corrections. Prototype on [`prototype/expense-entry`](https://github.com/fkeenv/tabby/tree/prototype/expense-entry).

## Not yet specified

- **Nudging the Organizer when claiming goes quiet.** The one notification candidate needing both a scheduler and a threshold nobody can defend yet, and [Who absorbs the Unclaimed residual](https://github.com/fkeenv/tabby/issues/13) already attacks the same problem from the side that can fix it, by nudging claimers in-app rather than emailing the person who then has to chase them. A week of real Groups answers what "quiet" means; guessing at 72 hours now just bakes in an undefendable number.
- **Per-Group notification muting.** Real, but nobody knows yet whether the annoyance it solves exists. Per-type opt-out ships first.

- **Activity stream readability over a long Group.** A week-long trip with steady corrections makes a long stream, and the per-Participant-per-Expense rollup is tuned for one dinner. Whether it needs day grouping, filtering, or nothing at all waits on there being a real Group to look at.

- **Partial and fractional Shares.** "I only had a bite" — a Share below 1. The claim screen prototype tested this and whole Shares did **not** chafe across a real receipt, so this stays fogged rather than graduating. Revisit only if it resurfaces from real use.
- **Offline behaviour on the Claim Link.** Restaurant wifi is bad and the claim screen is used inside restaurants. Unclear whether this is a real requirement or a nice-to-have.
- **The production runtime: who runs the scheduler, the queue worker, and the mail.** [Data model, schema, and route and permission map](https://github.com/fkeenv/tabby/issues/11) specifies the scheduled commands and the delayed notification jobs, and [Notification triggers](https://github.com/fkeenv/tabby/issues/9) left the mail provider open — but all three need a *process* that exists somewhere, and the day-30 purge is not optional. A deployment decision rather than a spec one, so the build-ready spec can be finished without it; the Group that never purges is the cost of leaving it.
- **Progress against a part-paid Suggested Transfer.** ADR-0001 makes a partial payment *shrink* the suggestion rather than mark it part-paid, so there is nothing to render progress against. Whether that reads as progress or as the number quietly moving is unknown until someone uses it.
- **Onboarding, empty states, and the first-run path** for an Organizer with no Groups.
- **Browser-level testing of the claim screen.** Vitest now covers the allocator's TS twin, but the tap-a-tile-to-take-a-Share flow has no end-to-end coverage. Playwright is the likely answer; waits on the claim screen having a real backend to drive.
- **Localisation and date formatting.** Number and currency formatting is settled (`Intl.NumberFormat` client-side, the Group's currency in the payload); UI copy and date formatting are not, and no locale beyond `en` is chosen.

## Out of scope

- **Categories and reporting.** Pulled in while charting, ruled out on both halves — see [Reporting and categories scope](https://github.com/fkeenv/tabby/issues/8). A category answers *what did we spend on*, which is the expense-tracker product next door, not *who owes whom*; the per-person breakdown that is Tabby's actual question already ships on the balance screen, itemised by Line Item. Nothing in the claim or allocation path would ever read a category. The Participant-spanning cross-Group view is **impossible rather than deferred** — Participants are Group-scoped and hold no account, so there is no Alice across two Groups — and the Organizer's own lifetime totals were rejected for making the dashboard's derived-balance read path (ADR-0001) unbounded in exchange for a decorative number. The escape hatch is export, which is in scope and graduated as [Group export: what is in the file](https://github.com/fkeenv/tabby/issues/15). Consequence: **no category column** anywhere in the schema.
- **Import / rehydration of an exported Group.** Ruled out while resolving [Group export: what is in the file](https://github.com/fkeenv/tabby/issues/15). The file is a durable copy for the Organizer and for purge safety, not a backup format Tabby reads back.

- **Per-record revision history and point-in-time reconstruction of a bill.** Ruled out while resolving [Mutating expenses after Claims exist](https://github.com/fkeenv/tabby/issues/10). What a group actually needs is *"the pizza went up, and it was Keen"*, which one readable Activity line delivers; versioning every ledger table additionally buys the ability to reconstruct the bill as it stood on Tuesday, which nobody has asked for. Out rather than deferred, but with an unusual property worth naming: it is **hard to reverse**, because the data is never captured, so adopting it later starts from the day it ships and not from the beginning. Consequence: **no version or revision columns** anywhere in the schema, and Activity holds summary strings that cannot be recomputed from.

- **Co-organizers.** A Group has exactly one Organizer. Ruled out while resolving [Group lifecycle and data retention](https://github.com/fkeenv/tabby/issues/7): a second authenticated owner needs an invite flow with an email address and a permission model, and it multiplies against every decision on this map — who rotates the token, who deletes the Group, whose account deletion cascades. A second product. The Claim Link already lets everyone else do everything except mutate the ledger.
- **Multi-currency.** One currency per Group. It forces an exchange-rate-at-time-of-expense decision that touches every Balance calculation.
- **Payment rails.** No Stripe, PayPal, or bank handoff. Payments are marked by hand on the honour system.
- **Ongoing-ledger Groups.** No running balances between the same people across events, no carry-forward. Groups are event-scoped.
- **Claimer accounts.** Participants never authenticate and never hold contact details.
- **Notifications to claimers.** No address exists to send to, and giving them one would make Participants PII-bearing. Organizer email only.
- **Balance locking or freezing.** No lock action, no frozen snapshot. See ADR-0001.
- **Receipt OCR / receipt scanning.** Pulled into scope while charting, ruled out on the evidence — see [Receipt OCR feasibility](https://github.com/fkeenv/tabby/issues/4). No published source measures per-item quantity and unit price (the only thing Tabby needs), a mis-parsed service charge fails silently by becoming claimable, and `main` has no Expense to populate. Out, not deferred: re-entry needs a hand-labelled eval with a pass bar set in advance. Evidence and the implementation shape it would take are on [`research/receipt-ocr`](https://github.com/fkeenv/tabby/tree/research/receipt-ocr).

Contributor guide

No contributing guide indexed for this repository

Research direction

Read CONTEXT.md, CLAUDE.md, and ADR-0001 through ADR-0005 before mapping the MVP. Capture the domain model, claim-flow semantics, schema, routes, permissions, and UX states for the two screens, then verify the result is complete enough to hand to /to-tickets as separate implementation tickets.

Written by the indexing model from the issue text.

Assessment

Tech stack
laravel, php, tailwindcss
Domain
authentication, backend-api-design, databases, documentation, full-stack
Issue type
Documentation
Difficulty
5/5
Estimated time
Over a week
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
25/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.