HIP-1141: Pref — One Document Per Person
Abstract
/v1/pref is the signed-in user's own UI state — theme, density, pinned nav —
one JSON document per person, following them across every Hanzo surface. It is
implemented in hanzoai/cloud at apps/pref. This HIP states the two facts
that define it: the tenancy key is the USER, not the org, and the write is a
shallow merge so no surface can delete another surface's keys.
Motivation
Each app keeping its own copy of a person's settings in its own localStorage
makes the same person look like two different users depending on which tab they
are in (apps/pref/prefs.go:7-11). One store behind the user menu ends that.
It is deliberately NOT settings: settings is per-org, per-product
configuration with KMS custody for secret fields; collapsing the two would put
one user's theme under an org key and make an org admin the owner of everyone's
UI (apps/pref/prefs.go:31-35).
Specification
The key words MUST, MUST NOT and SHOULD are to be interpreted as in RFC 2119.
The store
One system-namespace SQLite file, prefs.db, opened through sqlpool.Open —
born encrypted under the cek-derived key, single-connection
(apps/pref/store.go:47-58). One row per subject, holding an opaque JSON
document the server bounds but never interprets. It MUST hold no secret: a
preference that needs custody does not belong in this table.
Addresses
Two operations at one path:
GET /v1/pref— a typed op. A caller who has never saved anything gets an empty document at 200, never a 404, so the user menu always renders.PATCH /v1/pref— an untyped handler declared with prose (apps/pref/prefs.go:115-128), because three of its wire facts are unreachable from a typed op (apps/pref/prefs.go:148-165): a 16 KiB request-byte cap answering 413, an empty or literal-nullbody answering 400 where zip's decode would make both a successful no-op, and an OPEN key space whose only carrier —map[string]any— publishes no request body at all.
PATCH, not PUT: a surface saves the keys it owns without having to send back
keys it does not know about. The merge is shallow, a named key is replaced
whole, a null value deletes its key, and the merge runs inside one store
transaction so two tabs saving different keys both survive
(apps/pref/store.go:93-101). A document over 16 KiB or 128 keys is refused.
Tenancy
The subject is the canonical <owner>/<name> identity built from values the
identity boundary minted from a validated credential (HIP-0026), and it is the
mandatory predicate on every store statement (apps/pref/prefs.go:202-215).
The bare user name is not unique across orgs — hanzo/z and admin/z are two
people — which is why the org qualifies the key. There is deliberately NO path
to another user's preferences: not for an org admin, not for a platform
SuperAdmin, because no operational task requires reading someone's theme. An
unvalidated principal is 403.
Money, events, telemetry
Free, said in those words: plugin/pref/main.go declares cloud.Free and the
app appears in no metered list. It publishes nothing to the bus, so a
customer's webhooks receive nothing from it. It emits nothing beyond the
request span every route gets.
Stage
ga: the personal half of the identity core — every signed-in surface reads
it — and its manifest row (manifest/apps.go:362) declares no stage.
Upstream
Derives from none. The store is the platform's own encrypted SQLite through the one shared opener; nothing external is forked, embedded or mirrored.
Rationale
The alternative tenancy key is the org, which every sibling store uses, and it is wrong here in a way that matters: preferences are personal, and an org key makes the org's admin their reader. Keying on the qualified user and refusing every cross-read — rather than gating cross-reads behind admin-ness — removes the check that could be forgotten. The alternative to server-side merge is client-side read-modify-write, which is a lost update the moment two tabs save different keys.
Security Considerations
The wrong implementation is an identity fold. Key on the bare user name and two
users named z in different orgs read and overwrite each other's documents —
which is why the subject is <owner>/<name> and both halves are length-bounded
before use, so an oversized forged header cannot become a giant primary key
(apps/pref/prefs.go:199-214). The document bound is the other exposure: an
unbounded personal, unaudited row becomes free general-purpose storage, so the
16 KiB / 128-key caps are enforced on the raw bytes before any parse.
References
- HIP-0026 — Identity and Access Management
- HIP-0106 — The Hanzo Plugin Contract
- HIP-0139 — Capability
Copyright
Released under CC0 1.0 Universal Public Domain Dedication.