@@ -21,7 +21,9 @@ each managed independently.
2121` config_name ` is the ** deep merge of every fragment that applies to
2222them** , taken from ` app_config_fragments ` ordered by the ` rank ` of each
2323fragment's allow-list entry — one indexed join, ** no permission
24- lookup** .
24+ lookup** . Nor is one needed to keep callers apart: a read names only the
25+ domain, and the user half of the scope is injected from the session, so
26+ a caller has no way to ask for anyone else's config.
2527
2628** Write authorization is set up ahead of time.** Every config name is
2729** explicitly registered** in ` app_config_definitions ` , and ` app_config_allow_list `
@@ -71,11 +73,14 @@ Three scopes cover the use cases (`public` for the pre-login shell):
7173 fragment requires an entry, and the entry requires a registered name — so
7274 no direct fragment FK is needed). No fragment may exist for an
7375 unregistered ` config_name ` .
74- - ** Reads are unconditional .** The merge ** must** join
76+ - ** Reads cost no permission query .** The merge ** must** join
7577 ` app_config_fragments ` to ` app_config_allow_list ` — ` rank ` lives only on
7678 the allow-list entry, so the ordering cannot be computed without it (an
7779 indexed ` (config_name, scope_type) ` join). The join is for ` rank ` alone;
78- no permission or policy is evaluated at read time.
80+ no RBAC or policy is evaluated at read time. Tenancy is structural rather
81+ than checked: the read takes a ` domain_id ` and nothing else, and the
82+ service fills the user half of the scope from the session, so there is no
83+ field in which to name another user.
7984- ** Allow-list = the write gate and the merge order.** ` app_config_allow_list `
8085 holds ** one record per ` (config_name, scope_type) ` ** ; a fragment at
8186 that scope may be created ** only if** the record exists — through the
@@ -192,10 +197,11 @@ Two kinds of mutation:
192197
193198` create ` errors if the natural key already exists; ` update ` errors if it
194199does not; ` purge ` removes the row (and thus its contribution to the
195- merge). A caller "clears" a config without deleting it by ` update ` -ing
196- with ` {} ` , which reads back as ` null ` (null projection, §3). ` update `
197- replaces the stored JSON wholesale — no partial/deep update at the write
198- boundary.
200+ merge). ` update ` replaces the stored JSON wholesale — no partial/deep
201+ update at the write boundary. A caller "clears" a config without deleting
202+ it by ` update ` -ing with ` {} ` ; the fragment stays and still counts as a
203+ contribution, so the merge succeeds and yields whatever the other
204+ fragments hold. Removing the contribution entirely is ` purge ` .
199205
200206** Overridability is a write-grant decision:**
201207
@@ -260,18 +266,35 @@ those whose scope applies to them:
260266- the user's ` user ` fragment (` scope_id = the user's id ` ).
261267
262268A single ` app_config_fragments ` query selects exactly those rows (the
263- user's domain is known from the session — no permission check ), joins
269+ user's domain is known from the session — no RBAC lookup ), joins
264270each to its allow-list entry for the ` rank ` , orders by it (low → high),
265271and deep-merges: nested objects recurse, scalars and lists are
266272wholesale-replaced, and the higher ` rank ` wins on conflict.
267273
268- ** Null projection.** A stored ` config ` of ` {} ` reads back as ` null ` , and
269- a merged ` config ` that is empty after combining every fragment is
270- likewise ` null ` — clients fall back to their built-in defaults.
274+ ** Nothing to merge is a 404.** A ` config_name ` no visible fragment
275+ contributes to raises ` AppConfigFragmentNotFound ` rather than resolving
276+ to an empty or null value — the name is unregistered, or nothing is
277+ visible at the caller's scopes. Clients fall back to their built-in
278+ defaults on the 404.
279+
280+ The merged ` config ` is therefore always an object, never null. It is
281+ empty only when every contributing fragment's own ` config ` was ` {} ` : the
282+ merge adds and replaces keys but never drops one, so no combination of
283+ non-empty fragments can reduce to ` {} ` .
271284
272285### Read variants
273286
274- - ** Single** — resolve one ` (user, config_name) ` to its ` AppConfig ` .
287+ - ** Resolve** — the only read. Takes a list of ` config_name ` s and
288+ returns one ` AppConfig ` per requested name, in request order; a
289+ repeated name is repeated in the output. A single name is a
290+ one-element request — there is no separate single-name variant, since
291+ a client bootstrapping its shell asks for several configs at once and
292+ two entry points would only differ in how they report a missing name.
293+ ** All-or-nothing:** one requested name nothing contributes to fails
294+ the whole call. A partial result would have to mark the absent names
295+ somehow, and every way of doing that pushes the caller into branching
296+ on a second, quieter kind of failure. The cost is that a client cannot
297+ batch optional config names together with required ones.
275298- ** Search (self)** — paginate the user's own ` AppConfig ` s, grouped by
276299 ` (user_id, config_name) ` ; each name's merge is evaluated
277300 independently.
0 commit comments