diff --git a/crates/manager-core/migrations/0066_projects.sql b/crates/manager-core/migrations/0066_projects.sql new file mode 100644 index 0000000..6d0775c --- /dev/null +++ b/crates/manager-core/migrations/0066_projects.sql @@ -0,0 +1,41 @@ +-- §7 multi-repo ownership: the `projects` registry + wiring the inert +-- `owner_project` seam. +-- +-- A "project" is a repo-root that declaratively MANAGES a slice of the shared +-- group tree (contains its manifest as something it applies, not merely +-- references). §7's model is SINGLE-OWNER-PER-NODE: each group node is owned by +-- exactly one project; the first `pic apply` that declares a `[project]` claims +-- the node, and a second repo applying to an owned node is refused unless it +-- passes a group-admin-gated `--takeover`. Ownership ⟂ RBAC — it records which +-- manifest is authoritative, not whether a principal may act. +-- +-- Identity is a committed `[project] slug` (stable across clones); the server +-- assigns the UUID (server-internal). The `owner_project UUID` column already +-- exists on `groups` (0047, inert) — this migration creates the table it was +-- always meant to reference and wires the FK. Apps carry NO owner column: an +-- app inherits ownership from its NEAREST claimed ancestor group (the ancestor +-- walk is the isolation boundary), faithful to §7's "don't co-own a node — +-- split config downward" corollary. + +CREATE TABLE projects ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + -- Instance-global identity, declared in the repo's committed root manifest + -- and frozen. First apply with a new slug registers the project. + slug TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + -- The admin who first registered it. SET NULL (not CASCADE) — losing the + -- creator's account must not orphan-delete the project or un-claim its tree. + created_by UUID REFERENCES admin_users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Wire the inert 0047 seam. ON DELETE SET NULL un-claims the owned nodes (they +-- fall back to UI/API-owned) rather than cascading a destructive tree delete — +-- deleting a project must never destroy the groups/apps it happened to manage. +ALTER TABLE groups + ADD CONSTRAINT groups_owner_project_fk + FOREIGN KEY (owner_project) REFERENCES projects(id) ON DELETE SET NULL; + +-- Ownership lookups: "what does project X own?" (pic projects ls) and the +-- LEFT JOIN behind pic groups ls' owner column. +CREATE INDEX groups_owner_project_idx ON groups (owner_project); diff --git a/crates/manager-core/src/group_repo.rs b/crates/manager-core/src/group_repo.rs index aaa1b4f..48baf70 100644 --- a/crates/manager-core/src/group_repo.rs +++ b/crates/manager-core/src/group_repo.rs @@ -105,8 +105,8 @@ impl PostgresGroupRepository { } } -const GROUP_COLS: &str = - "id, parent_id, slug, name, description, structure_version, created_at, updated_at"; +const GROUP_COLS: &str = "id, parent_id, slug, name, description, structure_version, created_at, \ + updated_at, owner_project"; #[async_trait] impl GroupRepository for PostgresGroupRepository { @@ -157,7 +157,8 @@ impl GroupRepository for PostgresGroupRepository { SELECT {GROUP_COLS}, 0 AS depth FROM groups WHERE id = $1 UNION ALL SELECT g.id, g.parent_id, g.slug, g.name, g.description, \ - g.structure_version, g.created_at, g.updated_at, c.depth + 1 \ + g.structure_version, g.created_at, g.updated_at, g.owner_project, \ + c.depth + 1 \ FROM groups g JOIN chain c ON g.id = c.parent_id \ WHERE c.depth < 64 ) @@ -349,6 +350,7 @@ struct GroupRow { structure_version: i64, created_at: chrono::DateTime, updated_at: chrono::DateTime, + owner_project: Option, } impl From for Group { @@ -360,6 +362,7 @@ impl From for Group { name: r.name, description: r.description, structure_version: r.structure_version, + owner_project: r.owner_project.map(Into::into), created_at: r.created_at, updated_at: r.updated_at, } diff --git a/crates/manager-core/tests/expected_schema.txt b/crates/manager-core/tests/expected_schema.txt index 4d721a4..322abfd 100644 --- a/crates/manager-core/tests/expected_schema.txt +++ b/crates/manager-core/tests/expected_schema.txt @@ -318,6 +318,13 @@ table: outbox claimed_by: text NULL created_at: timestamp with time zone NOT NULL default=now() +table: projects + id: uuid NOT NULL default=gen_random_uuid() + slug: text NOT NULL + name: text NOT NULL + created_by: uuid NULL + created_at: timestamp with time zone NOT NULL default=now() + table: pubsub_trigger_details trigger_id: uuid NOT NULL topic_pattern: text NOT NULL @@ -579,6 +586,7 @@ indexes on group_queue_messages: idx_group_queue_messages_group_collection: public.group_queue_messages USING btree (group_id, collection) indexes on groups: + groups_owner_project_idx: public.groups USING btree (owner_project) groups_parent_id_idx: public.groups USING btree (parent_id) groups_pkey: public.groups USING btree (id) groups_slug_key: public.groups USING btree (slug) @@ -595,6 +603,10 @@ indexes on outbox: idx_outbox_due: public.outbox USING btree (next_attempt_at) WHERE (claimed_at IS NULL) outbox_pkey: public.outbox USING btree (id) +indexes on projects: + projects_pkey: public.projects USING btree (id) + projects_slug_key: public.projects USING btree (slug) + indexes on pubsub_trigger_details: pubsub_trigger_details_pkey: public.pubsub_trigger_details USING btree (trigger_id) @@ -817,6 +829,7 @@ constraints on group_queue_messages: [PRIMARY KEY] group_queue_messages_pkey: PRIMARY KEY (id) constraints on groups: + [FOREIGN KEY] groups_owner_project_fk: FOREIGN KEY (owner_project) REFERENCES projects(id) ON DELETE SET NULL [FOREIGN KEY] groups_parent_id_fkey: FOREIGN KEY (parent_id) REFERENCES groups(id) ON DELETE RESTRICT [PRIMARY KEY] groups_pkey: PRIMARY KEY (id) [UNIQUE] groups_slug_key: UNIQUE (slug) @@ -834,6 +847,11 @@ constraints on outbox: [FOREIGN KEY] outbox_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE [PRIMARY KEY] outbox_pkey: PRIMARY KEY (id) +constraints on projects: + [FOREIGN KEY] projects_created_by_fkey: FOREIGN KEY (created_by) REFERENCES admin_users(id) ON DELETE SET NULL + [PRIMARY KEY] projects_pkey: PRIMARY KEY (id) + [UNIQUE] projects_slug_key: UNIQUE (slug) + constraints on pubsub_trigger_details: [FOREIGN KEY] pubsub_trigger_details_trigger_id_fkey: FOREIGN KEY (trigger_id) REFERENCES triggers(id) ON DELETE CASCADE [PRIMARY KEY] pubsub_trigger_details_pkey: PRIMARY KEY (trigger_id) @@ -973,3 +991,4 @@ constraints on vars: 0063: materialized unique 0064: shared topic queue kinds 0065: group queues + 0066: projects diff --git a/crates/picloud/tests/api.rs b/crates/picloud/tests/api.rs index b68f6ea..b0715be 100644 --- a/crates/picloud/tests/api.rs +++ b/crates/picloud/tests/api.rs @@ -891,12 +891,12 @@ async fn version_includes_public_base_url(pool: PgPool) { assert!(v["public_base_url"].is_string()); assert_eq!(v["api"], 1); // `schema` is migrations::latest_version() — the highest embedded - // migration number, currently 65 (…0064_shared_topic_queue_kinds, - // 0065_group_queues from the v1.2 D2/D3 deferrals). This test is + // migration number, currently 66 (…0065_group_queues, then + // 0066_projects wiring the §7 multi-repo ownership seam). This test is // #[ignore]-gated so it doesn't run in the default `cargo test`; pinned to // current reality so an unintended schema change is still caught. Bump it // whenever a migration lands. - assert_eq!(v["schema"], 65); + assert_eq!(v["schema"], 66); assert_eq!(v["sdk"], "1.10"); } diff --git a/crates/shared/src/group.rs b/crates/shared/src/group.rs index b5a1d4e..7822109 100644 --- a/crates/shared/src/group.rs +++ b/crates/shared/src/group.rs @@ -11,7 +11,7 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -use crate::GroupId; +use crate::{GroupId, ProjectId}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Group { @@ -26,6 +26,10 @@ pub struct Group { /// Per-subtree structure version, bumped on every structural mutation /// (reparent/rename/delete). Not an authz input. pub structure_version: i64, + /// §7 multi-repo ownership: the project that declaratively manages this + /// node, or `None` when the node is UI/API-owned (no manifest claims it). + /// Apps inherit ownership from their nearest claimed ancestor group. + pub owner_project: Option, pub created_at: DateTime, pub updated_at: DateTime, } diff --git a/crates/shared/src/ids.rs b/crates/shared/src/ids.rs index e61e494..72bd002 100644 --- a/crates/shared/src/ids.rs +++ b/crates/shared/src/ids.rs @@ -58,3 +58,4 @@ id_type!(AppUserId); id_type!(InvitationId); id_type!(QueueMessageId); id_type!(GroupId); +id_type!(ProjectId); diff --git a/crates/shared/src/lib.rs b/crates/shared/src/lib.rs index c75d6cf..7b15b73 100644 --- a/crates/shared/src/lib.rs +++ b/crates/shared/src/lib.rs @@ -29,6 +29,7 @@ pub mod kv; pub mod log_sink; pub mod modules; pub mod outbox_writer; +pub mod project; pub mod pubsub; pub mod queue; pub mod realtime; @@ -77,8 +78,8 @@ pub use group_pubsub::{GroupPubsubError, GroupPubsubService, NoopGroupPubsubServ pub use group_queue::{GroupQueueError, GroupQueueService, NoopGroupQueueService}; pub use http::{HttpError, HttpRequest, HttpResponse, HttpService, NoopHttpService}; pub use ids::{ - AdminUserId, ApiKeyId, AppId, AppUserId, ExecutionId, GroupId, InvitationId, QueueMessageId, - RequestId, ScriptId, TriggerId, + AdminUserId, ApiKeyId, AppId, AppUserId, ExecutionId, GroupId, InvitationId, ProjectId, + QueueMessageId, RequestId, ScriptId, TriggerId, }; pub use inbox::{ InboxDeliveryOutcome, InboxFailureKind, InboxResolver, InboxResult, NoopInboxResolver, @@ -90,6 +91,7 @@ pub use modules::{ ModuleResolution, ModuleScript, ModuleSource, ModuleSourceError, NoopModuleSource, }; pub use outbox_writer::{HttpDispatchPayload, NewHttpOutbox, OutboxWriter, OutboxWriterError}; +pub use project::Project; pub use pubsub::{ topic_matches, validate_topic_pattern, NoopPubsubService, PubsubError, PubsubService, }; diff --git a/crates/shared/src/project.rs b/crates/shared/src/project.rs new file mode 100644 index 0000000..0acedf0 --- /dev/null +++ b/crates/shared/src/project.rs @@ -0,0 +1,27 @@ +//! Projects: the §7 multi-repo ownership registry. +//! +//! A project is a repo-root that declaratively MANAGES a slice of the shared +//! group tree. Each group node is owned by exactly one project (recorded in +//! `groups.owner_project`); apps inherit ownership from their nearest claimed +//! ancestor group. Identity is a committed `[project] slug` — stable across +//! clones; the server assigns the UUID. +//! +//! See docs/design/groups-and-project-tool.md §7. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::{AdminUserId, ProjectId}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Project { + pub id: ProjectId, + /// Instance-global identity, declared in the repo's root manifest and + /// frozen at registration. + pub slug: String, + pub name: String, + /// The admin who first registered the project (`None` once their account + /// is deleted — the FK is `ON DELETE SET NULL`). + pub created_by: Option, + pub created_at: DateTime, +}