{"name":"gitwally","visibility":"public","has_meta":true,"total_open":3,"shown":3,"tickets":[{"id":"9c35217e-2e17-4928-889b-6e9e322669b3","title":"Accounts via magic-email login and token claiming","description":"A user enters an email, clicks a link, and has a session. From there they can\nclaim the repositories behind any token they hold, and mint new tokens that\nwork on everything they have claimed. It is the feature that turns a pile of\nindependently-owned repositories into \"my repositories\".\n\n## The tension, first\n\nNarwal has no accounts on purpose, and the absence is load-bearing rather than\nunfinished. A repository's ACL is a list of `sha256(token)` values and nothing\nelse. A name belongs to whoever won the create-only CAS first. The host never\nreturns 401, because a 401 makes `git` prompt for a username and swallow the\nguidance the sideband is carrying. There is no user table, no session, no\nrecovery flow, and no personal data anywhere in the bucket — a repository is a\ncapability, and holding the token *is* the authorization.\n\nThis ticket adds an identity layer above that. It should be judged as a\ndeliberate change to a stated principle, not as a gap being filled. Two things\nfollow and neither is negotiable:\n\n- **The git paths must not change.** `git push` and `git fetch` keep\n  authenticating with a bearer token and keep never seeing a 401. Accounts are\n  a management surface beside the git surface, not in front of it.\n- **A repository must stay usable with only its token.** If the accounts\n  store is unavailable, or a user never logs in, pushing and cloning work\n  exactly as they do now. Identity is additive; it is not a dependency of the\n  data path.\n\nIf either of those slips, this stops being \"narwal with accounts\" and becomes\na different host.\n\n## Claiming: proving you hold a token\n\nThe account needs evidence that the person claiming a token actually has it.\nThree shapes, cheapest first:\n\n1. **Present the token to an authenticated session.** `POST /_narwal/account/tokens`\n   with the session cookie and the raw token in the body. The server hashes it\n   and records the association. Simple, and the same trust model as using the\n   token to push — if you can send it, you can push with it. Its weakness is\n   that the token crosses a second channel and lands in a second log surface.\n2. **Sign a challenge.** The server issues a nonce; the client returns\n   `HMAC(token, nonce)`. The raw token never leaves the client. Costs a client\n   tool, since `git` will not do this for you, which undercuts the \"no setup\"\n   story.\n3. **Prove by push.** Push a signed ref or a specific object to a repository\n   whose ACL already lists the token. Uses only `git`, but it is a strange\n   thing to ask a person to do and it writes to their repository to answer a\n   question about identity.\n\nRecommend (1) for a first cut, over TLS, with the raw token never stored and\nnever logged — only its hash — and (2) noted as the upgrade if the second\nchannel proves to be a real exposure.\n\nClaiming a token claims **the repositories whose ACL contains its hash**,\nwhich requires an index from token hash to repository. That index does not\nexist today: ACLs are per-repository and nothing enumerates the other\ndirection. Either scan `repos/*/repo.json` at claim time (fine at this scale,\nlinear in repository count, and it is a rare operation) or maintain a reverse\nmap. Start with the scan, and note that it becomes the wrong answer at some\nsize.\n\n## Where accounts live\n\nA new S3 prefix at the bucket root, beside `repos/` and `nodes/`:\n\n```\naccounts/{sha256(email)}.json    { email_hash, created_at, tokens: [hash…], repos: [name…] }\nlogin/{sha256(link_token)}.json  { email_hash, expires_at }     — create-only, deleted on use\nsessions/{sha256(session)}.json  { email_hash, expires_at }     — create-only\n```\n\nKeyed by `sha256(email)` rather than the address, so the object key itself is\nnot a mailing list for anyone who can enumerate the bucket. The addresses\nstill have to be stored somewhere to send mail to them, which is discussed\nunder PII below.\n\nSame object discipline as the rest of the host: create-only writes with\n`If-None-Match: *` for anything a race could duplicate, CAS with `If-Match`\nfor updates to an existing account, and no deletes on the data path.\n\n## Minting tokens that work everywhere: the real decision\n\nThis is the part that changes the auth check or the write pattern, and the\ntwo options are genuinely different systems.\n\n**Option A — ACL fan-out.** Minting writes the new token's hash into every\nclaimed repository's `repo.json`. The auth check is untouched: a repository\nstill trusts a list of hashes, and every existing invariant holds.\n\nThe costs are real. Minting becomes N compare-and-swaps across N repositories,\neach of which can lose a race with a concurrent push and need a retry, so it\nis a partial-failure surface where \"mint a token\" can half-succeed. It is not\nidempotent-by-construction, so a retried mint must be safe. And claiming a\nrepository *later* means back-filling every previously minted token into its\nACL, which means the account's token set and the repository's ACL can drift\napart and need reconciliation. ACLs also grow without bound as tokens\naccumulate.\n\n**Option B — account trust.** A repository's ACL gains a second kind of entry:\n`account:{email_hash}` alongside the token hashes. The auth check becomes: is\nthis hash in the ACL, *or* does it belong to an account this repository\ntrusts? Minting writes one object — the account — and every claimed\nrepository sees the new token immediately. Claiming a repository later is one\nCAS on that repository.\n\nThe cost is that the git data path now depends on the accounts store: a fetch\nor push authenticated by an account token cannot be answered without reading\nthe account. That is a new dependency on exactly the path the second rule\nabove says must keep working alone, and it needs a cache with an invalidation\nstory, which is the sort of thing that turns into a stale-permission bug.\n\nRecommend **A for the first cut**, with the fan-out made restartable and its\npartial-failure behavior stated in the guidance a user sees, and **B written\ndown as the shape to move to** if ACL growth or back-fill reconciliation\nbecomes the thing that hurts. A is worse at scale and better at not putting a\nnew hard dependency under `git push` on day one.\n\n## Magic-link mechanics\n\nSending mail is a new external dependency — SES is the obvious one in this\naccount, and it brings sender verification, a sandbox to leave, bounce\nhandling, and a per-region setup that Terraform will have to own. It is the\nfirst thing narwal needs that is not S3 and EC2, and the first that can be\n\"down\" in a way that blocks a user-facing flow.\n\nLink tokens: 32 bytes of randomness, stored as `sha256`, **single use**\n(delete the object on redemption, which is the one place a delete is correct),\nshort expiry — ten minutes. Sessions: a `Secure`, `HttpOnly`, `SameSite=Lax`\ncookie, a hashed session id, expiry measured in days, and revocable by\ndeleting the object.\n\nThis is a second credential model beside the URL-scoped git credential that\n`/setup` installs, and they should stay separate: a session cookie must never\nauthorize a git push, and a push token must never authorize an account\nchange. Two credentials, two surfaces, no crossover — otherwise a stolen\ncookie becomes a write capability on every repository the account owns.\n\n## PII\n\nToday the bucket holds no personal data at all: repository names, packs,\ntoken hashes, node ids. Adding email addresses changes what this system *is*\nfrom a data-protection standpoint — deletion requests, retention, breach\nexposure, and a reason to care where the bucket lives all arrive together.\n\nStore `sha256(email)` as the key and the address only where sending requires\nit. Consider whether the address needs storing at all after verification: a\none-way hash is enough to *recognize* a returning user, though not enough to\nmail them, so \"log in again from the same address\" works while \"notify this\nuser\" does not. That is a smaller feature and a much smaller obligation, and\nit is worth deciding deliberately rather than by default.\n\nAccount deletion must be real: remove the account object, and decide\nexplicitly what happens to repositories claimed under it. They should survive\nwith their token ACLs intact — the repository is still a capability — which\nis another argument for Option A, where the ACL never depended on the account\nin the first place.\n\n## Abuse and revocation\n\n- **Email verification is the claim gate.** No session exists until a link is\n  clicked, so an unverified address can do nothing.\n- **Rate-limit link requests** per address and per source, or the endpoint is\n  a mail cannon pointed at arbitrary inboxes. This is the abuse vector that\n  matters most, because the victim is a third party who never used the host.\n- **Nothing stops someone claiming a token they hold but did not create** —\n  and that is correct. The token *is* the authorization. Anyone who can read\n  a token can already push. Claiming grants no capability that pushing did\n  not.\n- **Revocation** exists per-repository today (`remove_push_token`). Accounts\n  need the account-level version: revoke a minted token everywhere it was\n  fanned out, which under Option A is another N-way write with the same\n  partial-failure shape as minting. Under Option B it is one write. Worth\n  weighing when choosing.\n- **The ACL can never be emptied** — the existing rule — so an account that\n  removes its last token must not lock a repository out of existence.\n\n## Phases\n\n1. Sessions only: request a link, redeem it, hold a session. Nothing is\n   claimed, nothing is minted. Proves the mail path and the cookie model.\n2. Claim tokens and list the repositories they reach. Read-only: the account\n   learns what it owns, and writes nothing to any `repo.json`.\n3. Mint with fan-out (Option A), restartable, with the partial-failure\n   behavior visible to the user.\n4. Revisit Option B only if ACL growth or back-fill reconciliation is the\n   thing actually hurting.\n\nStop after any phase. Phase 2 alone — \"show me my repositories\" — may be most\nof the value, and it changes nothing about how a push is authorized.\n\n## Open questions\n\n- Is a \"user\" the right idea here at all, or is the honest version a\n  **keyring**: a way to group tokens you hold, with no claim of who you are?\n  A keyring needs no email, no PII, and no mail dependency, and it delivers\n  most of phase 2.\n- What does an account mean for `/new`? Magic pushes currently belong to\n  whatever token pushed them; should a session make them adoptable?\n- Does anything in the fleet need the account store, or is it strictly a\n  control-plane concern that one node can serve?\n- What happens to a repository whose account is deleted but whose tokens are\n  lost — is that a support burden this host wants to have?","status":"open","state":"new","assigned":null,"created_at":"2026-08-24T07:50:26.441385Z","created_by":"schacon@gmail.com","tags":["narwal"],"comments":0},{"id":"6672b6fe-5616-4eb7-ad7e-5e94533edc1c","title":"Revisit receive-pack with a reference-transaction gate","description":"Narwal wraps `receive-pack` by hand: it parses framing itself, runs `index-pack`, checks ref preconditions, publishes to the WAL, and then applies `update-ref`. `decisions.md` records the alternative — running real `git receive-pack` with a hook as the CAS gate — as correct but heavier for v0. The article describes the same synchronization point from the other side: the reference update is coordinated after the packfile is persisted, and a push is only published once the local transaction is prepared.\n\nRevisiting matters because the hand-written path has to keep pace with the wire protocol on its own — capabilities, push options, atomic semantics, negotiation changes — and the guidance sideband was the main reason to own the stream. With `/setup` and the HTML surfaces now carrying onboarding, that reason is weaker than it was.\n\nApproach notes: a `reference-transaction` hook in the `prepared` state is the natural place to run the index CAS and abort the transaction on a loss, which is closer to the article's ordering than today's apply-after-publish. Risks: the sideband guidance channel, `Refusal` → `Guidance` in `src/guidance.rs`, and the ability to reject before claiming a name all currently depend on owning the stream. Open question: whether this is a rewrite or a second path used only for large pushes.","status":"open","state":"new","assigned":null,"created_at":"2026-08-24T07:50:26.428222Z","created_by":"schacon@gmail.com","tags":["narwal"],"comments":0},{"id":"0c464140-72f8-4983-a6c1-605596e9d496","title":"Per-push cost still grows with a repository's history","description":"Every push rewrites `repos/{name}/index.json` in full. The index is the CAS\narbiter, so its bytes are on the critical path of every write: serialize, PUT,\nand — on contention — do it again per retry.\n\n## What has already landed\n\n`push-mutex-presence` moved the cache-size walk off the per-repo mutex, and\n`pack_dir_bytes` now sums `objects/pack` instead of walking the whole bare\nrepo. `wal-compaction` collapses `entries`, so replay length is bounded. Those\nremoved the worst of the original quadratic behaviour: the concurrent-ref test\nwent from intervals that doubled (30s, 69s, 136s per 100 refs) to ones that\ngrow roughly linearly (58s, 62s, 101s, 114s per 50 refs, measured\n2026-08-23 on two nodes).\n\n## What is left, measured\n\nCompaction bounds `entries` but moves the retired keys to `superseded`, which\nnothing prunes. The index therefore still grows with total pushes — the growth\njust moved fields. Live objects, same day:\n\n```\nrepo                 index.json  entries  superseded  refs\nseqpush2-…  (1000 pushes)  29,627 B      55         960     1\nxnode2-…     (200 pushes)  21,298 B      10         192   199\nloadtest-…  (1000 pushes,\n             pre-compaction) 29,135 B   1000           0     1\n```\n\n`seqpush2` took 1000 pushes of a single ref and still carries a ~29 KB index,\nalmost all of it the superseded key list. Every subsequent push to that\nrepository serializes and PUTs those 29 KB to move one ref.\n\nTwo costs, and they are different:\n\n- `superseded` grows with **total pushes ever**, without bound. This is the\n  one worth removing.\n- `refs` grows with the **ref count**, which is inherent: the index is the ref\n  set, and one CAS on it is the commit point.\n\n## Approach: take `superseded` out of the CAS payload\n\n`superseded` is not replay input. `Cache::sync` walks `entries` from the\ncheckpoint; it never reads `superseded`. It exists so the monitor can tell\nretired history from an orphan left by a crashed push, and it is read by the\nprovenance view (`ref_history`, `state_at`, `state_diff`) and by\n`plan_rewind`.\n\nSo it can live beside the index rather than inside it:\n\n1. Add `repos/{name}/superseded.json`, an append-only list, written with\n   `PutCondition::Overwrite` after a successful index CAS — the same\n   best-effort shape `nodes/{id}.json` already uses.\n2. `compact` appends the keys it retires there instead of into the index.\n3. `WalIndex.superseded` stays in the struct as `#[serde(default)]` for\n   backward compatibility, and readers take the union of the sidecar and any\n   inline list, so indexes written before the change keep working with no\n   migration step.\n4. Provenance and rewind read the sidecar; the monitor's orphan check\n   subtracts it exactly as it subtracts `entries` today.\n\nPer-push PUT then costs `entries` (compaction-bounded) plus `refs`\n(inherent), and stops growing with history.\n\n## Why this is not implemented yet\n\nIt changes what is atomic with the commit point, and that deserves review\nrather than a quiet landing:\n\n- A crash between the index CAS and the sidecar write leaves a retired key in\n  neither list. No data is lost — the entry objects are still there and replay\n  never wanted them — but until the next compaction that key reads as an\n  **orphan** on the monitor, and `rewind` to that exact entry cannot resolve\n  it from the sidecar. Today both are impossible, because the CAS covers them.\n- `rewind` currently treats the index as the single authority for what\n  history exists. Splitting that across two objects means deciding what a\n  rewind does when the sidecar is behind: refuse, fall back to listing `wal/`,\n  or accept a partial history.\n- The alternative — capping `superseded` at the newest N keys — is a much\n  smaller change but silently shortens how far back provenance and rewind can\n  see, which is a product decision, not a performance one.\n\nRecommend deciding between \"sidecar with a stated crash window\" and \"bounded\nretention with a stated history horizon\" before writing code. The measurements\nabove are the argument for doing one of them; neither is urgent, because\ngrowth is now linear rather than quadratic.","status":"open","state":"new","assigned":null,"created_at":"2026-08-24T07:50:26.413396Z","created_by":"schacon@gmail.com","tags":["narwal"],"comments":0}]}