Add the runtime heart of extension points: `ModuleSource::resolve_policy`
decides lexical vs dynamic resolution by the NEAREST declaration of a name
walking up the importing origin's chain — a concrete module → lexical (seal
to origin, Phase 4b); an extension-point marker → dynamic, resolved against
the inheriting app (`cx.app_id`) so the app can override, falling back to the
default body up-chain; the EP wins a depth tie.
- shared: `ModuleResolution { Module | NoProvider | NotFound }` + a
`resolve_policy(origin, inheriting_app, name)` trait method with a
lexical-only default impl (existing fakes inherit it unchanged).
- PostgresModuleSource: one-round-trip nearest-declaration query (min EP depth
vs min module depth over the chain CTEs), then the lexical/dynamic branch.
- module_resolver: calls `resolve_policy(origin, cx.app_id, path)`; maps
NoProvider → a clear "extension point has no provider" error, NotFound →
module-not-found. Cache + AST-source sealing unchanged.
- executor-core tests: a policy fake + 3 cases (app override binds dynamically,
no-provider errors, non-EP stays lexical).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
176 lines
7.1 KiB
Rust
176 lines
7.1 KiB
Rust
//! `PostgresModuleSource` — the Postgres-backed `ModuleSource` impl.
|
|
//!
|
|
//! `resolve` is **lexical** (Phase 4b): a module is looked up by walking the
|
|
//! group chain rooted at the **importing script's defining node** (`origin`),
|
|
//! nearest-owner-wins — *not* the inheriting app's effective view. An `App`
|
|
//! origin walks `app → its group → ancestors` (reusing [`CHAIN_LEVELS_CTE`]);
|
|
//! a `Group` origin walks `group → ancestors` ([`GROUP_CHAIN_LEVELS_CTE`]) and
|
|
//! never sees app-owned modules below it (the trust boundary).
|
|
//!
|
|
//! `resolve_policy` adds §5.5 **extension points**: the nearest declaration of
|
|
//! a name on `origin`'s chain decides lexical (concrete module) vs **dynamic**
|
|
//! (an extension-point marker → resolve against the inheriting app so it can
|
|
//! override, falling back to a default body up-chain). EP wins a depth tie.
|
|
//!
|
|
//! The resolver lives in `executor-core` and consumes this trait through the
|
|
//! `Services` bundle, so manager-core stays the only crate that touches Postgres.
|
|
|
|
use async_trait::async_trait;
|
|
use chrono::{DateTime, Utc};
|
|
use picloud_shared::{
|
|
AppId, ModuleResolution, ModuleScript, ModuleSource, ModuleSourceError, ScriptOwner,
|
|
};
|
|
use sqlx::PgPool;
|
|
|
|
use crate::config_resolver::{CHAIN_LEVELS_CTE, GROUP_CHAIN_LEVELS_CTE};
|
|
|
|
pub struct PostgresModuleSource {
|
|
pool: PgPool,
|
|
}
|
|
|
|
impl PostgresModuleSource {
|
|
#[must_use]
|
|
pub fn new(pool: PgPool) -> Self {
|
|
Self { pool }
|
|
}
|
|
}
|
|
|
|
#[derive(sqlx::FromRow)]
|
|
struct ModuleRow {
|
|
id: uuid::Uuid,
|
|
app_id: Option<uuid::Uuid>,
|
|
group_id: Option<uuid::Uuid>,
|
|
name: String,
|
|
source: String,
|
|
updated_at: DateTime<Utc>,
|
|
}
|
|
|
|
impl From<ModuleRow> for ModuleScript {
|
|
fn from(r: ModuleRow) -> Self {
|
|
Self {
|
|
script_id: r.id.into(),
|
|
app_id: r.app_id.map(Into::into),
|
|
group_id: r.group_id.map(Into::into),
|
|
name: r.name,
|
|
source: r.source,
|
|
updated_at: r.updated_at,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl ModuleSource for PostgresModuleSource {
|
|
async fn resolve(
|
|
&self,
|
|
origin: ScriptOwner,
|
|
name: &str,
|
|
) -> Result<Option<ModuleScript>, ModuleSourceError> {
|
|
// Lexical lookup: JOIN module scripts against the chain rooted at
|
|
// `origin`, take the nearest level (lowest depth). The `kind =
|
|
// 'module'` filter + the CHECK constraint guarantee endpoint
|
|
// scripts are never importable. Case-insensitive to match the
|
|
// per-owner `LOWER(name)` partial-unique indexes (0050). `origin`
|
|
// is a trusted dispatch-derived value, so the chain it roots is
|
|
// the script's true ancestry — the cross-app isolation boundary
|
|
// is preserved (a group origin can't reach an app's modules).
|
|
let query = match origin {
|
|
ScriptOwner::App(_) => format!(
|
|
"{CHAIN_LEVELS_CTE} \
|
|
SELECT s.id, s.app_id, s.group_id, s.name, s.source, s.updated_at \
|
|
FROM scripts s \
|
|
JOIN chain c ON (s.app_id = c.app_owner OR s.group_id = c.group_owner) \
|
|
WHERE s.kind = 'module' AND LOWER(s.name) = LOWER($2) \
|
|
ORDER BY c.depth ASC LIMIT 1",
|
|
),
|
|
ScriptOwner::Group(_) => format!(
|
|
"{GROUP_CHAIN_LEVELS_CTE} \
|
|
SELECT s.id, s.app_id, s.group_id, s.name, s.source, s.updated_at \
|
|
FROM scripts s \
|
|
JOIN chain c ON s.group_id = c.group_owner \
|
|
WHERE s.kind = 'module' AND LOWER(s.name) = LOWER($2) \
|
|
ORDER BY c.depth ASC LIMIT 1",
|
|
),
|
|
};
|
|
let root = match origin {
|
|
ScriptOwner::App(a) => a.into_inner(),
|
|
ScriptOwner::Group(g) => g.into_inner(),
|
|
};
|
|
let row: Option<ModuleRow> = sqlx::query_as(&query)
|
|
.bind(root)
|
|
.bind(name)
|
|
.fetch_optional(&self.pool)
|
|
.await
|
|
.map_err(|e| ModuleSourceError::Backend(e.to_string()))?;
|
|
Ok(row.map(Into::into))
|
|
}
|
|
|
|
async fn resolve_policy(
|
|
&self,
|
|
origin: ScriptOwner,
|
|
inheriting_app: AppId,
|
|
name: &str,
|
|
) -> Result<ModuleResolution, ModuleSourceError> {
|
|
// §5.5 nearest-declaration-kind-wins. One round trip: the min chain
|
|
// depth at which `name` is declared as an extension-point marker vs a
|
|
// concrete module, walking up `origin`'s chain. The EP wins a tie (a
|
|
// marker + its co-located default body live at the same node).
|
|
let query = match origin {
|
|
ScriptOwner::App(_) => format!(
|
|
"{CHAIN_LEVELS_CTE} \
|
|
SELECT \
|
|
(SELECT MIN(c.depth) FROM extension_points e \
|
|
JOIN chain c ON (e.app_id = c.app_owner OR e.group_id = c.group_owner) \
|
|
WHERE LOWER(e.name) = LOWER($2)) AS ep_depth, \
|
|
(SELECT MIN(c.depth) FROM scripts s \
|
|
JOIN chain c ON (s.app_id = c.app_owner OR s.group_id = c.group_owner) \
|
|
WHERE s.kind = 'module' AND LOWER(s.name) = LOWER($2)) AS mod_depth",
|
|
),
|
|
ScriptOwner::Group(_) => format!(
|
|
"{GROUP_CHAIN_LEVELS_CTE} \
|
|
SELECT \
|
|
(SELECT MIN(c.depth) FROM extension_points e \
|
|
JOIN chain c ON e.group_id = c.group_owner \
|
|
WHERE LOWER(e.name) = LOWER($2)) AS ep_depth, \
|
|
(SELECT MIN(c.depth) FROM scripts s \
|
|
JOIN chain c ON s.group_id = c.group_owner \
|
|
WHERE s.kind = 'module' AND LOWER(s.name) = LOWER($2)) AS mod_depth",
|
|
),
|
|
};
|
|
let root = match origin {
|
|
ScriptOwner::App(a) => a.into_inner(),
|
|
ScriptOwner::Group(g) => g.into_inner(),
|
|
};
|
|
let (ep_depth, mod_depth): (Option<i32>, Option<i32>) = sqlx::query_as(&query)
|
|
.bind(root)
|
|
.bind(name)
|
|
.fetch_one(&self.pool)
|
|
.await
|
|
.map_err(|e| ModuleSourceError::Backend(e.to_string()))?;
|
|
|
|
// EP wins when it is declared and is no farther than the nearest
|
|
// concrete module (tie → EP). Then resolve dynamically against the
|
|
// inheriting app (its own override, else the default body up-chain).
|
|
let ep_wins = match (ep_depth, mod_depth) {
|
|
(Some(ep), Some(m)) => ep <= m,
|
|
(Some(_), None) => true,
|
|
_ => false,
|
|
};
|
|
if ep_wins {
|
|
return Ok(
|
|
match self.resolve(ScriptOwner::App(inheriting_app), name).await? {
|
|
Some(m) => ModuleResolution::Module(m),
|
|
None => ModuleResolution::NoProvider,
|
|
},
|
|
);
|
|
}
|
|
if mod_depth.is_some() {
|
|
// Concrete module nearest → lexical, exactly as Phase 4b.
|
|
return Ok(match self.resolve(origin, name).await? {
|
|
Some(m) => ModuleResolution::Module(m),
|
|
None => ModuleResolution::NotFound,
|
|
});
|
|
}
|
|
Ok(ModuleResolution::NotFound)
|
|
}
|
|
}
|