# Technical decisions

How Gone is built, and the alternatives we turned down: the choice, what we didn't do, why, and what would make us look again. Features we've said no to are in [rejected.md](https://getgone.dev/docs/rejected/index.md); this page is about the how, not the what.

Each phase of [the roadmap](https://getgone.dev/docs/roadmap/index.md) also records the decisions made while building it. This page collects the ones a contributor is most likely to question. A "Never" here means the alternative conflicts with one of Gone's guarantees or with a [non-goal](https://getgone.dev/docs/roadmap/index.md#non-goals).

## Encryption

| Decision | Instead of | Why | Revisit when |
| -------- | ---------- | --- | ------------ |
| Encryption runs on the browser's built-in WebCrypto, with a separate Go implementation for the CLI. Shared [test vectors](https://getgone.dev/docs/protocol/index.md#9-test-vectors) keep the two in step. | WebAssembly, for example the Go code compiled for the browser | WebAssembly would add about 2 MB to every page, need a `'wasm-unsafe-eval'` hole in the Content Security Policy, give up hardware-accelerated AES-GCM, and leave secrets in a garbage-collected heap that can't be wiped. | Never, while the strict CSP stands |
| Passphrases are stretched with PBKDF2-SHA-256 at 600,000 iterations. The format reserves IDs for other functions ([protocol.md §4.2](https://getgone.dev/docs/protocol/index.md#42-protocol-version-2-passphrase)). | Argon2id | Argon2id resists guessing better, but browsers only offer it through WebAssembly. Reconfirmed on 2026-10-16. | Browsers offer Argon2 natively |
| A wrong link key and a wrong passphrase give the same error. | Telling the two apart | AES-GCM can't tell them apart; both just fail to authenticate. | Never |

## Server

| Decision | Instead of | Why | Revisit when |
| -------- | ---------- | --- | ------------ |
| Nothing is kept once a secret is gone. Status answers only "pending" or `404`. | Tombstones, or an "opened" or "revoked" state | A row kept after deletion leaks that the ID existed and when it was opened. This is why there are no [read receipts](https://getgone.dev/docs/rejected/index.md#sending-and-receiving). | Never |
| SQLite through the pure-Go `modernc.org/sqlite`, with ciphertext over 8 KiB in files | `mattn/go-sqlite3` (CGO), or object storage | A fully static binary with `CGO_ENABLED=0`, and one file plus a folder to back up. | Instances outgrow it in practice |
| `synchronous=FULL` | Relaxing it for speed | A claim lost to a power cut would put the secret back to unclaimed, so it could be opened a second time. | Never |
| An ordered write gate. Contention answers `503 busy`. | Letting SQLite's busy handler race | Writers weren't served in order, and a timeout surfaced as a `500`. | — |
| Rate limits are per process, in memory. | A shared store, such as Redis | Gone runs as one process. Large floods belong at the edge anyway. | Gone is commonly run with several replicas |
| `GONE_REQUIRE_PASSPHRASE` covers only new secrets (`POST /api/secret`). Replies to requests are exempt. | Requiring a passphrase on replies too, or turning requests off while the setting is on | A passphrase protects against a leaked link. A secret link carries its own decryption key, so the passphrase is a second factor that travels another way. A reply link carries no decryption key: only the requester's public key and a fill token that allows one reply. The reply is encrypted to a private key that never leaves the requester's browser or CLI, and only the requester's manage token can claim it. So whoever intercepts a reply link can send the one reply, but can never read one. Requiring a passphrase anyway would need a new protocol version and a second channel running backwards, from the person replying to the requester, and the server could still only check a version number. Turning requests off would remove the one mode that never puts a key in a link. | An operator needs replies covered for a reason other than a leaked link |
| Sign-in for private instances (`GONE_AUTH`) checks that the proxy signed someone in, never who. Nothing about the user is stored, logged or shown. | Accounts in Gone, or recording who created what | Gone keeps no record of who sent what; the operator's proxy already knows who its users are. | Never for accounts |
| User-level audit logging is out of scope. | Logging the identity behind each create | It would be exactly the record Gone promises not to keep. A possible future enterprise-only feature, possibly under a different licence. | An enterprise deployment asks for it |
| Signed sign-in tokens are verified against the provider's keys. Plain identity headers are believed only from `GONE_TRUSTED_PROXIES`, and such providers refuse to start without it. | Trusting any non-empty identity header | A plain header proves nothing if someone reaches Gone without the proxy; every vendor that sends one says to firewall the app. | — |
| Configuration from environment variables only | Config files or command-line flags | One way to configure, and it suits containers. | Never |

## Deployment

| Decision | Instead of | Why | Revisit when |
| -------- | ---------- | --- | ------------ |
| The Helm chart lives in this repository and its version follows Gone's (chart `3.13.0` runs `v3.13.0`) | A separate chart repository with its own version | One release makes the image and the chart together, so they can't drift, and the chart's install test runs against the code in the same pull request. | The chart needs releases of its own |
| The chart is published as an OCI artifact on GHCR, attested like the image | A `gh-pages` Helm repository index, or cosign | It sits beside the image, needs no extra hosting, and `gh attestation verify` checks it the same way. | — |
| The chart runs one replica with the `Recreate` strategy, and refuses `replicaCount` | Several replicas, or a rolling update | SQLite, the blob directory and the rate limits each assume one process; two pods would lose or corrupt secrets. | Gone gains a shared store |
| Plain-header sign-in (`authelia`, `authentik`, `tailscale`, `custom`) isn't supported on Kubernetes. The chart refuses it; `goned` only warns when it finds itself in a pod. | Allowing it with a pod-network `GONE_TRUSTED_PROXIES`, or a NetworkPolicy | Proxy pods have no fixed address, and trusting the pod network lets any pod forge the header. A NetworkPolicy only helps where the network plugin enforces it, and fails open silently where it doesn't. `goned` warns rather than refusing because a proxy sidecar in Gone's own pod, trusted on `127.0.0.1`, is safe. See [Kubernetes](https://getgone.dev/docs/self-host/kubernetes/index.md#why-plain-header-sign-in-isnt-supported). | Never |
| The chart routes with the Gateway API (an HTTPRoute) and has no Ingress template | An Ingress, or both | The Gateway API is where Kubernetes routing is heading. An Ingress is a few lines to write by hand. | Users ask for an Ingress template |
| The chart doesn't install cert-manager or a Gateway controller | Bundling them as chart dependencies | Both install cluster-wide resources (CRDs and webhooks) that clash with copies a cluster already has, and make upgrades and uninstalls risky. | — |
| Linux packages are built with nfpm from the same binaries as the release archives, and attested with them | Building packages separately, or distribution build services (openSUSE Build Service, PPAs, COPR) | One build means the packages hold the exact attested binaries. Build services compile from source, so their binaries wouldn't match the attestation. | — |
| `gone-server` runs as a systemd `DynamicUser` and isn't started when installed | A `gone` system user made by the package, or starting on install | systemd manages the user and the data directory, so the package needs no user scripts. Gone needs settings and a TLS proxy before it should listen. | — |
| Packages are release downloads, not an apt or dnf repository | Hosting a signed repository (on GitHub Pages or with Cloudsmith) | A repository means a signing key to keep and more release machinery, for a small audience so far. | People ask for `apt upgrade` |
| `install.sh` installs only the CLI, into `~/.local/bin`, and never uses sudo | Installing the server too, or installing system-wide | The server needs settings, a proxy and a service; the packages and Docker handle that. A script piped to `sh` shouldn't ask for root. | — |

## Web client

| Decision | Instead of | Why | Revisit when |
| -------- | ---------- | --- | ------------ |
| Vanilla JavaScript in small classic scripts, with no build step. Each script publishes one `window.gone*` namespace. | Front-end frameworks, a bundler, TypeScript or ES modules | Nothing to audit beyond what ships, and it works under a strict CSP. The repo's only npm dependency is Playwright in `test/e2e`, which is test-only and never shipped. | Never, for shipped code |
| Everything is served from the operator's own server. | Third-party scripts, fonts, analytics or CDNs | Nothing loads from anywhere else, so no outside party sees who uses an instance. | Never |
| Our own QR encoder (`web/js/qr*.js`), checked against node-qrcode and Nayuki | A QR library | No new dependency, and nothing outside `'self'` in the CSP. | — |
| Translations in inlang's message format, rendered by our own small runtime. Pages embed only the `js.*` and `common.*` messages. | Paraglide | Paraglide needs ES modules or a bundler. The catalog format keeps it possible later. | The web client moves to ES modules |
| The language switches in place, from catalogs fetched on demand. | Reloading the page | A reload would lose a link you just made or a secret you just opened. | Never |
| A custom, keyboard-accessible language menu | The browser's native `<select>` | The native list didn't fit the design. | — |

## Command line

| Decision | Instead of | Why | Revisit when |
| -------- | ---------- | --- | ------------ |
| Saved requests in a gob file, written atomically under a lock, with permissions checked like `ssh` | SQLite | SQLite grew the binary from 10.2 to 16.7 MB. The format is deliberately not something to open, edit or copy-paste. | — |
| `gone` (client) and `goned` (server) are separate binaries. | One binary with server subcommands | The client carries no server code or web assets, and the name users type is the short one. | — |

## Dependencies

The server compiles in 17 external modules. Each new one needs a written reason in its PR.

| Decision | Instead of | Why | Revisit when |
| -------- | ---------- | --- | ------------ |
| Prometheus text format written by hand | `client_golang` | It would add about 10 modules for a small text format. | — |
| Locale matching written by hand | A locale-matching library | Matching five locales takes a few dozen lines. | Gone supports many more locales |
| `github.com/go-jose/go-jose/v4` verifies sign-in tokens (v3.12). It adds one module and has no dependencies of its own. It parses key sets and requires the allowed algorithms to be listed when a token is parsed, so each provider pins its algorithm and a token's own `alg` is never trusted. | A hand-written verifier; `golang-jwt/jwt`, which needs a separate key-set parser | Token verification is security-critical, and go-jose is widely used and reviewed. | — |
| Plain validation code | `go-playground/validator` | It was the only reason `x/crypto` was in the server's build. Dropping it removed six modules. | — |
