Skip to content

Projects & Teams — Concept

Status: Concept / design draft. Not yet a feature spec. Date: 2026-04-28 Origin: Brainstorm on enterprise visibility — letting team managers see who is using what, how agents behave, and how the bill is composed inside their scope, without giving them org-wide reach.


TapPass today has a single grouping primitive: Org. Everything below an org — agents, pipelines, BYOK keys, sessions, audit events, mandates — is a flat namespace under org_id. That works for a small company. It breaks for the enterprise buyer who wants:

  • visibility scoped to a business unit, not the whole org
  • cost lines split by project for showback / chargeback
  • policy that's stricter for one team than another
  • delegated administration without granting blanket org-admin

The proposal is to introduce two layers below the org, with one level of nesting:

Org → Teams → Projects → Resources. A Team groups people. A Project groups resources (agents, pipelines, BYOK keys). Each Team owns 0+ Projects. Each Project belongs to exactly one Team. Resources belong to exactly one Project. Sessions, audit events, and mandates inherit project from the agent or pipeline that produced them.

This is not a new product surface. It is a structural change to the existing namespace that unlocks enterprise sales motions (visibility, cost attribution, scoped admin) without rebuilding the core.


2. What prompted this — the ten questions

Section titled “2. What prompted this — the ten questions”

The shape was driven by ten polishing questions. Decisions:

#QuestionDecision
Q1One concept or two?Both. Teams = people, Projects = resources. Maximum flexibility for org-chart variation.
Q2Hierarchy depthOne level. Org → Team → Project. No team-of-teams or sub-projects.
Q3What gets project-scopedAgents, Pipelines, BYOK LLM keys. Sessions / audit / mandates inherit from the agent or pipeline. Vault credentials and OPA policies stay org-shared (with project policy overrides — see §6).
Q4Resource exclusivity1:1. An agent belongs to exactly one project. No multi-project agents in v1.
Q5Membership modelHybrid. Org roles unchanged. Team membership (team_manager, team_member) auto-derives project membership. Direct project membership is the escape hatch for outliers.
Q6Manager visibilityOrg-admin sees all, always. Team / project admins are scoped. Project members see their project's resources. Org operator / viewer / employee need project membership to see project-scoped data.
Q7Billing modelShowback only in v1. Filter on existing screens — no separate insights dashboard, no spend caps, no chargeback exports.
Q8Project-level policyYes, tightening only. Org policy is the floor. Projects can be stricter, never looser. Agent policies inherit the merged baseline.
Q9SSO / SCIM auto-mappingOut of scope for v1. Membership is managed in TapPass UI. IdP group → team mapping deferred to v2.
Q10Migration pathZero-touch. Migration creates a "Default" team and "Default" project per org, assigns every existing resource to it, auto-adds existing members. Day 1 visibility unchanged.

Five new tables. Six existing tables get a project_id column.

-- 045_teams_projects.sql
CREATE TABLE teams (
team_id TEXT PRIMARY KEY,
org_id TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by TEXT NOT NULL
);
CREATE TABLE projects (
project_id TEXT PRIMARY KEY,
org_id TEXT NOT NULL,
team_id TEXT NOT NULL REFERENCES teams(team_id) ON DELETE RESTRICT,
name TEXT NOT NULL,
description TEXT DEFAULT '',
policy_overrides JSONB DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by TEXT NOT NULL
);
CREATE TABLE team_memberships (
team_id TEXT NOT NULL REFERENCES teams(team_id) ON DELETE CASCADE,
email TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('team_manager', 'team_member')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (team_id, email)
);
CREATE TABLE project_memberships (
project_id TEXT NOT NULL REFERENCES projects(project_id) ON DELETE CASCADE,
email TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('project_admin', 'project_member', 'project_viewer')),
source TEXT NOT NULL CHECK (source IN ('direct', 'team')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (project_id, email, source)
);

source distinguishes team-derived rows from direct grants. When a user leaves a team, only source='team' rows cascade-delete; source='direct' survives. Effective role on a project = highest-privilege row across all sources.

