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:
@@ -14,6 +14,14 @@
|
||||
//! (a leaf can't shadow them — the trust boundary), while every SDK call
|
||||
//! *inside* a module still scopes to the inheriting app via `cx.app_id`.
|
||||
//!
|
||||
//! **Extension points (§5.5).** A node may opt a module name OUT of sealing
|
||||
//! by declaring it an extension point: an importer on that node's chain then
|
||||
//! resolves the name against the *inheriting app's* effective view
|
||||
//! (`App(cx.app_id)`), with the declared default body as fallback — so each
|
||||
//! descendant app supplies its own impl. The "is this an extension point"
|
||||
//! check keys on the importer's `origin` (trusted), so only the declaring
|
||||
//! parent can open the inversion; a descendant can't hijack a sealed import.
|
||||
//!
|
||||
//! Three runtime invariants are enforced:
|
||||
//!
|
||||
//! 1. **Lexical isolation** — `ModuleSource::resolve` is keyed by the
|
||||
@@ -377,8 +385,44 @@ impl ModuleResolver for PicloudModuleResolver {
|
||||
let origin = importer_source
|
||||
.and_then(decode_origin)
|
||||
.unwrap_or(self.default_origin);
|
||||
|
||||
// Extension-point inversion (§5.5): if `path`'s nearest declaration on
|
||||
// the importer's chain is an extension point (not a concrete module),
|
||||
// resolve it against the INHERITING app's effective view instead of the
|
||||
// sealed lexical chain — each descendant app supplies its own impl,
|
||||
// falling back to the declared default body. The decision keys on
|
||||
// `origin` (the trusted importer node), so only a declaration on the
|
||||
// importer's own ancestry can open this; a descendant can never make a
|
||||
// parent's sealed `import` dynamic, and a non-ext-point import never
|
||||
// consults `App(cx.app_id)`. All branches funnel any backend error into
|
||||
// the single redaction arm below.
|
||||
let lookup_result: Result<Option<picloud_shared::ModuleScript>, ModuleSourceError> =
|
||||
tokio::task::block_in_place(|| handle.block_on(self.source.resolve(origin, path)));
|
||||
(|| {
|
||||
let ep = tokio::task::block_in_place(|| {
|
||||
handle.block_on(self.source.resolve_extension_point(origin, path))
|
||||
})?;
|
||||
let Some(ep) = ep else {
|
||||
// Sealed lexical resolution (the default, unchanged).
|
||||
return tokio::task::block_in_place(|| {
|
||||
handle.block_on(self.source.resolve(origin, path))
|
||||
});
|
||||
};
|
||||
let app_origin = ScriptOwner::App(self.cx.app_id);
|
||||
if let Some(m) = tokio::task::block_in_place(|| {
|
||||
handle.block_on(self.source.resolve(app_origin, path))
|
||||
})? {
|
||||
return Ok(Some(m));
|
||||
}
|
||||
match ep.default_script_id {
|
||||
// The default body is a module declared at the node that
|
||||
// opened the ext point, i.e. on the importer's (`origin`)
|
||||
// chain — resolve it there, not against the app.
|
||||
Some(id) => tokio::task::block_in_place(|| {
|
||||
handle.block_on(self.source.resolve_by_id(origin, id))
|
||||
}),
|
||||
None => Ok(None),
|
||||
}
|
||||
})();
|
||||
|
||||
let module_row = match lookup_result {
|
||||
Ok(Some(m)) => m,
|
||||
|
||||
@@ -34,6 +34,22 @@ impl ModuleSource for FailingSource {
|
||||
) -> Result<Option<ModuleScript>, ModuleSourceError> {
|
||||
Err(ModuleSourceError::Backend(SENTINEL.to_string()))
|
||||
}
|
||||
|
||||
async fn resolve_extension_point(
|
||||
&self,
|
||||
_origin: ScriptOwner,
|
||||
_name: &str,
|
||||
) -> Result<Option<picloud_shared::ExtPointResolution>, ModuleSourceError> {
|
||||
Err(ModuleSourceError::Backend(SENTINEL.to_string()))
|
||||
}
|
||||
|
||||
async fn resolve_by_id(
|
||||
&self,
|
||||
_origin: ScriptOwner,
|
||||
_script_id: picloud_shared::ScriptId,
|
||||
) -> Result<Option<ModuleScript>, ModuleSourceError> {
|
||||
Err(ModuleSourceError::Backend(SENTINEL.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
/// `MakeWriter` that appends to a shared buffer.
|
||||
|
||||
@@ -95,6 +95,30 @@ impl ModuleSource for CountingModuleSource {
|
||||
.get(&(app_id, name.to_string()))
|
||||
.cloned())
|
||||
}
|
||||
|
||||
async fn resolve_extension_point(
|
||||
&self,
|
||||
_origin: ScriptOwner,
|
||||
_name: &str,
|
||||
) -> Result<Option<picloud_shared::ExtPointResolution>, ModuleSourceError> {
|
||||
// This flat fake has no extension points; the inversion is covered by
|
||||
// `LexicalModuleSource` below and the Postgres journey tests.
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn resolve_by_id(
|
||||
&self,
|
||||
_origin: ScriptOwner,
|
||||
script_id: ScriptId,
|
||||
) -> Result<Option<ModuleScript>, ModuleSourceError> {
|
||||
Ok(self
|
||||
.table
|
||||
.lock()
|
||||
.await
|
||||
.values()
|
||||
.find(|m| m.script_id == script_id)
|
||||
.cloned())
|
||||
}
|
||||
}
|
||||
|
||||
fn services_with(modules: Arc<dyn ModuleSource>) -> Services {
|
||||
@@ -623,6 +647,10 @@ fn validate_endpoint_skips_dynamic_imports_in_imports_list() {
|
||||
#[derive(Default)]
|
||||
struct LexicalModuleSource {
|
||||
table: Mutex<HashMap<(ScriptOwner, String), ModuleScript>>,
|
||||
/// Extension-point declarations, keyed by exact declaring owner →
|
||||
/// optional default module id. Flat (exact-owner) — the chain-walk +
|
||||
/// shadowing tie-break is covered by the Postgres journey tests.
|
||||
ext_points: Mutex<HashMap<(ScriptOwner, String), Option<ScriptId>>>,
|
||||
}
|
||||
|
||||
impl LexicalModuleSource {
|
||||
@@ -630,15 +658,16 @@ impl LexicalModuleSource {
|
||||
Arc::new(Self::default())
|
||||
}
|
||||
|
||||
async fn put(self: &Arc<Self>, owner: ScriptOwner, name: &str, source: &str) {
|
||||
async fn put(self: &Arc<Self>, owner: ScriptOwner, name: &str, source: &str) -> ScriptId {
|
||||
let (app_id, group_id) = match owner {
|
||||
ScriptOwner::App(a) => (Some(a), None),
|
||||
ScriptOwner::Group(g) => (None, Some(g)),
|
||||
};
|
||||
let script_id = ScriptId::new();
|
||||
self.table.lock().await.insert(
|
||||
(owner, name.to_string()),
|
||||
ModuleScript {
|
||||
script_id: ScriptId::new(),
|
||||
script_id,
|
||||
app_id,
|
||||
group_id,
|
||||
name: name.to_string(),
|
||||
@@ -646,6 +675,21 @@ impl LexicalModuleSource {
|
||||
updated_at: Utc::now(),
|
||||
},
|
||||
);
|
||||
script_id
|
||||
}
|
||||
|
||||
/// Declare `name` an extension point at `owner`, with an optional default
|
||||
/// module body id.
|
||||
async fn put_ext_point(
|
||||
self: &Arc<Self>,
|
||||
owner: ScriptOwner,
|
||||
name: &str,
|
||||
default_script_id: Option<ScriptId>,
|
||||
) {
|
||||
self.ext_points
|
||||
.lock()
|
||||
.await
|
||||
.insert((owner, name.to_string()), default_script_id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -663,6 +707,35 @@ impl ModuleSource for LexicalModuleSource {
|
||||
.get(&(origin, name.to_string()))
|
||||
.cloned())
|
||||
}
|
||||
|
||||
async fn resolve_extension_point(
|
||||
&self,
|
||||
origin: ScriptOwner,
|
||||
name: &str,
|
||||
) -> Result<Option<picloud_shared::ExtPointResolution>, ModuleSourceError> {
|
||||
Ok(self
|
||||
.ext_points
|
||||
.lock()
|
||||
.await
|
||||
.get(&(origin, name.to_string()))
|
||||
.map(|default_script_id| picloud_shared::ExtPointResolution {
|
||||
default_script_id: *default_script_id,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn resolve_by_id(
|
||||
&self,
|
||||
_origin: ScriptOwner,
|
||||
script_id: ScriptId,
|
||||
) -> Result<Option<ModuleScript>, ModuleSourceError> {
|
||||
Ok(self
|
||||
.table
|
||||
.lock()
|
||||
.await
|
||||
.values()
|
||||
.find(|m| m.script_id == script_id)
|
||||
.cloned())
|
||||
}
|
||||
}
|
||||
|
||||
fn req_with_owner(app_id: AppId, owner: ScriptOwner) -> ExecRequest {
|
||||
@@ -736,3 +809,174 @@ async fn lexical_entry_origin_selects_app_module() {
|
||||
.expect("should execute");
|
||||
assert_eq!(resp.body, serde_json::json!(999));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Extension points (§5.5) — the opt-in resolution inversion. A group declares
|
||||
// a module name an extension point; an importer on the group's chain resolves
|
||||
// it against the INHERITING APP, not the sealed lexical chain.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A group script importing a declared extension point resolves it against the
|
||||
/// executing app's own module — each tenant supplies its own body.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn ext_point_resolves_to_app_module() {
|
||||
let source = LexicalModuleSource::new();
|
||||
let app = AppId::new();
|
||||
let group = GroupId::new();
|
||||
// The group opens "theme" as an extension point (no default).
|
||||
source
|
||||
.put_ext_point(ScriptOwner::Group(group), "theme", None)
|
||||
.await;
|
||||
// The app provides its own "theme".
|
||||
source
|
||||
.put(
|
||||
ScriptOwner::App(app),
|
||||
"theme",
|
||||
r#"fn name() { "app-theme" }"#,
|
||||
)
|
||||
.await;
|
||||
|
||||
let engine = engine_with(source.clone());
|
||||
// Entry runs as the inherited GROUP script (default_origin = group), but
|
||||
// the executing app is `app` (cx.app_id).
|
||||
let resp = engine
|
||||
.execute(
|
||||
r#"import "theme" as t; t::name()"#,
|
||||
req_with_owner(app, ScriptOwner::Group(group)),
|
||||
)
|
||||
.expect("should execute");
|
||||
assert_eq!(
|
||||
resp.body,
|
||||
serde_json::json!("app-theme"),
|
||||
"extension point must resolve to the inheriting app's module"
|
||||
);
|
||||
}
|
||||
|
||||
/// When the app provides no module for the extension point, the declared
|
||||
/// default body is used.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn ext_point_falls_back_to_default() {
|
||||
let source = LexicalModuleSource::new();
|
||||
let app = AppId::new();
|
||||
let group = GroupId::new();
|
||||
// The group's default "theme" body, registered as a group module.
|
||||
let default_id = source
|
||||
.put(
|
||||
ScriptOwner::Group(group),
|
||||
"default-theme",
|
||||
r#"fn name() { "default-theme" }"#,
|
||||
)
|
||||
.await;
|
||||
source
|
||||
.put_ext_point(ScriptOwner::Group(group), "theme", Some(default_id))
|
||||
.await;
|
||||
// The app provides NO "theme".
|
||||
|
||||
let engine = engine_with(source.clone());
|
||||
let resp = engine
|
||||
.execute(
|
||||
r#"import "theme" as t; t::name()"#,
|
||||
req_with_owner(app, ScriptOwner::Group(group)),
|
||||
)
|
||||
.expect("should execute");
|
||||
assert_eq!(resp.body, serde_json::json!("default-theme"));
|
||||
}
|
||||
|
||||
/// An extension point with neither an app provider nor a default is a clean
|
||||
/// module-not-found at import time.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn ext_point_no_provider_no_default_errors() {
|
||||
let source = LexicalModuleSource::new();
|
||||
let app = AppId::new();
|
||||
let group = GroupId::new();
|
||||
source
|
||||
.put_ext_point(ScriptOwner::Group(group), "theme", None)
|
||||
.await;
|
||||
|
||||
let engine = engine_with(source.clone());
|
||||
let err = engine
|
||||
.execute(
|
||||
r#"import "theme" as t; t::name()"#,
|
||||
req_with_owner(app, ScriptOwner::Group(group)),
|
||||
)
|
||||
.expect_err("ext point with no provider/default should fail");
|
||||
let msg = format!("{err:?}").to_lowercase();
|
||||
assert!(
|
||||
msg.contains("module") || msg.contains("not found") || msg.contains("theme"),
|
||||
"expected module-not-found-flavoured error, got {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A NORMAL (non-extension-point) group import is NOT hijacked by a leaf app's
|
||||
/// module of the same name — the inversion only fires for declared extension
|
||||
/// points, so the trust boundary holds.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn sealed_import_not_hijacked_by_leaf_when_not_ext_point() {
|
||||
let source = LexicalModuleSource::new();
|
||||
let app = AppId::new();
|
||||
let group = GroupId::new();
|
||||
// "auth" is a concrete group module — NOT an extension point.
|
||||
source
|
||||
.put(
|
||||
ScriptOwner::Group(group),
|
||||
"auth",
|
||||
r#"fn who() { "group-auth" }"#,
|
||||
)
|
||||
.await;
|
||||
// The app has a trap "auth" that must NOT be selected.
|
||||
source
|
||||
.put(ScriptOwner::App(app), "auth", r#"fn who() { "leaf-trap" }"#)
|
||||
.await;
|
||||
|
||||
let engine = engine_with(source.clone());
|
||||
let resp = engine
|
||||
.execute(
|
||||
r#"import "auth" as a; a::who()"#,
|
||||
req_with_owner(app, ScriptOwner::Group(group)),
|
||||
)
|
||||
.expect("should execute");
|
||||
assert_eq!(
|
||||
resp.body,
|
||||
serde_json::json!("group-auth"),
|
||||
"a non-ext-point import must seal to the group, never the leaf's trap"
|
||||
);
|
||||
}
|
||||
|
||||
/// Two apps inheriting the same extension point get their OWN bodies — the
|
||||
/// id-keyed module cache does not bleed one tenant's body into another.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn ext_point_two_apps_get_distinct_bodies() {
|
||||
let source = LexicalModuleSource::new();
|
||||
let app1 = AppId::new();
|
||||
let app2 = AppId::new();
|
||||
let group = GroupId::new();
|
||||
source
|
||||
.put_ext_point(ScriptOwner::Group(group), "theme", None)
|
||||
.await;
|
||||
source
|
||||
.put(ScriptOwner::App(app1), "theme", r#"fn name() { "one" }"#)
|
||||
.await;
|
||||
source
|
||||
.put(ScriptOwner::App(app2), "theme", r#"fn name() { "two" }"#)
|
||||
.await;
|
||||
|
||||
let engine = engine_with(source.clone());
|
||||
let r1 = engine
|
||||
.execute(
|
||||
r#"import "theme" as t; t::name()"#,
|
||||
req_with_owner(app1, ScriptOwner::Group(group)),
|
||||
)
|
||||
.expect("app1 executes");
|
||||
let r2 = engine
|
||||
.execute(
|
||||
r#"import "theme" as t; t::name()"#,
|
||||
req_with_owner(app2, ScriptOwner::Group(group)),
|
||||
)
|
||||
.expect("app2 executes");
|
||||
assert_eq!(r1.body, serde_json::json!("one"));
|
||||
assert_eq!(
|
||||
r2.body,
|
||||
serde_json::json!("two"),
|
||||
"no cross-tenant cache bleed"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user