Decisions Accounts - axolotl-logic/project-buddy GitHub Wiki
Part of Decisions. Each decision has a name, which issues and code comments use to refer to it.
No public sign-up (Axlog, 2026-10-11). This replaced the original passphrase-only gate.
-
"Site passphrase" became "invite code" (Axlog, 2026-10-12: "Rename, including in variable names"). A rename only: still one shared code, in the
INVITE_CODEenvironment variable. Rejected by Axlog: per-person single-use codes. The field stays masked (type="password"), and migration0004's comment stays, since applied migrations don't change (orchestrator). -
Sign-up needs the invite code plus an email address. A wrong code sends nothing; the lockout (5 wrong codes per connection in 15 minutes) refuses even the right one. The lockout lives in
rate_limitsunder the keywrong-invite-code:<client>, and migration0006drops the oldfailed_loginstable (orchestrator, 2026-10-12). Rejected: a counter generic over two tables. Sign-up with an existing email sends a login link. - Sign-in is by an emailed link, with no passwords. A link lasts 30 minutes and works any number of times within that window (Axlog, 2026-10-12). The link opens a "Continue" page and only its POST sets the session, so mail scanners never get one. It isn't bound to the browser.
-
Answers never reveal accounts: the form always says "check your email", including for unknown addresses and rate-limited requests, and the email is sent in
after()so the timing is equal. - Link limits (orchestrator, 2026-10-12): 3 per address and connection per 15 minutes; 10 per address per 15 minutes, counting only requests within their connection's limit; 20 per connection per hour. Addresses are stored as HMACs, and expired rows are deleted as limits are counted. No single connection can use up another's links to an address, but many connections together can still fill an address's limit; the use case documents this.
-
Session: 30 days, in a signed cookie carrying the user's
session_epoch. Sign out everywhere bumps the epoch and deletes the user's links (Axlog, 2026-10-12). Afterwards an old cookie still gets each page's empty shell before the redirect to/login, sinceproxy.tschecks only the cookie's signature and every data read checks the epoch; this stays (Axlog, 2026-10-13; issue #55). Rejected: an epoch check inproxy.ts, a database read on every request. - Own code, no auth library (orchestrator, 2026-10-12): Better Auth makes links single-use with no way to turn it off, and Auth.js v5 is still beta and deletes tokens on use.
- No admin page and no seeded account: Axlog signs up like anyone else.
Resend sends the login emails (Axlog, 2026-10-11) from the address in EMAIL_FROM_ADDRESS, with the site's title as the display name (orchestrator, 2026-10-12). The sender's domain has Resend's DKIM, SPF and MX records and a DMARC p=none; the key is send-only. Calls use plain fetch with a zod-checked response, and errors are classified by status and name. In tests, EMAIL_TRANSPORT=outbox writes each email to a file instead: to a temporary name unique to the write, then renamed to <key>.json, so a reader never sees half a file (orchestrator, 2026-10-12). Rejected: one shared temporary name, and retrying parses in the reader.
Each user's projects, memory, chats and personas are private, with isolation tested between two users. user_id is on projects, and on chats only for a user's central chat; other chats, messages and memory reach their owner through the project. src/accounts/owner-scope.ts holds the only ownership predicates, every store function takes owner first, and a foreign id reads as not found (orchestrator, 2026-10-12). Rejected: user_id on every table, branded owned-id types, and Postgres row-level security (the Neon HTTP driver would need a batch per query).
-
Central chat (#299, #308, #315): a user's central chat is in no project, so
chats.user_idnames its owner. Exactly one ofchats.project_idandchats.user_idis set (thechats_one_ownercheck), and a user has at most one central chat (the unique indexchats_user_id_idx).chatOwnedBymatches a chat whoseuser_idis the owner's or whose project is the owner's, andmessageOwnedByfollows it.getChatreturns a union keyed bykind("project"or"central"), andgetOrCreateCentralChat(src/chats/central-chat.ts) makes or finds the one central chat with anon conflict do nothinginsert. Rejected: a separatecentral_chatstable, which would split every chat, message and ownership query in two; and a separate getter for a central chat, which would make every caller that looks a chat up by id try two getters. -
Coverage test (orchestrator, 2026-10-12):
owner-scope-coverage.test.tschecks every export of every module that touches user data. An insert passes when the owner is in the inserted values or comes through a parent row the same call inserted for the owner.login-links.tsis included, each export allowed by name with its reason, and a stale allowance fails the test. The rate limits and the invite-code check stay out, since they are keyed by address or connection, never a user. Rejected: leavinglogin-links.tsout. -
A foreign project's memory:
editMemoryreturnsundefined, as restoring does, and theupdate_memorytool throwsAppError("not-found")(orchestrator, 2026-10-12). Rejected: a new tool output status, and anot-foundresult variant. -
Hidden extras are stored in
users.hidden_extras./settingswrites both the database and the cookie, and sign-in copies the database into the cookie, because the pre-paint script reads only the cookie. Both hold the extras she has switched away from their default, not the hidden ones (2026-10-14, issue #104), so a flag off by default stays hidden for a user who saved a choice before it existed. Values saved earlier kept their meaning, since every earlier flag defaults on; the column and thehidden-extrascookie kept their names to avoid a migration.
Each user may spend $5 of Claude per calendar month in UTC, Axlog included (Axlog, 2026-10-11).
-
What counts: every model call (replies and every step, the answer-now call, openings, naming) at the model's prices, including cache reads and writes, metered once in
model.ts. Prices live insrc/quota/prices.ts, in micro-USD. - Over quota: new messages, chats and projects are refused with the quota message and the reset date (the 1st of next month, UTC), which the server sends. Reading, renaming and editing memory still work. A reply that crosses the line finishes, with its answer-now call and naming. No smaller model is offered. Rejected: allowing new chats over quota.
-
One gate: a model is made only from the
QuotaGrantthatcheckQuotainquota.tsreturns within the quota, so a new model caller can't skip the check (issue #28). Onlyquota.tsand the unit tests'quota-test-kit.tsmay make a grant (quota-grant.ts, held to that by its test), with no type cast outside tests, and only tests and other test kits may import a test kit. The answer-now call and naming reuse the reply's grant rather than checking again. The central chat'screate_projecttool starts a new project, so it checks the quota again, throughstartProject, rather than reusing the reply's grant (#310). Rejected: each caller remembering to callcheckQuota. - A refused send puts the user's text back in the box and leaves no unsaved message or empty reply behind, including in an empty chat.
-
Usage shows on the account page, with spend rounded up to the cent, but never shown as the limit itself before the quota is used up: 4,990,001 µ$ shows
$4.99, and 1 µ$ shows $0.01 (orchestrator, 2026-10-12). Rejected: rounding down.formatResetDayinquota-terms.tsis the only reset-day formatter. -
out-of-creditscopy speaks to invited users (the tutor is unavailable, try later), not to an admin. -
An answered message is never answered again: a resend gets 409
already-answered, which the browser handles by reloading.saveMessagereportsinsertedoralready-saved, and a message saved but missing from the history the request read is also refused (orchestrator, 2026-10-12). A resend that arrives while the first reply still streams is refused the same way (issue #51): a message'sreply_started_atmarks the request that is replying to it.saveMessagesets it when it inserts the message, a resend of a held last message must winclaimReply(one conditionalUPDATE, so of two at once only one wins), and the reply releases it when it fails, isn't saved, or fails before streaming, so a retry after a fast failure is answered at once. A saved reply keeps its marker, since the reply already answers the message. A marker older thanREPLY_CLAIM_SECONDS(300 s, the chat route'smaxDuration) has lapsed, so a function stopped mid-reply can't hold the message forever (orchestrator, 2026-10-13). The e2e test sends again once the first response's headers arrive, after the claim is taken, so it doesn't depend on timing. Rejected: answering a held last message again only after 300 s even when its reply failed, which would block a real retry after a fast failure. -
An old build reloads on a case name it doesn't know (issue #63): during a deploy, a browser on the previous build can get a case only the new build has, such as a new quota refusal. Text in a case name's form that names no case it knows (
isUnrecognisedCaseNameinapp-error.ts), in a chat failure or an error boundary's digest, reloads the page once to pick up the new build. A reload stamp in session storage holds back another for a minute, so a page that still doesn't know the case shows the unknown message instead of looping (newer-build-reload.ts). Rejected: reloading on any unknown failure (a network error would lose the page, and a real unknown would loop), and Vercel Skew Protection (hosting configuration, not code). No e2e test, since a mid-deploy version skew can't be staged. -
Spend for a deleted owner is dropped quietly, matched only by a foreign-key violation on
monthly_spend's user key (orchestrator, 2026-10-12). Rejected: test teardown waiting forafter(). -
finishwaits for the spend write, so spend is stored before the response ends. Typing was never lost in 144 forced fills on WebKit with it (orchestrator, 2026-10-12). -
Month ends in quota tests: quota tests cover a run that crosses a UTC month's end by seeding next month's spend too, and topping it up after a reply that crosses the quota (issue #56). Server tests seed with
spendandspendInMonthAfterfromsrc/test-database/spend-test-kit.ts, and accept either reset day through itsresetDaysSinceandquotaMessage(#347). That kit is the one module that holds this knowledge for every test: the account page's visual spec (e2e/visual/account.spec.ts) seeds its spend with the samespend, ande2e/quota-reset-days.tsande2e/fixtures.ts's copies are gone (#351). Rejected: an injected server clock, which changes production code for a test. -
A spend write is retried while the database is unreachable (issue #23):
recordSpendtries the upsert up to 3 times, waiting 100 ms and then 400 ms, beforefinishpasses on, so a brief outage after a reply doesn't lose its cost. Only an unreachable database is retried; a query Postgres rejects fails at once, and a deleted owner's spend is still dropped. A write that committed but whose answer was lost can be counted twice; that is accepted because it errs toward the quota, never past it. Rejected: a durable outbox or idempotency ledger for spend writes (it needs a schema change). -
Known gaps: concurrent replies can overshoot $5 by the replies in flight, and a stream stopped before
finishis charged an estimate (both by design). A spend write still unreachable after its retries is logged and lost.
main is released by one GitHub Actions workflow, .github/workflows/release.yml (Axlog, 2026-10-13, issue #74). Since #148, nothing deploys automatically (Axlog, 2026-10-14): a release starts only from a deploy ticket, an issue labelled deploy by someone with write access, or from a hand run (workflow_dispatch), and always releases main's head. On success the workflow comments on the ticket and closes it; on failure it comments there too. The issue pipeline skips deploy tickets (skipLabels). It builds on Vercel with the production environment but without the production domain (vercel deploy --prod --skip-domain) and waits until the build is READY, runs drizzle-kit migrate against PRODUCTION_DATABASE_URL, makes the build live through Vercel's REST API (.github/scripts/vercel-promote.sh, POST /v10/projects/<project>/promote/<deployment id>) and checks, for up to five minutes, that the production domain serves it, then smoke-tests the live site with signed-out requests only (scripts/release/smoke.ts). A failed build or migration stops the release with production on the previous build; any failed step opens, or comments on, a Production release failed: <short sha> issue labelled ops. The issue pipeline stays off that issue because its author, github-actions[bot], is not in allowedUsers; no label does this, as agent:skip is gone (no-skip-label). The long steps have their own timeout-minutes (build 25, go live 10, migrate and smoke 5 each), below the job's 60, so a slow step fails and is reported rather than the job being cancelled; the report also runs on a cancelled job and says "was cancelled" for a cancelled step, and it finds an open release-failed issue by its title alone, so removing ops doesn't cause a duplicate (issue #149). Releases never overlap (concurrency: production-release). Vercel's Git deployments are off for every branch, previews included (vercel.json), the workflow has no push trigger, and no build migrates; src/release-config.test.ts keeps all three so. Previews were also using up Vercel's free limit of 100 deployments a day, which failed the first release (#147). The Vercel CLI is pinned in package.json (64.0.1, whose deploy --skip-domain the workflow uses). The workflow waits for a build, and checks which build the domain serves, with Vercel's REST API (.github/scripts/vercel-deployment.sh), because vercel inspect answered "User not found" with the workflows' token (#151, PR #152). It goes live through the REST API too, because vercel promote answered the same (#239): the release of 38c90da migrated and then failed to go live, so the old build served the new schema until someone promoted by hand. No workflow runs a CLI command that looks up the token's user (promote, inspect, rollback, whoami, ls); vercel build and vercel deploy work with the token. src/release-config.test.ts checks this, and .github/scripts/vercel-promote.test.sh (run by npm run check) tests the promote script against a stand-in curl. If Go live fails after Migrate, production may be broken, and the release-failed issue says so and names the deployment: promote it by hand with a personal login, vercel promote <deployment> --scope modest-progress, then run the smoke test (node scripts/release/smoke.ts https://project-buddy-rust.vercel.app in apps/web), or fix forward. The migration runs while the old build is live, and a Vercel rollback restores code but never the schema, so every migration must work with the build that is live: expand first, contract in a later release (two merges can go out in one release). Rejected: keeping the migration in the production build (vercel-build, the previous decision migrate-on-production-build: nobody heard of a failed build or of a push Vercel never deployed, as for 3f6e57f); migrating after the deployment is live (the new build would run on the old schema until it finishes); the issue pipeline migrating when it merges (it would miss PRs merged by hand, such as #70); and migrating in the app at start-up (every cold start would race to run it). Rejected (2026-10-14): releasing every push to main. Cost: about 3–5 Actions minutes per release.
Every PR merges into integration, not main (Axlog, 2026-10-14; PR #150). main is what production runs, and only scripts/deploy.sh moves it. Each push to integration starts .github/workflows/integration.yml, which builds a Vercel preview (vercel deploy, Preview environment) and runs the release's smoke test against it. The preview sits behind Vercel's Deployment Protection, so the smoke test sends the project's automation bypass secret (VERCEL_AUTOMATION_BYPASS_SECRET, a repo secret). A failed preview opens, or comments on, an ops issue titled Integration preview failed: <short sha>. The workflow never migrates, because previews share the production database, so a preview of code that needs a pending migration can fail on the pages that read it. It never promotes either; src/release-config.test.ts checks both. A newer push cancels an older preview, which also saves Vercel's 100 deployments a day.
scripts/deploy.sh [--dry-run] [--yes] [<commit>] releases a tested commit, the head of integration unless another is named. It checks that the commit is on integration, that its Integration preview passed, and that main fast-forwards to it. It lists what will ship, fast-forwards main (never a force-push), and opens a deploy ticket, which starts release-workflow. release.yml always checks out main. A fix pushed straight to main must be merged back into integration before the next deploy, or the script stops.
main stays the default branch, so the release workflow's code and its production secrets come only from main, which only a deploy moves. GitHub closes an issue from "Closes #N" only when the PR merges into the default branch, so the issue pipeline closes the issue itself after it merges a PR into integration. Agent prompts say that releasing is never part of an issue's work. The two steps of an expand-then-contract migration still need separate releases: don't merge the second into integration until the first is live. Rejected: making integration the default branch (an issues event runs the default branch's workflow file, so integration code would run the production release).