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)
| Decision | Value | Why |
|---|---|---|
| PHP | 8.3 | Required by Laravel 11 and Filament 3. |
| Framework | Laravel 11.x | Latest LTS-adjacent, native Reverb, slim skeleton. |
| Default DB | MySQL 8.0 (or MariaDB 11.4+) | Operator preference; standard Laravel compatibility. |
| Optional DB | PostgreSQL 16 + pgvector 0.7 | Enabled only for RAG-heavy installs; switchable via .env. |
| Queue | Redis 7 • Horizon 5 | Long-running agent runs need supervision. |
| Cache / locks | Redis | Workspace locks one agent per project. |
| Broadcaster | Laravel Reverb | Native WS, no third-party. |
| Web auth | Laravel Fortify (session + 2FA) | For Filament admin login. |
| API auth | Sanctum personal-access tokens with abilities | iPhone pairing. |
| Admin shell | Filament v3 | Fastest path to admin CRUD. |
| Operator UI | Livewire 3 • Alpine + Tailwind | Console & diff viewer; not in B1 scope but layout shell installed. |
| Tests | Pest 3 | Modern, readable. |
| Static analysis | Larastan level 6 | Catch regressions early. |
| Vector storage | MySQL: embedding as JSON • content_hash index; PG: vector(3072) with ivfflat | Single 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=120Nothing 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.
users
personal_access_tokens(Sanctum default)
permissions,roles,model_has_*(Spatie default — not custom)
projects
project_connections
agent_runs
agent_events
workspace_files
workspace_snapshots
rag_chunks
agent_providers
agent_settings
git_operations
documentation_updates
command_executions
run_reviews
server_params
api_tokens(custom metadata wrapper around Sanctum)
audit_logs
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:
DatabaseSeederorchestrates.
RoleSeeder— Spatie roles:owner, admin, operator, reviewer, viewer.
AgentProviderSeeder— inserts the 4 providers withenabled=trueanddefault_model.
ServerParamSeeder— inserts every key fromconfig/agent_workspace.phpwith its default, marking API keys asis_secret=true.
DemoSeeder(only whenAPP_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:writeMap 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— defaultredis, named queues mirror Horizon supervisors.
config/broadcasting.php— defaultreverb.
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 anaudit_logsrow.
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 buildworks in CI.
12. Security baseline (B1 minimum)
Crypt::encryptStringon everyserver_params.value_encryptedandproject_connections.config_encryptedwrite.
- Never echo
value_encryptedin JSON;ServerParamResourceAPI exposes{key, label, type, is_secret, masked_value, updated_at}.
PathSafetyService::assertInsideWorkspaceis called from every controller that accepts a path parameter; throws403on traversal attempts.
CommandPolicyServicedenies everything in B1 (no agent commands run yet). B2 introduces the real allowlist.
- All mutating routes write an
audit_logsrow viaAuditLogger.
- Force HTTPS in production (
AppServiceProvider:URL::forceScheme('https')whenAPP_ENV=production).
13. Test plan (Pest)
tests/Feature/:
MigrationsTest— runs all migrations on the configured driver and asserts every expected index exists (introspect viaDB::select).
ProjectCrudTest— create/list/show/update/delete via API; policy checks.
RunLifecycleTest— create draft → start → assert status transitions and thatruns:startrate limit kicks in at the 7th call.
EventsPaginationTest— seed 200 events, paginate by cursor?since=, assert ordering.
ServerParamSecrecyTest— writeopenai.api_key, read API → secret never present; reveal endpoint requiressettings:writeability and writes an audit row.
SanctumAbilityTest— token withoutruns:controlcannot pause/resume; token with onlyprojects:readcannot create a run.
AuditTest— every mutating route appends an audit row with correctbefore/after.
MultiDriverTest— whenRAG_TEST_DRIVER=pgsqlis set in CI, runs therag_chunksmigration against a pgvector-enabled Postgres and asserts thevector(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)
php artisan migrate:fresh --seedsucceeds on MySQL 8 and (with driver flip) on PostgreSQL 16 + pgvector.
php artisan testpasses locally and in CI on both drivers.
vendor/bin/phpstan analysereturns 0 errors at level 6.
php artisan horizonboots, supervisors visible.
php artisan reverb:startboots; thedashboardchannel auth round-trips from a Livewire stub.
- Filament admin login works for the user created by
make:filament-user; all admin resources render.
GET /api/projects,POST /api/projects,GET /api/runs,POST /api/runswork end-to-end through a Sanctum token created viaApiTokenResource.
GET /api/settings/server-paramsreturns masked values;PUTupdates write an audit row and an encrypted value.
- SSE endpoint
GET /api/runs/{run}/events/streamreturns the seeded events when called with a valid token, and closes cleanly on client disconnect.
- Secrets do not appear in any HTTP response body, log line, or audit
before/afterpayload (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.examplecomplete,README.mdupdated with install steps,docker-compose.ymlfor 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.mdstarted with the B1 entry and the run id (run_id=B1-bootstrap).
- CI workflow
.github/workflows/ci.ymlrunscomposer 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
- 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.
api_tokensvs raw Sanctum. Keeping the wrapper table is a small write overhead but pays back for the iPhone pairing UI. Confirm OK.
- 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.
- 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).