---
phase: quick-260521-foj
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - lib/utils/sync-helpers.ts
  - lib/services/entity-sync.ts
autonomous: true
requirements:
  - QUICK-260521-FOJ
must_haves:
  truths:
    - "Full sync of Companies entity fetches all companies (active + inactive) — no isActive=true filter applied"
    - "Tickets sync with a company_id pointing at a company missing from the Pulse mirror does not raise tickets_company_id_fkey; the company_id is nullified and the ticket is preserved"
    - "`hasAppliedFilters` remains true on Companies full sync, so soft-delete of companies is NOT triggered"
    - "Existing behavior unchanged for all other entities — getActiveField/buildActiveFilter still works as before for resources, contacts, statuses, etc."
    - "`npx tsc --noEmit --pretty` passes after both edits"
  artifacts:
    - path: "lib/utils/sync-helpers.ts"
      provides: "New exported `buildCompaniesFilter()` returning `[{ field: 'id', op: 'gt', value: 0 }]`"
      contains: "export function buildCompaniesFilter"
    - path: "lib/services/entity-sync.ts"
      provides: "New `getValidCompanyIds()` private method + COMPANIES branch in full-sync filter chain + ticket company_id nullification block"
      contains: "buildCompaniesFilter"
  key_links:
    - from: "lib/services/entity-sync.ts (full-sync filter chain ~line 133-165)"
      to: "buildCompaniesFilter (lib/utils/sync-helpers.ts)"
      via: "import + branch above the generic `else` that calls buildActiveFilter"
      pattern: "entity === EntityType.COMPANIES.*buildCompaniesFilter"
    - from: "lib/services/entity-sync.ts (ticket validation block)"
      to: "companies table (is_deleted = false)"
      via: "getValidCompanyIds() called inside the `if (entity === EntityType.TICKETS)` block"
      pattern: "getValidCompanyIds"
---

<objective>
Fix the weekly-full and full ticket sync FK error (`tickets_company_id_fkey`) caused by Companies sync filtering out inactive companies that nonetheless have tickets in the same sync window.

Purpose: Every full and weekly-full sync since 2026-05-15 fails because Companies full sync applies `isActive=true` (via `buildActiveFilter`), which misses inactive companies that have tickets. The ticket transaction rolls back on the FK violation. Live evidence in `sync_history`: every recent full sync shows `[DATABASE_CONSTRAINT_ERROR] ... tickets_company_id_fkey`.

Output:
- A new `buildCompaniesFilter()` in `lib/utils/sync-helpers.ts` mirroring the `buildProjectPhasesFilter` template (`id > 0` to fetch all).
- A new branch in `entity-sync.ts` full-sync filter chain that routes COMPANIES through `buildCompaniesFilter()` instead of the generic `buildActiveFilter`.
- A defensive ticket `company_id` nullification block in `entity-sync.ts` (belt-and-suspenders for the rare case of truly hard-deleted Autotask companies).
</objective>

<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@./CLAUDE.md
@lib/utils/sync-helpers.ts
@lib/services/entity-sync.ts
@lib/services/postgres-client.ts

<interfaces>
<!-- Key contracts the executor will use. Already in the codebase — no exploration needed. -->

From `lib/utils/sync-helpers.ts` — existing filter-builder templates this task must mirror:
```ts
/**
 * Build filter for contract services (requires contractID filter — fetch all via contractIDs)
 * @returns Query filter array for active contract services
 */
export function buildContractServicesFilter(): Array<{ field: string; op: string; value: any }> {
  return [
    {
      field: 'contractID',
      op: 'gt',
      value: 0,
    },
  ];
}

/**
 * Build filter for project phases (Phases endpoint requires a filter)
 * @returns Query filter array for all phases
 */
export function buildProjectPhasesFilter(): Array<{ field: string; op: string; value: any }> {
  return [
    {
      field: 'id',
      op: 'gt',
      value: 0,
    },
  ];
}

/**
 * Get active status field name for entity
 * Returns 'isActive' for COMPANIES — DO NOT change this mapping. It is still
 * referenced by `buildActiveFilter` for other entities (resources, contacts, etc.).
 */
export function getActiveField(entity: EntityType): string | null { /* ... */ }
```

