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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user