# Epic 4 Technical Spec: Shadow Event Emitter

## Architecture & Execution Flow

The capture mechanism must be completely asynchronous and decoupled from the publishing infrastructure to avoid disrupting the legacy sync or blocking the user's HTTP request.

Execution path:

A user saves a Project via the existing SuiteX controller.

ProjectObserver (at src/Domain/Projects/Observers/ProjectObserver.php) detects the Eloquent created, updated, or deleted event.

The observer checks the shadow_sync feature flag via ShadowSyncFeatureFlag::enabledForCurrentTenant().

If true, the observer dispatches a queued job (FormatAndPublishSyncEvent).

The background job uses CanonicalEventMapper to transform the model into the Canonical JSON Envelope.

The job passes the payload to the injected SyncEventPublisherInterface (currently bound to the local log driver).

## Data Interception — Observers

Both observers already exist in the codebase. They must be extended, not replaced.

Observer

Path

Already registered?

ProjectObserver

src/Domain/Projects/Observers/ProjectObserver.php

Yes — AppServiceProvider::boot()

ProjectTaskObserver

src/Domain/ProjectTasks/Observers/ProjectTaskObserver.php

No — must be added

Observer registration in SuiteX lives in AppServiceProvider::boot(), not in an EventServiceProvider.

Note on coexistence with RecordObserver: Both Project and ProjectTask are also registered against the legacy  RecordObserver  and  RecordCacheInvalidator  in  EventServiceProvider::$observedModels.  Laravel fires multiple observers on the same model sequentially — this is intentional and there is no conflict. The domain observers fire post-commit ($afterCommit = true), while RecordObserver fires inline during the transaction. Shadow dispatch must not be added to RecordObserver for two reasons: (1) it fires before the record is fully committed, meaning external_id/refid/synced fields may not yet be set; (2) it couples the new event pipeline to legacy NetSuite sync logic that will eventually be removed.

Namespace Convention

Domain\{DomainName}\Observers\{ModelName}Observer

Registration (AppServiceProvider)

// src/App/Providers/AppServiceProvider.php — boot() method

// Already present — do not change:
\Domain\Projects\Models\Project::observe(\Domain\Projects\Observers\ProjectObserver::class);

// Add if not present:
\Domain\ProjectTasks\Models\ProjectTask::observe(\Domain\ProjectTasks\Observers\ProjectTaskObserver::class);

ProjectObserver

Extends the existing cache-invalidation logic with shadow dispatch.

// src/Domain/Projects/Observers/ProjectObserver.php

namespace Domain\Projects\Observers;

use App\Jobs\Sync\FormatAndPublishSyncEvent;
use App\Services\Sync\ShadowSyncFeatureFlag;
use Domain\Projects\Models\Project;
use Domain\Projects\Services\ProjectLookupService;
use Illuminate\Support\Str;

class ProjectObserver
{
    public $afterCommit = true;

    public function created(Project $project): void
    {
        app(ProjectLookupService::class)->forget($project->refid);
        $this->triggerShadowEvent($project, 'create');
    }

    public function updated(Project $project): void
    {
        app(ProjectLookupService::class)->forget($project->refid);
        $this->triggerShadowEvent($project, 'update');
    }

    public function deleted(Project $project): void
    {
        app(ProjectLookupService::class)->forget($project->refid);
        $this->triggerShadowEvent($project, 'delete');
    }

    public function restored(Project $project): void
    {
        app(ProjectLookupService::class)->forget($project->refid);
    }

    private function triggerShadowEvent(Project $project, string $eventType): void
    {
        if (app(ShadowSyncFeatureFlag::class)->enabledForCurrentTenant()) {
            $writeId = (string) Str::uuid();
            FormatAndPublishSyncEvent::dispatch($project, 'project', $eventType, $writeId);
        }
    }
}

ProjectTaskObserver

The isImport() guard (from the HasImportFlag trait) prevents shadow events from firing during bulk import jobs — consistent with the existing observer convention.

// src/Domain/ProjectTasks/Observers/ProjectTaskObserver.php

namespace Domain\ProjectTasks\Observers;

