Projects & Teams — Concept
Projects & Teams — Concept
Section titled “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.
1. The thesis
Section titled “1. The thesis”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:
| # | Question | Decision |
|---|---|---|
| Q1 | One concept or two? | Both. Teams = people, Projects = resources. Maximum flexibility for org-chart variation. |
| Q2 | Hierarchy depth | One level. Org → Team → Project. No team-of-teams or sub-projects. |
| Q3 | What gets project-scoped | Agents, 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). |
| Q4 | Resource exclusivity | 1:1. An agent belongs to exactly one project. No multi-project agents in v1. |
| Q5 | Membership model | Hybrid. Org roles unchanged. Team membership (team_manager, team_member) auto-derives project membership. Direct project membership is the escape hatch for outliers. |
| Q6 | Manager visibility | Org-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. |
| Q7 | Billing model | Showback only in v1. Filter on existing screens — no separate insights dashboard, no spend caps, no chargeback exports. |
| Q8 | Project-level policy | Yes, tightening only. Org policy is the floor. Projects can be stricter, never looser. Agent policies inherit the merged baseline. |
| Q9 | SSO / SCIM auto-mapping | Out of scope for v1. Membership is managed in TapPass UI. IdP group → team mapping deferred to v2. |
| Q10 | Migration path | Zero-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. |
3. Data model
Section titled “3. Data model”Five new tables. Six existing tables get a project_id column.
3.1 New tables
Section titled “3.1 New tables”-- 045_teams_projects.sqlCREATE 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.
3.2 Columns added to existing tables
Section titled “3.2 Columns added to existing tables”| Table | Column | Nullable | Why |
|---|---|---|---|
agents | project_id | NO | Resource of record |
pipelines | project_id | NO | Resource of record |
vault_llm_keys | project_id | YES | NULL = org-default key; non-NULL = project override |
audit_events | project_id | NO (denorm) | Hot-path filter — avoid join through agent on dashboard reads |
sessions | project_id | NO (denorm) | Same |
mandates | project_id | NO (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.
3.3 BYOK LLM keys — special case
Section titled “3.3 BYOK LLM keys — special case”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}".
3.4 Backfill (zero-touch, per-org)
Section titled “3.4 Backfill (zero-touch, per-org)”In one transaction per org:
- Insert
teamsrow:team_id=team_default_<org_id>,name=Default. - Insert
projectsrow:project_id=proj_default_<org_id>,team_id=<above>,name=Default. - Insert
team_memberships: every existing org member becomesteam_member; the org's super_admin becomesteam_manager. - Insert
project_memberships(source='team') — derived from team rows. UPDATEagents / pipelines / audit_events / sessions / mandates: setproject_idto the default.- After verification,
ALTER TABLE ... SET NOT NULLon 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.
4. Roles & permissions
Section titled “4. Roles & permissions”4.1 Role set
Section titled “4.1 Role set”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_memberon every project the team owns.team_manager→ automaticproject_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_managerin one team grants nothing elsewhere.
4.2 Permission matrix
Section titled “4.2 Permission matrix”✓ = full CRUD, R = read-only, — = no access. Project / Team columns mean within their scope.
| Resource | super_admin | org_admin | team_manager | project_admin | project_member | project_viewer |
|---|---|---|---|---|---|---|
| Org config (SSO, billing, branding, IdP, vault) | ✓ | ✓ | — | — | — | — |
| Org policies (OPA baseline) | ✓ | ✓ | R | R | R | R |
| Teams (create / rename / delete) | ✓ | ✓ | rename own | — | — | — |
| Projects (create / rename / delete) | ✓ | ✓ | ✓ in own team | rename own | — | — |
| Team members | ✓ | ✓ | ✓ | — | — | — |
| Project members | ✓ | ✓ | ✓ | ✓ | — | — |
| Agents | ✓ | ✓ | ✓ | ✓ | R | R |
| Pipelines | ✓ | ✓ | ✓ | ✓ | R | R |
| BYOK LLM keys (project) | ✓ | ✓ | ✓ | ✓ | — | — |
| Project policy overrides | ✓ | ✓ | ✓ | ✓ | R | R |
| 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.
4.4 Out of scope
Section titled “4.4 Out of scope”- 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)
5. Policy inheritance
Section titled “5. Policy inheritance”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.
5.1 Tightening relation by dimension
Section titled “5.1 Tightening relation by dimension”| Dimension type | Example | Tightening rule | Merge (org ∩ project) |
|---|---|---|---|
| Allowed-list | allowed providers, models, regions, tools | project ⊆ org | set intersection |
| Required flag | require PII redaction, require audit signing | project ≥ org | OR (true wins) |
| Numeric cap | max tokens per request, per day, rate limit | project ≤ org | min |
| Banned-list | blocked tools, blocked patterns | project ⊇ org | set union |
| Tool-arg constraint | send_email.to must end with @finance.acme.com | additive only | concat (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 = NoneAnything outside this typed list isn't overridable at project level in v1. Custom Rego per project is deferred.
5.3 Two enforcement points
Section titled “5.3 Two enforcement points”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.
5.5 Agent-level policies
Section titled “5.5 Agent-level policies”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.
5.6 Out of scope
Section titled “5.6 Out of scope”- Custom Rego rules per project (typed-field overrides only)
- Time-windowed overrides (e.g., "loosen until 2026-06-01")
- Override approval workflows
6. UX surfaces
Section titled “6. UX surfaces”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”OverviewAgentsPipelinesSessionsAuditCopilotPlaygroundSettingsNo "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.
6.3 Existing screens — what changes
Section titled “6.3 Existing screens — what changes”| Screen | Default scope | Filter behavior | Create button |
|---|---|---|---|
| Overview | current project | "All projects" mode shows aggregate tiles | — |
| Agents | current project | filter dropdown can narrow further | creates in current project |
| Pipelines | current project | same | creates in current project |
| Sessions | current project | filter via denormalized project_id | — |
| Audit Trail | current project | filter via denormalized project_id | — |
| Copilot | current project | assistant only sees current project's data | — |
| Playground | current project | uses project's BYOK keys + merged policy | — |
| Settings | splits in two | see §6.4 | — |
6.4 Management surfaces
Section titled “6.4 Management surfaces”In-context, not in the sidebar:
| Surface | Reachable from | Who sees |
|---|---|---|
Project settings (/app/projects/{id}/settings) | sidebar Settings while in project, or gear in top bar | project_admin+ |
Team page (/app/teams/{id}) — projects + members tabs | clicking team name in breadcrumb or switcher | team_member+ (read), team_manager (manage) |
Org settings → Teams & Projects (/app/settings/teams-projects) | existing org Settings nav | org_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.
6.5 Creation flows
Section titled “6.5 Creation flows”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.
6.6 Cross-project guardrails
Section titled “6.6 Cross-project guardrails”- 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_managerwithin team,org_adminacross 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.
6.7 Out of scope
Section titled “6.7 Out of scope”- 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
7. Rollout
Section titled “7. Rollout”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.
| Phase | Scope | Visible to user? | Risk |
|---|---|---|---|
| 0. Schema + backfill | Migration 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. | No | Low — additive |
| 1. Org-admin only | Flag 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 overrides | Project settings → Policy tab, BYOK Keys tab. Org-tightening guard activates. | org_admin, team_manager, project_admin | Medium — policy merge logic in hot path |
| 3. Membership flows | Team page, project members tab, switcher gets full team-grouped dropdown. Existing org operator / viewer / employee start needing project membership for scoped resources. | All roles | High — behavior change for existing users below admin |
| 4. GA | Flag default-on for new orgs, opt-in for existing, then flag removed. | All | — |
Each phase is independently shippable and rollback-able.
8. Risks & deliberate trade-offs
Section titled “8. Risks & deliberate trade-offs”| # | Risk / trade-off | Decision |
|---|---|---|
| 1 | Historical audit / session rows keep their original project_id after agent moves | Accepted — preserves audit immutability and reproducible cost reports across time windows. UI surfaces this when a move is initiated. |
| 2 | Below-admin org roles lose blanket org visibility once project scoping turns on | Mitigated — 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 deleted | Accepted — system-managed, renameable but not deletable. UI badge marks it. |
| 4 | Project 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. |
| 5 | Org-policy tightening blocked by conflicting project overrides could frustrate fast-moving admins | Accepted with super_admin force=true escape hatch (audited). |
| 6 | Denormalized project_id on audit_events introduces a small amount of write-time work | Acceptable — single-column write, no extra query, vastly cheaper than join-per-read on the dashboard hot path. |
9. Out of scope (consolidated)
Section titled “9. Out of scope (consolidated)”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
10. Open questions
Section titled “10. Open questions”- 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.