Identifiers use the existing TapPass pattern: short readable strings (team_<random>, proj_<random>), not UUIDs. No slug column — opaque IDs go in URLs and CLI; name is display only. Slugs can be added in a later migration if pretty URLs become a real ask.

TableColumnNullableWhy
agentsproject_idNOResource of record
pipelinesproject_idNOResource of record
vault_llm_keysproject_idYESNULL = org-default key; non-NULL = project override
audit_eventsproject_idNO (denorm)Hot-path filter — avoid join through agent on dashboard reads
sessionsproject_idNO (denorm)Same
mandatesproject_idNO (denorm)Same

Indexes: (org_id, project_id) on every table that gains the column, plus (team_id) on projects, (email) on both membership tables. The hot-path dashboard query is "give me everything in project X", which is index-only.

Source of truth: agents.project_id and pipelines.project_id. Denormalized columns on event tables are written at insert time and never updated retroactively. If an agent moves project (rare admin op), historical events stay tagged to the old project. Preserves audit immutability and reproducible cost reports across time windows.

vault_llm_keys was keyed by (org_id, provider). Becomes (org_id, project_id_or_null, provider). Lookup is two-tier: project key first, fall back to org key (project_id IS NULL). Lets you keep one OpenAI org-default and override with EU-only key for a Legal project. The AAD extends from f"llm:{org_id}:{provider}" to f"llm:{org_id}:{project_id or '_org'}:{provider}".

In one transaction per org:

  1. Insert teams row: team_id=team_default_<org_id>, name=Default.
  2. Insert projects row: project_id=proj_default_<org_id>, team_id=<above>, name=Default.
  3. Insert team_memberships: every existing org member becomes team_member; the org's super_admin becomes team_manager.
  4. Insert project_memberships (source='team') — derived from team rows.
  5. UPDATE agents / pipelines / audit_events / sessions / mandates: set project_id to the default.
  6. After verification, ALTER TABLE ... SET NOT NULL on the new columns.

Production-safe: backfill runs as a paginated background job (~10k rows per batch). The NOT NULL constraint is added in a follow-up migration after the count of NULL rows hits zero.

The license server (tappass-platform/) is left untouched — it's single-tenant airgapped and has no multi-team workload.


Existing org roles are unchanged: super_admin > org_admin > operator > viewer > employee.

Two new layers:

  • Team: team_manager, team_member
  • Project: project_admin, project_member, project_viewer

