🧱

B1-Laravel-Web-Core

B1 — Laravel Web App Core + SQL Database

Type. Executable build prompt. Hand this page (or its exported .md) to Cursor or Claude Code and it should produce a working Laravel core with all migrations, models, factories, controllers, services, queues, and an admin Control Center scaffold.

🧭

Canonical diagrams (Option 1). Link to 🧭ARCHITECTURE_DIAGRAMS (Canonical) for shared Mermaid diagrams (system, lifecycle, RAG, Git/snapshots, security, export). Keep B1 focused on executable build steps and contracts.

Scope of B1. Backend skeleton, SQL schema, models + relations, server-param store, queue infra, Sanctum API, Filament admin shell, and seed data. Not in B1: real provider calls to OpenAI/Claude, RAG embeddings, Git execution, iPhone client. Those are B2+.

Goal. After running B1, an operator can log into the Control Center, create a project, see all admin resources, and call every API endpoint that returns deterministic data (lists, details, settings). Agent runs can be created and persisted; their execution is stubbed.


1. Decisions (locked for B1)

DecisionValueWhy
PHP8.3Required by Laravel 11 and Filament 3.
FrameworkLaravel 11.xLatest LTS-adjacent, native Reverb, slim skeleton.
Default DBMySQL 8.0 (or MariaDB 11.4+)Operator preference; standard Laravel compatibility.
Optional DBPostgreSQL 16 + pgvector 0.7Enabled only for RAG-heavy installs; switchable via .env.
QueueRedis 7 • Horizon 5Long-running agent runs need supervision.
Cache / locksRedisWorkspace locks one agent per project.
BroadcasterLaravel ReverbNative WS, no third-party.
Web authLaravel Fortify (session + 2FA)For Filament admin login.
API authSanctum personal-access tokens with abilitiesiPhone pairing.
Admin shellFilament v3Fastest path to admin CRUD.
Operator UILivewire 3 • Alpine + TailwindConsole & diff viewer; not in B1 scope but layout shell installed.
TestsPest 3Modern, readable.
Static analysisLarastan level 6Catch regressions early.
Vector storageMySQL: embedding as JSON • content_hash index; PG: vector(3072) with ivfflatSingle migration, driver-aware via Schema::hasColumn • DB::connection()->getDriverName().

2. Project bootstrap (commands)

composer create-project laravel/laravel agent-workspace "^11.0"
cd agent-workspace

# core packages
composer require laravel/sanctum laravel/horizon laravel/reverb laravel/fortify
composer require filament/filament:"^3.2"
composer require livewire/livewire:"^3.5"
composer require spatie/laravel-permission:"^6.9"
composer require predis/predis

# dev
composer require --dev pestphp/pest pestphp/pest-plugin-laravel larastan/larastan nunomaduro/collision

# publish & install
php artisan install:api                 # sanctum routes + migration
php artisan horizon:install
php artisan reverb:install
php artisan fortify:install || true
php artisan vendor:publish --tag=filament-config
php artisan make:filament-user          # creates first admin

# tailwind + alpine for non-filament layout
npm install -D tailwindcss postcss autoprefixer @tailwindcss/typography alpinejs
npx tailwindcss init -p

# environment
cp .env.example .env
php artisan key:generate
php artisan storage:link

.env keys (B1 minimum):

APP_NAME="Agent Workspace"
APP_ENV=local
APP_URL=http://localhost:8000

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=agent_workspace
DB_USERNAME=root
DB_PASSWORD=

REDIS_CLIENT=predis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379

QUEUE_CONNECTION=redis
BROADCAST_CONNECTION=reverb
SESSION_DRIVER=redis
CACHE_STORE=redis

REVERB_APP_ID=local
REVERB_APP_KEY=local-key
REVERB_APP_SECRET=local-secret
REVERB_HOST="localhost"
REVERB_PORT=8080
REVERB_SCHEME=http

