feat(hierarchies): extension points — opt-in dynamic module resolution (§5.5)

M1 of the remaining-hierarchies work. An extension point lets a PARENT/group
node opt a module name out of sealed lexical resolution: a parent script's
`import "x"` then resolves against the INHERITING app's module (with a declared
default body as fallback) instead of the parent's sealed chain — so each
descendant app supplies its own `x`. Only the declaring parent can open the
inversion; a descendant can never make a parent's sealed import dynamic or
hijack one (the "is this an extension point" decision keys on the importer's
trusted defining node, never a script-passed value).

Resolver (executor-core):
- `ModuleSource` gains `resolve_extension_point(origin, name)` and a
  chain-constrained `resolve_by_id(origin, id)`. The resolve seam checks for an
  ext point on the importer's chain first; on a hit it resolves against
  `App(cx.app_id)`, else the declared default, else ModuleNotFound. The Postgres
  query returns Some only when the NEAREST declaration of the name is an ext
  point (a peer/nearer concrete module shadows it). A group origin can't reach
  app-owned rows — the trust boundary holds. Cache stays id-keyed (no bleed).

Apply (manager-core, mirrors the `vars` pattern):
- migration 0051: owner-polymorphic `extension_points` table (group XOR app),
  optional `default_script_id` FK ON DELETE SET NULL, per-owner LOWER(name)
  unique indexes.
- Bundle/Plan/CurrentState gain extension_points; `diff_extension_points`
  (name-based, default change = Update), reconcile (upsert after scripts so the
  default resolves) + prune, `validate_bundle` (module-name shape; default must
  be a local module), `check_imports_resolve` (a declared/on-chain ext point
  satisfies an import), and the state_token fold. Writes gate on the
  script-write capability (AppWriteScript / GroupScriptsWrite).
- GET /apps|groups/{id}/extension-points so `pic pull` round-trips them — a
  re-applied pulled manifest is all-NoOp, so `--prune` can't silently drop one.

CLI: `[[extension_points]]` manifest table; plan/apply build + render; pull
exports them.

Tests: 5 resolver units (inversion, default fallback, no-provider error,
no-hijack of a sealed import, no cross-tenant cache bleed), 2 diff/state-token
units, 2 journeys (per-app resolution + default fallback via invoke; pull
round-trip). Schema golden re-blessed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-26 21:35:29 +02:00
parent 52da8a8704
commit 4dcb8d1136
18 changed files with 1316 additions and 16 deletions

View File