From `lib/services/entity-sync.ts` — the full-sync filter chain (~lines 130-170) is where the new COMPANIES branch goes:
```ts
const filters: Array<{ field: string; op: string; value: any }> = [];

// Special handling for entities that require filters
if (entity === EntityType.CONTRACTS) {
  filters.push(...buildContractsFilter());
  entityLogger.info('Full sync with status filter for active contracts');
} else if (entity === EntityType.CONTRACT_SERVICES) {
  filters.push(...buildContractServicesFilter());
  entityLogger.info('Full sync of all contract services');
} else if (entity === EntityType.PROJECTS) {
  filters.push(...buildProjectsFilter());
  entityLogger.info('Full sync with status filter for non-completed projects');
} else if (entity === EntityType.PROJECT_PHASES) {
  filters.push(...buildProjectPhasesFilter());
  entityLogger.info('Full sync of all project phases');
} else if (entity === EntityType.TIME_ENTRIES) { /* ... */
} else if (entity === EntityType.BILLING_ITEMS) { /* ... */
} else {
  // Add active filter if applicable
  const activeFilter = buildActiveFilter(entity);
  if (activeFilter) {
    filters.push(...activeFilter);
    entityLogger.info('Full sync with active filter');
  }
  // ... date range filter ...
}
```

From `lib/services/entity-sync.ts` — the existing `getValidResourceIds` template the new method must mirror (lines 710-718):
```ts
/**
 * Get all valid resource IDs from the database
 * Used to validate foreign key references before insert
 * @returns Set of valid resource IDs
 */
private async getValidResourceIds(): Promise<Set<number>> {
  const query = 'SELECT id FROM resources WHERE is_deleted = false';
  const result = await postgresClient.query<{ id: number }>(query);
  return new Set(result.rows.map(row => Number(row.id)));
}
```

From `lib/services/entity-sync.ts` — the existing tickets validation block where the new `company_id` validation slots in (lines 235-285):
- Lines 238-252: filters out tickets without `company_id` (`recordsWithoutCompany`). KEEP AS-IS.
- Lines 255-285: nullifies invalid `assigned_resource_id` / `first_response_*` resource FKs against `getValidResourceIds`. KEEP AS-IS.
- NEW VALIDATION goes immediately after the `recordsWithoutCompany` filter, before or alongside the resource validation block. Must use the same `mappedRecords = mappedRecords.map(...)` mutation pattern and the same `entityLogger.warn` shape.

From `lib/services/postgres-client.ts` — singleton DB interface:
```ts
postgresClient.query<{ id: number }>(sql, params?: any[]): Promise<QueryResult<{ id: number }>>
```
</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: Add buildCompaniesFilter and wire COMPANIES into the full-sync filter chain</name>
  <files>lib/utils/sync-helpers.ts, lib/services/entity-sync.ts</files>
  <action>
**Step 1 — `lib/utils/sync-helpers.ts`:**

Add a new exported function `buildCompaniesFilter()` adjacent to `buildContractServicesFilter` / `buildProjectPhasesFilter` (around line 92, right after `buildContractServicesFilter`). Mirror the JSDoc style of `buildContractServicesFilter` exactly:

```ts
/**
 * Build filter for companies (Companies endpoint requires a filter — use id > 0 to fetch all
 * companies regardless of isActive. Active-only filtering misses inactive companies that
 * have tickets, causing tickets_company_id_fkey violations on full sync.)
 * @returns Query filter array for all companies
 */
export function buildCompaniesFilter(): Array<{ field: string; op: string; value: any }> {
  return [
    {
      field: 'id',
      op: 'gt',
      value: 0,
    },
  ];
}
```

DO NOT touch `getActiveField(COMPANIES)` — leave it returning `'isActive'`. It's still referenced by `buildActiveFilter` for other entities and by call sites we are not changing.