use App\Jobs\Sync\FormatAndPublishSyncEvent;
use App\Services\Sync\ShadowSyncFeatureFlag;
use Domain\ProjectTasks\Models\ProjectTask;
use Illuminate\Support\Str;

class ProjectTaskObserver
{
    public function created(ProjectTask $projectTask): void
    {
        $this->triggerShadowEvent($projectTask, 'create');
    }

    public function updated(ProjectTask $projectTask): void
    {
        $this->triggerShadowEvent($projectTask, 'update');
    }

    public function deleted(ProjectTask $projectTask): void
    {
        $this->triggerShadowEvent($projectTask, 'delete');
    }

    public function restored(ProjectTask $projectTask): void
    {
        //
    }

    public function forceDeleted(ProjectTask $projectTask): void
    {
        //
    }

    private function triggerShadowEvent(ProjectTask $projectTask, string $eventType): void
    {
        if (app(ShadowSyncFeatureFlag::class)->enabledForCurrentTenant() && !$projectTask->isImport()) {
            $writeId = (string) Str::uuid();
            FormatAndPublishSyncEvent::dispatch($projectTask, 'projecttask', $eventType, $writeId);
        }
    }
}

## Payload Transformation — CanonicalEventMapper

Per the Service Placement Matrix, this service is cross-domain infrastructure and belongs in src/App/Services/Sync/.

Note: SuiteX has no account_id column on projects or projecttasks. Tenant identity is resolved at runtime via TenantService::getCurrentTenantId(). Field names follow NetSuite convention (e.g., isinactive, startdate) — verify exact column names against the migration files before finalising.

// src/App/Services/Sync/CanonicalEventMapper.php

namespace App\Services\Sync;

use App\Services\TenantService;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;

class CanonicalEventMapper
{
    public function __construct(private TenantService $tenantService) {}

    public function buildEnvelope(Model $model, string $recordType, string $eventType, string $writeId): array
    {
        return [
            'schemaVersion' => 'v1',
            'eventId'       => (string) Str::uuid(),
            'accountId'     => $this->tenantService->getCurrentTenantId(),
            'recordType'    => $recordType,
            'recordId'      => (string) $model->id,
            'eventType'     => $eventType,
            'source'        => 'suitex',
            'timestamp'     => now()->timezone('UTC')->toIso8601String(),
            'sourceSystem'  => 'suitex',
            'writeId'       => $writeId,
            'changes'       => $this->mapChanges($model, $recordType),
        ];
    }

    private function mapChanges(Model $model, string $recordType): array
    {
        if ($recordType === 'project') {
            return [
                'title'      => $model->title,
                'isInactive' => (bool) ($model->isinactive ?? false),
                'startDate'  => $model->startdate ? Carbon::parse($model->startdate)->toDateString() : null,
            ];
        }

        if ($recordType === 'projecttask') {
            return [
                'title'     => $model->title,
                'status'    => $model->status ?? null,
                'startDate' => $model->startdate ? Carbon::parse($model->startdate)->toDateString() : null,
                'endDate'   => $model->enddate ? Carbon::parse($model->enddate)->toDateString() : null,
            ];
        }

        return [];
    }
}

## Publisher — Dependency Inversion

Because Epic 2 (GCP Pub/Sub) infrastructure is not yet provisioned, we define an interface so this Epic can proceed independently. When Epic 2 is complete, a single binding change in AppServiceProvider switches the driver.

Interface

// src/App/Contracts/Sync/SyncEventPublisherInterface.php

namespace App\Contracts\Sync;

interface SyncEventPublisherInterface
{
    public function publish(string $topic, array $canonicalPayload): void;
}

Local Log Driver (temporary)

// src/App/Services/Sync/Publishers/LocalLogPublisher.php

namespace App\Services\Sync\Publishers;

use App\Contracts\Sync\SyncEventPublisherInterface;
use Illuminate\Support\Facades\Log;

class LocalLogPublisher implements SyncEventPublisherInterface
{
    public function publish(string $topic, array $canonicalPayload): void
    {
        Log::channel('sync_shadow')->info("Mock Publish to {$topic}", $canonicalPayload);
    }
}

Service Provider Binding

Bind in AppServiceProvider::register(). To upgrade to GCP Pub/Sub (Epic 2), change the second argument to GoogleCloudPubSubPublisher::class.