@@ -123,6 +123,19 @@ impl Client {
decode(resp).await
}
/// `GET /api/v1/admin/apps/{id}/extension-points` — the app's OWN
/// extension-point declarations (for `pic pull`).
pub async fn extension_points_list(&self, ident: &str) -> Result<Vec<ExtPointDto>> {
let resp = self
.request(
Method::GET,
&format!("/api/v1/admin/apps/{}/extension-points", seg(ident)),
)
.send()
.await?;
decode(resp).await
}
/// `GET /api/v1/admin/scripts` — every script the caller can see
/// (server filters by membership for `Member`). Lets `pic scripts ls`
/// (no `--app`) collapse what used to be an N+1 per-app walk into a
@@ -1320,6 +1333,8 @@ pub struct PlanDto {
pub secrets: Vec<ChangeDto>,
#[serde(default)]
pub vars: Vec<ChangeDto>,
#[serde(default)]
pub extension_points: Vec<ChangeDto>,
/// Fingerprint of the live state this plan was computed against; carried
/// in `.picloud/` and replayed to `apply` for the bound-plan check.
#[serde(default)]
@@ -1334,6 +1349,14 @@ pub struct ChangeDto {
pub detail: Option<String>,
}
/// One of an owner's OWN extension-point declarations (`pic pull`).
#[derive(Debug, Deserialize)]
pub struct ExtPointDto {
pub name: String,
#[serde(default)]
pub default: Option<String>,
}
/// Response of `POST .../tree/plan` (Phase 5): a per-node diff + one combined
/// bound-plan token for the whole subtree.
#[derive(Debug, Deserialize)]
@@ -1358,6 +1381,8 @@ pub struct NodePlanDto {
pub secrets: Vec<ChangeDto>,
#[serde(default)]
pub vars: Vec<ChangeDto>,
#[serde(default)]
pub extension_points: Vec<ChangeDto>,
}
/// Response of `POST .../apply`: counts of what changed.
@@ -1386,6 +1411,12 @@ pub struct ApplyReportDto {
#[serde(default)]
pub vars_deleted: u32,
#[serde(default)]
pub extension_points_created: u32,
#[serde(default)]
pub extension_points_updated: u32,
#[serde(default)]
pub extension_points_deleted: u32,
#[serde(default)]
pub warnings: Vec<String>,
}

View File

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

View File

@@ -139,6 +139,7 @@ fn scaffold_manifest(slug: &str, name: &str) -> Manifest {
triggers: crate::manifest::ManifestTriggers::default(),
secrets: crate::manifest::ManifestSecrets::default(),
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"]);
for n in &plan.nodes {
let node = format!("{}:{}", n.kind, n.slug);
let groups: [(&str, &Vec<ChangeDto>); 5] = [
let groups: [(&str, &Vec<ChangeDto>); 6] = [
("script", &n.scripts),
("route", &n.routes),
("trigger", &n.triggers),
("secret", &n.secrets),
("var", &n.vars),
("extension-point", &n.extension_points),
];
for (rk, changes) in groups {
for c in changes {
@@ -154,12 +155,28 @@ pub fn build_bundle(manifest: &Manifest, base_dir: &Path) -> Result<Value> {
);
}
// Extension points: name + optional default module. The server's
// `ExtPointSpec` deserializes this shape directly.
let extension_points: Vec<Value> = manifest
.extension_points
.iter()
.map(|e| {
let mut o = Map::new();
o.insert("name".into(), json!(e.name));
if let Some(d) = &e.default {
o.insert("default".into(), json!(d));
}
Value::Object(o)
})
.collect();
Ok(json!({
"scripts": scripts,
"routes": routes,
"triggers": triggers,
"secrets": manifest.secrets.names,
"vars": vars,
"extension_points": extension_points,
}))
}
@@ -174,12 +191,13 @@ fn tagged(kind: &str, spec: impl Serialize) -> Result<Value> {
fn render(plan: &PlanDto, mode: OutputMode) {
let mut table = Table::new(["kind", "op", "resource", "detail"]);
let groups: [(&str, &Vec<ChangeDto>); 5] = [
let groups: [(&str, &Vec<ChangeDto>); 6] = [
("script", &plan.scripts),
("route", &plan.routes),
("trigger", &plan.triggers),
("secret", &plan.secrets),
("var", &plan.vars),
("extension-point", &plan.extension_points),
];
for (kind, changes) in groups {
for c in changes {

View File

@@ -18,8 +18,8 @@ use crate::client::{Client, VarOwnerArg};
use crate::config;
use crate::manifest::{
CronTriggerSpec, DocsTriggerSpec, FilesTriggerSpec, KvTriggerSpec, Manifest, ManifestApp,
ManifestRoute, ManifestScript, ManifestSecrets, ManifestTriggers, PubsubTriggerSpec,
QueueTriggerSpec, MANIFEST_FILE,
ManifestExtensionPoint, ManifestRoute, ManifestScript, ManifestSecrets, ManifestTriggers,
PubsubTriggerSpec, QueueTriggerSpec, MANIFEST_FILE,
};
use crate::output::{KvBlock, OutputMode};
@@ -210,6 +210,18 @@ pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) ->
eprintln!("warning: skipping {s} trigger — not yet representable in the manifest");
}
// Extension points (§5.5): the app's own declarations, exported so a
// re-applied pulled manifest doesn't prune them.
let extension_points: Vec<ManifestExtensionPoint> = client
.extension_points_list(app_ident)
.await?
.into_iter()
.map(|e| ManifestExtensionPoint {
name: e.name,
default: e.default,
})
.collect();
let manifest = Manifest {
app: Some(ManifestApp {
slug: app.app.slug.clone(),
@@ -224,6 +236,7 @@ pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) ->
names: secrets.iter().map(|s| s.name.clone()).collect(),
},
vars: manifest_vars,
extension_points,
};
std::fs::write(&manifest_path, manifest.to_toml()?)

View File

@@ -49,6 +49,10 @@ pub struct Manifest {
/// The overlay merges per-env, last-write-wins by key.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub vars: BTreeMap<String, toml::Value>,
/// `[[extension_points]]` — module names this node opens for per-tenant
/// resolution (§5.5). Allowed on both app and group manifests.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub extension_points: Vec<ManifestExtensionPoint>,
}
impl Manifest {
@@ -397,6 +401,17 @@ impl ManifestSecrets {
}
}
/// `[[extension_points]]` — a module name opened for per-tenant resolution
/// (§5.5). A parent/group script importing `name` resolves it against the
/// inheriting app's module instead of the sealed lexical chain; `default`
/// names a local module used when an app provides none.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ManifestExtensionPoint {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default: Option<String>,
}
// ---- serde skip/default helpers ----
fn is_endpoint(kind: &ScriptKind) -> bool {
@@ -488,6 +503,10 @@ mod tests {
("region".to_string(), toml::Value::String("eu".into())),
("max-retries".to_string(), toml::Value::Integer(3)),
]),
extension_points: vec![ManifestExtensionPoint {
name: "theme".into(),
default: Some("default-theme".into()),
}],
}
}
@@ -591,12 +610,14 @@ mod tests {
triggers: ManifestTriggers::default(),
secrets: ManifestSecrets::default(),
vars: BTreeMap::new(),
extension_points: Vec::new(),
};
let text = m.to_toml().unwrap();
assert!(!text.contains("[[scripts]]"), "got:\n{text}");
assert!(!text.contains("triggers"), "got:\n{text}");
assert!(!text.contains("secrets"), "got:\n{text}");
assert!(!text.contains("vars"), "got:\n{text}");
assert!(!text.contains("extension_points"), "got:\n{text}");
// Still round-trips.
assert_eq!(m, Manifest::parse(&text).unwrap());
}

View File

@@ -23,6 +23,7 @@ mod dead_letters;
mod email_queue;
mod enabled;
mod env_overlay;
mod extension_points;
mod group_modules;
mod group_scripts;
mod group_secrets;

View File

@@ -0,0 +1,243 @@
//! Extension points (§5.5) end-to-end via `pic apply` + `invoke`:
//! * a GROUP declares a module name `theme` an extension point (with a
//! default body) and a group endpoint `render` that imports it,
//! * an app under the group provides its OWN `theme`; invoking the inherited
//! `render` in that app's context resolves the APP's `theme` (the
//! inversion) — NOT sealed to the group,
//! * an app that provides no `theme` falls back to the group's default body,
//! * `pull` round-trips the declaration (a re-applied pulled manifest is a
//! no-op, so `--prune` never silently drops it).
use std::fs;
use tempfile::TempDir;
use crate::common;
use crate::common::cleanup::{AppGuard, GroupGuard, ScriptGuard};
fn manifest_dir() -> TempDir {
let dir = TempDir::new().expect("tempdir");
fs::create_dir_all(dir.path().join("scripts")).expect("scripts dir");
dir
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn extension_point_resolves_per_app_with_default_fallback() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let group = common::unique_slug("ep-grp");
let app_a = common::unique_slug("ep-a");
let app_b = common::unique_slug("ep-b");
// GroupGuard first so it drops LAST (after apps + group scripts).
let _g = GroupGuard::new(&env.url, &env.token, &group);
common::pic_as(&env)
.args(["groups", "create", &group])
.assert()
.success();
let dir = manifest_dir();
// --- Group manifest: a default `theme` body, a `render` endpoint that
// imports `theme`, and the `[[extension_points]]` declaration. ---
fs::write(
dir.path().join("scripts/defaulttheme.rhai"),
r#"fn name() { "default" }"#,
)
.unwrap();
fs::write(
dir.path().join("scripts/render.rhai"),
r#"import "theme" as t; t::name()"#,
)
.unwrap();
let group_manifest = format!(
"[group]\nslug = \"{group}\"\nname = \"EP Group\"\n\n\
[[scripts]]\nname = \"defaulttheme\"\nfile = \"scripts/defaulttheme.rhai\"\nkind = \"module\"\n\n\
[[scripts]]\nname = \"render\"\nfile = \"scripts/render.rhai\"\n\n\
[[extension_points]]\nname = \"theme\"\ndefault = \"defaulttheme\"\n"
);
let group_path = dir.path().join("group.toml");
fs::write(&group_path, &group_manifest).unwrap();
let out = common::pic_as(&env)
.args(["apply", "--file"])
.arg(&group_path)
.output()
.expect("group apply");
assert!(
out.status.success(),
"group apply failed: {}",
String::from_utf8_lossy(&out.stderr)
);
// Group scripts block group deletion (RESTRICT) — guard them.
let _gr = ScriptGuard::new(
&env.url,
&env.token,
&group_script_id(&env, &group, "render"),
);
let _gd = ScriptGuard::new(
&env.url,
&env.token,
&group_script_id(&env, &group, "defaulttheme"),
);
// --- App A under the group: provides its OWN `theme` + a caller that
// invokes the inherited `render`. ---
let _ga = AppGuard::new(&env.url, &env.token, &app_a);
common::pic_as(&env)
.args(["apps", "create", &app_a, "--group", &group])
.assert()
.success();
fs::write(
dir.path().join("scripts/theme-a.rhai"),
r#"fn name() { "app-a" }"#,
)
.unwrap();
fs::write(
dir.path().join("scripts/caller.rhai"),
r#"invoke("render", #{})"#,
)
.unwrap();
let app_a_manifest = format!(
"[app]\nslug = \"{app_a}\"\nname = \"EP A\"\n\n\
[[scripts]]\nname = \"theme\"\nfile = \"scripts/theme-a.rhai\"\nkind = \"module\"\n\n\
[[scripts]]\nname = \"caller\"\nfile = \"scripts/caller.rhai\"\n"
);
let app_a_path = dir.path().join("a.toml");
fs::write(&app_a_path, &app_a_manifest).unwrap();
common::pic_as(&env)
.args(["apply", "--file"])
.arg(&app_a_path)
.assert()
.success();
// The inherited `render` imports the ext point `theme`; in App A's context
// it resolves App A's OWN `theme`.
let caller_a = app_script_id(&env, &app_a, "caller");
assert_eq!(
invoke_body(&env, &caller_a),
serde_json::json!("app-a"),
"extension point must resolve the inheriting app's module"
);
// --- App B under the group: provides NO `theme` → render falls back to the
// group's default body. ---
let _gb = AppGuard::new(&env.url, &env.token, &app_b);
common::pic_as(&env)
.args(["apps", "create", &app_b, "--group", &group])
.assert()
.success();
let app_b_manifest = format!(
"[app]\nslug = \"{app_b}\"\nname = \"EP B\"\n\n\
[[scripts]]\nname = \"caller\"\nfile = \"scripts/caller.rhai\"\n"
);
let app_b_path = dir.path().join("b.toml");
fs::write(&app_b_path, &app_b_manifest).unwrap();
common::pic_as(&env)
.args(["apply", "--file"])
.arg(&app_b_path)
.assert()
.success();
let caller_b = app_script_id(&env, &app_b, "caller");
assert_eq!(
invoke_body(&env, &caller_b),
serde_json::json!("default"),
"an app providing no module must fall back to the ext point's default"
);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn extension_point_round_trips_through_pull() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let app = common::unique_slug("ep-pull");
let _a = AppGuard::new(&env.url, &env.token, &app);
common::pic_as(&env)
.args(["apps", "create", &app])
.assert()
.success();
// Apply an app manifest that declares an extension point with a default.
let dir = manifest_dir();
fs::write(
dir.path().join("scripts/theme.rhai"),
r#"fn name() { "x" }"#,
)
.unwrap();
let manifest = format!(
"[app]\nslug = \"{app}\"\nname = \"EP Pull\"\n\n\
[[scripts]]\nname = \"theme\"\nfile = \"scripts/theme.rhai\"\nkind = \"module\"\n\n\
[[extension_points]]\nname = \"theme_slot\"\ndefault = \"theme\"\n"
);
let manifest_path = dir.path().join("picloud.toml");
fs::write(&manifest_path, &manifest).unwrap();
common::pic_as(&env)
.args(["apply", "--file"])
.arg(&manifest_path)
.assert()
.success();
// Pull into a fresh dir, then re-plan: the extension point must round-trip
// (no Create/Delete), so `--prune` would never drop it.
let pulled = manifest_dir();
common::pic_as(&env)
.args(["pull", &app, "--dir"])
.arg(pulled.path())
.assert()
.success();
let toml = fs::read_to_string(pulled.path().join("picloud.toml")).unwrap();
assert!(
toml.contains("theme_slot"),
"pulled manifest must export the extension point:\n{toml}"
);
let plan = common::pic_as(&env)
.args(["plan", "--file"])
.arg(pulled.path().join("picloud.toml"))
.output()
.expect("plan");
let stdout = String::from_utf8_lossy(&plan.stdout);
assert!(
!stdout.to_lowercase().contains("create") && !stdout.to_lowercase().contains("delete"),
"re-planning a pulled manifest must be a no-op:\n{stdout}"
);
}
/// Id of the named script in a group (`pic scripts ls --group <g>`).
fn group_script_id(env: &common::TestEnv, group: &str, name: &str) -> String {
named_id(env, &["scripts", "ls", "--group", group], name)
}
/// Id of the named script in an app (`pic scripts ls --app <a>`).
fn app_script_id(env: &common::TestEnv, app: &str, name: &str) -> String {
named_id(env, &["scripts", "ls", "--app", app], name)
}
fn named_id(env: &common::TestEnv, args: &[&str], name: &str) -> String {
let ls = common::pic_as(env).args(args).output().expect("scripts ls");
let table = String::from_utf8(ls.stdout).unwrap();
table
.lines()
.map(common::cells)
.find(|c| c.get(2) == Some(&name))
.and_then(|c| c.first().map(|s| (*s).to_string()))
.unwrap_or_else(|| panic!("script `{name}` not found:\n{table}"))
}
fn invoke_body(env: &common::TestEnv, id: &str) -> serde_json::Value {
let out = common::pic_as(env)
.args(["scripts", "invoke", id])
.output()
.expect("scripts invoke");
assert!(
out.status.success(),
"invoke failed: {}",
String::from_utf8_lossy(&out.stderr)
);
serde_json::from_slice(&out.stdout).expect("invoke body is JSON")
}