**Step 2 — `lib/services/entity-sync.ts`:**

1. Update the sync-helpers import at the top of the file (currently lines 13-25). Add `buildCompaniesFilter` to the named imports — alphabetize alongside the other `build*` helpers. The import block becomes:

```ts
import {
  getAutotaskEntityName,
  buildIncrementalFilter,
  buildActiveFilter,
  buildDateRangeFilter,
  buildCompaniesFilter,
  buildContractsFilter,
  buildContractServicesFilter,
  buildProjectsFilter,
  buildProjectPhasesFilter,
  buildTimeEntriesFilter,
  buildBillingItemsFilter,
  getTableName
} from '../utils/sync-helpers';
```

2. In the full-sync filter chain (around lines 133-165), add a new branch for COMPANIES. Place it as the FIRST branch in the chain (just after the `const filters: Array<...> = [];` declaration and before the existing `if (entity === EntityType.CONTRACTS)` branch). The chain after edit:

```ts
const filters: Array<{ field: string; op: string; value: any }> = [];

// Special handling for entities that require filters
if (entity === EntityType.COMPANIES) {
  filters.push(...buildCompaniesFilter());
  entityLogger.info('Full sync of all companies (active + inactive)');
} else if (entity === EntityType.CONTRACTS) {
  filters.push(...buildContractsFilter());
  entityLogger.info('Full sync with status filter for active contracts');
} else if (entity === EntityType.CONTRACT_SERVICES) {
  // ... existing branches unchanged ...
```

Critical: the new COMPANIES branch MUST execute BEFORE the generic `else` block that calls `buildActiveFilter`. Result: the active filter is no longer applied to Companies on full sync, so inactive companies are fetched too.

**Why this is safe (state this in your verification reasoning, do not add it as a code comment):**
- `hasAppliedFilters` (entity-sync.ts:173) stays true because the new filter array is non-empty (`id > 0`). So `softDeleteMissingRecords` is NOT triggered for companies — no accidental mass soft-delete.
- The existing DB already has 145 `is_active=false` companies referenced by tickets; downstream UI/API code tolerates inactive companies.
- Companies don't support incremental sync (entity-sync.ts:106 `supportsIncremental` excludes COMPANIES), so this code path runs on every Companies sync — full, weekly-full, and the incremental→full fallback path.

DO NOT touch:
- `getActiveField`.
- The `lastActivityDate` filter on tickets (out of scope).
- Any other branch in the filter chain.
- Adjacent formatting / unrelated code.
  </action>
  <verify>
    <automated>cd /opt/stacks/pulse && npx tsc --noEmit --pretty 2>&1 | tail -40 && echo "---" && grep -n "buildCompaniesFilter" lib/utils/sync-helpers.ts lib/services/entity-sync.ts && echo "---" && grep -n "entity === EntityType.COMPANIES" lib/services/entity-sync.ts</automated>
  </verify>
  <done>
- `buildCompaniesFilter` exported from `lib/utils/sync-helpers.ts` with the exact JSDoc and body shown above.
- `buildCompaniesFilter` imported in `lib/services/entity-sync.ts` named-imports block.
- Exactly one new COMPANIES branch in the full-sync filter chain, placed before all other `if/else if` branches, with the `entityLogger.info('Full sync of all companies (active + inactive)')` line.
- `getActiveField` and `buildActiveFilter` definitions UNCHANGED.
- `npx tsc --noEmit --pretty` passes with zero errors.
- `grep -n "buildCompaniesFilter" lib/services/entity-sync.ts` shows exactly two matches (one import, one usage).
  </done>
</task>

<task type="auto">
  <name>Task 2: Defensive company_id nullification in tickets sync (belt-and-suspenders)</name>
  <files>lib/services/entity-sync.ts</files>
  <action>
This task adds a second layer of defense after Task 1: even if a ticket somehow references a company that doesn't exist in the Pulse mirror (e.g. hard-deleted from Autotask), nullify the `company_id` rather than letting the FK violation roll back the whole ticket transaction.

