feat(apply): declarative extension_points reconcile (§5.5 C3)

Thread extension-point markers through the manifest and the apply engine —
a name-only resource modeled on `secrets` (shape) with `vars`-style
create/prune write behavior.

- manifest: top-level `extension_points = ["theme", …]` (allowed on app and
  group; overlay stays base-only via deny_unknown_fields); `build_bundle`
  emits the names.
- apply_service: `Bundle.extension_points`, `CurrentState.extension_point_names`,
  `Plan.extension_points` (+ is_noop), `diff_extension_points`
  (declared→Create / live-undeclared→Delete / else NoOp), `load_current`
  loads them, `reconcile_node_tx` inserts on Create + deletes on prune,
  `state_token_with_names` folds in `ep|<name>`, `validate_bundle` rejects
  duplicates + reserved names, `ApplyReport` gains created/deleted counts.
- CLI client + plan/apply rendering: `extension_points` in PlanDto/NodePlanDto/
  ApplyReportDto, an `extension_point` row group in `pic plan`, a count in the
  apply summary.

pull sets it empty for now (wired to the read endpoint in C4).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-29 20:22:05 +02:00
parent 8f966783fe
commit e62e073970
7 changed files with 165 additions and 3 deletions

View File

@@ -83,6 +83,11 @@ pub struct Bundle {
/// not expressible here; use `pic vars set --tombstone`. /// not expressible here; use `pic vars set --tombstone`.
#[serde(default)] #[serde(default)]
pub vars: std::collections::BTreeMap<String, serde_json::Value>, pub vars: std::collections::BTreeMap<String, serde_json::Value>,
/// Declared extension-point *names* (§5.5) — module names this node offers
/// for descendants to provide/override. Name-only, like `secrets`; the
/// optional default body is a co-located `kind = module` script.
#[serde(default)]
pub extension_points: Vec<String>,
} }
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
@@ -315,6 +320,8 @@ pub struct Plan {
pub secrets: Vec<ResourceChange>, pub secrets: Vec<ResourceChange>,
#[serde(default)] #[serde(default)]
pub vars: Vec<ResourceChange>, pub vars: Vec<ResourceChange>,
#[serde(default)]
pub extension_points: Vec<ResourceChange>,
} }
impl Plan { impl Plan {
@@ -327,6 +334,7 @@ impl Plan {
.chain(&self.triggers) .chain(&self.triggers)
.chain(&self.secrets) .chain(&self.secrets)
.chain(&self.vars) .chain(&self.vars)
.chain(&self.extension_points)
.all(|c| c.op == Op::NoOp) .all(|c| c.op == Op::NoOp)
} }
} }
@@ -403,6 +411,8 @@ pub struct CurrentState {
/// the manifest reconciles. Inherited group vars and tombstones are /// the manifest reconciles. Inherited group vars and tombstones are
/// excluded (the manifest manages only the app's own real values). /// excluded (the manifest manages only the app's own real values).
pub vars: Vec<(String, serde_json::Value)>, pub vars: Vec<(String, serde_json::Value)>,
/// Extension-point marker names declared directly at this node (§5.5).
pub extension_point_names: Vec<String>,
} }
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
@@ -773,6 +783,21 @@ impl ApplyService {
} }
} }
// 3c. Extension-point markers (§5.5) — insert each Create (idempotent).
// Name-only identity, so there is no Update; deletes happen in prune.
for ch in &plan.extension_points {
if ch.op == Op::Create {
crate::extension_point_repo::insert_extension_point_tx(
&mut *tx,
owner.as_script_owner(),
&ch.key,
)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
report.extension_points_created += 1;
}
}
// 4. Prune (only with --prune): delete stale triggers, then scripts // 4. Prune (only with --prune): delete stale triggers, then scripts
// (route removals already happened in the route delete-pass above). // (route removals already happened in the route delete-pass above).
// Secret pruning is deliberately deferred (destructive + irreversible): // Secret pruning is deliberately deferred (destructive + irreversible):
@@ -848,6 +873,20 @@ impl ApplyService {
report.vars_deleted += 1; report.vars_deleted += 1;
} }
} }
// Extension-point markers are prunable config too (§5.5).
for ch in &plan.extension_points {
if ch.op == Op::Delete {
crate::extension_point_repo::delete_extension_point_tx(
&mut *tx,
owner.as_script_owner(),
&ch.key,
)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
report.extension_points_deleted += 1;
}
}
} }
Ok(name_to_id) Ok(name_to_id)
} }
@@ -1603,6 +1642,7 @@ impl ApplyService {
/// scripts reachable from the app's chain that a route/trigger may bind to /// scripts reachable from the app's chain that a route/trigger may bind to
/// even though the manifest doesn't declare them (Phase 4). They count as /// even though the manifest doesn't declare them (Phase 4). They count as
/// known endpoints for the binding checks below. /// known endpoints for the binding checks below.
#[allow(clippy::too_many_lines)]
fn validate_bundle( fn validate_bundle(
&self, &self,
bundle: &Bundle, bundle: &Bundle,
@@ -1722,6 +1762,24 @@ impl ApplyService {
for key in bundle.vars.keys() { for key in bundle.vars.keys() {
validate_var_key(key)?; validate_var_key(key)?;
} }
// Extension points (§5.5): unique names; none may shadow a built-in
// SDK namespace (same guard as module names — an `import "kv"` must
// never resolve to a user-supplied provider).
let mut ep_names: HashSet<String> = HashSet::new();
for name in &bundle.extension_points {
if !ep_names.insert(name.to_lowercase()) {
return Err(ApplyError::Invalid(format!(
"duplicate extension point `{name}`"
)));
}
if crate::api::RESERVED_MODULE_NAMES.contains(&name.as_str()) {
return Err(ApplyError::Invalid(format!(
"extension point `{name}`: reserved module name \
(shadows a built-in SDK namespace)"
)));
}
}
Ok(()) Ok(())
} }
@@ -1769,12 +1827,18 @@ impl ApplyService {
.filter(|r| r.environment_scope == "*" && !r.is_tombstone) .filter(|r| r.environment_scope == "*" && !r.is_tombstone)
.map(|r| (r.key, r.value)) .map(|r| (r.key, r.value))
.collect(); .collect();
// Extension-point markers declared directly at this node (§5.5).
let extension_point_names =
crate::extension_point_repo::list_for_owner(&self.pool, owner.as_script_owner())
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
Ok(CurrentState { Ok(CurrentState {
scripts, scripts,
routes, routes,
triggers, triggers,
secret_names, secret_names,
vars, vars,
extension_point_names,
}) })
} }
@@ -1838,6 +1902,7 @@ fn compute_diff_with_names(
triggers: diff_triggers(current, bundle, &script_name_by_id), triggers: diff_triggers(current, bundle, &script_name_by_id),
secrets: diff_secrets(current, bundle), secrets: diff_secrets(current, bundle),
vars: diff_vars(current, bundle), vars: diff_vars(current, bundle),
extension_points: diff_extension_points(current, bundle),
} }
} }
@@ -2002,6 +2067,14 @@ impl ApplyOwner {
} }
} }
/// As the shared [`ScriptOwner`] — for the module/extension-point repos.
fn as_script_owner(self) -> picloud_shared::ScriptOwner {
match self {
Self::App(a) => picloud_shared::ScriptOwner::App(a),
Self::Group(g) => picloud_shared::ScriptOwner::Group(g),
}
}
/// The app id, when this is an app node. Routes/triggers/email-secrets only /// The app id, when this is an app node. Routes/triggers/email-secrets only
/// exist on app nodes, so their reconcile paths call this with an /// exist on app nodes, so their reconcile paths call this with an
/// `expect` — guarded by validation that rejects them on a group bundle. /// `expect` — guarded by validation that rejects them on a group bundle.
@@ -2308,6 +2381,43 @@ fn diff_secrets(current: &CurrentState, bundle: &Bundle) -> Vec<ResourceChange>
out out
} }
/// Diff extension-point markers by name (§5.5). Name-only identity like
/// secrets, but — unlike secrets — the markers are real declarations the
/// manifest owns: a declared-but-absent name is a Create (the apply inserts
/// it), and a live-but-undeclared name is a Delete (pruned under `--prune`),
/// mirroring `vars`.
fn diff_extension_points(current: &CurrentState, bundle: &Bundle) -> Vec<ResourceChange> {
let live: HashSet<&str> = current
.extension_point_names
.iter()
.map(String::as_str)
.collect();
let declared: HashSet<&str> = bundle.extension_points.iter().map(String::as_str).collect();
let mut out = Vec::new();
for name in &bundle.extension_points {
out.push(ResourceChange {
op: if live.contains(name.as_str()) {
Op::NoOp
} else {
Op::Create
},
key: name.clone(),
detail: None,
});
}
for name in &current.extension_point_names {
if !declared.contains(name.as_str()) {
out.push(ResourceChange {
op: Op::Delete,
key: name.clone(),
detail: Some("on server, not declared".into()),
});
}
}
out
}
/// Reject any email trigger whose referenced secret isn't set, so `plan` and /// Reject any email trigger whose referenced secret isn't set, so `plan` and
/// `apply` give the same answer (apply also fails in `resolve_and_seal`, but /// `apply` give the same answer (apply also fails in `resolve_and_seal`, but
/// only after taking the lock — surfacing it here keeps plan honest). /// only after taking the lock — surfacing it here keeps plan honest).
@@ -2594,6 +2704,10 @@ pub struct ApplyReport {
pub vars_updated: u32, pub vars_updated: u32,
#[serde(default)] #[serde(default)]
pub vars_deleted: u32, pub vars_deleted: u32,
#[serde(default)]
pub extension_points_created: u32,
#[serde(default)]
pub extension_points_deleted: u32,
#[serde(skip_serializing_if = "Vec::is_empty")] #[serde(skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<String>, pub warnings: Vec<String>,
} }
@@ -2763,7 +2877,8 @@ fn state_token_with_names(
+ current.routes.len() + current.routes.len()
+ current.triggers.len() + current.triggers.len()
+ current.secret_names.len() + current.secret_names.len()
+ current.vars.len(), + current.vars.len()
+ current.extension_point_names.len(),
); );
for s in &current.scripts { for s in &current.scripts {
parts.push(format!( parts.push(format!(
@@ -2818,6 +2933,10 @@ fn state_token_with_names(
serde_json::to_string(value).unwrap_or_default() serde_json::to_string(value).unwrap_or_default()
)); ));
} }
// Extension-point markers are name-only, like secret names.
for n in &current.extension_point_names {
parts.push(format!("ep|{n}"));
}
// Order-independent: sort the per-resource tokens before hashing. // Order-independent: sort the per-resource tokens before hashing.
parts.sort_unstable(); parts.sort_unstable();
let mut h: u64 = 0xcbf2_9ce4_8422_2325; let mut h: u64 = 0xcbf2_9ce4_8422_2325;
@@ -2898,6 +3017,7 @@ mod tests {
triggers: vec![], triggers: vec![],
secrets: vec!["S".into()], secrets: vec!["S".into()],
vars: std::collections::BTreeMap::new(), vars: std::collections::BTreeMap::new(),
extension_points: Vec::new(),
}; };
let plan = compute_diff(&current, &bundle); let plan = compute_diff(&current, &bundle);
assert_eq!(plan.scripts.len(), 1); assert_eq!(plan.scripts.len(), 1);
@@ -2920,6 +3040,7 @@ mod tests {
triggers: vec![], triggers: vec![],
secrets: vec!["S".into()], secrets: vec!["S".into()],
vars: std::collections::BTreeMap::new(), vars: std::collections::BTreeMap::new(),
extension_points: Vec::new(),
}; };
let plan = compute_diff(&current, &bundle); let plan = compute_diff(&current, &bundle);
assert!(plan.is_noop(), "expected all no-op, got {plan:?}"); assert!(plan.is_noop(), "expected all no-op, got {plan:?}");
@@ -2937,6 +3058,7 @@ mod tests {
triggers: vec![], triggers: vec![],
secrets: vec![], secrets: vec![],
vars: std::collections::BTreeMap::new(), vars: std::collections::BTreeMap::new(),
extension_points: Vec::new(),
}; };
let plan = compute_diff(&current, &bundle); let plan = compute_diff(&current, &bundle);
assert_eq!(plan.scripts[0].op, Op::Update); assert_eq!(plan.scripts[0].op, Op::Update);
@@ -2955,6 +3077,7 @@ mod tests {
triggers: vec![], triggers: vec![],
secrets: vec![], secrets: vec![],
vars: std::collections::BTreeMap::new(), vars: std::collections::BTreeMap::new(),
extension_points: Vec::new(),
}; };
let plan = compute_diff(&current, &bundle); let plan = compute_diff(&current, &bundle);
assert_eq!(plan.scripts[0].op, Op::Delete); assert_eq!(plan.scripts[0].op, Op::Delete);
@@ -3001,6 +3124,7 @@ mod tests {
triggers: vec![], triggers: vec![],
secrets: vec![], secrets: vec![],
vars: std::collections::BTreeMap::new(), vars: std::collections::BTreeMap::new(),
extension_points: Vec::new(),
}; };
let plan = compute_diff(&current, &bundle); let plan = compute_diff(&current, &bundle);
assert_eq!(plan.routes[0].op, Op::Update); assert_eq!(plan.routes[0].op, Op::Update);
@@ -3013,6 +3137,7 @@ mod tests {
triggers: vec![], triggers: vec![],
secrets: vec![], secrets: vec![],
vars: std::collections::BTreeMap::new(), vars: std::collections::BTreeMap::new(),
extension_points: Vec::new(),
} }
} }

