Skip to content

Team-Centric Architecture Guide

Version: 1.0 Last Updated: 2026-01-09 Purpose: Comprehensive guide to Vellocity's team-centric multi-tenancy architecture


Overview

Vellocity uses a team-centric multi-tenancy model where Team is the primary tenant, not User. This architecture enables:

  • Multi-team collaboration: Users can belong to multiple teams
  • Shared resources: Companies, products, and credits are team-owned
  • Data isolation: Clear boundaries between teams
  • Flexible permissions: Role-based access within teams

Architecture Diagram

┌─────────────────────────────────────────────────────────────────────────────┐
│                         TEAM-CENTRIC DATA MODEL                              │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                              │
│                           ┌─────────────────┐                               │
│                           │      USER       │                               │
│                           │                 │                               │
│                           │ - active_team_id│ ← Current team context        │
│                           │ - email         │                               │
│                           │ - aws_creds     │                               │
│                           └────────┬────────┘                               │
│                                    │                                         │
│              ┌─────────────────────┴─────────────────────┐                  │
│              │ teams() (Many-to-Many via TeamMember)     │                  │
│              │ myCreatedTeam() (One owns, many joins)    │                  │
│              ▼                                           ▼                  │
│     ┌─────────────────┐                        ┌─────────────────┐         │
│     │   TEAM_MEMBER   │                        │      TEAM       │         │
│     │   (Pivot)       │                        │   (Tenant)      │         │
│     │                 │                        │                 │         │
│     │ - team_id       │◄───────────────────────│ - user_id       │ (owner) │
│     │ - user_id       │                        │ - entity_credits│         │
│     │ - role          │                        │ - word_credit   │         │
│     │ - status        │                        │ - allow_seats   │         │
│     │ - joined_at     │                        │                 │         │
│     │ - remaining_*   │                        └────────┬────────┘         │
│     └─────────────────┘                                 │                  │
│                                                         │                  │
│              ┌──────────────────────────────────────────┼──────────┐       │
│              │                                          │          │       │
│              ▼                                          ▼          ▼       │
│     ┌─────────────────┐                        ┌────────────┐ ┌─────────┐ │
│     │    COMPANY      │                        │  PARTNER   │ │  etc.   │ │
│     │    (Brand)      │                        │ (Co-Sell)  │ │         │ │
│     │                 │                        │            │ │         │ │
│     │ - team_id (FK)  │                        │ - team_id  │ │         │ │
│     │ - user_id (FK)  │                        │            │ │         │ │
│     └────────┬────────┘                        └────────────┘ └─────────┘ │
│              │                                                             │
│              ▼                                                             │
│     ┌─────────────────┐                                                   │
│     │    PRODUCT      │                                                   │
│     │   (Listing)     │                                                   │
│     │                 │                                                   │
│     │ - team_id (FK)  │ ← Inherits from company                          │
│     │ - company_id    │                                                   │
│     └─────────────────┘                                                   │
│                                                                            │
└─────────────────────────────────────────────────────────────────────────────┘

Key Concepts

1. Team as Primary Tenant

Unlike user-centric models, Vellocity treats Team as the primary organizational unit:

Aspect User-Centric Team-Centric (Vellocity)
Resource ownership User owns resources Team owns resources
Collaboration Share via permissions Native multi-user access
Subscriptions Per-user billing Team-level billing
Data isolation Per-user Per-team

2. User-Team Relationships

Users interact with teams in three ways:

// 1. Team the user CREATED (ownership)
$user->myCreatedTeam();  // HasOne - Team where teams.user_id = users.id

// 2. Teams the user JOINED (membership)
$user->teams();          // BelongsToMany via team_members

// 3. Currently ACTIVE team (context)
$user->activeTeam();     // Based on active_team_id field

3. Multi-Team Membership

Users can belong to multiple teams simultaneously:

// User joins multiple teams
TeamMember::create(['team_id' => 1, 'user_id' => $user->id, 'role' => 'admin']);
TeamMember::create(['team_id' => 2, 'user_id' => $user->id, 'role' => 'member']);

// User's active team determines current context
$user->setActiveTeam($team2);

// Queries automatically scope to active team
Company::forTeam()->get();  // Returns team 2's companies

Database Schema

Users Table

users
├── id
├── email
├── team_id              -- Legacy: user's "default" team (deprecated)
├── active_team_id       -- NEW: Currently selected team context
├── team_manager_id
├── training_data_opt_out
├── analytics_opt_in
└── ...

Teams Table

teams
├── id
├── user_id              -- Team owner/creator (FK to users)
├── name
├── is_shared
├── allow_seats          -- Max team members
├── word_credit          -- Team credit pool
├── entity_credits       -- JSON: per-model credits
├── training_data_opt_out
├── analytics_opt_in
└── ...

Team Members Table

team_members
├── id
├── team_id              -- FK to teams
├── user_id              -- FK to users (nullable for invites)
├── email                -- For pending invitations
├── role                 -- owner|admin|member|viewer
├── status               -- waiting|joined|declined
├── joined_at
├── allow_unlimited_credits
├── remaining_words      -- Per-member limit (optional)
├── remaining_images     -- Per-member limit (optional)
└── ...

Companies Table

