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

View File

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

View File

@@ -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<ProjectId>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}

View File

@@ -58,3 +58,4 @@ id_type!(AppUserId);
id_type!(InvitationId);
id_type!(QueueMessageId);
id_type!(GroupId);
id_type!(ProjectId);

View File

@@ -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,
};

View File

@@ -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<AdminUserId>,
pub created_at: DateTime<Utc>,
}