Membership derivation:

  • org_admin ⇒ admin on every team and project, always.
  • Team membership → automatic project_member on every project the team owns. team_manager → automatic project_admin.
  • Direct project membership exists for outliers (e.g., a Legal reviewer who needs viewer on one project but isn't on the team).
  • A user can be in multiple teams; team_manager in one team grants nothing elsewhere.

= full CRUD, R = read-only, = no access. Project / Team columns mean within their scope.

Resourcesuper_adminorg_adminteam_managerproject_adminproject_memberproject_viewer
Org config (SSO, billing, branding, IdP, vault)
Org policies (OPA baseline)RRRR
Teams (create / rename / delete)rename own
Projects (create / rename / delete)✓ in own teamrename own
Team members
Project members
AgentsRR
PipelinesRR
BYOK LLM keys (project)
Project policy overridesRR
Sessions (read)
Audit events (read)
Move agent between projects✓ within team
Move project between teams

4.3 Behavior change for existing org roles below admin

Section titled “4.3 Behavior change for existing org roles below admin”

Today operator / viewer / employee have blanket org visibility. After this change they need project membership to see project-scoped data. Mitigated on rollout: backfill auto-adds them to the Default team, so day-1 visibility is unchanged. Going forward, new agents in new projects are invisible to them unless they're added.

  • Custom roles (no role builder)
  • Per-resource ACLs (roles apply uniformly within a project)
  • Approval workflows (no request / approve flow on policy edits)
  • Per-role redaction inside a single project (member sees same fidelity as project_admin within their scope)

Two-layer model: org = floor, project = tightening only. Enforced at edit time (validation) and at evaluation time (merge), so a bug in one layer can't loosen the other.

Dimension typeExampleTightening ruleMerge (org ∩ project)
Allowed-listallowed providers, models, regions, toolsproject ⊆ orgset intersection
Required flagrequire PII redaction, require audit signingproject ≥ orgOR (true wins)
Numeric capmax tokens per request, per day, rate limitproject ≤ orgmin
Banned-listblocked tools, blocked patternsproject ⊇ orgset union
Tool-arg constraintsend_email.to must end with @finance.acme.comadditive onlyconcat (both must pass at eval)

Anything not in the override (None) inherits org as-is.

Tool-arg constraints are additive: the project layer adds extra constraints, never removes org's. At evaluation, all constraints in the merged list must pass — AND semantics — which means physically impossible to loosen via this dimension.

5.2 v1 schema for project policy overrides

Section titled “5.2 v1 schema for project policy overrides”
class ToolArgConstraint(BaseModel):
tool: str # "send_email", "http_request", "file_write"
arg: str # the argument name to constrain
operator: Literal["match", "in", "prefix", "suffix", "range"]
value: Any # interpreted per operator
class ProjectPolicyOverride(BaseModel):
allowed_providers: list[str] | None = None
allowed_models: list[str] | None = None
allowed_regions: list[str] | None = None
require_pii_redaction: bool | None = None
require_audit_signing: bool | None = None
max_tokens_per_request: int | None = None
max_tokens_per_day: int | None = None
blocked_tools: list[str] | None = None
tool_arg_constraints: list[ToolArgConstraint] | None = None

Anything outside this typed list isn't overridable at project level in v1. Custom Rego per project is deferred.

Edit-time validation (tappass/policy/inheritance.py, new file). When project_admin saves an override, validate_tightening(org_policy, project_override) checks each dimension against its rule and returns human-readable violations. UI surfaces them inline (e.g. "gpt-4o isn't in the org's allowed models — can't add it here").

For tool-arg constraints, validation only checks syntax (operator is supported, value parses, tool / arg exist in catalog). No semantic subset check needed because adding any well-formed constraint tightens by construction.

Evaluation-time merge (existing OPA hot path). Org Rego rules already evaluate against input + data. We extend the input passed to OPA with the merged policy (computed in Python before the OPA call). Rego rules consult merged values — they don't know about org-vs-project. Single source of policy truth at eval time. If the validator ever has a bug and a looser override slips into storage, the merge function still tightens it before OPA sees it. Defense in depth.

The merge function is the same one validation uses — guarantees they can't disagree.

5.4 Org-policy changes when project overrides exist

Section titled “5.4 Org-policy changes when project overrides exist”

If org_admin tightens the org policy and a project override would become invalid:

v1 rule: block the org change. Error names the affected projects and the conflicting values. Forces the admin to reconcile explicitly — "remove this from the project, then tighten the org" — instead of silent auto-trimming an auditor would never spot.

super_admin can pass force=true that auto-trims and emits an org_policy_force_tightened audit event listing every project that was modified. Escape hatch, fully audited.

Org loosening is always safe — project overrides stay tighter than the new floor by definition.

Agents inherit the merged baseline. Existing per-agent policy logic is unchanged in structure — the only difference is what it inherits from. Today: org_policy. After this: merge(org_policy, project_policy). Same merge() function applied one more time if an agent has its own override. Order: org → project → agent, each tightening only.

  • Custom Rego rules per project (typed-field overrides only)
  • Time-windowed overrides (e.g., "loosen until 2026-06-01")
  • Override approval workflows

The whole UX is one mental model: pick a project, everything else is scoped. No new dashboards, no new charts — existing screens get a project filter and a top-bar switcher.

6.1 Sidebar — unchanged, always project-scoped

Section titled “6.1 Sidebar — unchanged, always project-scoped”
Overview
Agents
Pipelines
Sessions
Audit
Copilot
Playground
Settings

No "Teams" item. No "Projects" item. The sidebar always reflects "what I do inside the current project." Members and admins see the same nav — admins just have more enabled inside Settings.

6.2 Top bar — the only place hierarchy surfaces

Section titled “6.2 Top bar — the only place hierarchy surfaces”
Acme › Platform Engineering ⚙ › Inference Gateway ⚙ ▼

Each segment is interactive:

  • Acme — org switcher (existing, multi-org users only)
  • Platform Engineering ⚙ — clicking the name navigates to the team page; the gear opens team settings (visible only to team_manager + org_admin)
  • Inference Gateway ⚙ — project home; gear opens project settings (visible to project_admin + above)
  • — opens the project switcher dropdown

The switcher dropdown groups projects under team headers, with inline gear icons next to team names for managers. That's the only place teams appear as a list.

Last-used project persists in user prefs. URL carries ?project=<project_id> so links are shareable.

ScreenDefault scopeFilter behaviorCreate button
Overviewcurrent project"All projects" mode shows aggregate tiles
Agentscurrent projectfilter dropdown can narrow furthercreates in current project
Pipelinescurrent projectsamecreates in current project
Sessionscurrent projectfilter via denormalized project_id
Audit Trailcurrent projectfilter via denormalized project_id
Copilotcurrent projectassistant only sees current project's data
Playgroundcurrent projectuses project's BYOK keys + merged policy
Settingssplits in twosee §6.4

In-context, not in the sidebar:

SurfaceReachable fromWho sees
Project settings (/app/projects/{id}/settings)sidebar Settings while in project, or gear in top barproject_admin+
Team page (/app/teams/{id}) — projects + members tabsclicking team name in breadcrumb or switcherteam_member+ (read), team_manager (manage)
Org settings → Teams & Projects (/app/settings/teams-projects)existing org Settings navorg_admin only — master tree, create teams, move projects

Project settings tabs: Members | Policy | BYOK Keys | Danger zone. The Policy tab renders each tightening dimension with the org floor as a faded baseline and the project's tightened value layered on. Banned values from the org are visibly blocked with a tooltip.

Single dialogs, no multi-step wizards:

  • Create team — name → optional description → done.
  • Create project — pick team (defaulted from current context) → name → optional description → done.
  • Add member to team — search by email/name → pick role → done. Project memberships derive automatically.
  • Add member to project (direct) — search → pick role → done. source=direct, survives team membership changes.
  • Deleting a project requires it be empty (no agents, no pipelines, no project-level keys). UI surfaces "move N agents and 1 key first" with quick links.
  • Deleting a team requires zero projects.
  • Moving an agent between projects (team_manager within team, org_admin across teams) is a confirmation dialog; historical sessions / audit stay tagged to the original project.
  • Moving a project between teams is a confirmation dialog; team-derived memberships rebuild on save.
  • Cross-project comparison views, leaderboards, dedicated insights screens
  • Drag-drop project organizer
  • Bulk member CSV import
  • IdP group → team auto-mapping UI
  • Mobile-specific layouts

PostHog flag projects_enabled per-org, mirroring the BYOK pattern. This is a ramp control, not a permanent toggle — it disappears once 100% rollout completes.

PhaseScopeVisible to user?Risk
0. Schema + backfillMigration 045 adds tables, nullable project_id columns, default team + default project per org, one-shot backfill job. Migration 046 (after backfill verified) sets NOT NULL.NoLow — additive
1. Org-admin onlyFlag enabled for select pilots. Top-bar shows "Default" greyed (single-state — one default project, no switcher dropdown yet). Org settings → Teams & Projects sub-page lets org_admin create teams / projects. Existing screens get a project filter chip that, while there's only one project, is non-interactive. Project management UI is org_admin-only; everyone else's experience is unchanged.org_admin (mgmt UI), all (filter chip)Medium — UI exposure starts
2. Policy + key overridesProject settings → Policy tab, BYOK Keys tab. Org-tightening guard activates.org_admin, team_manager, project_adminMedium — policy merge logic in hot path
3. Membership flowsTeam page, project members tab, switcher gets full team-grouped dropdown. Existing org operator / viewer / employee start needing project membership for scoped resources.All rolesHigh — behavior change for existing users below admin
4. GAFlag default-on for new orgs, opt-in for existing, then flag removed.All

Each phase is independently shippable and rollback-able.


#Risk / trade-offDecision
1Historical audit / session rows keep their original project_id after agent movesAccepted — preserves audit immutability and reproducible cost reports across time windows. UI surfaces this when a move is initiated.
2Below-admin org roles lose blanket org visibility once project scoping turns onMitigated — backfill auto-adds them to Default team. Going forward, new agents in new projects are invisible until they're added. Comms required at GA.
3"Default" team / project becomes load-bearing — can't be deletedAccepted — system-managed, renameable but not deletable. UI badge marks it.
4Project switcher state in URL means stale links (linking to a project the user lost access to)Mitigated — middleware redirects to user's accessible project on 403 with a flash message.
5Org-policy tightening blocked by conflicting project overrides could frustrate fast-moving adminsAccepted with super_admin force=true escape hatch (audited).
6Denormalized project_id on audit_events introduces a small amount of write-time workAcceptable — single-column write, no extra query, vastly cheaper than join-per-read on the dashboard hot path.

Across all sections, explicitly not in v1:

  • IdP / SCIM group → team auto-mapping
  • Spend caps and hard billing enforcement
  • Cross-project comparison / leaderboards / dedicated insights screens
  • Custom Rego rules per project
  • Time-windowed policy overrides
  • Override approval workflows
  • Custom roles
  • Per-resource ACLs
  • Bulk member CSV import
  • Mobile-specific layouts
  • ERP / chargeback line-item exports
  • License server (tappass-platform/) parity — single-tenant airgapped, untouched

  • Existing pipeline ownership. Pipelines are project-scoped per Q3. If a customer has a single org-wide pipeline today serving multiple distinct workloads, they'll either keep it in Default (everything routes through Default's policy) or duplicate into each project. Migration assigns to Default; admins can split later. Worth a UX note in the rollout comms.
  • "All projects" pseudo-option in the switcher. Useful for org_admin, possibly noise for everyone else. Keep behind admin-only render, or expose to all? Default in this draft: visible to all with read-only aggregate scope.