companies
├── id
├── user_id              -- Creator (legacy, kept for migration)
├── team_id              -- NEW: Team owner (primary ownership)
├── name
├── team_access          -- JSON: shared access to other teams
└── ...

Team Member Roles

Role Description Permissions
owner Team creator Full control, billing, delete team
admin Team administrator Manage members, all resources
member Standard member Create/edit own, view team resources
viewer Read-only View team resources only
api_service Service account API access only, no console login

Note: For detailed capability-based permissions, see Team Member Capabilities.


BelongsToTeam Trait

Models that are team-owned use the BelongsToTeam trait:

use App\Models\Concerns\Team\BelongsToTeam;

class Company extends Model
{
    use BelongsToTeam;
}

Provided Methods

// Relationships
$company->team();                    // BelongsTo Team

// Query Scopes
Company::forTeam($teamId)->get();              // Filter by team
Company::accessibleByUser($user)->get();       // User's accessible
Company::accessibleByAnyTeam($user)->get();    // All user's teams

// Access Control
$company->teamHasAccess($teamId);              // Check access
$company->getAccessibleTeamIds();              // List teams with access
$company->grantTeamAccess($teamId, 'read');    // Share with team
$company->revokeTeamAccess($teamId);           // Remove sharing

Auto-Assignment

When creating resources, team_id is automatically set from the user's active team:

// BelongsToTeam::bootBelongsToTeam()
static::creating(function ($model) {
    if (empty($model->team_id) && auth()->check()) {
        $model->team_id = auth()->user()->activeTeam()?->id;
    }
});

Credit & Subscription Model

Team-Level Credits

Credits are allocated and tracked at the Team level:

SUBSCRIPTION (Plan)
┌─────────────────────────────────────────────────────┐
│                      TEAM                           │
│                                                     │
│  word_credit: 500,000      ← Shared pool            │
│  entity_credits: {                                  │
│    "bedrock-claude-haiku": 50000,                   │
│    "bedrock-nova-lite": 100000                      │
│  }                                                  │
│  seo_credits_remaining: 100                         │
│                                                     │
└─────────────────────────────────────────────────────┘
       ▼ Members consume from pool
┌─────────────────┐  ┌─────────────────┐
│  Team Member A  │  │  Team Member B  │
│  (unlimited)    │  │  limit: 50,000  │
└─────────────────┘  └─────────────────┘

Per-Member Limits (Optional)

Team admins can set individual member limits:

$member->allow_unlimited_credits = false;
$member->remaining_words = 50000;
$member->remaining_images = 50;

Data Isolation

Hierarchy

Data privacy settings cascade through the hierarchy:

User (base consent)
  └── training_data_opt_out, analytics_opt_in
       ▼ inherits (nullable = use parent)
Team
  └── training_data_opt_out, analytics_opt_in
       ▼ inherits
Company
  └── data_isolation_enabled, kb_cross_brand_sharing
       ▼ inherits
Product
  └── training_data_opt_out (nullable = use company)

Team Boundary Enforcement

Resources are isolated by team:

// Good: Uses team scope
Company::forTeam()->get();

// Bad: Returns all companies (no isolation)
Company::all();

Common Patterns

1. Get User's Active Team Context

$user = auth()->user();
$team = $user->activeTeam();

if ($team) {
    // User has an active team
    $companies = Company::forTeam($team->id)->get();
}

2. Switch User's Active Team

$user->setActiveTeam($newTeam);

// Verifies user has access before switching
// Updates active_team_id in database

3. Invite User to Team

// Create invitation
TeamMember::create([
    'team_id' => $team->id,
    'email' => 'newuser@example.com',
    'role' => 'member',
    'status' => 'waiting',
]);

// User accepts invitation
$teamMember->update([
    'user_id' => $user->id,
    'status' => 'joined',
    'joined_at' => now(),
]);

4. Check Resource Access

// Does this team have access to the company?
if ($company->teamHasAccess($teamId)) {
    // Allow access
}

// Get all companies user can access
$companies = Company::accessibleByUser($user)->get();

5. Share Resource with Another Team

// Grant read access to another team
$company->grantTeamAccess($otherTeamId, 'read');

// Revoke access
$company->revokeTeamAccess($otherTeamId);

Migration Guide

For Existing Code

  1. Replace user-scoped queries with team-scoped:
// Before (user-centric)
Company::where('user_id', auth()->id())->get();

// After (team-centric)
Company::forTeam()->get();
// or
Company::accessibleByUser()->get();
  1. Use activeTeam() instead of team_id:
// Before
$teamId = auth()->user()->team_id;

// After
$teamId = auth()->user()->activeTeam()?->id;
  1. Set team_id on resource creation:
// Automatic (via BelongsToTeam trait)
Company::create(['name' => 'Acme']);  // team_id auto-set

// Explicit
Company::create([
    'name' => 'Acme',
    'team_id' => $user->activeTeam()->id,
]);

Database Migrations Run

  1. 2026_01_09_000001_add_team_id_to_companies_table.php
  2. 2026_01_09_000002_add_active_team_id_to_users_table.php
  3. 2026_01_09_000003_backfill_companies_team_id.php
  4. 2026_01_09_000004_backfill_products_team_id.php
  5. 2026_01_09_000005_backfill_users_active_team_id.php


Document generated: 2026-01-09