# agent workspace specifics — read by config/agent_workspace.php
AGENT_WORKSPACE_ROOT=/var/lib/agent-workspaces
AGENT_DEFAULT_PROVIDER=openai
AGENT_OPENAI_API_KEY=
AGENT_OPENAI_MODEL=gpt-4.1
AGENT_OPENAI_EMBEDDING_MODEL=text-embedding-3-large
AGENT_CLAUDE_API_KEY=
AGENT_CLAUDE_MODEL=claude-3.7-sonnet
AGENT_CLAUDE_CODE_PATH=/usr/local/bin/claude
AGENT_GIT_PATH=/usr/bin/git
AGENT_SAFE_MODE=true
AGENT_AUTO_COMMIT=false
AGENT_AUTO_PUSH=false
AGENT_RAG_ENABLED=true
AGENT_AUTO_DOCS=true
AGENT_SNAPSHOT_BEFORE_RUN=true
AGENT_MAX_RUN_SECONDS=3600
AGENT_MAX_COMMAND_SECONDS=120

Nothing here is a secret in code. All credentials must also exist in the server_params table so the admin UI can override at runtime. .env is just the bootstrap source.


3. Final table list (B1)

User-listed plus required dependencies. 15 tables.

  1. users
  1. personal_access_tokens (Sanctum default)
  1. permissions, roles, model_has_* (Spatie default — not custom)
  1. projects
  1. project_connections
  1. agent_runs
  1. agent_events
  1. workspace_files
  1. workspace_snapshots
  1. rag_chunks
  1. agent_providers
  1. agent_settings
  1. git_operations
  1. documentation_updates
  1. command_executions
  1. run_reviews
  1. server_params
  1. api_tokens (custom metadata wrapper around Sanctum)
  1. audit_logs
  1. horizon_* (Horizon default)

