feat(scripts): polymorphic script owner (app XOR group) — Phase 4 foundation
Phase 4-lite C1. Make script ownership polymorphic so a script can be owned
by a GROUP (a template inherited by descendant apps) instead of an app —
mirroring vars/secrets (0048/0049), but ON DELETE RESTRICT (code is not data).
Schema (0050): `scripts.group_id` (nullable FK→groups RESTRICT), `app_id` made
nullable, `scripts_owner_exactly_one` CHECK, and the per-app name index split
into two per-owner partial-unique indexes. Existing app-owned rows keep their
exact `(app_id, lower(name))` uniqueness.
Type: `Script.app_id` becomes `Option<AppId>`; add `Script.group_id` +
`ScriptOwner` / `is_owned_by_app()`. `NewScript` gains the same polymorphic
owner. The execution-context app (what a script runs *under*) is supplied by
the invoking route/trigger/caller, never read off the script — group scripts
have no single app.
Behavior is fully preserved for app-owned scripts (the only kind creatable
today): every isolation backstop and authz site now uses `is_owned_by_app`,
which is byte-identical for app owners and **fails closed** for group owners.
A group script therefore can't yet be run, route/trigger-bound, invoked, or
managed via the app-script API — those land in C2 (group-script creation) and
C3 (chain-membership resolution + binding). The `/execute/{id}` bypass 404s a
group script (no app context to run under).
Re-blesses expected_schema.txt; note the golden was last blessed at migration
0044, so this also captures the already-committed 0045–0049 schema.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{AppId, ScriptId, ScriptSandbox};
|
||||
use crate::{AppId, GroupId, ScriptId, ScriptSandbox};
|
||||
|
||||
/// Semantic role of a script (v1.1.3).
|
||||
///
|
||||
@@ -94,10 +94,26 @@ mod kind_tests {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Script {
|
||||
pub id: ScriptId,
|
||||
/// Owning app. Set on create, immutable thereafter — a "move to
|
||||
/// another app" is a copy+delete, not an in-place edit (snapshot
|
||||
/// semantics — see blueprint §11.5).
|
||||
pub app_id: AppId,
|
||||
/// Owning app, when app-owned. Set on create, immutable thereafter — a
|
||||
/// "move to another app" is a copy+delete, not an in-place edit
|
||||
/// (snapshot semantics — see blueprint §11.5).
|
||||
///
|
||||
/// Phase 4 (v1.2 Hierarchies) made script ownership **polymorphic**:
|
||||
/// exactly one of `app_id` / `group_id` is set (DB CHECK + [`ScriptOwner`]).
|
||||
/// A group-owned script (`app_id: None`, `group_id: Some`) is a template
|
||||
/// inherited by every descendant app — it has no single app, so the
|
||||
/// **execution context** app is supplied by the route/trigger/caller that
|
||||
/// invoked it, never read off the script. Reading `app_id` to mean "the
|
||||
/// app this runs under" is therefore a bug for group scripts; use the
|
||||
/// invoking surface's app_id instead.
|
||||
#[serde(default)]
|
||||
pub app_id: Option<AppId>,
|
||||
/// Owning group, when group-owned (Phase 4). Mutually exclusive with
|
||||
/// `app_id`. The script is resolved by name, nearest-owner-wins, down the
|
||||
/// `apps.group_id → groups.parent_id` chain (CoW: an app's own script of
|
||||
/// the same name shadows the inherited one).
|
||||
#[serde(default)]
|
||||
pub group_id: Option<GroupId>,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub version: i32,
|
||||
@@ -131,3 +147,36 @@ pub struct Script {
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Who owns a script (Phase 4). Exactly one owner — the DB enforces it with
|
||||
/// a `CHECK ((group_id IS NULL) <> (app_id IS NULL))`; this enum is the
|
||||
/// in-memory witness of that invariant so call sites can `match` exhaustively
|
||||
/// instead of juggling two `Option`s.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ScriptOwner {
|
||||
App(AppId),
|
||||
Group(GroupId),
|
||||
}
|
||||
|
||||
impl Script {
|
||||
/// The script's owner, reconstructed from the polymorphic columns.
|
||||
/// A row that violates the exactly-one invariant (both/neither set —
|
||||
/// impossible under the CHECK) is reported as whichever is present,
|
||||
/// preferring the app, so a corrupt row never panics on the hot path.
|
||||
#[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,
|
||||
}
|
||||
}
|
||||
|
||||
/// True iff this script is owned by `app` directly (not via a group).
|
||||
/// The cheap, app-owned-only ownership check — group inheritance is
|
||||
/// resolved separately (chain-membership), never by this method.
|
||||
#[must_use]
|
||||
pub fn is_owned_by_app(&self, app: AppId) -> bool {
|
||||
self.app_id == Some(app)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user