Appendix A — Why a hybrid Team + Project model

Section titled “Appendix A — Why a hybrid Team + Project model”

Considered alternatives:

  • Workspace-only (Anthropic Console pattern) — flat list of workspaces under org, each holds resources + members. Rejected: no clean way to reflect the "Finance BU owns 3 projects, all share managers" reality without inventing tags.
  • Teams-only (GitHub Teams pattern) — a team owns resources directly. Rejected: collapses two genuinely different things (people grouping, resource grouping) into one, which prevents a team from owning multiple workstreams with separate cost / policy lines.
  • Hybrid Team + Project — chosen. People grouping is decoupled from resource grouping. A team can own many projects. A project's membership derives from its team's roster but allows direct overrides. Matches enterprise org-chart patterns (BU → product → service).

Appendix B — Why "tightening only" for project policy

Section titled “Appendix B — Why "tightening only" for project policy”

Considered alternatives:

  • Full override (replace org policy entirely) — rejected: defeats the org-baseline purpose. CISO sets a floor for compliance reasons; project admins shouldn't be able to remove it.
  • Loosen with approval — considered, deferred. Adds an approval workflow surface that doesn't exist yet in TapPass. Worth revisiting when org-policy review workflows materialize.
  • Tighten only — chosen. Mathematically guaranteed safe at the merge layer (intersection / OR / min / set-union / concat all preserve the invariant). Works without any approval infrastructure.