Skip to content

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.

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 rowPhysical realityproject_id column?
artifactscinatra.objects rows where type ∈ artifactObjectTypeIds()covered by objects.project_id
objectscinatra.objects (raw-SQL store)text NULL (Phase 384)
agent_runscinatra.agent_runs (raw-SQL store)text NULL (Phase 384)
chat_threadscinatra.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.

-- 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).

Per project_id-bearing table:

  • (owner_level, owner_id, project_id, created_at DESC) where the table has owner cols (objects only).
  • 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 on principal_user_id, principal_team_id, principal_org_id, plus project_access_workspace_idx (project_id) WHERE principal_level='workspace' AND principal_id='__workspace__'.

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.projects for rows whose (owner_level, owner_id) matches the actor’s membership chain. Returns role by authority (user-owned → owner; team-owned → read|write|admin by team role; org-owned → read|write|admin by 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.

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.

PrimitiveRole gatePurpose
projects_listderived from grantsowned ∪ accessed, with effectiveRole/accessSource per row; default archived=false
projects_get / _create / _updatekernelunchanged Phase 82 shape
projects_archive / _unarchiveadminidempotent (already-archived / already-active returns flag); Phase 390
project_access_grantadminidempotent (ON CONFLICT update role); admin-role grants are owner-only (SC-7.a); owner self-insert rejected (SC-4)
project_access_revokeadminidempotent
project_access_listreadreturns the synthesised owner row + every stored row
project_access_checkreadresolves principal → effectiveRole + accessSource
project_agent_template_bindings_createwritepins an ambient agent template into the project with visibility / pinned_version / default_context_overrides
project_agent_template_bindings_updatewritesparse update; 404 on zero-rows-affected
project_agent_template_bindings_deletewriteunbind; the template itself stays ambient
project_agent_template_bindings_listreadenumerate bindings for the project
agent_run_move_with_outputsadmin both endsatomic 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:

  1. Runtime D1 (Phase 387): when an agent run is started inside project P, every output object (artifact, derived row) inherits project_id = P via a ProjectContext propagation boundary the write path enforces. Chat threads pin their project_id at create time and never re-derive.

  2. Substrate exclusion list (drift-gate G3): the following tables are substrate and never gain a project_id column: 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 the project_agent_template_bindings table is for.

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 = $projectId is appended in the data-layer function (listObjectsByFilter, etc.) — handlers cannot bypass.
  • A 404-hide read gate runs first: if actor.projectGrants has no read+ entry for the project, the call throws with reason: "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 (buildContextForRun etc.) is a sealed-room-leaking surface; sealed-room semantics live behind the data-layer filter, not the resolver. Drift-gate G6 forbids the import.

projects.archived_at timestamptz NULL is the lifecycle marker:

  • projects_archive(projectId) sets archived_at = now(); idempotent.
  • projects_unarchive(projectId) sets archived_at = NULL; idempotent.
  • assertProjectWritable(actor, projectId, "write" | "admin") rejects every D1 inheritance write, D2 move-INTO target, and binding mutation when archived_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_delete does not exist on the MCP surface and never will in v5.3 (drift-gate G5 + G7).

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_id provenance:
    • cinatra.agent_runs.input_params.projectId (older free-form runs)
    • cinatra.chat_threads.payload.projectId (chat-thread metadata)
    • cinatra.objects.data.projectId for 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.md showing 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).

Routes:

  • /projects — index. Renders owned ∪ accessed with effectiveRole + accessSource badges per row + ?archived=1 toggle.
  • /projects/[projectId] — detail. Metadata card + sealed-room counts card (objects / agent_runs / chat_threads) + Archived banner when archived_at IS NOT NULL.
  • /projects/[projectId]/permissionsproject_access management. Principal-level picker (user/team/organization/workspace) + role picker (read/write/admin); revoke per row; owner row read-only.
  • /projects/[projectId]/agentsproject_agent_template_bindings management: 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.
  • ScopeBadge is the canonical ownership / principal level indicator; inline palette classes are forbidden outside scope-badge.tsx itself.
  • Top-level page chrome uses <Card> from @/components/ui/card; inner sub-sections use .soft-panel.
  • The principal picker on /projects/[projectId]/permissions writes __workspace__ automatically for the workspace level — the user does not type the sentinel.
  • .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.