feat(modules): extension-point-aware resolver policy (§5.5 C2)

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>
This commit is contained in:
MechaCat02
2026-06-29 20:07:59 +02:00
parent 8b68a7d7e8
commit 8f966783fe
5 changed files with 299 additions and 16 deletions

View File

@@ -76,7 +76,9 @@ pub use inbox::{
pub use invoke::{InvokeError, InvokeService, InvokeTarget, NoopInvokeService, ResolvedScript};
pub use kv::{KvError, KvListPage, KvService, NoopKvService};
pub use log_sink::{ExecutionLogSink, LogSinkError};
pub use modules::{ModuleScript, ModuleSource, ModuleSourceError, NoopModuleSource};
pub use modules::{
ModuleResolution, ModuleScript, ModuleSource, ModuleSourceError, NoopModuleSource,
};
pub use outbox_writer::{HttpDispatchPayload, NewHttpOutbox, OutboxWriter, OutboxWriterError};
pub use pubsub::{
topic_matches, validate_topic_pattern, NoopPubsubService, PubsubError, PubsubService,

View File

@@ -62,6 +62,19 @@ impl ModuleScript {
}
}
/// Outcome of [`ModuleSource::resolve_policy`] — the §5.5 lexical-vs-dynamic
/// decision the resolver acts on.
#[derive(Debug)]
pub enum ModuleResolution {
/// Bind this module (lexical, or an extension point's resolved provider).
Module(ModuleScript),
/// The name is an extension point, but no provider exists for the
/// inheriting app (no app override and no default body) — a hard error.
NoProvider,
/// No declaration of the name anywhere on the chain → module-not-found.
NotFound,
}
/// 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
@@ -80,6 +93,28 @@ pub trait ModuleSource: Send + Sync {
origin: ScriptOwner,
name: &str,
) -> Result<Option<ModuleScript>, ModuleSourceError>;
/// §5.5 extension-point-aware resolution. Decides lexical vs dynamic by
/// the **nearest declaration** of `name` walking up `origin`'s chain: a
/// concrete module → lexical (`resolve(origin, …)`); an extension-point
/// marker → dynamic, resolved against the **inheriting app**
/// (`resolve(App(inheriting_app), …)`) so the app can override, falling
/// back to the default body up-chain; the EP wins on a depth tie.
///
/// The default impl is lexical-only (no extension points) — the Postgres
/// source overrides it. Test fakes that don't exercise EPs inherit this.
async fn resolve_policy(
&self,
origin: ScriptOwner,
inheriting_app: AppId,
name: &str,
) -> Result<ModuleResolution, ModuleSourceError> {
let _ = inheriting_app;
Ok(match self.resolve(origin, name).await? {
Some(m) => ModuleResolution::Module(m),
None => ModuleResolution::NotFound,
})
}
}
/// Failure modes surfaced from `ModuleSource::resolve`. "Not found" is