// src/App/Providers/AppServiceProvider.php — register() method

$this->app->bind(
    \App\Contracts\Sync\SyncEventPublisherInterface::class,
    \App\Services\Sync\Publishers\LocalLogPublisher::class
);

Asynchronous Job

Runs on Laravel Horizon/Redis in the background. Follows the existing src/App/Jobs/ convention with a Sync subfolder.

// src/App/Jobs/Sync/FormatAndPublishSyncEvent.php

namespace App\Jobs\Sync;

use App\Contracts\Sync\SyncEventPublisherInterface;
use App\Services\Sync\CanonicalEventMapper;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;

class FormatAndPublishSyncEvent implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public function __construct(
        public Model $model,
        public string $recordType,
        public string $eventType,
        public string $writeId
    ) {}

    public function handle(CanonicalEventMapper $mapper, SyncEventPublisherInterface $publisher): void
    {
        $payload = $mapper->buildEnvelope($this->model, $this->recordType, $this->eventType, $this->writeId);
        $publisher->publish('events.raw', $payload);
    }
}

## Feature Flag — ShadowSyncFeatureFlag Service

The flag is stored in the core mysql database's feature_flags table — the same table used by the chunked_import flag. No new config file is needed.

The tenant_ids column is a JSON array of integer tenant IDs. A tenant is considered enabled if its ID appears in that array, regardless of the top-level enabled column (matching the chunked_import convention).

A dedicated service class avoids duplicating the DB lookup across both observers and provides a single place to add caching later.

// src/App/Services/Sync/ShadowSyncFeatureFlag.php

namespace App\Services\Sync;

use App\Services\TenantService;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;

class ShadowSyncFeatureFlag
{
    public function __construct(private TenantService $tenantService) {}

    public function enabledForCurrentTenant(): bool
    {
        try {
            $tenantId = $this->tenantService->getCurrentTenantId();

            if (!$tenantId) {
                return false;
            }

            $flag = DB::connection('mysql')
                ->table('feature_flags')
                ->where('name', 'shadow_sync')
                ->first();

            if (!$flag) {
                return false;
            }

            $enabledTenants = array_map('intval', json_decode($flag->tenant_ids, true) ?? []);

            return in_array((int) $tenantId, $enabledTenants, true);
        } catch (\Exception $e) {
            Log::warning('ShadowSyncFeatureFlag: could not read feature flag', ['error' => $e->getMessage()]);
            return false;
        }
    }
}

## Database Seeding — Migration

Add a migration to insert the shadow_sync row into feature_flags. The flag starts with an empty tenant_ids array — enable it per tenant via a direct DB update or admin tooling.

// database/migrations/YYYY_MM_DD_000000_add_shadow_sync_feature_flag.php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;

class AddShadowSyncFeatureFlag extends Migration
{
    public function up(): void
    {
        DB::table('feature_flags')->insertOrIgnore([
            'name'        => 'shadow_sync',
            'enabled'     => false,
            'tenant_ids'  => '[]',
            'description' => 'Enables shadow event emission to the new Event-Driven Architecture pipeline (GCP Pub/Sub) while the legacy NetSuite sync continues to run.',
            'created_at'  => now(),
            'updated_at'  => now(),
        ]);
    }

    public function down(): void
    {
        DB::table('feature_flags')->where('name', 'shadow_sync')->delete();
    }
}

## Directory Layout

```text
database/
  migrations/
    YYYY_MM_DD_000000_add_shadow_sync_feature_flag.php

src/
  App/
    Contracts/
      Sync/
        SyncEventPublisherInterface.php
    Jobs/
      Sync/
        FormatAndPublishSyncEvent.php
    Providers/
      AppServiceProvider.php                      ← binding + observer registration
    Services/
      Sync/
        CanonicalEventMapper.php
        ShadowSyncFeatureFlag.php
        Publishers/
          LocalLogPublisher.php
  Domain/
    Projects/
      Observers/
        ProjectObserver.php                       ← extended with shadow dispatch
    ProjectTasks/
      Observers/
        ProjectTaskObserver.php                   ← extended with shadow dispatch
```