**Step 1 — Add new private method `getValidCompanyIds`:**

Find the existing `getValidResourceIds` method (currently lines ~710-718, just after `calculateMonthlyChunks` is defined further down — search for `private async getValidResourceIds`). Add the new method immediately AFTER it, before `getValidContactIds`. Match the exact pattern:

```ts
  /**
   * Get all valid company IDs from the database
   * Used to validate foreign key references before insert
   * @returns Set of valid company IDs
   */
  private async getValidCompanyIds(): Promise<Set<number>> {
    const query = 'SELECT id FROM companies WHERE is_deleted = false';
    const result = await postgresClient.query<{ id: number }>(query);
    return new Set(result.rows.map(row => Number(row.id)));
  }
```

**Step 2 — Add the company_id validation block in the tickets validation pipeline:**

Find the existing `if (entity === EntityType.TICKETS) {` block that nullifies invalid resource IDs (currently lines ~254-285, the block that uses `getValidResourceIds`). Add a NEW `if (entity === EntityType.TICKETS) { ... }` block IMMEDIATELY ABOVE it (between the `recordsWithoutCompany` filter block at lines ~238-252 and the resource validation block at lines ~254-285). The new block:

```ts
      // Validate company_id foreign keys for tickets (defensive — Task 1 widening
      // the Companies filter should make this a near-zero count, but catches the
      // truly hard-deleted Autotask company case).
      if (entity === EntityType.TICKETS) {
        const validCompanyIds = await this.getValidCompanyIds();
        let nullifiedCompanyCount = 0;
        const nullifiedCompanySamples: number[] = [];
        mappedRecords = mappedRecords.map(ticket => {
          if (ticket.company_id && !validCompanyIds.has(ticket.company_id)) {
            if (nullifiedCompanySamples.length < 5) {
              nullifiedCompanySamples.push(ticket.id);
            }
            ticket.company_id = null;
            nullifiedCompanyCount++;
          }
          return ticket;
        });
        if (nullifiedCompanyCount > 0) {
          entityLogger.warn('Nullified ticket company_id for companies missing from mirror', {
            nullifiedCount: nullifiedCompanyCount,
            sampleTicketIds: nullifiedCompanySamples,
          });
        }
      }
```

Ordering rationale (must be respected):
1. `recordsWithoutCompany` filter (existing) — drops tickets without any `company_id`. Runs first so we only validate tickets that have a `company_id`.
2. NEW company_id nullification block — nullifies `company_id` that doesn't exist in mirror. Runs second.
3. Resource validation block (existing) — nullifies invalid resource refs. Runs third.
4. Everything else (`bulkUpsert` at ~line 372) runs after.

The `tickets.company_id` column is nullable (confirmed via the recent stopgap reconciliation work and existing handling that filters rows without `company_id`). Nullifying it is non-destructive — it preserves the ticket row and just severs the company link, matching the resource-FK handling pattern already used in the same function.

DO NOT touch:
- The existing `recordsWithoutCompany` filter block.
- The existing resource validation block.
- The chunked tickets sync (`syncTicketsChunked`) — out of scope. That path has its own (separate) flow; this fix is for `syncEntity`.
- `cachedValidResourceIds` or `cachedValidContactIds` fields.
- Adjacent code / formatting.

DO NOT manually trigger a sync from this task. Note in SUMMARY.md only:
```
Manual verify (user runs after merge):
  curl -X POST http://localhost:3100/api/sync/full \
    -H 'content-type: application/json' \
    -d '{"triggeredBy":"manual-verify"}'
Then check `sync_history` for the next ticket sync row — it should complete without `tickets_company_id_fkey` in `error_message`.
```
  </action>
  <verify>
    <automated>cd /opt/stacks/pulse && npx tsc --noEmit --pretty 2>&1 | tail -40 && echo "---" && grep -cn "getValidCompanyIds" lib/services/entity-sync.ts && echo "---" && grep -n "Nullified ticket company_id for companies missing from mirror" lib/services/entity-sync.ts && echo "---" && grep -n "if (entity === EntityType.TICKETS)" lib/services/entity-sync.ts</automated>
  </verify>
  <done>
