HIP-1141: Pref — One Document Per Person. Status Final. Hanzo architectural specification.
/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.
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).
The key words MUST, MUST NOT and SHOULD are to be interpreted as in RFC 2119.
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.
Two operations at one path:
GET /v1/pref — a typed op. A caller who has never saved anything gets anempty 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-null body 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.
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.
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.
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.
Derives from none. The store is the platform's own encrypted SQLite through the one shared opener; nothing external is forked, embedded or mirrored.
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.
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.
Released under CC0 1.0 Universal Public Domain Dedication.