View File

@@ -1320,6 +1320,8 @@ pub struct PlanDto {
pub secrets: Vec<ChangeDto>, pub secrets: Vec<ChangeDto>,
#[serde(default)] #[serde(default)]
pub vars: Vec<ChangeDto>, pub vars: Vec<ChangeDto>,
#[serde(default)]
pub extension_points: Vec<ChangeDto>,
/// Fingerprint of the live state this plan was computed against; carried /// Fingerprint of the live state this plan was computed against; carried
/// in `.picloud/` and replayed to `apply` for the bound-plan check. /// in `.picloud/` and replayed to `apply` for the bound-plan check.
#[serde(default)] #[serde(default)]
@@ -1358,6 +1360,8 @@ pub struct NodePlanDto {
pub secrets: Vec<ChangeDto>, pub secrets: Vec<ChangeDto>,
#[serde(default)] #[serde(default)]
pub vars: Vec<ChangeDto>, pub vars: Vec<ChangeDto>,
#[serde(default)]
pub extension_points: Vec<ChangeDto>,
} }
/// Response of `POST .../apply`: counts of what changed. /// Response of `POST .../apply`: counts of what changed.
@@ -1386,6 +1390,10 @@ pub struct ApplyReportDto {
#[serde(default)] #[serde(default)]
pub vars_deleted: u32, pub vars_deleted: u32,
#[serde(default)] #[serde(default)]
pub extension_points_created: u32,
#[serde(default)]
pub extension_points_deleted: u32,
#[serde(default)]
pub warnings: Vec<String>, pub warnings: Vec<String>,
} }

