feat(projects): projects table + owner_project FK; ProjectId/Project types

First commit of §7 multi-repo ownership. 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 (single-owner-per-node). This lands the registry
+ shared types and wires the inert 0047 `owner_project` seam — no behavior
change yet (claim logic is the next commit).

- migration 0066: `projects` table (UUID pk, unique slug, name, created_by →
  admin_users ON DELETE SET NULL); `groups.owner_project` gains its FK →
  projects(id) ON DELETE SET NULL (un-claim, never cascade-destroy a tree) +
  an index.
- shared: `ProjectId` (id_type! macro) + `Project` struct; `Group` gains
  `owner_project: Option<ProjectId>`.
- group_repo: GROUP_COLS + the ancestors recursive-CTE term now carry
  `owner_project`, so `ancestors()` surfaces ownership for the nearest-claimed
  fold; GroupRow + From updated.
- schema snapshot re-blessed; /version schema assertion 65 → 66.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-06 20:01:57 +02:00
parent f1730a1ba0
commit ba97c35aaf
8 changed files with 106 additions and 9 deletions

View File

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

View File

@@ -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<chrono::Utc>,
updated_at: chrono::DateTime<chrono::Utc>,
owner_project: Option<Uuid>,
}
impl From<GroupRow> for Group {
@@ -360,6 +362,7 @@ impl From<GroupRow> 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,
}

View File

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