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

@@ -359,9 +359,14 @@ impl Dispatcher {
// existing consumers. Dead-letter the message so the misfire is
// observable rather than executing one app's script under
// another app's SdkCallCx.
if script.app_id != claimed.app_id {
// Phase 4: a group-owned script (`app_id: None`) is not yet
// dispatchable here — the chain-membership check that authorizes
// inherited scripts lands with group-script binding. Until then
// `is_owned_by_app` is the exact app-owned guard as before and a
// group script fails closed (dead-lettered, not silently run).
if !script.is_owned_by_app(claimed.app_id) {
tracing::error!(
script_app = %script.app_id,
script_app = ?script.app_id,
claim_app = %claimed.app_id,
script_id = %consumer.script_id,
"queue consumer script belongs to a different app; dead-lettering"
@@ -786,7 +791,9 @@ impl Dispatcher {
// boundary. Not reachable via the trigger-create or `apply` paths
// (both resolve the script within the app's own scope), so this is
// the runtime backstop the other arms already carry.
if script.app_id != row.app_id {
// Phase 4: group scripts (`app_id: None`) fail closed here until the
// chain-membership check lands with group-script binding.
if !script.is_owned_by_app(row.app_id) {
return Err(DispatcherError::ResolveTrigger(
"trigger outbox target belongs to a different app".into(),
));
@@ -876,7 +883,8 @@ impl Dispatcher {
// build_invoke_request and dispatch_one_queue. A hand-edited
// outbox row could otherwise execute one app's script under
// another app's SdkCallCx.
if script.app_id != row.app_id {
// Phase 4: group scripts fail closed here (see queue/trigger arms).
if !script.is_owned_by_app(row.app_id) {
return Err(DispatcherError::ResolveTrigger(
"http outbox target belongs to a different app".into(),
));
@@ -969,7 +977,8 @@ impl Dispatcher {
})?;
// Same-app guard — the script could have been re-assigned or
// the row could have been hand-edited. Reject mismatches.
if script.app_id != row.app_id {
// Phase 4: group scripts fail closed here (see queue/trigger arms).
if !script.is_owned_by_app(row.app_id) {
return Err(DispatcherError::ResolveTrigger(
"invoke target belongs to a different app".into(),
));
@@ -2097,7 +2106,8 @@ mod tests {
pub(super) fn disabled_script(app_id: AppId) -> Script {
Script {
id: ScriptId::new(),
app_id,
app_id: Some(app_id),
group_id: None,
name: "worker".into(),
description: None,
version: 1,