Project scoping (v5.3)
Developer reference for the v5.3 “project scoping” milestone. This document is the authoritative description of the data model, authorization axes, MCP surface, inheritance rules, sealed-room semantics, archive lifecycle, the one-shot migration, and the UI patterns that surface the model.
v5.3 retraction (PROJ-23, drift-gate G8): Project is never an
ownership tier. The Phase 202 framing of a “ratchet promotion (irreversible)”
between user → team → organization → workspace for projects is
retracted. Project access is N:M via project_access (see
MCP surface below). The four-tier ownership ladder still
applies to every other resource (objects, agent_runs, chat_threads, agent
templates, skills) — but a project is a bounded execution and context space
that lives at one of those four levels and gains N:M access rows on
top, not a fifth tier.
Data model
Section titled “Data model”D6 — three physical project_id columns
Section titled “D6 — three physical project_id columns”Schema contract SC-1: artifacts are objects rows with an artifact type;
there is no physical artifacts table (v5.1 Phase 347.1 dropped
artifact_versions). The “four D6 tables” in the milestone spec collapse
to three project_id columns:
| Spec D6 row | Physical reality | project_id column? |
|---|---|---|
artifacts | cinatra.objects rows where type ∈ artifactObjectTypeIds() | covered by objects.project_id |
objects | cinatra.objects (raw-SQL store) | text NULL (Phase 384) |
agent_runs | cinatra.agent_runs (raw-SQL store) | text NULL (Phase 384) |
chat_threads | cinatra.chat_threads (id, payload, project_id, created_at, updated_at) | text NULL typed column (SC-2) |
chat_threads gained two typed timestamp columns (created_at,
updated_at) alongside project_id. The writer mirrors project_id /
created_at / updated_at from payload → columns on every write
(lockstep — Phase 387/388). Sealed-room chat listing reads the columns,
never payload JSON.
artifact_blobs, artifact_refs, artifact_audit, artifact_provider_cache
do not get project_id. These are immutable physical/provenance rows;
membership lives on the logical objects row, mirroring v5.1 doctrine.
Four new tables (Phase 384)
Section titled “Four new tables (Phase 384)”-- N:M access. Owner is implicit and NEVER a row here (SC-4).CREATE TABLE cinatra.project_access ( project_id text NOT NULL REFERENCES cinatra.projects(id) ON DELETE CASCADE, principal_level text NOT NULL CHECK (principal_level IN ('user','team','organization','workspace')), principal_id text NOT NULL, role text NOT NULL CHECK (role IN ('read','write','admin')), granted_by text NOT NULL, granted_at timestamptz NOT NULL DEFAULT now(), -- Generated columns + same-org trigger materialize the polymorphic FK. principal_user_id text GENERATED ALWAYS AS (CASE WHEN principal_level='user' THEN principal_id END) STORED, principal_team_id text GENERATED ALWAYS AS (CASE WHEN principal_level='team' THEN principal_id END) STORED, principal_org_id text GENERATED ALWAYS AS (CASE WHEN principal_level='organization' THEN principal_id END) STORED, CONSTRAINT project_access_workspace_principal_chk CHECK ( (principal_level = 'workspace' AND principal_id = '__workspace__') OR (principal_level <> 'workspace' AND principal_id <> '__workspace__') ), PRIMARY KEY (project_id, principal_level, principal_id));
-- Per-project agent template binding — pins ambient agent templates into-- a project with a visibility filter + optional pinned_version + per-project-- default context overrides. The agent_templates table itself stays ambient-- (D6 / D7.a — substrate tables never gain project_id; drift-gate G3).CREATE TABLE cinatra.project_agent_template_bindings ( project_id text NOT NULL REFERENCES cinatra.projects(id) ON DELETE CASCADE, agent_template_id text NOT NULL, visibility text NOT NULL CHECK (visibility IN ('visible','hidden','project-private')) DEFAULT 'visible', pinned_version text, default_context_overrides jsonb CHECK (jsonb_typeof(default_context_overrides) = 'object'), created_by text NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY (project_id, agent_template_id));
-- D2 move audit + provenance: every cross-project move records the-- (resource_kind, resource_id, from_project_id, to_project_id, moved_by).CREATE TABLE cinatra.resource_project_moves (...);
-- Per-resource refs: where else this project_id is "stamped" (e.g.-- chat_thread → agent_run lineage during the v5.1 dual-lineage path).CREATE TABLE cinatra.project_resource_refs (...);__workspace__ is the reserved workspace sentinel; the CHECK constraint
makes the canonicalization explicit so a project can’t accumulate multiple
semantically-identical workspace grants. Owner is never a
project_access row — handlers synthesize the owner row at read time
(project_access_list) and reject self-insert at write time
(project_access_grant, SC-4).
Indexing (SC-10)
Section titled “Indexing (SC-10)”Per project_id-bearing table:
(owner_level, owner_id, project_id, created_at DESC)where the table has owner cols (objectsonly).- Partial
(project_id, created_at DESC) WHERE project_id IS NOT NULL— the project-listing path. - Table-specific:
agent_runs: also(project_id, status, created_at DESC) WHERE project_id IS NOT NULL.chat_threads: no owner cols → just the partial project index + PK.
project_access: partial indexes onprincipal_user_id,principal_team_id,principal_org_id, plusproject_access_workspace_idx (project_id) WHERE principal_level='workspace' AND principal_id='__workspace__'.
Authorization extension (Phase 385)
Section titled “Authorization extension (Phase 385)”ActorContext.projectGrants (canonical v5.3 axis):
export type ProjectRole = "read" | "write" | "admin" | "owner";export type ProjectAccessSource = | "owner" | "user" | "team" | "organization" | "workspace";export type ProjectGrant = { projectId: string; effectiveRole: ProjectRole; accessSource: ProjectAccessSource;};readProjectGrantsForUser(userId, actorOrgId, hints) resolves the union
owned ∪ accessed:
- Source 1 — implicit owned: scans
cinatra.projectsfor rows whose(owner_level, owner_id)matches the actor’s membership chain. Returns role by authority (user-owned →owner; team-owned →read|write|adminby team role; org-owned →read|write|adminby org role). Multi-org safe — self-anchors via the owner clauses. - Source 2 — explicit project_access rows: SQL union over the four
partial generated-column indexes. Active-org anchored (
actorOrgId) so a stale session token can’t surface grants from a former org. - Source 3 — back-compat co-owners (
project_co_owners.user_id): every row maps to{effectiveRole: "admin", accessSource: "user"}. Active-org anchored.
mergeProjectGrants(...) collapses duplicates via max-not-last-wins:
the merged role is the highest authority across all matching rows for a
given projectId — so an implicit team:read grant cannot silently
demote an explicit project_access admin grant.
Stale-membership guard (BLOCKER-2): Sources 2+3 only fire when
actorOrgId appears in the actor’s current accessible org list. If a
session carries a stale activeOrganizationId, the resolver does NOT
even issue the project_access / co_owners queries (fail-closed,
no wasted round-trip). Source 1 is unaffected because it self-anchors.
Dual lineage (MCP + session)
Section titled “Dual lineage (MCP + session)”The MCP registry (packages/projects/src/mcp/registry.ts) and the
human-session lineage (src/lib/auth-session.ts) both call
readProjectGrantsForUser and stamp the result onto the request actor.
Handlers consult actor.projectGrants for authz; the MCP transport
context flows through mcpRequestContextStorage so platform_admin /
orgId / userId are available even mid-stream.
assertProjectGrantRole(actor, projectId, required) is the canonical
check inside every handler that mutates project state; rank order is
read < write < admin < owner. Platform admin bypasses.
MCP surface (Phase 386)
Section titled “MCP surface (Phase 386)”| Primitive | Role gate | Purpose |
|---|---|---|
projects_list | derived from grants | owned ∪ accessed, with effectiveRole/accessSource per row; default archived=false |
projects_get / _create / _update | kernel | unchanged Phase 82 shape |
projects_archive / _unarchive | admin | idempotent (already-archived / already-active returns flag); Phase 390 |
project_access_grant | admin | idempotent (ON CONFLICT update role); admin-role grants are owner-only (SC-7.a); owner self-insert rejected (SC-4) |
project_access_revoke | admin | idempotent |
project_access_list | read | returns the synthesised owner row + every stored row |
project_access_check | read | resolves principal → effectiveRole + accessSource |
project_agent_template_bindings_create | write | pins an ambient agent template into the project with visibility / pinned_version / default_context_overrides |
project_agent_template_bindings_update | write | sparse update; 404 on zero-rows-affected |
project_agent_template_bindings_delete | write | unbind; the template itself stays ambient |
project_agent_template_bindings_list | read | enumerate bindings for the project |
agent_run_move_with_outputs | admin both ends | atomic move of an agent run + its emitted artifacts to a different project (D2); recorded in resource_project_moves |
projects_delete is NOT on the surface (SC-5 retired it; archive is
the lifecycle). Drift-gate G5 enforces the absence.
Inheritance rules (D1 + substrate exclusion list)
Section titled “Inheritance rules (D1 + substrate exclusion list)”Two layers govern project_id propagation:
-
Runtime D1 (Phase 387): when an agent run is started inside project P, every output object (artifact, derived row) inherits
project_id = Pvia aProjectContextpropagation boundary the write path enforces. Chat threads pin theirproject_idat create time and never re-derive. -
Substrate exclusion list (drift-gate G3): the following tables are substrate and never gain a
project_idcolumn:agent_templates, skills (cinatra.skills), extensions (cinatra.extensions_installed), contacts, accounts,model_pricing. These are catalog/registry tables that span the tenant — binding them into a project is what theproject_agent_template_bindingstable is for.
Sealed-room semantics (D3)
Section titled “Sealed-room semantics (D3)”When a caller supplies projectId to a sealed-room-aware primitive
(objects_list, agent_run_list, chat_thread_list, the artifact
listing path), four invariants hold (drift-gate G6 + G5):
- The SQL filter
AND project_id = $projectIdis appended in the data-layer function (listObjectsByFilter, etc.) — handlers cannot bypass. - A 404-hide read gate runs first: if
actor.projectGrantshas no read+ entry for the project, the call throws withreason: "hidden"before any rows are scanned. - Cross-project Graphiti candidate sets are re-filtered (ADV-6): the ID set may include rows from P+Q+ambient, but the SQL filter intersects to P-only before authz.
- List handlers never import the v5.1 context resolver. The v5.1
context resolver (
buildContextForRunetc.) is a sealed-room-leaking surface; sealed-room semantics live behind the data-layer filter, not the resolver. Drift-gate G6 forbids the import.
Archive lifecycle (D4 / Phase 390)
Section titled “Archive lifecycle (D4 / Phase 390)”projects.archived_at timestamptz NULL is the lifecycle marker:
projects_archive(projectId)setsarchived_at = now(); idempotent.projects_unarchive(projectId)setsarchived_at = NULL; idempotent.assertProjectWritable(actor, projectId, "write" | "admin")rejects every D1 inheritance write, D2 move-INTO target, and binding mutation whenarchived_at IS NOT NULL. Archived projects remain readable for grant-holders, and a resource can still be moved out of an archived project (the archive is a write-freeze, not a delete).projects_deletedoes not exist on the MCP surface and never will in v5.3 (drift-gate G5 + G7).
Migration (Phase 391)
Section titled “Migration (Phase 391)”scripts/v53/migrate-project-scope.mjs is the one-shot legacy-data
backfill (DDL itself lives in buildCreateStoreSchemaQueries, Phase 384
— project_id is added there because columns/tables must materialize on
every fresh schema). The script:
- Discovers three sources of legacy
project_idprovenance:cinatra.agent_runs.input_params.projectId(older free-form runs)cinatra.chat_threads.payload.projectId(chat-thread metadata)cinatra.objects.data.projectIdfor object types that historically carried project metadata in the JSON body
--dry-run(default): emits a deterministic report under.planning/milestones/v5.3-migration-report.mdshowing counts + ambiguity classes per spec §4.--apply: idempotent backfill (re-runs against a partially-migrated DB are no-ops). The blog “project” concept (src/app/api/assets/asset-blog/projects/) is report-only and never written to — it’s a pre-existing unrelated domain.
buildCreateStoreSchemaQueries() carries the column DDL because every
fresh schema (worktree clone, CI fixture, prod boot) must materialize it;
the standalone script handles only the data backfill so it can stay out of
the boot-time migration array (where it would re-run every dev-server boot).
UI patterns
Section titled “UI patterns”Routes:
/projects— index. Renders owned ∪ accessed witheffectiveRole+accessSourcebadges per row +?archived=1toggle./projects/[projectId]— detail. Metadata card + sealed-room counts card (objects / agent_runs / chat_threads) + Archived banner whenarchived_at IS NOT NULL./projects/[projectId]/permissions—project_accessmanagement. Principal-level picker (user/team/organization/workspace) + role picker (read/write/admin); revoke per row; owner row read-only./projects/[projectId]/agents—project_agent_template_bindingsmanagement: bind / unbind agent templates, visibility filter (visible/hidden/project-private), optional pinned_version, optional default_context_overrides.
UI rules that must hold:
- Every interactive element uses shadcn/ui components from
src/components/ui/. Semantic tokens only — never raw Tailwind palette classes. ScopeBadgeis the canonical ownership / principal level indicator; inline palette classes are forbidden outsidescope-badge.tsxitself.- Top-level page chrome uses
<Card>from@/components/ui/card; inner sub-sections use.soft-panel. - The principal picker on
/projects/[projectId]/permissionswrites__workspace__automatically for the workspace level — the user does not type the sentinel.
Cross-references
Section titled “Cross-references”.planning/milestones/v5.3-SCHEMA-CONTRACT.md— the binding addendum..planning/milestones/v5.3-MILESTONE.md— milestone-level prose.scripts/v53/drift-gate.mjs— G1-G8 invariants.packages/projects/AGENTS.md— package-scoped reference (now points here).CLAUDE.md§ Scope Model — the four-tier ownership ladder + the v5.3 retraction of project-as-ratchet-step.