Design decisions
This page records the decisions and their discarded alternatives. Each entry is a fork where the obvious or convenient option was rejected for a specific reason. Reading it is the fastest way to understand why the system looks the way it does.
Isolation: subject-bound token over shared integration key
Section titled “Isolation: subject-bound token over shared integration key”Chosen: isolation is enforced by a short-lived, subject-bound token minted
at login (a user token for a Master, a space token for a Ruta).
Rejected: a logical partition keyed on the shared integration key.
Why: the integration key ships inside the app build. A mobile build can be
unpacked and its key extracted in minutes, so the key cannot be the security
boundary — it only identifies which app is calling. The login is the real
boundary: it produces a token bound to one identity, revocable by jti, and
short-lived. Minting authority is itself partitioned (Pro mints space tokens only,
Master mints user tokens) so a stolen build key can never escalate to a different
identity class. See the isolation model and
docs/proposals/scoped-spaces-isolation.md §3.
Guard confinement: onSegment over per-route dedup flags
Section titled “Guard confinement: onSegment over per-route dedup flags”Chosen: hoist the transversal middleware (project-auth,
integration-policy, count-requests) onto a single parent router, and
confine each router-specific scope/actor guard with onSegment(segment, guard)
so it runs only on its own router’s paths.
Rejected: leaving each sub-router’s guards on use('*') and adding
per-request dedup flags (requestCounted, integrationPolicyApplied) to stop
double execution.
Why: several routers share the /project/users prefix, and in Hono a
use('*') fires for every sibling under a shared prefix. Dedup flags fixed the
counting/policy double-run but did not fix the projectAuth N× lookup, and —
more importantly — once SCOPE_ENFORCEMENT flipped to enforce, a bleeding scope
guard would 403 legitimate sibling requests (managing a space wrongly demanding
files:read + users:read). Hoisting transversals removes the duplication at the
source; onSegment makes each remaining guard a no-op off its own path, is robust
to new routes (no per-route list to forget), and ignores the external_id slot so
an integrator-named user cannot trip a sibling’s guard. The dedup flags remain as a
defensive backstop. See authorization axes.
Compression queue: Postgres SKIP LOCKED + Redis wakeup over BunQueue
Section titled “Compression queue: Postgres SKIP LOCKED + Redis wakeup over BunQueue”Chosen: Postgres is the source of truth for the compression queue —
SELECT … FOR UPDATE SKIP LOCKED lets any number of workers claim jobs with no
double-claim — with Redis only as a low-latency wakeup (BRPOP). No Redis
degrades to polling; correctness is unaffected.
Rejected: a dedicated queue library (BunQueue).
Why: a dedicated queue is an external dependency that contradicts the repo’s
native-Bun line, and running two sources of truth (the queue and the files /
compression_jobs tables) invites dual-write divergence — a job acknowledged in
the queue but not reflected in Postgres, or vice versa. Keeping Postgres
authoritative means the job row, the file row, and the storage counters all move
together under one transaction, and Redis can fail without losing a job.
Compression is always asynchronous
Section titled “Compression is always asynchronous”Chosen: there is no synchronous compression path. Every image upload
returns 202 and is compressed by a separate worker process.
Rejected: compressing inline in the request when the image is “small enough”.
Why: Sharp is CPU-bound. Running it in the API process — even conditionally — blocks the event loop and starves request handling under load. A separate process keeps the API responsive and lets the worker be scaled and bounded independently (pixel cap + timeout). The cost is a poll, which the client already tolerates.
Redis client: native Bun.redis over ioredis
Section titled “Redis client: native Bun.redis over ioredis”Chosen: Bun.redis (Bun 1.3, zero dependencies) for the blocklist, rate
limiter, stats, and job wakeup, each with an in-memory fallback.
Rejected: ioredis.
Why: the same capability without adding an external dependency, and one
consistent pattern (Redis when REDIS_URL is set, in-memory otherwise) reused
across all four features. Keeping the dependency surface small is a standing
project value.
The N× transversal fix: parent router over deduplication
Section titled “The N× transversal fix: parent router over deduplication”Chosen: a parent /project/users router that mounts projectAuth +
enforceIntegrationPolicy + countRequests once and wraps the sub-routers.
Rejected: keeping the middleware on each sub-router and relying on dedup flags to make the repeated executions harmless.
Why: dedup made the side effects idempotent but still paid N× the work — N auth
lookups and N last_used_at writes per request for an API key. A parent router
removes the duplication structurally, so the expensive work runs exactly once. This
is the structural companion to the onSegment decision above.
Branch strategy: feature branch + one fix branch, file-level split
Section titled “Branch strategy: feature branch + one fix branch, file-level split”Chosen: land a feature on its working branch and isolate the security-audit fixes on one stacked fix branch, splitting at file granularity.
Rejected: a per-finding stacked chain of branches.
Why: interactive staging (git add -p / -i) is disabled in this
environment, so a per-finding chain would require re-deriving each diff by hand
against a shared file. A file-level split is the finest separation the tooling
supports without error-prone manual diffing.
Distributed revocation & rate limiting (resolved)
Section titled “Distributed revocation & rate limiting (resolved)”Chosen (now resolved): the token blocklist and rate limiter are backed by
Redis for distributed, persistent behavior across instances; blocklist keys use
absolute expiry (SET … EXAT) so they auto-evict and no purge job is needed.
Prior state: both were process-scoped (in-memory), so revocation and limits did not hold across multiple API instances.
Why it mattered: without shared state, a token revoked on one instance stayed valid on another, and rate limits were per-process rather than per-cluster. The same native-Redis pattern now backs the blocklist, rate limiter, and stats.
Hardening from the security & flow audit
Section titled “Hardening from the security & flow audit”A three-axis audit (smoke / security / flow) closed these:
- Single-device login race — space login used to read
active_token_jti, blocklist it, then write the new one (not atomic). It now runs the read-old + write-new in one transaction withSELECT … FOR UPDATEon the space row, so concurrent logins serialize and no superseded token is left un-revoked. - Owner / space storage-limit TOCTOU — the quota check-then-increment was a
race. The increment is now the guard itself:
UPDATE … SET storage_used += size WHERE id = ? AND (limit IS NULL OR storage_used + size <= limit) RETURNING— zero rows rolls the upload back and returns422(lib/storage-quota.ts). - Per-tenant queue fairness — a per-project in-flight cap
(
MAX_INFLIGHT_PER_PROJECT = 2) is enforced at claim time, so one tenant can’t monopolize the worker pool. - Atomic job completion — the worker’s
processing → doneclaim and its storage reconcile now commit in one transaction, so a crash mid-completion can never strand a jobdonewith an inflated storage counter. - Orphan-object reaper — if an upload’s rollback cleanup delete fails, the
key is recorded in
orphaned_objectsand the worker retries the delete on its reap cadence (giving up after 10 attempts).
Still open (non-blocking)
Section titled “Still open (non-blocking)”- Worker re-compression on requeue — a job that crashes after the R2 replace and is requeued recompresses an already-compressed image (quality loss only; storage counters stay correct). Benign, kept for the simpler R2-replace-before- claim ordering.