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`.
#[serde(default)]
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)]
@@ -315,6 +320,8 @@ pub struct Plan {
pub secrets: Vec<ResourceChange>,
#[serde(default)]
pub vars: Vec<ResourceChange>,
#[serde(default)]
pub extension_points: Vec<ResourceChange>,
}
impl Plan {
@@ -327,6 +334,7 @@ impl Plan {
.chain(&self.triggers)
.chain(&self.secrets)
.chain(&self.vars)
.chain(&self.extension_points)
.all(|c| c.op == Op::NoOp)
}
}
@@ -403,6 +411,8 @@ pub struct CurrentState {
/// the manifest reconciles. Inherited group vars and tombstones are
/// excluded (the manifest manages only the app's own real values).
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
// (route removals already happened in the route delete-pass above).
// Secret pruning is deliberately deferred (destructive + irreversible):
@@ -848,6 +873,20 @@ impl ApplyService {
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)
}
@@ -1603,6 +1642,7 @@ impl ApplyService {
/// 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
/// known endpoints for the binding checks below.
#[allow(clippy::too_many_lines)]
fn validate_bundle(
&self,
bundle: &Bundle,
@@ -1722,6 +1762,24 @@ impl ApplyService {
for key in bundle.vars.keys() {
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(())
}
@@ -1769,12 +1827,18 @@ impl ApplyService {
.filter(|r| r.environment_scope == "*" && !r.is_tombstone)
.map(|r| (r.key, r.value))
.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 {
scripts,
routes,
triggers,
secret_names,
vars,
extension_point_names,
})
}
@@ -1838,6 +1902,7 @@ fn compute_diff_with_names(
triggers: diff_triggers(current, bundle, &script_name_by_id),
secrets: diff_secrets(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
/// exist on app nodes, so their reconcile paths call this with an
/// `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
}
/// 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
/// `apply` give the same answer (apply also fails in `resolve_and_seal`, but
/// only after taking the lock — surfacing it here keeps plan honest).
@@ -2594,6 +2704,10 @@ pub struct ApplyReport {
pub vars_updated: u32,
#[serde(default)]
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")]
pub warnings: Vec<String>,
}
@@ -2763,7 +2877,8 @@ fn state_token_with_names(
+ current.routes.len()
+ current.triggers.len()
+ current.secret_names.len()
+ current.vars.len(),
+ current.vars.len()
+ current.extension_point_names.len(),
);
for s in &current.scripts {
parts.push(format!(
@@ -2818,6 +2933,10 @@ fn state_token_with_names(
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.
parts.sort_unstable();
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
@@ -2898,6 +3017,7 @@ mod tests {
triggers: vec![],
secrets: vec!["S".into()],
vars: std::collections::BTreeMap::new(),
extension_points: Vec::new(),
};
let plan = compute_diff(&current, &bundle);
assert_eq!(plan.scripts.len(), 1);
@@ -2920,6 +3040,7 @@ mod tests {
triggers: vec![],
secrets: vec!["S".into()],
vars: std::collections::BTreeMap::new(),
extension_points: Vec::new(),
};
let plan = compute_diff(&current, &bundle);
assert!(plan.is_noop(), "expected all no-op, got {plan:?}");
@@ -2937,6 +3058,7 @@ mod tests {
triggers: vec![],
secrets: vec![],
vars: std::collections::BTreeMap::new(),
extension_points: Vec::new(),
};
let plan = compute_diff(&current, &bundle);
assert_eq!(plan.scripts[0].op, Op::Update);
@@ -2955,6 +3077,7 @@ mod tests {
triggers: vec![],
secrets: vec![],
vars: std::collections::BTreeMap::new(),
extension_points: Vec::new(),
};
let plan = compute_diff(&current, &bundle);
assert_eq!(plan.scripts[0].op, Op::Delete);
@@ -3001,6 +3124,7 @@ mod tests {
triggers: vec![],
secrets: vec![],
vars: std::collections::BTreeMap::new(),
extension_points: Vec::new(),
};
let plan = compute_diff(&current, &bundle);
assert_eq!(plan.routes[0].op, Op::Update);
@@ -3013,6 +3137,7 @@ mod tests {
triggers: vec![],
secrets: vec![],
vars: std::collections::BTreeMap::new(),
extension_points: Vec::new(),
}
}