feat(config): vars schema + the §3 resolution engine

Phase 3 foundation (docs §3): env-filtered, proximity-first config
inheritance down the group tree.

- Migration 0048: `vars` (polymorphic group|app owner via two nullable FKs
  + CHECK exactly-one, env scope, JSONB value, explicit tombstone) +
  `apps.environment` (the env marker the resolver filters on — 'an
  environment is an app').
- config_resolver: a shared chain-walk CTE (mirrors effective_app_role) that
  walks app → ancestor groups, env-filters in SQL, plus a pure Rust
  resolution pass implementing the §3 semantics SQL can't — per-key
  proximity-first, @E-over-* within a level, map deep-merge, tombstone
  suppression, and provenance for --explain.

Verified: 8 unit tests (incl. the §3.2 proximity-beats-env-specificity call,
deep-merge, tombstone, boundary) + the candidate-fetch CTE against live
Postgres (env-filter drops a non-matching @production value; nearer level
shadows farther).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-24 20:43:16 +02:00
parent 6db057fb08
commit 35dbd9f368
3 changed files with 411 additions and 0 deletions

View File

@@ -0,0 +1,47 @@
-- Phase 3: group-inherited config — the `vars` table + an app environment
-- marker. See docs/design/groups-and-project-tool.md §3, §5.1.
--
-- `vars` is the net-new, env-scoped configuration layer (greenfield — there
-- is no env-agnostic config today, only `secrets`). A var is owned by
-- exactly one group OR one app, optionally scoped to an environment, and
-- resolved down the tree (§3): env-filter first, then nearest level wins.
--
-- "An environment is an app": each env is already a distinct app row. To
-- env-filter group-level `@E` values, the resolver must know which env an
-- app represents — recorded here on `apps.environment` (NULL = env-agnostic,
-- set at app-create from the CLI's known env).
ALTER TABLE apps ADD COLUMN environment TEXT;
CREATE TABLE vars (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Polymorphic owner: exactly one of the two FKs is set. Real FKs (not a
-- bare owner_id) so ON DELETE CASCADE works per owner kind and a dangling
-- owner is impossible.
group_id UUID REFERENCES groups(id) ON DELETE CASCADE,
app_id UUID REFERENCES apps(id) ON DELETE CASCADE,
CONSTRAINT vars_owner_exactly_one
CHECK ((group_id IS NULL) <> (app_id IS NULL)),
-- '*' = env-agnostic; otherwise an env name matched against
-- apps.environment. NOT NULL with a '*' sentinel so the uniqueness
-- indexes are total (a NULL scope would break UNIQUE).
environment_scope TEXT NOT NULL DEFAULT '*',
key TEXT NOT NULL,
-- JSONB per the v1.1 data-plane convention. A tombstone (deletion of an
-- inherited key, §3) is the explicit boolean below, NOT value = 'null'
-- — JSON null is a legitimate value and must stay distinguishable.
value JSONB NOT NULL,
is_tombstone BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- One row per (owner, scope, key). Partial unique indexes because the owner
-- is split across two nullable columns.
CREATE UNIQUE INDEX vars_group_uidx
ON vars (group_id, environment_scope, key) WHERE group_id IS NOT NULL;
CREATE UNIQUE INDEX vars_app_uidx
ON vars (app_id, environment_scope, key) WHERE app_id IS NOT NULL;
CREATE INDEX vars_group_id_idx ON vars (group_id) WHERE group_id IS NOT NULL;
CREATE INDEX vars_app_id_idx ON vars (app_id) WHERE app_id IS NOT NULL;

View File

@@ -0,0 +1,363 @@
//! The §3 configuration-resolution engine: env-filtered, proximity-first
//! inheritance down the group tree.
//!
//! To resolve a key for app A in environment E (docs/design §3):
//! 1. **Env-filter first, per level** — a value scoped `@E` is eligible;
//! `*` (env-agnostic) is the fallback. Within one level `@E` beats `*`.
//! Env is *eligibility*, not a precedence tier.
//! 2. **Nearest level wins** — walk A → parent group → … → root; the
//! closest level that defines the (filtered) key wins. Proximity beats
//! farther-level env-specificity (a leaf's `*` beats an ancestor's `@E`).
//! 3. **Maps deep-merge per key; scalars/arrays replace; deletion is an
//! explicit tombstone** that suppresses the inherited key.
//!
//! The recursive CTE that walks `apps.group_id → groups.parent_id → root`
//! mirrors `app_members_repo::effective_app_role`. The env-eligibility
//! filter happens in SQL; the §3 merge/replace/tombstone semantics — which
//! a window-function pick can't express — happen in the pure `resolve`
//! function below, so they're unit-tested without Postgres.
use std::collections::BTreeMap;
use serde_json::{Map, Value};
use sqlx::PgPool;
use uuid::Uuid;
use picloud_shared::AppId;
/// Shared chain-walk CTE: emits one row per owner-level for an app —
/// depth 0 = the app itself (`app_owner` set), then ancestor groups
/// nearest-first (`group_owner` set), each carrying the app's environment.
/// Depth-bounded `< 64` (the group cycle guard already forbids cycles; this
/// is a runaway guard). Bind `$1 = app_id`. Reused by the vars and secret
/// resolvers, which each append their own owner-keyed JOIN.
pub(crate) const CHAIN_LEVELS_CTE: &str = "\
WITH RECURSIVE chain AS ( \
SELECT a.id AS app_owner, NULL::uuid AS group_owner, \
a.group_id AS next_group, 0 AS depth, a.environment AS app_env \
FROM apps a WHERE a.id = $1 \
UNION ALL \
SELECT NULL::uuid, g.id, g.parent_id, c.depth + 1, c.app_env \
FROM groups g JOIN chain c ON g.id = c.next_group \
WHERE c.depth < 64 \
)";
/// Owner kind of a resolved value, for `--explain` provenance.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OwnerKind {
App,
Group,
}
impl OwnerKind {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::App => "app",
Self::Group => "group",
}
}
}
/// One eligible (env-filtered) candidate row pulled by the resolver, before
/// §3 proximity/merge resolution.
#[derive(Debug, Clone)]
pub struct Candidate {
/// 0 = the app itself; 1 = its parent group; … (nearest-first).
pub depth: i32,
pub owner_kind: OwnerKind,
pub owner_id: Uuid,
/// `*` (env-agnostic) or a concrete environment name.
pub scope: String,
pub key: String,
pub value: Value,
pub is_tombstone: bool,
}
/// Where a resolved key came from (for `config --effective --explain`).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Provenance {
pub owner_kind: OwnerKind,
pub owner_id: Uuid,
pub depth: i32,
pub scope: String,
/// For a deep-merged map, the `(depth, scope)` of every layer that
/// contributed, nearest-first. Empty for a plain scalar/array winner.
pub merged_from: Vec<(i32, String)>,
}
/// `@E`-scoped values outrank `*` *within the same level* (§3 step 1).
fn env_priority(scope: &str) -> u8 {
u8::from(scope != "*")
}
/// Deep-merge `src` into `dst` per key: nested objects merge recursively,
/// everything else is replaced by `src`. Caller merges farthest→nearest so
/// nearer layers overwrite.
fn deep_merge(dst: &mut Map<String, Value>, src: &Map<String, Value>) {
for (k, sv) in src {
match (dst.get_mut(k), sv) {
(Some(Value::Object(dm)), Value::Object(sm)) => deep_merge(dm, sm),
_ => {
dst.insert(k.clone(), sv.clone());
}
}
}
}
/// Resolve one key's candidate rows (any order in) to its effective value,
/// plus the provenance. Returns `None` when a tombstone suppresses the key.
fn resolve_one(mut rows: Vec<Candidate>) -> Option<(Value, Provenance)> {
// Nearest-first; within a level, `@E` before `*` (§3 steps 1-2).
rows.sort_by(|a, b| {
a.depth
.cmp(&b.depth)
.then(env_priority(&b.scope).cmp(&env_priority(&a.scope)))
});
// Collect the contiguous run of map-valued rows from the nearest. A
// tombstone or scalar/array boundary stops the run (and, if it's the
// nearest row, decides the result outright).
let mut maps: Vec<&Candidate> = Vec::new();
let mut boundary: Option<&Candidate> = None;
for r in &rows {
if r.is_tombstone {
boundary = Some(r);
break;
}
if r.value.is_object() {
maps.push(r);
} else {
boundary = Some(r);
break;
}
}
if let Some(first) = maps.first() {
// Map run wins; deep-merge farthest→nearest so nearest keys win.
let mut acc = Map::new();
for m in maps.iter().rev() {
if let Value::Object(obj) = &m.value {
deep_merge(&mut acc, obj);
}
}
let prov = Provenance {
owner_kind: first.owner_kind,
owner_id: first.owner_id,
depth: first.depth,
scope: first.scope.clone(),
merged_from: maps.iter().map(|m| (m.depth, m.scope.clone())).collect(),
};
return Some((Value::Object(acc), prov));
}
// No leading map run: the nearest row is the boundary.
match boundary {
// Nearest is a tombstone → key deleted.
Some(b) if b.is_tombstone => None,
// Nearest is a scalar/array → take it verbatim.
Some(b) => Some((
b.value.clone(),
Provenance {
owner_kind: b.owner_kind,
owner_id: b.owner_id,
depth: b.depth,
scope: b.scope.clone(),
merged_from: Vec::new(),
},
)),
// No rows at all (caller only passes non-empty groups).
None => None,
}
}
/// Resolve a full candidate set (mixed keys, any order) into the effective
/// config map + per-key provenance. Pure — unit-tested without Postgres.
#[must_use]
pub fn resolve(
candidates: Vec<Candidate>,
) -> (BTreeMap<String, Value>, BTreeMap<String, Provenance>) {
let mut by_key: BTreeMap<String, Vec<Candidate>> = BTreeMap::new();
for c in candidates {
by_key.entry(c.key.clone()).or_default().push(c);
}
let mut values = BTreeMap::new();
let mut provenance = BTreeMap::new();
for (key, rows) in by_key {
if let Some((v, p)) = resolve_one(rows) {
values.insert(key.clone(), v);
provenance.insert(key, p);
}
}
(values, provenance)
}
/// Pull every env-eligible `vars` candidate for `app_id`: the app's own
/// rows + every ancestor group's rows, filtered to `*` or the app's
/// environment, ordered nearest-first.
///
/// # Errors
/// Propagates sqlx errors.
pub async fn fetch_var_candidates(
pool: &PgPool,
app_id: AppId,
) -> Result<Vec<Candidate>, sqlx::Error> {
let sql = format!(
"{CHAIN_LEVELS_CTE} \
SELECT c.depth, \
CASE WHEN v.app_id IS NOT NULL THEN 'app' ELSE 'group' END AS owner_kind, \
COALESCE(v.app_id, v.group_id) AS owner_id, \
v.environment_scope, v.key, v.value, v.is_tombstone \
FROM chain c \
JOIN vars v ON (v.app_id = c.app_owner OR v.group_id = c.group_owner) \
WHERE v.environment_scope = '*' OR v.environment_scope = c.app_env \
ORDER BY c.depth ASC"
);
let rows = sqlx::query_as::<_, VarCandidateRow>(&sql)
.bind(app_id.into_inner())
.fetch_all(pool)
.await?;
Ok(rows.into_iter().map(Into::into).collect())
}
#[derive(sqlx::FromRow)]
struct VarCandidateRow {
depth: i32,
owner_kind: String,
owner_id: Uuid,
environment_scope: String,
key: String,
value: Value,
is_tombstone: bool,
}
impl From<VarCandidateRow> for Candidate {
fn from(r: VarCandidateRow) -> Self {
Self {
depth: r.depth,
owner_kind: if r.owner_kind == "app" {
OwnerKind::App
} else {
OwnerKind::Group
},
owner_id: r.owner_id,
scope: r.environment_scope,
key: r.key,
value: r.value,
is_tombstone: r.is_tombstone,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn cand(depth: i32, scope: &str, key: &str, value: Value) -> Candidate {
Candidate {
depth,
owner_kind: if depth == 0 {
OwnerKind::App
} else {
OwnerKind::Group
},
owner_id: Uuid::nil(),
scope: scope.into(),
key: key.into(),
value,
is_tombstone: false,
}
}
fn tomb(depth: i32, scope: &str, key: &str) -> Candidate {
Candidate {
is_tombstone: true,
value: Value::Null,
..cand(depth, scope, key, Value::Null)
}
}
#[test]
fn nearest_level_wins() {
// app (depth 0) overrides group (depth 2).
let (v, p) = resolve(vec![
cand(2, "*", "region", json!("eu")),
cand(0, "*", "region", json!("us")),
]);
assert_eq!(v["region"], json!("us"));
assert_eq!(p["region"].depth, 0);
}
#[test]
fn env_scoped_beats_agnostic_within_a_level() {
let (v, p) = resolve(vec![
cand(1, "*", "db_url", json!("default")),
cand(1, "staging", "db_url", json!("staging-db")),
]);
assert_eq!(v["db_url"], json!("staging-db"));
assert_eq!(p["db_url"].scope, "staging");
}
#[test]
fn proximity_beats_env_specificity_across_levels() {
// §3.2 deliberately-novel call: a leaf's `*` beats an ancestor's `@E`.
let (v, _) = resolve(vec![
cand(2, "production", "db_url", json!("anc-prod")),
cand(0, "*", "db_url", json!("leaf-default")),
]);
assert_eq!(v["db_url"], json!("leaf-default"));
}
#[test]
fn maps_deep_merge_nearest_wins() {
// ancestor sets {title, region}; leaf overrides title, adds locale.
let (v, p) = resolve(vec![
cand(2, "*", "ui", json!({"title": "Base", "region": "eu"})),
cand(0, "*", "ui", json!({"title": "Leaf", "locale": "en"})),
]);
assert_eq!(
v["ui"],
json!({"title": "Leaf", "region": "eu", "locale": "en"})
);
assert_eq!(p["ui"].merged_from.len(), 2);
}
#[test]
fn nearer_scalar_replaces_whole_inherited_map() {
let (v, _) = resolve(vec![
cand(2, "*", "x", json!({"a": 1})),
cand(0, "*", "x", json!("scalar")),
]);
assert_eq!(v["x"], json!("scalar"));
}
#[test]
fn tombstone_suppresses_inherited_key() {
let (v, _) = resolve(vec![
cand(2, "*", "secret_flag", json!(true)),
tomb(0, "*", "secret_flag"),
]);
assert!(!v.contains_key("secret_flag"));
}
#[test]
fn json_null_is_a_value_not_a_deletion() {
let (v, _) = resolve(vec![cand(0, "*", "k", Value::Null)]);
assert!(v.contains_key("k"));
assert_eq!(v["k"], Value::Null);
}
#[test]
fn map_merge_stops_at_a_nearer_scalar_boundary() {
// nearest map, then a scalar, then a farther map: only the
// contiguous top map run merges; the scalar bounds it.
let (v, _) = resolve(vec![
cand(3, "*", "m", json!({"deep": 1})),
cand(2, "*", "m", json!("scalar-boundary")),
cand(0, "*", "m", json!({"near": 2})),
]);
// depth 0 map is the only one above the depth-2 scalar boundary.
assert_eq!(v["m"], json!({"near": 2}));
}
}

View File

@@ -31,6 +31,7 @@ pub mod auth_api;
pub mod auth_bootstrap; pub mod auth_bootstrap;
pub mod auth_middleware; pub mod auth_middleware;
pub mod authz; pub mod authz;
pub mod config_resolver;
pub mod cron_scheduler; pub mod cron_scheduler;
pub mod dead_letter_repo; pub mod dead_letter_repo;
pub mod dead_letter_service; pub mod dead_letter_service;