(Spatie + Horizon + Sanctum tables come from their published migrations and don't need custom files.)


4. Migrations — column-by-column

All migrations live in database/migrations/. All use bigIncrements('id'), timestamps(), and softDeletes() where the entity is user-facing.

All FKs use foreignId(...)->constrained()->cascadeOnDelete() unless noted.

4.1 projects

Schema::create('projects', function (Blueprint $t) {
    $t->id();
    $t->string('name');
    $t->string('slug')->unique();
    $t->text('description')->nullable();
    $t->string('repository_url')->nullable();
    $t->string('local_workspace_path')->nullable();
    $t->string('default_branch')->default('main');
    $t->string('current_branch')->nullable();
    $t->string('framework_detected')->nullable();   // laravel, rails, next, ...
    $t->string('language_detected')->nullable();    // php, ts, py, ...
    $t->string('status')->default('inactive');      // active, inactive, archived, error
    $t->foreignId('created_by')->constrained('users')->restrictOnDelete();
    $t->json('detection_metadata')->nullable();
    $t->timestamp('last_indexed_at')->nullable();
    $t->timestamps();
    $t->softDeletes();
    $t->index(['status', 'updated_at']);
});

4.2 project_connections

Git/remote credentials per project.

Schema::create('project_connections', function (Blueprint $t) {
    $t->id();
    $t->foreignId('project_id')->constrained()->cascadeOnDelete();
    $t->string('kind');                              // git, github_app, ssh_key
    $t->string('provider')->nullable();              // github, gitlab, bitbucket, custom
    $t->text('config_encrypted')->nullable();        // Laravel encrypter (Crypt::encryptString)
    $t->string('status')->default('ok');
    $t->timestamp('verified_at')->nullable();
    $t->timestamps();
    $t->index(['project_id', 'kind']);
});

4.3 agent_runs

Schema::create('agent_runs', function (Blueprint $t) {
    $t->id();
    $t->foreignId('project_id')->constrained()->cascadeOnDelete();
    $t->foreignId('user_id')->constrained()->restrictOnDelete();
    $t->string('title');
    $t->longText('prompt');
    $t->string('status')->default('draft');          // draft|queued|running|paused|waiting_for_user|completed|failed|cancelled
    $t->string('current_step')->nullable();
    $t->string('selected_agent')->nullable();        // architect|backend|frontend|iphone|rag|qa|devops|docs
    $t->string('selected_provider')->nullable();     // openai|claude_api|claude_code|local_shell
    $t->string('selected_model')->nullable();
    $t->string('branch_name')->nullable();
    $t->boolean('rag_enabled')->default(true);
    $t->boolean('safe_mode')->default(true);
    $t->timestamp('started_at')->nullable();
    $t->timestamp('completed_at')->nullable();
    $t->text('failed_reason')->nullable();
    $t->json('metadata')->nullable();
    $t->timestamps();
    $t->softDeletes();
    $t->index(['project_id', 'status']);
    $t->index(['user_id', 'created_at']);
    $t->index('status');
});

4.4 agent_events

Hot table — append-only.

Schema::create('agent_events', function (Blueprint $t) {
    $t->id();
    $t->foreignId('run_id')->constrained('agent_runs')->cascadeOnDelete();
    $t->foreignId('project_id')->constrained()->cascadeOnDelete();
    $t->string('event_type', 64);                    // 24 types from spec page 02
    $t->string('severity', 16)->default('info');     // info|success|warning|error|debug
    $t->string('title');
    $t->text('message')->nullable();
    $t->json('payload')->nullable();
    $t->timestamp('created_at')->useCurrent();
    $t->index(['run_id', 'id']);                     // for cursor pagination
    $t->index(['run_id', 'event_type']);
    $t->index(['project_id', 'created_at']);
    $t->index('severity');
});

No updated_at, no soft-deletes — events are immutable.

4.5 workspace_files

Schema::create('workspace_files', function (Blueprint $t) {
    $t->id();
    $t->foreignId('project_id')->constrained()->cascadeOnDelete();
    $t->foreignId('run_id')->nullable()->constrained('agent_runs')->nullOnDelete();
    $t->string('path', 1024);
    $t->string('action', 16);                        // read|create|update|delete
    $t->string('before_hash', 64)->nullable();       // sha256 hex
    $t->string('after_hash', 64)->nullable();
    $t->longText('diff')->nullable();                // unified diff, server-rendered
    $t->foreignId('snapshot_id')->nullable()->constrained('workspace_snapshots')->nullOnDelete();
    $t->string('review_status')->default('pending'); // pending|approved|rejected
    $t->foreignId('reviewed_by')->nullable()->constrained('users')->nullOnDelete();
    $t->timestamp('reviewed_at')->nullable();
    $t->timestamps();
    $t->index(['project_id', 'path']);
    $t->index(['run_id', 'action']);
    $t->index('after_hash');
});

4.6 workspace_snapshots

Schema::create('workspace_snapshots', function (Blueprint $t) {
    $t->id();
    $t->foreignId('project_id')->constrained()->cascadeOnDelete();
    $t->foreignId('run_id')->nullable()->constrained('agent_runs')->nullOnDelete();
    $t->string('snapshot_type', 32);                 // pre_run|pre_command|manual|final
    $t->string('git_commit_hash', 64)->nullable();
    $t->string('archive_path')->nullable();          // tar.gz on disk or S3 key
    $t->unsignedBigInteger('archive_size_bytes')->nullable();
    $t->text('description')->nullable();
    $t->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
    $t->timestamps();
    $t->index(['project_id', 'snapshot_type']);
    $t->index('git_commit_hash');
});

4.7 rag_chunks

Driver-aware migration. The same migration class branches on connection driver so the migrator runs on either MySQL or PostgreSQL.

public function up(): void
{
    $driver = DB::connection()->getDriverName();

    Schema::create('rag_chunks', function (Blueprint $t) use ($driver) {
        $t->id();
        $t->foreignId('project_id')->constrained()->cascadeOnDelete();
        $t->string('source_type', 64);               // route|controller|model|migration|service|doc|...
        $t->string('source_path', 1024);
        $t->string('symbol_name')->nullable();
        $t->string('chunk_title')->nullable();
        $t->longText('chunk_text');
        $t->string('content_hash', 64);              // sha256 of chunk_text
        $t->json('metadata')->nullable();
        $t->unsignedInteger('token_count')->nullable();
        $t->timestamps();
        $t->index(['project_id', 'source_type']);
        $t->index(['project_id', 'content_hash']);
        $t->index('source_path');
    });

    if ($driver === 'pgsql') {
        DB::statement('CREATE EXTENSION IF NOT EXISTS vector');
        DB::statement('ALTER TABLE rag_chunks ADD COLUMN embedding vector(3072)');
        DB::statement('CREATE INDEX rag_chunks_embedding_idx ON rag_chunks USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100)');
    } else {
        // MySQL / MariaDB: store as JSON; cosine similarity computed in app layer.
        Schema::table('rag_chunks', fn (Blueprint $t) => $t->json('embedding')->nullable());
    }
}

MySQL note. The MySQL path is correct but slow above ~50k chunks per project. Upgrade path: switch DB_CONNECTION=pgsql, run php artisan agent:reindex --all, and the indexer will repopulate the vector(3072) column.

4.8 agent_providers

Schema::create('agent_providers', function (Blueprint $t) {
    $t->id();
    $t->string('key')->unique();                     // openai, claude_api, claude_code, local_shell
    $t->string('label');
    $t->boolean('enabled')->default(true);
    $t->string('default_model')->nullable();
    $t->json('models')->nullable();                  // list of allowed models
    $t->json('config')->nullable();                  // base_url, timeouts, headers
    $t->timestamp('verified_at')->nullable();
    $t->timestamps();
});

4.9 agent_settings

Per-user (or global if user_id null) overrides.

Schema::create('agent_settings', function (Blueprint $t) {
    $t->id();
    $t->foreignId('user_id')->nullable()->constrained()->cascadeOnDelete();
    $t->string('scope', 32);                         // global|user|project
    $t->foreignId('project_id')->nullable()->constrained()->cascadeOnDelete();
    $t->string('key');
    $t->json('value')->nullable();
    $t->timestamps();
    $t->unique(['scope', 'user_id', 'project_id', 'key'], 'agent_settings_unique');
});

4.10 git_operations

Schema::create('git_operations', function (Blueprint $t) {
    $t->id();
    $t->foreignId('project_id')->constrained()->cascadeOnDelete();
    $t->foreignId('run_id')->nullable()->constrained('agent_runs')->nullOnDelete();
    $t->string('operation', 32);                     // clone|fetch|pull|branch|checkout|diff|commit|push|reset
    $t->string('ref_before')->nullable();
    $t->string('ref_after')->nullable();
    $t->text('command')->nullable();
    $t->longText('stdout')->nullable();
    $t->longText('stderr')->nullable();
    $t->integer('exit_code')->nullable();
    $t->timestamps();
    $t->index(['project_id', 'operation']);
    $t->index('run_id');
});

4.11 documentation_updates

Schema::create('documentation_updates', function (Blueprint $t) {
    $t->id();
    $t->foreignId('project_id')->constrained()->cascadeOnDelete();
    $t->foreignId('run_id')->nullable()->constrained('agent_runs')->nullOnDelete();
    $t->string('doc_path');                           // docs/PROJECT_MAP.md, .cursor/rules/...
    $t->string('section')->nullable();                // AUTO-GENERATED block key
    $t->string('before_hash', 64)->nullable();
    $t->string('after_hash', 64)->nullable();
    $t->longText('diff')->nullable();
    $t->timestamps();
    $t->index(['project_id', 'doc_path']);
});

4.12 command_executions

Schema::create('command_executions', function (Blueprint $t) {
    $t->id();
    $t->foreignId('run_id')->constrained('agent_runs')->cascadeOnDelete();
    $t->foreignId('project_id')->constrained()->cascadeOnDelete();
    $t->string('command_kind', 32);                  // shell|composer|npm|artisan|test|git
    $t->text('command');
    $t->string('cwd')->nullable();
    $t->json('env')->nullable();                     // masked, no secrets
    $t->integer('exit_code')->nullable();
    $t->unsignedBigInteger('stdout_bytes')->nullable();
    $t->unsignedBigInteger('stderr_bytes')->nullable();
    $t->string('stdout_path')->nullable();           // large logs stored on disk
    $t->string('stderr_path')->nullable();
    $t->unsignedInteger('duration_ms')->nullable();
    $t->string('status')->default('queued');         // queued|running|completed|failed|timeout|blocked
    $t->timestamp('started_at')->nullable();
    $t->timestamp('finished_at')->nullable();
    $t->timestamps();
    $t->index(['run_id', 'status']);
    $t->index('command_kind');
});

4.13 run_reviews

Approval / rejection of a run's diff before commit.

Schema::create('run_reviews', function (Blueprint $t) {
    $t->id();
    $t->foreignId('run_id')->constrained('agent_runs')->cascadeOnDelete();
    $t->foreignId('reviewer_id')->constrained('users')->restrictOnDelete();
    $t->string('decision', 16);                      // approved|rejected|changes_requested
    $t->text('notes')->nullable();
    $t->boolean('committed')->default(false);
    $t->boolean('pushed')->default(false);
    $t->timestamps();
    $t->index(['run_id', 'decision']);
});

4.14 server_params

Runtime overrides for config/agent_workspace.php. Secrets stored encrypted at rest.

Schema::create('server_params', function (Blueprint $t) {
    $t->id();
    $t->string('key')->unique();                     // openai.api_key, git.path, ...
    $t->string('label')->nullable();
    $t->string('type', 16)->default('string');       // string|int|bool|json|secret
    $t->text('value_encrypted')->nullable();         // always encrypted; secrets are write-once-reveal-once in UI
    $t->boolean('is_secret')->default(false);
    $t->boolean('is_overridable')->default(true);
    $t->text('description')->nullable();
    $t->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete();
    $t->timestamps();
});

4.15 api_tokens

Metadata wrapper around Sanctum's personal_access_tokens. We don't duplicate the token hash — we reference it.

Schema::create('api_tokens', function (Blueprint $t) {
    $t->id();
    $t->foreignId('user_id')->constrained()->cascadeOnDelete();
    $t->foreignId('personal_access_token_id')->constrained('personal_access_tokens')->cascadeOnDelete();
    $t->string('device_name')->nullable();           // "iPhone 15 Pro", "web-session"
    $t->string('client_kind', 16)->default('web');   // web|iphone|cli|ci
    $t->json('abilities_snapshot')->nullable();      // mirror at creation time
    $t->string('last_ip', 45)->nullable();
    $t->string('last_user_agent', 512)->nullable();
    $t->timestamp('last_used_at')->nullable();
    $t->timestamp('revoked_at')->nullable();
    $t->timestamps();
    $t->index(['user_id', 'client_kind']);
});

4.16 audit_logs

Schema::create('audit_logs', function (Blueprint $t) {
    $t->id();
    $t->foreignId('actor_id')->nullable()->constrained('users')->nullOnDelete();
    $t->string('actor_kind', 16)->default('user');   // user|system|agent
    $t->string('action', 64);                        // server_param.update, run.start, run.push, ...
    $t->string('target_type')->nullable();           // morph type
    $t->unsignedBigInteger('target_id')->nullable();
    $t->json('before')->nullable();
    $t->json('after')->nullable();
    $t->string('ip', 45)->nullable();
    $t->string('user_agent', 512)->nullable();
    $t->timestamp('created_at')->useCurrent();
    $t->index(['action', 'created_at']);
    $t->index(['target_type', 'target_id']);
    $t->index('actor_id');
});

5. Eloquent models & relationships

All models in app/Models/. Snippet of relationship signatures only — full bodies generated by make:model -m and filled per the migration.

class Project extends Model {
    use HasFactory, SoftDeletes;
    public function connections(): HasMany { return $this->hasMany(ProjectConnection::class); }
    public function runs(): HasMany       { return $this->hasMany(AgentRun::class); }
    public function events(): HasMany     { return $this->hasMany(AgentEvent::class); }
    public function files(): HasMany      { return $this->hasMany(WorkspaceFile::class); }
    public function snapshots(): HasMany  { return $this->hasMany(WorkspaceSnapshot::class); }
    public function chunks(): HasMany     { return $this->hasMany(RagChunk::class); }
    public function gitOperations(): HasMany { return $this->hasMany(GitOperation::class); }
    public function docUpdates(): HasMany { return $this->hasMany(DocumentationUpdate::class); }
    public function createdBy(): BelongsTo { return $this->belongsTo(User::class, 'created_by'); }
}

class AgentRun extends Model {
    use HasFactory, SoftDeletes;
    protected $casts = ['metadata' => 'array', 'rag_enabled' => 'bool', 'safe_mode' => 'bool',
                       'started_at' => 'datetime', 'completed_at' => 'datetime'];
    public function project(): BelongsTo  { return $this->belongsTo(Project::class); }
    public function user(): BelongsTo     { return $this->belongsTo(User::class); }
    public function events(): HasMany     { return $this->hasMany(AgentEvent::class, 'run_id'); }
    public function files(): HasMany      { return $this->hasMany(WorkspaceFile::class, 'run_id'); }
    public function snapshots(): HasMany  { return $this->hasMany(WorkspaceSnapshot::class, 'run_id'); }
    public function commands(): HasMany   { return $this->hasMany(CommandExecution::class, 'run_id'); }
    public function reviews(): HasMany    { return $this->hasMany(RunReview::class, 'run_id'); }
    public function gitOperations(): HasMany { return $this->hasMany(GitOperation::class, 'run_id'); }
}

class AgentEvent extends Model {
    public $timestamps = false;
    protected $casts = ['payload' => 'array', 'created_at' => 'datetime'];
    public function run(): BelongsTo      { return $this->belongsTo(AgentRun::class, 'run_id'); }
    public function project(): BelongsTo  { return $this->belongsTo(Project::class); }
}

class WorkspaceFile extends Model {
    protected $casts = ['reviewed_at' => 'datetime'];
    public function project(): BelongsTo  { return $this->belongsTo(Project::class); }
    public function run(): BelongsTo      { return $this->belongsTo(AgentRun::class, 'run_id'); }
    public function snapshot(): BelongsTo { return $this->belongsTo(WorkspaceSnapshot::class); }
}

class WorkspaceSnapshot extends Model {
    public function project(): BelongsTo  { return $this->belongsTo(Project::class); }
    public function run(): BelongsTo      { return $this->belongsTo(AgentRun::class, 'run_id'); }
    public function files(): HasMany      { return $this->hasMany(WorkspaceFile::class, 'snapshot_id'); }
}

class RagChunk extends Model {
    protected $casts = ['metadata' => 'array', 'embedding' => 'array'];
    public function project(): BelongsTo  { return $this->belongsTo(Project::class); }
}

class ServerParam extends Model {
    protected $hidden = ['value_encrypted'];
    protected $casts = ['is_secret' => 'bool', 'is_overridable' => 'bool'];
    public function getValueAttribute() {
        if (!$this->value_encrypted) return null;
        $plain = Crypt::decryptString($this->value_encrypted);
        return match($this->type) {
            'int' => (int) $plain, 'bool' => (bool) $plain,
            'json' => json_decode($plain, true), default => $plain,
        };
    }
    public function setValueAttribute($v): void {
        $this->value_encrypted = Crypt::encryptString(
            is_scalar($v) ? (string) $v : json_encode($v)
        );
    }
}

// AgentProvider, AgentSetting, GitOperation, DocumentationUpdate, CommandExecution,
// RunReview, ApiToken, AuditLog, ProjectConnection — analogous shapes.

6. Factories & seeders

  • Factory for every user-facing model. Use Str::random, fake()->paragraph() for text fields, realistic enum sets for status/event_type/severity.
  • Seeders:
    • DatabaseSeeder orchestrates.
    • RoleSeeder — Spatie roles: owner, admin, operator, reviewer, viewer.
    • AgentProviderSeeder — inserts the 4 providers with enabled=true and default_model.
    • ServerParamSeeder — inserts every key from config/agent_workspace.php with its default, marking API keys as is_secret=true.
    • DemoSeeder (only when APP_ENV=local) — 2 demo projects, 1 demo run with 30 events spanning all event types and severities.

7. Service classes (B1 contracts, bodies stubbed)

Create empty service classes with method signatures, PHPDoc, and throw new \RuntimeException('Not implemented in B1') bodies. Real implementations come in B2+. Folder: app/Services/.

Services/
  Agents/
    AgentRunOrchestrator.php       start, pause, resume, cancel, retry
    AgentProviderInterface.php     prepareContext, startRun, continueRun, pauseRun, cancelRun, summarizeRun, extractFileChanges, streamEvents
    OpenAIAgentService.php         implements AgentProviderInterface
    ClaudeAgentService.php
    ClaudeCodeAgentService.php
    LocalShellAgentService.php
  Rag/
    RagContextService.php          search, indexProject, indexFile, purgeProject, stats
    ProjectIndexer.php
    EmbeddingClient.php            driver-aware (OpenAI text-embedding-3-large in B2)
  Workspace/
    WorkspaceService.php           cloneRepo, resolvePath, readFile, applyPatch, listFiles, lockProject, releaseLock
    CommandExecutionService.php    run, allowlistCheck, blocklistCheck, stream
    SnapshotService.php            createPreRun, createPreCommand, restore, compare
  Git/
    GitService.php                 status, branch, checkout, pull, diff, commit, push, reset
  Docs/
    DocumentationAutoUpdateService.php  update, replaceMarkedBlock
    ProjectMapService.php          regenerate
    SchemaAnalyzerService.php      regenerate
  Events/
    ConsoleEventService.php        publish(event) → DB insert + broadcast(Reverb) + SSE fanout
  Settings/
    ServerParamService.php         get(key), set(key, value), reveal(key), maskedExport()
  Security/
    CommandPolicyService.php       isAllowed(command, kind), reasons
    PathSafetyService.php          assertInsideWorkspace(project, path)
  Audit/
    AuditLogger.php                log(action, target, before, after)

8. HTTP layer

8.1 Routes

Three files: routes/web.php (Filament + Livewire), routes/api.php (Sanctum), routes/channels.php (Reverb).

routes/api.php skeleton (B1 returns real data for reads; mutations enqueue jobs):

Route::middleware('auth:sanctum')->group(function () {
    Route::get('/me', [MeController::class, 'show']);

    Route::apiResource('projects', ProjectController::class);
    Route::post('projects/{project}/clone', [ProjectController::class, 'clone']);
    Route::post('projects/{project}/index-rag', [ProjectController::class, 'indexRag']);
    Route::get('projects/{project}/map', [ProjectController::class, 'map']);

    Route::apiResource('runs', RunController::class)->except(['update']);
    Route::post('runs/{run}/{action}', [RunController::class, 'action'])
         ->whereIn('action', ['start','pause','resume','cancel','retry','approve','reject','commit','push']);

    Route::get('runs/{run}/events', [RunEventController::class, 'index']);
    Route::get('runs/{run}/events/stream', [RunEventController::class, 'stream']); // SSE
    Route::get('runs/{run}/files', [RunFileController::class, 'index']);
    Route::get('runs/{run}/diff', [RunFileController::class, 'diff']);
    Route::get('runs/{run}/snapshots', [SnapshotController::class, 'index']);
    Route::post('snapshots/{snapshot}/restore', [SnapshotController::class, 'restore']);

    Route::get('settings/server-params', [ServerParamController::class, 'index']);
    Route::put('settings/server-params', [ServerParamController::class, 'update']);
});

8.2 Controllers

Generate via php artisan make:controller Api/ProjectController --api etc. All controllers thin — delegate to services. Standard Laravel API resources (ProjectResource, AgentRunResource, AgentEventResource, …) shape responses. Errors as RFC 7807 via app/Exceptions/Handler.php override (see spec page 14).

8.3 Sanctum abilities

Register in AppServiceProvider::boot():

Sanctum::$accessTokenAuthenticationCallback = null; // default
// abilities: projects:read, projects:write, runs:start, runs:control, git:push, settings:write

Map Spatie roles to default ability sets in app/Policies/AbilityMap.php.

8.4 Rate limiting

In RouteServiceProvider::boot():

RateLimiter::for('api',        fn ($r) => Limit::perMinute(60)->by($r->user()?->id ?? $r->ip()));
RateLimiter::for('runs-start', fn ($r) => Limit::perMinute(6)->by($r->user()->id));

Apply runs-start to POST /api/runs and POST /api/runs/{run}/start.


9. Queue & Reverb infra

  • config/horizon.php — supervisors: agents-default, agents-long (timeout 3600s), rag-index, docs.
  • config/queue.php — default redis, named queues mirror Horizon supervisors.
  • config/broadcasting.php — default reverb.
  • routes/channels.php:
Broadcast::channel('runs.{runId}',  fn ($user, $runId) => $user->can('view', AgentRun::findOrFail($runId)));
Broadcast::channel('projects.{id}', fn ($user, $id)    => $user->can('view', Project::findOrFail($id)));
Broadcast::channel('dashboard',     fn ($user)         => true);

10. Filament admin shell (B1)

Under app/Filament/Resources/:

  • ProjectResource — table + create/edit; "Clone now" and "Reindex RAG" header actions (stubbed in B1).
  • AgentRunResource — read-only list + view page; status pill column; link to web run console.
  • AgentProviderResource — toggle enabled, edit default model.
  • ServerParamResource — list with masked secrets, edit form per param, reveal action writes an audit_logs row.
  • UserResource — Spatie role assignment.
  • ApiTokenResource — create (one-time-reveal + QR), revoke.
  • AuditLogResource — read-only, filters by actor/action/target/time.
  • RunReviewResource — read-only.

Filament policy classes per resource map Spatie roles to abilities.


11. Web layout shell (no live console yet)

  • resources/views/layouts/console.blade.php — Tailwind full-bleed layout with sidebar slot, header slot, content slot.
  • Empty Livewire components registered for Console\RunConsole, Files\DiffViewer, etc., rendering "Coming in B2" placeholders so routes don't 404.
  • Vite config wired; npm run build works in CI.

12. Security baseline (B1 minimum)

  • Crypt::encryptString on every server_params.value_encrypted and project_connections.config_encrypted write.
  • Never echo value_encrypted in JSON; ServerParamResource API exposes {key, label, type, is_secret, masked_value, updated_at}.
  • PathSafetyService::assertInsideWorkspace is called from every controller that accepts a path parameter; throws 403 on traversal attempts.
  • CommandPolicyService denies everything in B1 (no agent commands run yet). B2 introduces the real allowlist.
  • All mutating routes write an audit_logs row via AuditLogger.
  • Force HTTPS in production (AppServiceProvider: URL::forceScheme('https') when APP_ENV=production).

13. Test plan (Pest)

tests/Feature/:

  • MigrationsTest — runs all migrations on the configured driver and asserts every expected index exists (introspect via DB::select).
  • ProjectCrudTest — create/list/show/update/delete via API; policy checks.
  • RunLifecycleTest — create draft → start → assert status transitions and that runs:start rate limit kicks in at the 7th call.
  • EventsPaginationTest — seed 200 events, paginate by cursor ?since=, assert ordering.
  • ServerParamSecrecyTest — write openai.api_key, read API → secret never present; reveal endpoint requires settings:write ability and writes an audit row.
  • SanctumAbilityTest — token without runs:control cannot pause/resume; token with only projects:read cannot create a run.
  • AuditTest — every mutating route appends an audit row with correct before/after.
  • MultiDriverTest — when RAG_TEST_DRIVER=pgsql is set in CI, runs the rag_chunks migration against a pgvector-enabled Postgres and asserts the vector(3072) column and ivfflat index exist.

Target coverage in B1: 80%+ on Models, Services, and Controllers.


14. Acceptance criteria (B1 done = green if all true)

  1. php artisan migrate:fresh --seed succeeds on MySQL 8 and (with driver flip) on PostgreSQL 16 + pgvector.
  1. php artisan test passes locally and in CI on both drivers.
  1. vendor/bin/phpstan analyse returns 0 errors at level 6.
  1. php artisan horizon boots, supervisors visible.
  1. php artisan reverb:start boots; the dashboard channel auth round-trips from a Livewire stub.
  1. Filament admin login works for the user created by make:filament-user; all admin resources render.
  1. GET /api/projects, POST /api/projects, GET /api/runs, POST /api/runs work end-to-end through a Sanctum token created via ApiTokenResource.
  1. GET /api/settings/server-params returns masked values; PUT updates write an audit row and an encrypted value.
  1. SSE endpoint GET /api/runs/{run}/events/stream returns the seeded events when called with a valid token, and closes cleanly on client disconnect.
  1. Secrets do not appear in any HTTP response body, log line, or audit before/after payload (regex scan in CI).

15. Deliverable checklist (for the build agent)

  • Repository scaffolded as above, committed in logical chunks (feat: per migration group, per service folder).
  • .env.example complete, README.md updated with install steps, docker-compose.yml for MySQL + Redis + (optional) Postgres+pgvector.
  • docs/ folder created with stubs for all 14 auto-doc files from spec page 10; the auto-generated blocks are present but empty.
  • CHANGELOG_AI.md started with the B1 entry and the run id (run_id=B1-bootstrap).
  • CI workflow .github/workflows/ci.yml runs composer install, npm ci && npm run build, php artisan migrate --env=testing, php artisan test, vendor/bin/phpstan.
  • Tag the commit v0.1.0-B1.

16. Open questions to confirm before starting

  1. Default DB driver flip. Spec pages 01 and 03 currently say PostgreSQL+pgvector is the default. This build prompt locks MySQL as the default per your message. Confirm and I'll update pages 01/03 to match.
  1. api_tokens vs raw Sanctum. Keeping the wrapper table is a small write overhead but pays back for the iPhone pairing UI. Confirm OK.
  1. Vector storage on MySQL. Plan is JSON + app-layer cosine. Acceptable for v1; we'll cap warning at 50k chunks/project and prompt the operator to flip to Postgres. Confirm.
  1. Filament v3 stays through B2+. No plan to switch to Inertia. Confirm.

17. What ships next (B2 preview)

  • B2: OpenAI provider real calls + embeddings + indexer.
  • B3: Claude API + Claude Code adapter.
  • B4: GitService real exec + SnapshotService + WorkspaceService apply-patch.
  • B5: Live console (Livewire + Reverb) + Monaco diff viewer.
  • B6: iPhone client (per spec page 13).