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:
MechaCat02
2026-06-25 19:53:05 +02:00
parent 27dc04819f
commit 48178c5f60
13 changed files with 298 additions and 51 deletions

View File

@@ -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)
}
}