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:
@@ -1,51 +1,88 @@
|
||||
//! `ModuleSource` — the v1.1.3 Rhai module-loading contract.
|
||||
//! `ModuleSource` — the Rhai module-loading contract (v1.1.3; made
|
||||
//! origin-aware in v1.2 Phase 4b).
|
||||
//!
|
||||
//! The executor-core `PicloudModuleResolver` calls into this trait to
|
||||
//! load `kind = 'module'` scripts referenced by `import "<name>" as <alias>;`
|
||||
//! statements. The Postgres impl in `manager-core` reads from the
|
||||
//! `scripts` table; tests pin in-memory fakes.
|
||||
//!
|
||||
//! Implementations MUST derive `app_id` from `cx.app_id` and pass it
|
||||
//! to every backend query. The `name` argument carries only the
|
||||
//! script's name (the literal between the import quotes); the trait
|
||||
//! has no way to express a cross-app lookup. That asymmetry is the
|
||||
//! load-bearing cross-app isolation boundary — see `docs/sdk-shape.md`.
|
||||
//! **Resolution is lexical (§5.5).** A module is resolved against the
|
||||
//! module set visible at the **importing script's own defining node**
|
||||
//! (`origin`), walking up the group chain from there — *not* the
|
||||
//! inheriting app's effective view. So `resolve` is keyed by a
|
||||
//! [`ScriptOwner`] origin, not by `cx.app_id`. This deliberately differs
|
||||
//! from the SDK-call isolation boundary: a group script's `import`
|
||||
//! resolves from the **group** (sealed from below — a leaf app cannot
|
||||
//! shadow it), while every SDK call *inside* that module still scopes to
|
||||
//! the inheriting app via `cx.app_id`. The `origin` argument is a trusted
|
||||
//! dispatch-derived value (the resolved script's owner), never a
|
||||
//! script-passed argument, so the cross-app isolation boundary holds.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::{AppId, ScriptId, SdkCallCx};
|
||||
use crate::{AppId, GroupId, ScriptId, ScriptOwner};
|
||||
|
||||
/// A module script as returned by `ModuleSource::lookup`. Carries only
|
||||
/// the fields the resolver needs: the id (for diagnostics), the source
|
||||
/// (to compile), and `updated_at` (the cache-staleness comparator).
|
||||
/// A module script as returned by `ModuleSource::resolve`. Carries the
|
||||
/// fields the resolver needs: the id (cache key + diagnostics), the
|
||||
/// resolved module's **owner** (so nested imports inside it resolve from
|
||||
/// *its* defining node), the source (to compile), and `updated_at` (the
|
||||
/// cache-staleness comparator).
|
||||
///
|
||||
/// Ownership is polymorphic (Phase 4): exactly one of `app_id` / `group_id`
|
||||
/// is set — mirroring [`crate::Script`] and the DB CHECK.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ModuleScript {
|
||||
pub script_id: ScriptId,
|
||||
pub app_id: AppId,
|
||||
/// Owning app, when app-owned. Mutually exclusive with `group_id`.
|
||||
#[serde(default)]
|
||||
pub app_id: Option<AppId>,
|
||||
/// Owning group, when group-owned (Phase 4b group modules).
|
||||
#[serde(default)]
|
||||
pub group_id: Option<GroupId>,
|
||||
pub name: String,
|
||||
pub source: String,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Lookup contract used by `PicloudModuleResolver`. `lookup` MUST
|
||||
/// scope by `cx.app_id`; cross-app reads must be unreachable.
|
||||
impl ModuleScript {
|
||||
/// The resolved module's defining node, reconstructed from the
|
||||
/// polymorphic columns. Used by the resolver to seal *this* module's
|
||||
/// own nested imports to its node (lexical chaining). Prefers the app
|
||||
/// if a corrupt row sets both, so the hot path never panics.
|
||||
#[must_use]
|
||||
pub fn owner(&self) -> Option<ScriptOwner> {
|
||||
match (self.app_id, self.group_id) {
|
||||
(Some(a), _) => Some(ScriptOwner::App(a)),
|
||||
(None, Some(g)) => Some(ScriptOwner::Group(g)),
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Lookup contract used by `PicloudModuleResolver`. `resolve` walks the
|
||||
/// module set up the chain rooted at `origin` (the importing script's
|
||||
/// defining node), nearest-owner-wins. The `origin` is trusted
|
||||
/// (dispatch-derived); the `name` is the literal between the import
|
||||
/// quotes. See the module docs for why this is lexical, not `cx`-scoped.
|
||||
#[async_trait]
|
||||
pub trait ModuleSource: Send + Sync {
|
||||
/// Resolve a module script by `(cx.app_id, name)`. Returns `None`
|
||||
/// when no row exists, or when a row exists but its `kind` is
|
||||
/// `'endpoint'` (endpoints are never importable). The resolver
|
||||
/// surfaces `None` as `ErrorModuleNotFound` to Rhai.
|
||||
async fn lookup(
|
||||
/// Resolve a `kind = 'module'` script named `name`, visible from the
|
||||
/// `origin` node walking up its group chain (nearest depth wins).
|
||||
/// Returns `None` when no module of that name exists anywhere on the
|
||||
/// chain (or a row exists but its `kind` is `'endpoint'` — endpoints
|
||||
/// are never importable). The resolver surfaces `None` as
|
||||
/// `ErrorModuleNotFound` to Rhai.
|
||||
async fn resolve(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
origin: ScriptOwner,
|
||||
name: &str,
|
||||
) -> Result<Option<ModuleScript>, ModuleSourceError>;
|
||||
}
|
||||
|
||||
/// Failure modes surfaced from `ModuleSource::lookup`. "Not found" is
|
||||
/// Failure modes surfaced from `ModuleSource::resolve`. "Not found" is
|
||||
/// not exceptional — it's `Ok(None)`.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ModuleSourceError {
|
||||
@@ -57,7 +94,7 @@ pub enum ModuleSourceError {
|
||||
}
|
||||
|
||||
/// Stub used by the executor-core test harness so engine integration
|
||||
/// tests don't need a real DB-backed source. Every lookup returns
|
||||
/// tests don't need a real DB-backed source. Every resolve returns
|
||||
/// `Ok(None)` — `import "x"` always errors as "module not found"
|
||||
/// under this impl.
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
@@ -65,9 +102,9 @@ pub struct NoopModuleSource;
|
||||
|
||||
#[async_trait]
|
||||
impl ModuleSource for NoopModuleSource {
|
||||
async fn lookup(
|
||||
async fn resolve(
|
||||
&self,
|
||||
_cx: &SdkCallCx,
|
||||
_origin: ScriptOwner,
|
||||
_name: &str,
|
||||
) -> Result<Option<ModuleScript>, ModuleSourceError> {
|
||||
Ok(None)
|
||||
|
||||
Reference in New Issue
Block a user