- `getValidCompanyIds` method exists exactly once in `lib/services/entity-sync.ts`, mirroring `getValidResourceIds`.
- A new `if (entity === EntityType.TICKETS)` block exists between the `recordsWithoutCompany` filter and the resource validation block.
- The new block calls `await this.getValidCompanyIds()` and logs via `entityLogger.warn('Nullified ticket company_id for companies missing from mirror', ...)` when count > 0.
- `grep -c "getValidCompanyIds" lib/services/entity-sync.ts` returns 2 (one definition, one call site).
- `grep -n "if (entity === EntityType.TICKETS)" lib/services/entity-sync.ts` returns at least 2 matches (the new block + the existing resource validation block); ordering must put the new block earlier in the file (lower line number) than the resource block.
- `npx tsc --noEmit --pretty` passes with zero errors.
- No edits to `syncTicketsChunked`, `getValidResourceIds`, `getValidContactIds`, or `getValidProjectIds`.
  </done>
</task>

</tasks>

<verification>
1. Type check passes: `npx tsc --noEmit --pretty` from repo root — zero errors.
2. Targeted greps:
   - `grep -n "buildCompaniesFilter" lib/utils/sync-helpers.ts lib/services/entity-sync.ts` → 3 matches (one definition + import + usage).
   - `grep -c "getValidCompanyIds" lib/services/entity-sync.ts` → 2.
   - `grep -n "entity === EntityType.COMPANIES" lib/services/entity-sync.ts` → 1 new match in the filter chain.
3. Negative greps (proving we didn't touch out-of-scope code):
   - `git diff lib/utils/sync-helpers.ts -- ':!**/buildCompaniesFilter*'` should show only the new function added.
   - `git diff lib/services/entity-sync.ts` should show: import addition, COMPANIES filter branch, new `getValidCompanyIds` method, and new ticket company_id validation block. NOTHING else.
4. Read-back sanity:
   - `entity-sync.ts` line ~152 (was the start of the generic `else` calling `buildActiveFilter`) is now NOT reached for COMPANIES because the new branch fires first.
   - `hasAppliedFilters` (line ~173) is still true for COMPANIES full sync because `buildCompaniesFilter()` returns a non-empty filter.
</verification>

<success_criteria>
- `lib/utils/sync-helpers.ts` exports `buildCompaniesFilter()` returning `[{ field: 'id', op: 'gt', value: 0 }]`.
- `lib/services/entity-sync.ts` routes COMPANIES through `buildCompaniesFilter` on full sync; the generic `buildActiveFilter` branch is no longer hit for COMPANIES.
- `lib/services/entity-sync.ts` defensively nullifies ticket `company_id` for any company missing from the Pulse mirror, mirroring the existing resource-FK nullification pattern.
- `npx tsc --noEmit --pretty` passes.
- No changes to `getActiveField`, `buildActiveFilter`, `syncTicketsChunked`, the reconciliation service from quick-260521-fci, or the sync-progress-lock code.
- SUMMARY.md notes the manual `curl -X POST http://localhost:3100/api/sync/full` verification step but does NOT execute it.
</success_criteria>

<output>
After completion, create `.planning/quick/260521-foj-fix-weekly-full-fk-error-widen-companies/260521-foj-SUMMARY.md` with:
- What changed in each file (1-2 lines per file).
- Why this fixes the FK error (1 paragraph: Companies sync was filtering on `isActive=true` via `buildActiveFilter`, missing inactive companies referenced by tickets; widening to `id > 0` closes the gap; the defensive nullification handles the residual hard-deleted-in-Autotask edge case).
- Manual verify step for the user: `curl -X POST http://localhost:3100/api/sync/full -H 'content-type: application/json' -d '{"triggeredBy":"manual-verify"}'`, then check `sync_history` for the next full ticket sync.
- Explicit note: did NOT modify `getActiveField`, `syncTicketsChunked`, or the reconciliation service.
</output>
