# Implement Shadow Sync Event Publisher for Projects & Tasks

## Context

We are transitioning to a **Google Cloud Pub/Sub Event-Driven Architecture (EDA)**. To execute this safely without production outages, we are deploying a **"Shadow Sync"**. Whenever a Project or ProjectTask is created/updated/deleted in SuiteX, the backend must secretly wrap that change into a strict **Canonical JSON Envelope** and drop it onto an async queue (`events.raw`). This allows us to test the new EDA pipeline with real production data while the legacy NetSuite sync continues to handle the actual live updates untouched.

## Current System Behavior

- Saves currently trigger the legacy outbound sync to the NetSuite API directly.
- No asynchronous JSON "Canonical Payload" emission exists natively in SuiteX today for these specific entities.

## Description

Build the backend foundation for Epic 1 ("Capture"). Using **Laravel Observers**, trace lifecycle events on the **Project** and **ProjectTask** models. Map their data using a new **CanonicalEventMapper** to match the canonical JSON contract (located in the `/schema` directory), and publish them asynchronously using a custom **SyncEventPublisherInterface**. This entire functionality must be wrapped in a database-driven global **Feature Flag** so emission only happens on enabled tenants.

## Areas to Review

- The `/schema` directory (specifically the ProjectPayload contract introduced in PR #551).
- **Project** and **ProjectTask** Eloquent models.

## Deliverables

| Deliverable | Description |
|-------------|-------------|
| **Observers** | Create ProjectObserver and ProjectTaskObserver configured for `created`, `updated`, and `deleted` events. |
| **Mapper Service** | Build a CanonicalEventMapper class that translates SuiteX database fields strictly into the expected `/schema/ProjectPayload.json` structure (adding `sourceSystem: suitex`, and injecting a UUID string as the generic `writeId` per event). |
| **Publisher Contract** | Create a SyncEventPublisherInterface with a LocalLogPublisher driver implementation for local testing before the final Pub/Sub SDK is injected. |
| **Queue Worker Structure** | Dispatch these jobs to a dedicated Horizon/Laravel Job Queue (e.g. `sync-events-queue`) to decouple payloads from the HTTP request cycle completely. |
| **Feature Flagging** | Build or utilize a ShadowSyncFeatureFlag service. Wrap the Publisher dispatch logic so the shadow event is **only** emitted if the `shadow_sync` flag is enabled for the current Tenant in the `feature_flags` database. |
| **Migration** | Create the migration/seeder to inject the `shadow_sync` row into the `feature_flags` table. |

## Acceptance Criteria

- [ ] **Scenario 1 (Shadow Emission):** Given `shadow_sync` is enabled; When a User saves a Project; Then the Legacy sync executes normally **and** an async job successfully payload stringifies the canonical JSON envelope utilizing the LocalLogPublisher.
- [ ] **Scenario 2 (Asynchronous Failure Safety):** If the LocalLogPublisher crashes or the async worker fails, the HTTP request must **not** return a 500 error, and the UI save must complete seamlessly to the user.
- [ ] Observers only trigger the mapper logic for the explicitly requested Models (Project, ProjectTask).

## Related Files (No Changes Required)

Legacy NetSuite Restlet/Sync Controllers must remain 100% active and untouched during this phase.

## Technical Notes

### 1. Payload Mapping Rules (The Contract)

The CanonicalEventMapper must strictly adhere to the schema structure defined in `ProjectPayload.json`.

```json
{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "title": "ProjectPayload",
  "type": "object",
  "properties": {
    "title": { "type": "string" },
    "status": { "type": "string" },
    "customer": { "type": "string" },
    "subsidiary": { "type": "string" },
    "projectmanager": { "type": "string" },
    "isInactive": { "type": "boolean" },
    "startDate": { "type": "string", "format": "date-time" },
    "tags": { "type": "array", "items": { "type": "string" } }
  }
}
```

**Strict rules on the mapping:**

- **isInactive:** Must be implicitly cast to a strict standard PHP boolean prior to JSON encoding (do not pass `"T"` or `"F"`).
- **startDate:** Must be formatted to strict ISO8601 Date-Time format string natively.
- **tags:** If applicable, sort the array alphanumerically before packing.

### 2. Deletion Event Exception (Tombstones)

When the `deleted` observer triggers, do **not** map the full payload above. It must emit a lightweight **"tombstone"** event to the Publisher indicating raw removal. Example expected output:

```json
{
  "writeId": "<uuid>",
  "sourceSystem": "suitex",
  "action": "deleted",
  "id": 1234
}
```
