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:
@@ -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::{
|
||||
ExtPointResolution, ModuleScript, ModuleSource, ModuleSourceError, NoopModuleSource,
|
||||
};
|
||||
pub use outbox_writer::{HttpDispatchPayload, NewHttpOutbox, OutboxWriter, OutboxWriterError};
|
||||
pub use pubsub::{
|
||||
topic_matches, validate_topic_pattern, NoopPubsubService, PubsubError, PubsubService,
|
||||
|
||||
@@ -62,6 +62,17 @@ impl ModuleScript {
|
||||
}
|
||||
}
|
||||
|
||||
/// Outcome of an extension-point lookup (§5.5). Present when `name` is
|
||||
/// declared an extension point on `origin`'s chain *and* is not shadowed by
|
||||
/// a nearer concrete module of the same name — i.e. the nearest declaration
|
||||
/// of `name` is the extension point, so resolution inverts to the inheriting
|
||||
/// app. `default_script_id` is the optional fallback module body to use when
|
||||
/// the app provides no module of `name`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ExtPointResolution {
|
||||
pub default_script_id: Option<ScriptId>,
|
||||
}
|
||||
|
||||
/// 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 +91,32 @@ pub trait ModuleSource: Send + Sync {
|
||||
origin: ScriptOwner,
|
||||
name: &str,
|
||||
) -> Result<Option<ModuleScript>, ModuleSourceError>;
|
||||
|
||||
/// Extension-point check (§5.5). Returns `Some` iff the **nearest**
|
||||
/// declaration of `name` on `origin`'s chain is an extension point (not
|
||||
/// a concrete module). On a tie in depth a concrete module wins (so a
|
||||
/// nearer/peer real module shadows the extension-point declaration). The
|
||||
/// `origin` is the trusted importer node, so the inversion can only be
|
||||
/// opened by a declaration on the importer's own ancestry — a descendant
|
||||
/// can never make a parent's sealed import dynamic.
|
||||
async fn resolve_extension_point(
|
||||
&self,
|
||||
origin: ScriptOwner,
|
||||
name: &str,
|
||||
) -> Result<Option<ExtPointResolution>, ModuleSourceError>;
|
||||
|
||||
/// Load the extension-point fallback module `script_id`, but ONLY if it is
|
||||
/// a `kind = 'module'` script reachable on `origin`'s chain. The `origin`
|
||||
/// is the trusted importer node; constraining the lookup to its chain makes
|
||||
/// the resolver self-defending — a `default_script_id` that (through a bug
|
||||
/// or DB-direct write) pointed at another tenant's module resolves to
|
||||
/// `None` rather than executing cross-tenant code. Returns `None` if the id
|
||||
/// is absent, names an endpoint, or is off-chain.
|
||||
async fn resolve_by_id(
|
||||
&self,
|
||||
origin: ScriptOwner,
|
||||
script_id: ScriptId,
|
||||
) -> Result<Option<ModuleScript>, ModuleSourceError>;
|
||||
}
|
||||
|
||||
/// Failure modes surfaced from `ModuleSource::resolve`. "Not found" is
|
||||
@@ -109,4 +146,20 @@ impl ModuleSource for NoopModuleSource {
|
||||
) -> Result<Option<ModuleScript>, ModuleSourceError> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn resolve_extension_point(
|
||||
&self,
|
||||
_origin: ScriptOwner,
|
||||
_name: &str,
|
||||
) -> Result<Option<ExtPointResolution>, ModuleSourceError> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn resolve_by_id(
|
||||
&self,
|
||||
_origin: ScriptOwner,
|
||||
_script_id: ScriptId,
|
||||
) -> Result<Option<ModuleScript>, ModuleSourceError> {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user