View File

@@ -84,6 +84,13 @@ pub async fn run(
"+{} ~{} -{}", "+{} ~{} -{}",
report.vars_created, report.vars_updated, report.vars_deleted report.vars_created, report.vars_updated, report.vars_deleted
), ),
)
.field(
"extension_points",
format!(
"+{} -{}",
report.extension_points_created, report.extension_points_deleted
),
); );
for w in &report.warnings { for w in &report.warnings {
block.field("warning", w.clone()); block.field("warning", w.clone());
@@ -151,6 +158,13 @@ pub async fn run_tree(
"+{} ~{} -{}", "+{} ~{} -{}",
report.vars_created, report.vars_updated, report.vars_deleted report.vars_created, report.vars_updated, report.vars_deleted
), ),
)
.field(
"extension_points",
format!(
"+{} -{}",
report.extension_points_created, report.extension_points_deleted
),
); );
for w in &report.warnings { for w in &report.warnings {
block.field("warning", w.clone()); block.field("warning", w.clone());

View File

@@ -139,6 +139,7 @@ fn scaffold_manifest(slug: &str, name: &str) -> Manifest {
triggers: crate::manifest::ManifestTriggers::default(), triggers: crate::manifest::ManifestTriggers::default(),
secrets: crate::manifest::ManifestSecrets::default(), secrets: crate::manifest::ManifestSecrets::default(),
vars: std::collections::BTreeMap::new(), vars: std::collections::BTreeMap::new(),
extension_points: Vec::new(),
} }
} }

