feat(modules): owner-aware module lookup — lexical chain walk (Phase 4b C1)

Make the module-loading contract origin-aware so group-owned modules
become resolvable and imports can resolve lexically (§5.5).

- `ModuleScript` gains a polymorphic owner (`app_id`/`group_id` + `owner()`),
  mirroring `Script`.
- `ModuleSource::lookup(&cx, name)` -> `resolve(origin: ScriptOwner, name)`:
  resolution walks the group chain rooted at the importing script's
  defining node (nearest-owner-wins), not the inheriting app's view.
- `PostgresModuleSource::resolve` branches on origin: app-rooted
  (`CHAIN_LEVELS_CTE`) or a new group-rooted `GROUP_CHAIN_LEVELS_CTE`,
  joining `kind='module'` rows.

The executor resolver passes a temporary `App(cx.app_id)` origin (behaviour-
preserving for app scripts; true lexical origin lands in C3). Group scripts
still can't carry imports until C4, so no trust-inversion window opens here.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-26 07:07:04 +02:00
parent 3ef3038eb7
commit 7fef098b48
6 changed files with 158 additions and 61 deletions

View File

@@ -42,6 +42,22 @@ pub(crate) const CHAIN_LEVELS_CTE: &str = "\
WHERE c.depth < 64 \
)";
/// Group-rooted variant of [`CHAIN_LEVELS_CTE`]: the chain starts at a
/// **group** (depth 0 = the group itself, `group_owner` set), then walks
/// its ancestor groups nearest-first. There is no `app_owner` level — a
/// group origin never sees app-owned rows below it (the §5.5 trust
/// boundary). Bind `$1 = group_id`. Used for the lexical module resolver
/// when the importing script's defining node is a group.
pub(crate) const GROUP_CHAIN_LEVELS_CTE: &str = "\
WITH RECURSIVE chain AS ( \
SELECT g.id AS group_owner, g.parent_id AS next_group, 0 AS depth \
FROM groups g WHERE g.id = $1 \
UNION ALL \
SELECT g.id, g.parent_id, c.depth + 1 \
FROM groups g JOIN chain c ON g.id = c.next_group \
WHERE c.depth < 64 \
)";
/// Owner kind of a resolved value, for `--explain` provenance.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OwnerKind {

View File

@@ -1,18 +1,22 @@
//! `PostgresModuleSource` — the Postgres-backed `ModuleSource` impl.
//!
//! Mirrors the structure of [`crate::kv_repo::PostgresKvRepo`] /
//! [`crate::docs_repo::PostgresDocsRepo`]: thin wrapper around a
//! `PgPool` that owns a single statement returning the module by
//! `(cx.app_id, name, kind = 'module')`. The resolver lives in
//! `executor-core` and consumes this trait through the `Services`
//! bundle, so manager-core stays the only crate that touches
//! Postgres.
//! Resolution is **lexical** (§5.5): 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). 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::{ModuleScript, ModuleSource, ModuleSourceError, SdkCallCx};
use picloud_shared::{ModuleScript, ModuleSource, ModuleSourceError, ScriptOwner};
use sqlx::PgPool;
use crate::config_resolver::{CHAIN_LEVELS_CTE, GROUP_CHAIN_LEVELS_CTE};
pub struct PostgresModuleSource {
pool: PgPool,
}
@@ -27,7 +31,8 @@ impl PostgresModuleSource {
#[derive(sqlx::FromRow)]
struct ModuleRow {
id: uuid::Uuid,
app_id: uuid::Uuid,
app_id: Option<uuid::Uuid>,
group_id: Option<uuid::Uuid>,
name: String,
source: String,
updated_at: DateTime<Utc>,
@@ -37,7 +42,8 @@ impl From<ModuleRow> for ModuleScript {
fn from(r: ModuleRow) -> Self {
Self {
script_id: r.id.into(),
app_id: r.app_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,
@@ -47,28 +53,47 @@ impl From<ModuleRow> for ModuleScript {
#[async_trait]
impl ModuleSource for PostgresModuleSource {
async fn lookup(
async fn resolve(
&self,
cx: &SdkCallCx,
origin: ScriptOwner,
name: &str,
) -> Result<Option<ModuleScript>, ModuleSourceError> {
// The query is the cross-app isolation boundary: app_id comes
// from cx (never from the script-passed argument), and the
// CHECK constraint `kind IN ('endpoint','module')` plus the
// `kind = 'module'` filter together guarantee endpoint scripts
// are never importable. The `(app_id, kind)` index from
// migration 0015 makes this an index scan returning at most
// one row (per-app uniqueness on `name`).
let row: Option<ModuleRow> = sqlx::query_as(
"SELECT id, app_id, name, source, updated_at \
FROM scripts \
WHERE app_id = $1 AND kind = 'module' AND name = $2",
)
.bind(cx.app_id.into_inner())
.bind(name)
.fetch_optional(&self.pool)
.await
.map_err(|e| ModuleSourceError::Backend(e.to_string()))?;
// 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))
}
}