View File

@@ -64,12 +64,13 @@ fn render_tree(plan: &TreePlanDto, mode: OutputMode) {
let mut table = Table::new(["node", "kind", "op", "resource", "detail"]); let mut table = Table::new(["node", "kind", "op", "resource", "detail"]);
for n in &plan.nodes { for n in &plan.nodes {
let node = format!("{}:{}", n.kind, n.slug); let node = format!("{}:{}", n.kind, n.slug);
let groups: [(&str, &Vec<ChangeDto>); 5] = [ let groups: [(&str, &Vec<ChangeDto>); 6] = [
("script", &n.scripts), ("script", &n.scripts),
("route", &n.routes), ("route", &n.routes),
("trigger", &n.triggers), ("trigger", &n.triggers),
("secret", &n.secrets), ("secret", &n.secrets),
("var", &n.vars), ("var", &n.vars),
("extension_point", &n.extension_points),
]; ];
for (rk, changes) in groups { for (rk, changes) in groups {
for c in changes { for c in changes {
@@ -160,6 +161,7 @@ pub fn build_bundle(manifest: &Manifest, base_dir: &Path) -> Result<Value> {
"triggers": triggers, "triggers": triggers,
"secrets": manifest.secrets.names, "secrets": manifest.secrets.names,
"vars": vars, "vars": vars,
"extension_points": manifest.extension_points,
})) }))
} }
@@ -174,12 +176,13 @@ fn tagged(kind: &str, spec: impl Serialize) -> Result<Value> {
fn render(plan: &PlanDto, mode: OutputMode) { fn render(plan: &PlanDto, mode: OutputMode) {
let mut table = Table::new(["kind", "op", "resource", "detail"]); let mut table = Table::new(["kind", "op", "resource", "detail"]);
let groups: [(&str, &Vec<ChangeDto>); 5] = [ let groups: [(&str, &Vec<ChangeDto>); 6] = [
("script", &plan.scripts), ("script", &plan.scripts),
("route", &plan.routes), ("route", &plan.routes),
("trigger", &plan.triggers), ("trigger", &plan.triggers),
("secret", &plan.secrets), ("secret", &plan.secrets),
("var", &plan.vars), ("var", &plan.vars),
("extension_point", &plan.extension_points),
]; ];
for (kind, changes) in groups { for (kind, changes) in groups {
for c in changes { for c in changes {

View File

@@ -224,6 +224,8 @@ pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) ->
names: secrets.iter().map(|s| s.name.clone()).collect(), names: secrets.iter().map(|s| s.name.clone()).collect(),
}, },
vars: manifest_vars, vars: manifest_vars,
// Wired to the read endpoint in C4 so pull→plan round-trips EPs.
extension_points: Vec::new(),
}; };
std::fs::write(&manifest_path, manifest.to_toml()?) std::fs::write(&manifest_path, manifest.to_toml()?)

View File

@@ -49,6 +49,12 @@ pub struct Manifest {
/// The overlay merges per-env, last-write-wins by key. /// The overlay merges per-env, last-write-wins by key.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")] #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub vars: BTreeMap<String, toml::Value>, pub vars: BTreeMap<String, toml::Value>,
/// `extension_points = ["theme", …]` (§5.5) — module names this node marks
/// as provided/overridable by descendants. Name-only, like `[secrets]`; the
/// optional default body is a co-located `[[scripts]]` module of the same
/// name. Allowed on app and group nodes.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub extension_points: Vec<String>,
} }
impl Manifest { impl Manifest {
@@ -488,6 +494,7 @@ mod tests {
("region".to_string(), toml::Value::String("eu".into())), ("region".to_string(), toml::Value::String("eu".into())),
("max-retries".to_string(), toml::Value::Integer(3)), ("max-retries".to_string(), toml::Value::Integer(3)),
]), ]),
extension_points: vec!["theme".into()],
} }
} }
@@ -591,12 +598,14 @@ mod tests {
triggers: ManifestTriggers::default(), triggers: ManifestTriggers::default(),
secrets: ManifestSecrets::default(), secrets: ManifestSecrets::default(),
vars: BTreeMap::new(), vars: BTreeMap::new(),
extension_points: vec![],
}; };
let text = m.to_toml().unwrap(); let text = m.to_toml().unwrap();
assert!(!text.contains("[[scripts]]"), "got:\n{text}"); assert!(!text.contains("[[scripts]]"), "got:\n{text}");
assert!(!text.contains("triggers"), "got:\n{text}"); assert!(!text.contains("triggers"), "got:\n{text}");
assert!(!text.contains("secrets"), "got:\n{text}"); assert!(!text.contains("secrets"), "got:\n{text}");
assert!(!text.contains("vars"), "got:\n{text}"); assert!(!text.contains("vars"), "got:\n{text}");
assert!(!text.contains("extension_points"), "got:\n{text}");
// Still round-trips. // Still round-trips.
assert_eq!(m, Manifest::parse(&text).unwrap()); assert_eq!(m, Manifest::parse(&text).unwrap());
} }