# Scratchpad — App-Wide Search Rolling notes, decisions, links, and gotchas. Append, don't rewrite history. --- ## Decisions (with rationale) - **2026-05-13 — Single global index table `app_search_index`** rather than per-table `tsvector` columns. *Why:* one query searches everything; uniform ranking; one GIN index to maintain; lets us denormalize ACL columns alongside content. Trade-off: re-index needed on permission changes — accepted. - **2026-05-13 — Postgres FTS + pg_trgm**, not Meilisearch/Typesense. *Why:* no new infra; respects Citus tenant sharding natively; simpler ACL story. Can graduate later if relevance becomes the bottleneck — the indexer interface won't change. - **2026-05-13 — MSP portal only for v1.** Client portal deferred to v2. *Why:* tighter ACL surface area; the same index table can be queried from client portal later with a stricter filter. - **2026-05-13 — CE codebase, EE inherits.** No edition-conditional code at v1. *Why:* EE is a superset of CE; new EE entities (extensions, etc.) can register their own indexers later without touching the core. - **2026-05-13 — No PostHog feature flag.** *Why:* user choice. Implies the test suite must be the gate; ACL leakage tests in particular must be exhaustive. - **2026-05-13 — Denormalized ACL columns on the index** (`visible_to_user_ids`, `visible_to_roles`, `is_internal_only`, `is_private`, `client_scope_id`, `required_permission`). *Why:* filtering in SQL is the only way pagination/ranking stay correct without massive over-fetch. Cost: ACL changes require re-index — handled by the existing event-driven indexer. - **2026-05-13 — Two-layer ACL** (denormalized SQL filter + record-level final pass). *Why:* defence in depth; record-level pass catches drift bugs and is logged as `search.acl_drift` telemetry. - **2026-05-13 — 64 KB body cap.** *Why:* FTS quality plateaus past this; documents and long comment threads would bloat the index without recall benefit. - **2026-05-13 — Time-decay ranking** `exp(-age_days / 90)`, floor 0.05. *Why:* MSP users almost always want recent records; pure `ts_rank_cd` ignores recency. Constants are best-guess; revisit with telemetry. - **2026-05-13 — Reuse existing `public.process_large_lexemes()`** Postgres function as the body cleanser (strips base64 data URIs, caps to 500 KB) before `to_tsvector`. *Why:* it already exists and was written for exactly this hazard. --- ## Recon findings ### Sidebar / nav - Main MSP sidebar: `server/src/components/layout/Sidebar.tsx` - Sub-components: `SidebarMenuItem.tsx`, `SidebarSubMenuItem.tsx`, `SidebarBottomMenuItem.tsx` - `cmdk@^1.0.4` is already in `package.json` but no command palette is rendered yet. - i18n namespace `msp/core` (`server/public/locales/en/msp/core.json`); add `search.*` keys. ### Entity tables (CE) | Entity | Table | PK | Tenant in PK | |---|---|---|---| | Client | `clients` | `client_id` | yes | | Contact | `contacts` | `contact_name_id` | yes | | User | `users` | `user_id` (+ email) | yes | | Ticket | `tickets` | `ticket_id` | yes | | Ticket comment | `comments` | `comment_id` | yes — `is_internal` boolean present | | Project | `projects` | `project_id` | yes | | Project phase | `project_phases` | `phase_id` | yes | | Project task | `project_tasks` | `task_id` | yes | | Project task comment | `project_task_comments` | `task_comment_id` | yes — body is **BlockNote JSON** in `note` + `markdown_content` | | Asset | `assets` | `asset_id` | yes | | Invoice | `invoices` | `invoice_id` | yes | | Invoice item | `invoice_items` | `item_id` | yes | | Invoice annotation | `invoice_annotations` | `annotation_id` | yes | | Contract | `contracts` | `plan_id` | yes | | Contract line | `contract_lines` | (composite) | yes | | Client contract | `client_contracts` | `contract_id` | yes | | Document | `documents` | `document_id` | yes — `content` is **BlockNote JSON** | | KB article | `kb_articles` | `article_id` | yes — FK to `documents` | | Service catalog | `service_catalog` | `service_id` | yes | | Service request submission | `service_request_submissions` | `submission_id` | yes — `submitted_payload` JSONB | | Service request definition | `service_request_definitions` | `definition_id` | yes | | Workflow task | `workflow_tasks` | `task_id` (string) | **NOT in PK** — verify | | Interaction | `interactions` | `interaction_id` | yes | | Schedule entry | `schedule_entries` | `entry_id` | yes | | Time entry | `time_entries` | `entry_id` | yes | | Board | `boards` | `channel_id` | yes (renamed from `channels`) | | Category | `categories` | `category_id` | yes | | Tag | `tags` | `tag_id` | yes | **TODO** — verify `workflow_tasks` distribution column. If it's not distributed by tenant, joining/upserting from the indexer needs extra care. ### Existing FTS code - **CE migrations:** zero `tsvector` columns or GIN indexes today. - **EE migrations:** tsvector indexes already exist on `tickets.title`, `comments.note`, `documents.content` (in `ee/server/migrations/202410291100_create_ai_schema.cjs`) — these are for AI/chat features, **not** to be confused with the new `app_search_index`. - **`public.process_large_lexemes()`** function exists (added in `20260302031500_strip_data_image_payloads_from_comment_search_vector.cjs`). Strips base64 data URIs, caps input at 500 KB. Reuse as-is. ### Event bus - Publisher: `server/src/lib/eventBus/publishers/index.ts` — `publishEvent()`. - Existing events covering our entities: - `TICKET_CREATED`, `TICKET_UPDATED`, `TICKET_CLOSED`, `TICKET_ASSIGNED` - `TICKET_COMMENT_ADDED` - **Missing events** that the plan must add (one feature per family): - `CLIENT_CREATED` / `_UPDATED` / `_DELETED` - `CONTACT_*` - `USER_*` (probably already exists for auth — verify) - `PROJECT_*`, `PROJECT_PHASE_*`, `PROJECT_TASK_*`, `PROJECT_TASK_COMMENT_*` - `ASSET_*` - `INVOICE_*`, `INVOICE_ITEM_*`, `INVOICE_ANNOTATION_*` - `CONTRACT_*`, `CLIENT_CONTRACT_*` - `DOCUMENT_*`, `KB_ARTICLE_*` - `SERVICE_CATALOG_*` - `SERVICE_REQUEST_SUBMISSION_*`, `SERVICE_REQUEST_DEFINITION_*` - `WORKFLOW_TASK_*` (verify which already exist) - `INTERACTION_*` - `SCHEDULE_ENTRY_*` - `TIME_ENTRY_*` - `BOARD_*`, `CATEGORY_*`, `TAG_*` - **Add events at the corresponding actions** under `server/src/lib/actions/` and any model save points. Use Zod schemas in `server/src/lib/eventBus/events.ts`. ### withAuth example - Canonical reference: `server/src/app/msp/service-requests/actions.ts` lines 67–74. - Pattern: `withAuth(async (user, { tenant }): Promise => { ... })`, imported from `@alga-psa/auth`. --- ## Architecture file layout (new) ``` server/src/ lib/ search/ index.ts # registry export types.ts # SearchDoc, SearchObjectType normalize.ts # BlockNote/Markdown/JSONB → text + truncate upsert.ts # writes to app_search_index query.ts # SQL builder for FTS + pg_trgm acl.ts # SQL predicate builder + record-level verifier ts_headline.ts # snippet helper indexers/ client.ts contact.ts user.ts ticket.ts ticket_comment.ts project.ts project_phase.ts project_task.ts project_task_comment.ts asset.ts invoice.ts invoice_item.ts invoice_annotation.ts contract.ts client_contract.ts document.ts kb_article.ts service_catalog.ts service_request_submission.ts service_request_definition.ts workflow_task.ts interaction.ts schedule_entry.ts time_entry.ts board.ts category.ts tag.ts eventBus/ subscribers/ searchIndexSubscriber.ts # NEW actions/ searchActions.ts # NEW — withAuth wrapper around query.ts scripts/ search-backfill.ts # NEW — CLI; also wired into package.json components/ search/ # NEW SearchPalette.tsx # cmdk command palette SearchResultRow.tsx SearchResultGroup.tsx useSearch.ts # debounce + server action hook layout/ Sidebar.tsx # add search trigger at top app/ msp/ search/ page.tsx # NEW — "see all results" page SearchPageClient.tsx migrations/ NNNN_create_app_search_index.cjs # NEW migration ``` --- ## Migration sketch ```js // server/migrations/NNNN_create_app_search_index.cjs exports.up = async (knex) => { await knex.raw('CREATE EXTENSION IF NOT EXISTS pg_trgm'); await knex.raw(` CREATE TABLE app_search_index ( tenant uuid NOT NULL, object_type text NOT NULL, object_id text NOT NULL, parent_type text, parent_id text, title text NOT NULL, subtitle text, body text, url text NOT NULL, metadata jsonb NOT NULL DEFAULT '{}'::jsonb, visible_to_user_ids uuid[] NOT NULL DEFAULT '{}', visible_to_roles text[] NOT NULL DEFAULT '{}', is_internal_only boolean NOT NULL DEFAULT false, is_private boolean NOT NULL DEFAULT false, client_scope_id uuid, required_permission text, search_vector tsvector NOT NULL, search_lang text NOT NULL DEFAULT 'english', source_updated_at timestamptz NOT NULL, indexed_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY (tenant, object_type, object_id) ) `); // Citus distribution — only if Citus is the active backend await knex.raw(` DO $$ BEGIN IF EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'citus') THEN PERFORM create_distributed_table('app_search_index', 'tenant'); END IF; END$$; `); await knex.raw('CREATE INDEX app_search_index_vector_gin ON app_search_index USING gin (search_vector)'); await knex.raw('CREATE INDEX app_search_index_title_trgm ON app_search_index USING gin (title gin_trgm_ops)'); await knex.raw('CREATE INDEX app_search_index_subtitle_trgm ON app_search_index USING gin (subtitle gin_trgm_ops)'); await knex.raw('CREATE INDEX app_search_index_recent ON app_search_index (tenant, source_updated_at DESC)'); await knex.raw('CREATE INDEX app_search_index_type ON app_search_index (tenant, object_type)'); }; exports.down = async (knex) => { await knex.raw('DROP TABLE IF EXISTS app_search_index'); }; ``` --- ## Commands / runbook ```bash # Run the migration locally npm run migrate # Backfill all tenants npm run search:backfill # Backfill one tenant, one entity type npm run search:backfill -- --tenant= --type=ticket # Manually run reconciliation npm run search:reconcile -- --tenant= # Inspect index health for a tenant psql -c "SELECT object_type, count(*), max(indexed_at) FROM app_search_index WHERE tenant = '' GROUP BY 1 ORDER BY 1" # Sample row psql -c "SELECT object_type, object_id, title, left(body, 80) AS snippet FROM app_search_index WHERE tenant = '' AND title ILIKE '%acme%' LIMIT 5" # Drop-and-rebuild one tenant's index psql -c "DELETE FROM app_search_index WHERE tenant = ''" && \ npm run search:backfill -- --tenant= ``` ## Implementation log - **2026-05-13 — F001 complete.** Added migration file `server/migrations/20260513120000_create_app_search_index.cjs`. The file already includes the planned table/index/down-migration body because those pieces are inseparable from a useful migration skeleton. Validation: `node --check server/migrations/20260513120000_create_app_search_index.cjs`. - **2026-05-13 — F002 complete.** Migration enables fuzzy matching support with `CREATE EXTENSION IF NOT EXISTS pg_trgm`. Validation: `rg "CREATE EXTENSION IF NOT EXISTS pg_trgm" server/migrations/20260513120000_create_app_search_index.cjs`. - **2026-05-13 — F003 complete.** Migration creates `app_search_index` with PRD §9.1 columns, UUID/text ACL hint arrays, `tsvector` search column, timestamps, and primary key `(tenant, object_type, object_id)`. Validation: `node --check ...` plus targeted `rg` for table, PK, ACL, and search-vector columns. - **2026-05-13 — F004 complete.** Migration checks `pg_extension` for `citus`, checks `pg_dist_partition` for pre-existing distribution, and only then calls `create_distributed_table('app_search_index', 'tenant')`. It exports `transaction: false` because Citus distribution cannot run in a transaction block. - **2026-05-13 — F005 complete.** Migration creates `app_search_index_vector_gin` using `gin (search_vector)` for FTS matching. Validation: targeted `rg` on the migration. - **2026-05-13 — F006 complete.** Migration creates `app_search_index_title_trgm` and `app_search_index_subtitle_trgm` using `gin_trgm_ops` for the fuzzy fallback branch. Validation: targeted `rg` on both index names/opclasses. - **2026-05-13 — F007 complete.** Migration creates `app_search_index_recent` on `(tenant, source_updated_at DESC)` and `app_search_index_type` on `(tenant, object_type)` for recency sorting and type filtering. Validation: targeted `rg` on both definitions. - **2026-05-13 — F008 complete.** Migration down step uses `knex.schema.dropTableIfExists('app_search_index')`. Validation: targeted `rg` on `exports.down` and the drop call. - **2026-05-13 — F009 complete.** Added `server/src/lib/search/types.ts` with `SEARCH_OBJECT_TYPES` covering the 27 CE v1 entity types and deriving `SearchObjectType` from that tuple. Validation: `npx tsc --noEmit --pretty false --skipLibCheck server/src/lib/search/types.ts`. - **2026-05-13 — F010 complete.** `SearchDoc` now models tenant/type/id, optional parent, title/subtitle/body/url, metadata, required ACL metadata, and `sourceUpdatedAt`. Validation: targeted `rg` on the interface fields. - **2026-05-13 — F011 complete.** `AclMetadata` covers `visibleToUserIds`, `visibleToRoles`, `isInternalOnly`, `isPrivate`, `clientScopeId`, and `requiredPermission` for indexer-produced ACL hints. Validation: targeted `rg` on the interface fields. - **2026-05-13 — F012 complete.** Added `flattenBlockNote(json)` in `server/src/lib/search/normalize.ts`. It parses JSON strings when needed, walks BlockNote `content`/`children`/`items`, collects visible text leaves, supports unexpected plain text fallback, and strips image data URIs. Validation: `npx tsc --noEmit --pretty false --skipLibCheck server/src/lib/search/normalize.ts`. - **2026-05-13 — F013 complete.** Added `flattenMarkdown(md)` to strip headings, list markers, links/images, bold/italic/code markers, code fences, blockquotes, and HTML tags while preserving readable text. Validation: targeted `rg` on function and replacement rules. - **2026-05-13 — F014 complete.** Added `flattenJsonbPayload(obj)` to recursively concatenate string leaves from objects/arrays, skip secret-like keys (`password|secret|token|api_key|authorization`), strip image data URIs, and ignore scalar top-level input. Validation: targeted `rg` on function, secret regex, and object traversal. - **2026-05-13 — F015 complete.** Added `truncateForIndex(text, maxBytes = 65_536)` using `Buffer.byteLength` and `for...of` code-point iteration so truncation respects UTF-8 byte limits without splitting characters. Validation: targeted `rg` on the function and byte-length loop. - **2026-05-13 — F016 complete.** Added `server/src/lib/search/sql.ts` with `buildTsvectorSql(title, subtitle, body)`. It returns a bound SQL fragment with title/subtitle/body weights A/B/C and runs all inputs through `public.process_large_lexemes()` before `to_tsvector('english', ...)`. Validation: `npx tsc --noEmit --pretty false --skipLibCheck server/src/lib/search/sql.ts`. - **2026-05-13 — F017 complete.** `EntityIndexer` in `types.ts` defines `objectType`, `sourceEvents`, `loadOne`, and paged `loadBatch` methods. `sourceEvents` is typed as `readonly EventType[]` from `@alga-psa/event-schemas`; loaders accept an explicit `tenant`. Validation: targeted `rg` on the interface. - **2026-05-13 — F018 complete.** Added `server/src/lib/search/index.ts` registry with `getIndexer`, `allIndexers`, and `registeredObjectTypes`. Added empty CE `ceIndexers` export and a CE-side `@ee/lib/search/indexers` stub returning `eeIndexers = []` under `packages/ee/src`, matching the repo's current alias pattern. Note: the later F131 stub-registration cleanup should reconcile this with the plan's `ee/server/src/...` wording if needed. Validation: targeted `rg` on registry exports and imports. - **2026-05-13 — F019 complete.** Added `upsertSearchDoc(knex, doc)` in `server/src/lib/search/upsert.ts`. It inserts all denormalized search/ACL columns, computes `search_vector` server-side with `buildTsvectorSql`, and updates existing rows via `ON CONFLICT (tenant, object_type, object_id)`. Validation: `npx tsc --noEmit --pretty false --skipLibCheck server/src/lib/search/upsert.ts`. - **2026-05-13 — F020 complete.** Added `deleteSearchDoc(knex, tenant, objectType, objectId)` in `upsert.ts`; it deletes by `(tenant, object_type, object_id)` and is naturally a no-op when the row is absent. Validation: targeted `rg` on function and WHERE/delete chain. - **2026-05-13 — F021 complete.** Added typed `composeAclHints(opts)` in `server/src/lib/search/acl.ts` and wired `upsertSearchDoc` through it. Defaults: user/role arrays empty, internal/private booleans false, optional `clientScopeId` and `requiredPermission` passed through. Validation: `npx tsc --noEmit --pretty false --skipLibCheck server/src/lib/search/acl.ts server/src/lib/search/upsert.ts`. - **2026-05-13 — F022 complete.** Added `clientIndexer` with `loadOne`/`loadBatch` against `clients`, title=`client_name`, subtitle=`email | phone_no`, body=`notes`, URL `/msp/clients/{client_id}`, and ACL `requiredPermission='client:read'`. Registered it in `ceIndexers`. Current source events use existing `CLIENT_CREATED`, `CLIENT_UPDATED`, and `CLIENT_ARCHIVED`; F049 should add/swap in `CLIENT_DELETED` when that event exists. Validation: `npx tsc --noEmit --pretty false --skipLibCheck server/src/lib/search/indexers/client.ts server/src/lib/search/indexers/index.ts`. - **2026-05-13 — F023 complete.** Added `contactIndexer` with title=`full_name`, subtitle=`email | phone_number | role`, URL `/msp/contacts/{contact_name_id}`, ACL `requiredPermission='contact:read'`, and tenant-scoped `loadOne`/`loadBatch`. Registered it in `ceIndexers`. Current source events use existing `CONTACT_CREATED`, `CONTACT_UPDATED`, and `CONTACT_ARCHIVED`; F050 should add/swap in delete semantics if needed. Validation: `npx tsc --noEmit --pretty false --skipLibCheck server/src/lib/search/indexers/contact.ts server/src/lib/search/indexers/index.ts`. - **2026-05-13 — F024 complete.** Added `userIndexer` for internal users only (`user_type='internal'`), title from first/last name with username/email/id fallback, subtitle=`username | email | role`, URL `/msp/team/{user_id}`, and ACL `requiredPermission='user:read'`. Registered it in `ceIndexers`. Current schema has `role` but no separate `title` column; F051 should add `USER_*` source events and can adjust subtitle if a title column exists by then. Validation: `npx tsc --noEmit --pretty false --skipLibCheck server/src/lib/search/indexers/user.ts server/src/lib/search/indexers/index.ts`. - **2026-05-13 — F025 complete.** Added `ticketIndexer` with client join, title=`tickets.title` fallback to ticket number/id, subtitle=`client_name | ticket_number`, URL `/msp/tickets/{ticket_id}`, `metadata.identifier=ticket_number`, and ACL `requiredPermission='ticket:read'`. Registered it in `ceIndexers` and included current ticket events including `TICKET_DELETED`. Gap: no current board-role ACL table/column was found, so `visibleToRoles` remains default-empty until the ACL/query layer or a board-scope source is identified. Validation: `npx tsc --noEmit --pretty false --skipLibCheck server/src/lib/search/indexers/ticket.ts server/src/lib/search/indexers/index.ts`. - **2026-05-13 — F026 complete.** Added `ticketCommentIndexer` joining `comments` to `tickets`, title from parent ticket title/number, parent pointer `(ticket, ticket_id)`, body=`flattenMarkdown(note)`, URL `/msp/tickets/{ticket_id}#comment-{comment_id}`, ACL `requiredPermission='ticket:read'`, and `isInternalOnly` from `comments.is_internal`. Registered it in `ceIndexers`. Validation: `npx tsc --noEmit --pretty false --skipLibCheck server/src/lib/search/indexers/ticket_comment.ts server/src/lib/search/indexers/index.ts`. - **2026-05-13 — F027 complete.** Added `projectIndexer` with title=`project_name`, body=`description`, URL `/msp/projects/{project_id}`, ACL `requiredPermission='project:read'`, and `clientScopeId` from `projects.client_id`. Registered it in `ceIndexers`. Current events use existing `PROJECT_CREATED`, `PROJECT_UPDATED`, and `PROJECT_STATUS_CHANGED`; F052 should add delete/child publishes. Validation: `npx tsc --noEmit --pretty false --skipLibCheck server/src/lib/search/indexers/project.ts server/src/lib/search/indexers/index.ts`. - **2026-05-13 — F028 complete.** Added `projectPhaseIndexer` joining phases to parent projects, title=`phase_name`, subtitle=`project_name`, body=`description`, URL `/msp/projects/{project_id}/phases/{phase_id}`, parent pointer `(project, project_id)`, and inherited project ACL/client scope. Registered it in `ceIndexers`. `sourceEvents` is empty until F052 adds project phase events. Validation: `npx tsc --noEmit --pretty false --skipLibCheck server/src/lib/search/indexers/project_phase.ts server/src/lib/search/indexers/index.ts`. - **2026-05-13 — F029 complete.** Added `projectTaskIndexer` joining tasks through phases to projects, title=`task_name`, subtitle=`project_name`, body=`description`, URL `/msp/projects/{project_id}/tasks/{task_id}`, parent pointer `(project, project_id)`, and inherited project ACL/client scope. Registered it in `ceIndexers`. Source events use existing project-task event names. Validation: `npx tsc --noEmit --pretty false --skipLibCheck server/src/lib/search/indexers/project_task.ts server/src/lib/search/indexers/index.ts`. - **2026-05-13 — F030 complete.** Added `projectTaskCommentIndexer` joining comments through task/phase/project, title from parent task, subtitle project name, body preferring `markdown_content` with `flattenBlockNote(note)` fallback, URL `/msp/projects/{project_id}/tasks/{task_id}#comment-{task_comment_id}`, parent pointer `(project_task, task_id)`, and inherited project ACL/client scope. Registered it in `ceIndexers`; source events use existing `TASK_COMMENT_*` names. Validation: `npx tsc --noEmit --pretty false --skipLibCheck server/src/lib/search/indexers/project_task_comment.ts server/src/lib/search/indexers/index.ts`. - **2026-05-13 — F031 complete.** Added `assetIndexer` with title=`name`, subtitle=`asset_tag | serial_number`, body=`location | flattenJsonbPayload(attributes)`, URL `/msp/assets/{asset_id}`, `metadata.identifier=asset_tag`, ACL `requiredPermission='asset:read'`, and optional `clientScopeId`. Registered it in `ceIndexers`; source events use existing `ASSET_CREATED/UPDATED/ASSIGNED/UNASSIGNED`. Validation: `npx tsc --noEmit --pretty false --skipLibCheck server/src/lib/search/indexers/asset.ts server/src/lib/search/indexers/index.ts`. - **2026-05-13 — F032 complete.** Added `invoiceIndexer` joining invoices to clients, title=`invoice_number`, subtitle=`client_name | status | total_amount`, URL `/msp/invoices/{invoice_id}`, `metadata.identifier=invoice_number`, ACL `requiredPermission='invoice:read'`, and `clientScopeId` from `invoices.client_id`. Registered it in `ceIndexers`; source events use existing invoice lifecycle events until F054 adds CRUD-specific events. Validation: `npx tsc --noEmit --pretty false --skipLibCheck server/src/lib/search/indexers/invoice.ts server/src/lib/search/indexers/index.ts`. - **2026-05-13 — F033 complete.** Added `invoiceItemIndexer` joining invoice items to invoices, title parent invoice number, body item description, URL `/msp/invoices/{invoice_id}#item-{item_id}`, parent pointer `(invoice, invoice_id)`, and inherited invoice ACL/client scope. Registered it in `ceIndexers`. `sourceEvents` is empty until F054 adds invoice-item events. Validation: `npx tsc --noEmit --pretty false --skipLibCheck server/src/lib/search/indexers/invoice_item.ts server/src/lib/search/indexers/index.ts`. - **2026-05-13 — F034 complete.** Added `invoiceAnnotationIndexer` joining annotations to invoices, title parent invoice number, body annotation content, URL `/msp/invoices/{invoice_id}#annotation-{annotation_id}`, parent pointer `(invoice, invoice_id)`, and inherited invoice ACL/client scope. It also maps `invoice_annotations.is_internal` to `isInternalOnly` as a conservative visibility hint. Registered it in `ceIndexers`; `sourceEvents` is empty until F054 adds invoice-annotation events. Validation: `npx tsc --noEmit --pretty false --skipLibCheck server/src/lib/search/indexers/invoice_annotation.ts server/src/lib/search/indexers/index.ts`. - **2026-05-13 — F035 complete.** Added `contractIndexer` against `contracts`, title=`contract_name`, body=`contract_description`, subtitle=`Quote` for `status='draft'` else `Contract`, URL `/msp/billing/contracts/{contract_id}`, `metadata.identifier=contract_name`, and ACL `requiredPermission='contract:read'`. Registered it in `ceIndexers`; source events use existing `CONTRACT_CREATED/UPDATED/STATUS_CHANGED`. Validation: `npx tsc --noEmit --pretty false --skipLibCheck server/src/lib/search/indexers/contract.ts server/src/lib/search/indexers/index.ts`. - **2026-05-13 — F036 complete.** Added `clientContractIndexer` joining `client_contracts`, `clients`, and `contracts`, title=`{client_name} – {contract_name}`, body with start/end dates and active state, URL `/msp/clients/{client_id}/contracts/{client_contract_id}`, parent pointer `(contract, contract_id)`, ACL `requiredPermission='contract:read'`, and `clientScopeId=client_id`. Registered it in `ceIndexers`; `sourceEvents` is empty until F055 adds client-contract events. Validation: `npx tsc --noEmit --pretty false --skipLibCheck server/src/lib/search/indexers/client_contract.ts server/src/lib/search/indexers/index.ts`. - **2026-05-13 — F037 complete.** Added `documentIndexer` with title=`document_name`, body=`truncateForIndex(flattenBlockNote(content))`, URL `/msp/documents/{document_id}`, ACL `requiredPermission='document:read'`, and optional `clientScopeId` from `documents.client_id`. It intentionally does not set `isPrivate` or `visibleToUserIds` because CE has no internal per-user document share model in v1. Registered it in `ceIndexers`; source events use existing document lifecycle/association events. Validation: `npx tsc --noEmit --pretty false --skipLibCheck server/src/lib/search/indexers/document.ts server/src/lib/search/indexers/index.ts`. - **2026-05-13 — F038 complete.** Added `kbArticleIndexer` joining `kb_articles` to `documents`, title=`documents.document_name`, body from flattened/truncated document content, URL `/msp/knowledge-base/{article_id}`, parent pointer `(document, document_id)`, and ACL `requiredPermission='kb:read'`. Registered it in `ceIndexers`; `sourceEvents` is empty until F056 adds KB events. Validation: `npx tsc --noEmit --pretty false --skipLibCheck server/src/lib/search/indexers/kb_article.ts server/src/lib/search/indexers/index.ts`. --- ## Gotchas - **`comments.note` is plain text or markdown, not BlockNote.** The ticket-comment indexer uses `flattenMarkdown`, not `flattenBlockNote`. Confirmed via `server/src/interfaces/comment.interface.ts`. - **`project_task_comments.note` IS BlockNote.** Prefer `markdown_content` if non-null; else flatten BlockNote. - **`documents.content` is BlockNote.** Always flatten + truncate. - **`service_request_submissions.submitted_payload` is JSONB form data.** Flatten string leaves; skip keys named `password`, `secret`, `token`, `api_key`. - **Quotes are draft contracts.** Indexer marks `subtitle = 'Quote'` when `contracts.status = 'draft'`. - **`channels` was renamed to `boards`.** Use the current table name; column is `channel_name`, column key is `channel_id` (the rename was table-only). - **Citus distribution column must be `tenant`.** If we ever try to `JOIN app_search_index s ON s.tenant = t.tenant AND s.object_id = t.ticket_id`, that join must include `t.tenant` to stay co-located. - **`ts_headline` output contains HTML.** Render via a sanitized component, never `dangerouslySetInnerHTML` of raw output. - **`process_large_lexemes()` is a Postgres function, not a Node helper.** Call it via `to_tsvector('english', process_large_lexemes($body))` inside the indexer's SQL. --- ## Open follow-ups (to verify at implementation time) - [ ] Confirm existing `USER_*` event semantics — do we already publish on user create/update/delete? Check `server/src/lib/eventBus/events.ts` and `packages/event-schemas/src/schemas/eventBusSchema.ts`. If yes, just reuse; if no, add per F051. - [ ] Workflow task PK: confirmed below in §Schema reference — `workflow_tasks.task_id` is the only PK column; `tenant` is a regular text column. Indexer must include `tenant` in every WHERE clause anyway. ## Resolved (2026-05-13) - **Snippet sanitizer → controlled-sentinel rebuild on the server.** Emit `ts_headline` with unique sentinel tokens (e.g., `«MARK»` / `«/MARK»`), split the response on sentinels, HTML-escape each text segment, re-wrap match segments in ``. No DOMPurify dep on the client; the field is safe by construction. - **Query length cap → 200 chars.** Enforced in Zod input. - **`time_entries.notes` indexing rule → `notes IS NOT NULL AND notes <> ''`.** No length threshold beyond non-empty. - **Board / Category / Tag → keep as result rows**, not filter chips. - **`interactions` schema → `title` (renamed from `description`) + `notes` (BlockNote JSON).** Confirmed by migration `server/migrations/20250530000000_improve_interactions_schema.cjs` lines 6–13. Sample `notes` payload (from user): ``` [{"id":"f3e01073-…","type":"bulletListItem","content":[{"type":"text","text":"Added Sciton Tribrid Laser\n",…}]},…] ``` Indexer behavior: - `title` ← `interactions.title` - `body` ← `flattenBlockNote(notes)` (truncated to 64 KB; falls back to raw text if `notes` is unexpectedly plain text) - `subtitle` ← derived from `interaction_types.type_name` + counterparty (client, contact, or linked ticket) - `acl.requiredPermission` = `'interaction:read'` ## Implementation log — 2026-05-14 - **F061 — Schedule/time-entry search events.** - Added missing `TIME_ENTRY_CREATED`, `TIME_ENTRY_UPDATED`, `TIME_ENTRY_DELETED`, and `TIME_ENTRY_CHANGES_REQUESTED` event types to `packages/event-schemas/src/schemas/eventBusSchema.ts`; kept existing `TIME_ENTRY_SUBMITTED` and `TIME_ENTRY_APPROVED`. - Relaxed `TimeEntryEventPayloadSchema` to match real payloads from both REST services and server actions (`workItemType` is stored as lowercase DB values and `workItemId` can be non-ticket/non-project values). - Wired `scheduleEntryIndexer.sourceEvents` to `SCHEDULE_ENTRY_CREATED/UPDATED/DELETED` and `timeEntryIndexer.sourceEvents` to all time-entry CRUD/status events. - Existing schedule actions already publish schedule events; added schedule-entry publishes for the `TimeSheetService` schedule mutation path. - Added time-entry publishes to `packages/scheduling/src/actions/timeEntryCrudActions.ts` for create/update/delete and approval status transitions. Event publish failures are logged and do not block the user-facing action, matching the action-layer pattern used elsewhere. - Validation: `npm -w @alga-psa/event-schemas run typecheck`, `npm -w @alga-psa/scheduling run typecheck`, `git diff --check`. - **F062 — Board/category/tag search events.** - Added `BOARD_CREATED/UPDATED/DELETED`, `CATEGORY_CREATED/UPDATED/DELETED`, and `TAG_DEFINITION_DELETED` to `packages/event-schemas/src/schemas/eventBusSchema.ts`. Existing `TAG_DEFINITION_CREATED/UPDATED` workflow events are reused for tag index upserts. - Wired `boardIndexer`, `categoryIndexer`, and `tagIndexer` `sourceEvents` to the relevant event families. - Added board event publishes to `server/src/lib/api/services/BoardService.ts`; added board/category import/delete publishes to `packages/reference-data/src/actions/referenceDataActions.ts`. - Added tag-definition update/delete publishes to both the package actions (`packages/tags/src/actions/tagActions.ts`) and REST API service (`server/src/lib/api/services/TagService.ts`) so tag definition changes re-index the `tag_definitions` row. - Note: the category indexer remains scoped to the `categories` table per the existing F047 implementation and PRD source-table choice; `ticket_categories` API mutations are not wired to avoid emitting event IDs the current indexer cannot load. - Validation: `npm -w @alga-psa/event-schemas run typecheck`, `npm -w @alga-psa/tags run typecheck`, `npm -w @alga-psa/reference-data run typecheck`, `git diff --check`. - **F063 — Search index subscriber shell.** - Added `server/src/lib/eventBus/subscribers/searchIndexSubscriber.ts` with register/unregister lifecycle hooks and idempotent registration state. - Registered the subscriber in `server/src/lib/eventBus/subscribers/index.ts` so normal event-bus initialization invokes it. - Deliberately kept event handling out of this commit; F064-F067 own event resolution, writes/deletes, and the `SEARCH_INDEX_LIVE` gate. - **F064 — Registry-driven subscriber resolution.** - `searchIndexSubscriber` now builds an event-type map from `allIndexers()` and subscribes to the union of every registered indexer's `sourceEvents`. - Added `resolveSearchIndexersForEvent(eventType)` so the event handler resolves each event to one or more indexers by registry metadata rather than a hard-coded switch. - Handler currently logs the resolved object types only; F065/F066 add upsert/delete behavior. - **F065 — Subscriber upsert path.** - Non-delete events now extract `tenantId` plus an object-type-specific source ID from the event payload, call `indexer.loadOne(knex, tenant, id)`, and pass the resulting `SearchDoc` to `upsertSearchDoc`. - ID extraction is centralized in `OBJECT_ID_FIELDS` in `searchIndexSubscriber.ts`; this absorbs the mixed camelCase/snake_case payload names used across the current event publishers. - Delete events are detected and explicitly skipped for now; F066 wires `deleteSearchDoc`. - **F066 — Subscriber delete path.** - Delete-style events (`*_DELETED` plus `TAG_DEFINITION_DELETED`) now call `deleteSearchDoc(knex, tenant, objectType, objectId)` for each resolved indexer. - Missing IDs on delete events are logged and skipped, matching the non-delete path's defensive behavior. - **F067 — Live-indexing gate.** - Added `isSearchIndexLiveEnabled()` to `searchIndexSubscriber.ts`; it returns true only when `SEARCH_INDEX_LIVE === 'true'`, so the default/unset behavior is disabled. - The event handler resolves and acknowledges events but returns before opening a DB connection or writing rows when live indexing is disabled. - The env var is read at event-handling time, so future events see a changed value without code changes; process env propagation still depends on the deployment/runtime. - **F068 — Ticket comment cascade.** - On `TICKET_UPDATED`, after the ticket document is upserted, the subscriber selects all comment IDs for the same `(tenant, ticket_id)` and re-upserts each `ticket_comment` document. - Rationale: ticket-comment search rows denormalize the parent ticket title, so ticket title edits must refresh existing comment rows even when comment bodies did not change. - **F069 — Invoice child cascade.** - On `INVOICE_UPDATED`, after the invoice document is upserted, the subscriber re-upserts invoice item and invoice annotation rows for the same `(tenant, invoice_id)`. - Rationale: item/annotation rows denormalize invoice number and invoice client ACL hints from their parent invoice. - **F070 — Project child cascade.** - On `PROJECT_UPDATED`, after the project document is upserted, the subscriber pages through phases, tasks, and task comments for the project in 500-row batches and re-upserts each child document. - Rationale: phase/task/comment rows denormalize parent project information and inherit project ACL hints, so project edits must refresh children. - This is implemented inside the async event-bus handler rather than a separate pg-boss job for now; the work is paged and bounded per batch to avoid a single large read. - **F071 — Document-association re-index.** - Already covered by the F056 event publishes plus F065 subscriber upsert path: `DOCUMENT_ASSOCIATED` and `DOCUMENT_DETACHED` carry `documentId`, and `documentIndexer.sourceEvents` includes both events. - When those events arrive with `SEARCH_INDEX_LIVE=true`, the subscriber resolves the `document` indexer and reloads/upserts the document row. - **F072 — User role-change ACL refresh job.** - Added `server/src/lib/jobs/handlers/searchVisibleUserReindexHandler.ts` with job name `search-visible-user-reindex`. - The job pages through `app_search_index` rows for a tenant where `visible_to_user_ids` contains the changed user, re-runs the registered indexer for each row, upserts refreshed ACL/content, and deletes stale index rows when the source row no longer loads. - Registered the job in both `registerAllJobHandlers()` and the legacy `initializeScheduler()` path, and exposed `scheduleSearchVisibleUserReindexJob()`. - `searchIndexSubscriber` now enqueues this job after processing `USER_ROLES_UPDATED`, gated behind `SEARCH_INDEX_LIVE` with the rest of live indexing. Enqueue failures are logged but do not fail the original search-index event handling. - Tightened several cascade queries from object-style `.where({ ... })` to chained column predicates because the server typecheck reached those earlier subscriber lines and rejected the overload. - Validation: `git diff --check`; `npm -w server run typecheck`. - **F073 — Search backfill CLI entrypoint.** - Created `server/src/scripts/search-backfill.ts` with a typed `parseSearchBackfillArgs()` and `runSearchBackfill()` entrypoint. - The file is intentionally a scaffold in this commit; F074-F077 fill tenant discovery, indexer selection, paging, and idempotent upsert behavior in separate commits. - Validation: `git diff --check`; `npm -w server run typecheck`. - **F074 — Backfill tenant selection.** - `search-backfill.ts` now opens the server Knex config, discovers all tenants from the `tenants` catalog by default, and accepts `--tenant=` / `--tenant ` to run a single tenant. - `runSearchBackfill()` accepts an optional existing Knex instance for future tests and destroys only connections it creates itself. - Validation: `git diff --check`; `npm -w server run typecheck`. - **F075 — Backfill indexer selection.** - The CLI now resolves indexers through the search registry: default is `allIndexers()`, and `--type=` / `--type ` narrows to one registered indexer. - Unknown object types fail fast with a typed error before any backfill loop runs. - Validation: `git diff --check`; `npm -w server run typecheck`. - **F076 — Backfill paging.** - Added a 500-row backfill loop that calls each indexer's `loadBatch(knex, tenant, cursor, 500)` and advances the cursor from the last returned `SearchDoc.objectId`. - The loop logs per-batch progress and stops on an empty or short page. Writes are intentionally deferred to F077. - Validation: `git diff --check`; `npm -w server run typecheck`. - **F077 — Idempotent backfill upserts.** - The backfill loop now calls `upsertSearchDoc(knex, doc)` for every loaded `SearchDoc`, using the existing `(tenant, object_type, object_id)` `ON CONFLICT` path. - Re-running the CLI overwrites the same index rows with source-derived content/ACLs rather than creating duplicates. - Validation: `git diff --check`; `npm -w server run typecheck`. - **F078 — Root backfill npm script.** - Added root `package.json` script `search:backfill` -> `tsx server/src/scripts/search-backfill.ts`. - This matches the deployment runbook command and supports passthrough args such as `npm run search:backfill -- --tenant= --type=client`. - Validation: `git diff --check`; `npm -w server run typecheck`. - **F079 — Search reconciliation job registration.** - Added `server/src/lib/jobs/handlers/searchReconcileHandler.ts` with job name `search:reconcile` and registered it in both `registerAllJobHandlers()` and the legacy scheduler initialization. - The handler is a shell in this commit; F080-F082 add watermark re-indexing, missing-row inserts, and stale-index deletion. - Validation: `git diff --check`; `npm -w server run typecheck`. - **F080 — Reconcile rows updated after index watermark.** - `searchReconcileHandler` now resolves tenants and indexers, computes `max(source_updated_at)` from `app_search_index` per `(tenant, object_type)`, scans source rows through `indexer.loadBatch()`, and upserts any `SearchDoc` whose `sourceUpdatedAt` is newer than the watermark. - The implementation intentionally uses the indexer contract instead of per-table SQL so every entity keeps its own source joins, normalization, URL, and ACL logic. - Validation: `git diff --check`; `npm -w server run typecheck`. - **F081 — Reconcile stale index deletes.** - Reconciliation now scans existing `app_search_index` rows for each registered `(tenant, object_type)`, calls `indexer.loadOne()` for each `object_id`, and deletes the index row when the source no longer loads. - This also removes rows for sources that still exist but no longer qualify for indexing (for example, a time entry whose notes became empty). - Validation: `git diff --check`; `npm -w server run typecheck`. - **F082 — Reconcile missing index inserts.** - Reconciliation now scans source docs in 500-row batches, loads existing `app_search_index.object_id`s for the same batch, and upserts any source doc missing from the index. - This covers backfill gaps and direct SQL deletes of index rows even when the source row's `sourceUpdatedAt` is older than the current indexed watermark. - Validation: `git diff --check`; `npm -w server run typecheck`. - **F083 — Daily search reconciliation schedule.** - Added `scheduleSearchReconcileJob(tenantId, cron='0 6 * * *')` and scheduled it once per tenant from `initializeScheduledJobs()`. - The deploy runbook below now calls out that `search:reconcile` runs daily at 6:00 AM per tenant after scheduled jobs initialize. - Validation: `git diff --check`; `npm -w server run typecheck`. - **F084 — Query parsing.** - Added `server/src/lib/search/query.ts` with `parseQuery(raw)`. - The parser collapses whitespace, trims, rejects empty input, enforces the 200-character cap, detects `^[A-Z]+-?\d+$` identifier-style queries case-insensitively, and lowercases identifier keys for later metadata matching. - Validation: `git diff --check`; `npm -w server run typecheck`. - **F085 — FTS search query branch.** - Added `runSearchQuery()` in `server/src/lib/search/query.ts`. - The initial SQL path uses `websearch_to_tsquery('english', ?)` and `ts_rank_cd(s.search_vector, q.tsq)` with mandatory `tenant = ?`, `object_type = ANY(?::text[])`, and `search_vector @@ tsq` predicates. - Results are ordered by FTS rank, recency, and object ID. ACL, trigram fallback, identifier pinning, snippets, and cursor pagination are intentionally left to F086-F093/F089-F092. - Validation: `git diff --check`; `npm -w server run typecheck`. - **F086 — pg_trgm fallback.** - `runSearchQuery()` now includes `s.title % q.raw` and `coalesce(s.subtitle, '') % q.raw` fallback predicates in addition to FTS. - The returned score now combines `ts_rank_cd` with `GREATEST(similarity(title), similarity(subtitle)) * 0.4` so fuzzy-only hits can rank while still favoring FTS matches. - Validation: `git diff --check`; `npm -w server run typecheck`. - **F087 — Identifier match pinning.** - `parseQuery()` already detects identifier-style input and lowercases the key; `runSearchQuery()` now probes `lower(metadata->>'identifier')` for exact matches when that key is present. - Exact identifier matches are included even if FTS/trigram do not match and receive score `1000`, pinning tickets/assets/invoices/contracts with matching identifiers above normal relevance results. - Validation: `git diff --check`; `npm -w server run typecheck`. - **F088 — Time-decayed relevance.** - Non-identifier search scores now multiply the FTS/trigram composite by `GREATEST(exp(-age_seconds / (90 * 86400)), 0.05)`. - Exact identifier matches keep the explicit high score so identifier lookup remains pinned above decayed relevance results. - Validation: `git diff --check`; `npm -w server run typecheck`. - **F089 — Opaque search cursors.** - Added `encodeSearchCursor()` / `decodeSearchCursor()` in `server/src/lib/search/query.ts`. The cursor is base64url JSON containing score, updated timestamp, and object ID. - `runSearchQuery()` now accepts `cursor`; when present it applies keyset pagination against `(score DESC, source_updated_at DESC, object_id ASC)` and ignores offset. - Malformed cursors throw `SearchQueryError('invalid_cursor')` instead of falling through to a 500-prone parse path. - Validation: `git diff --check`; `npm -w server run typecheck`. - **F090 — Snippet generation.** - `runSearchQuery()` now returns optional `snippet` text and includes `ts_headline('english', coalesce(body, ''), tsq, 'MaxFragments=2,StartSel=,StopSel=')` when snippets are enabled. - Added `includeSnippets` query option, defaulting to true; F092 uses it to skip snippets for typeahead. - Validation: `git diff --check`; `npm -w server run typecheck`. - **F091 — Snippet sanitization.** - `ts_headline` now emits controlled sentinel strings instead of literal HTML tags. - Added `sanitizeHeadline()` to HTML-escape every text segment and re-wrap only sentinel-delimited matches in ``. - Malformed/unpaired sentinel output falls back to fully escaped text, preventing arbitrary HTML from surviving snippet generation. - Validation: `git diff --check`; `npm -w server run typecheck`. - **F092 — Typeahead skips snippets.** - Added `runSearchTypeaheadQuery()` wrapper around `runSearchQuery()` that forces `limit=5` and `includeSnippets=false`. - The future typeahead server action can use this path without emitting `ts_headline` in its SQL. - Validation: `git diff --check`; `npm -w server run typecheck`. - **F093 — ACL SQL predicate helper.** - Added `aclPredicateSql(user)` in `server/src/lib/search/acl.ts`. - The helper returns a parameterized SQL fragment covering `required_permission`, `visible_to_user_ids`, `visible_to_roles`, `is_internal_only`, `is_private`, and `client_scope_id`. - `is_private` is treated as share-list-only via `visible_to_user_ids`; CE v1 document rows do not set it, but the predicate is wired for future private rows. - Validation: `git diff --check`; `npm -w server run typecheck`. - **F094 — Single permission-set resolution.** - Added `resolveSearchAclPrincipal(knex, user, accessibleClientIds)` in `server/src/lib/search/acl.ts`. - It calls `User.getUserRolesWithPermissions()` once, filters role/permission applicability by MSP vs client user type, and returns unique `resource:action` strings for the SQL `required_permission = ANY(?::text[])` predicate. - It also returns role names and `isInternal` for the rest of the ACL predicate. - Validation: `git diff --check`; `npm -w server run typecheck`. - **F095 — Per-user visibility overlap predicate.** - Covered by the F093 ACL predicate: rows with non-empty `visible_to_user_ids` require `visible_to_user_ids && ARRAY[user_id]::uuid[]`. - Empty `visible_to_user_ids` remains unrestricted by user ID and is controlled by the other ACL columns. - Validation: `git diff --check`; `npm -w server run typecheck` from F094 still covers the helper. - **F096 — Internal/private/client-scope ACL predicates.** - Covered by the F093 ACL predicate: `is_internal_only` requires an internal user, `is_private` requires membership via `visible_to_user_ids`, and `client_scope_id` must be in `accessibleClientIds`. - For CE v1 documents, `is_private` remains false by indexer policy; the column is still enforced for future rows or synthetic tests. - Validation: `git diff --check`; `npm -w server run typecheck` from F094 still covers the helper. - **F097 — Record-level visibility pass framework.** - Added `registerSearchVisibilityVerifier(objectType, verifier)` and `verifyResultVisibility(knex, user, rows)` to `server/src/lib/search/acl.ts`. - Rows without a registered verifier pass through; rows with a verifier are kept only when the authoritative per-entity verifier returns true. - F098 wires concrete entity verifiers; F099 adds drift telemetry for dropped rows. - Validation: `git diff --check`; `npm -w server run typecheck`. - **F098 — Concrete record-level visibility verifiers.** - No existing `assertTicketReadable` / `assertProjectReadable` helpers were found by recon; implemented equivalent source-table verifiers in `acl.ts`. - Ticket verifier checks source existence; ticket-comment verifier checks source existence, parent ticket existence, and internal-comment visibility. - Project, phase, task, and task-comment verifiers check source existence plus parent project client scope; document verifier checks source existence plus `documents.client_id` scope; workflow-task verifier checks source existence plus `assigned_users` membership. - Validation: `git diff --check`; `npm -w server run typecheck`. - **F099 — ACL drift telemetry.** - `verifyResultVisibility()` now emits `search.acl_drift` when a row passed SQL ACL filtering but failed the record-level verifier. - Telemetry is a server warning log with metric/object/user/tenant fields, plus an optional global `Sentry.captureMessage()` call when a Sentry client is present in the runtime. - The repo currently has no direct Sentry package dependency, so the Sentry path is intentionally optional and dependency-free. - Validation: `git diff --check`; `npm -w server run typecheck`. - **F100 — Full search server action.** - Added `server/src/lib/actions/searchActions.ts` with `searchAppAction` wrapped in `withAuth`. - The action resolves registered object types, loads a single ACL principal/permission set, runs `runSearchQuery()` with snippets and SQL ACL filtering, applies `verifyResultVisibility()`, and returns `SearchAppResult` rows plus grouped counts and next cursor. - Current grouped counts are computed from the visible fetched page; a broader count query can be expanded when the results page work needs full pre-pagination counts. - Validation: `git diff --check`; `npm -w server run typecheck`. - **F101 — Typeahead search server action.** - Added `searchAppTypeaheadAction` in `searchActions.ts`. - It uses the same registered-type and ACL resolution path as full search, but calls `runSearchTypeaheadQuery()` and returns at most five rows with `snippet` stripped. - Validation: `git diff --check`; `npm -w server run typecheck`. - **F102 — Search action input schema.** - Added `searchAppInputSchema` in `searchActions.ts` with Zod validation for `query` (trimmed, 1-200 chars), `types` (`SearchObjectType` enum values), `limit` (1-100), and optional `cursor`. - Both full search and typeahead actions parse input at the action boundary before touching the database. - Validation: `git diff --check`; `npm -w server run typecheck`. - **F103 — Search action output schemas.** - Added Zod schemas for `SearchResultRow`, `SearchAppResult`, and the typeahead result in `server/src/lib/actions/searchActions.ts`. - Both authenticated search actions now parse their returned payloads at the action boundary, keeping result URLs, ISO timestamps, score values, group counts, and optional cursors under the documented contract. - Validation: `git diff --check`; `npm -w server run typecheck`. - **F104 — SearchPalette component.** - Added `server/src/components/search/SearchPalette.tsx` as a client component using `cmdk` for the sidebar search input and suggestion list. - The component debounces typeahead queries by 200 ms against `searchAppTypeaheadAction`, suppresses the popup before two trimmed characters, supports a collapsed icon button, and renders title-only suggestion rows. - Native anchor behavior, the global shortcut, and sidebar insertion remain separate feature checkpoints (F105-F108). - Validation: `git diff --check`; `npm -w server run typecheck`. - **F105 — Cmd/Ctrl+K search shortcut.** - Added a global keydown listener to `SearchPalette`. - `Cmd+K` / `Ctrl+K` prevents the browser default, focuses the sidebar search input when expanded, and requests sidebar expansion before focusing when collapsed. - Validation: `git diff --check`; `npm -w server run typecheck`. - **F106 — Native-anchor typeahead rows.** - Typeahead suggestions now render at most five rows and each row is a real `` inside `cmdk`. - This preserves browser-native Cmd/Ctrl-click, middle-click, and context-menu behavior while keeping title-only suggestion text. - Validation: `git diff --check`; `npm -w server run typecheck`. - **F107 — See-all typeahead row.** - Added a permanent last `cmdk` row linking to `/msp/search?q={query}` once the query has at least two trimmed characters. - The row uses the typeahead action's `totalCount` value and remains a native anchor, so users can open the full results page in a new tab. - Validation: `git diff --check`; `npm -w server run typecheck`. - **F108 — Sidebar launcher insertion.** - Replaced the old commented-out sidebar search placeholder in `Sidebar.tsx` with the new `SearchPalette`. - The collapsed sidebar renders an icon button that expands the sidebar; the expanded sidebar renders the full typeahead input above the nav. - Validation: `git diff --check`; `npm -w server run typecheck`. - **F109 — Search results route.** - Added `server/src/app/msp/search/page.tsx` as a dynamic server component. - It reads `q`, `type`, `cursor`, and `sort` from `searchParams`, calls `searchAppAction()` for non-empty queries, and renders initial SSR result anchors. - The `sort` param is read and reflected in page data for URL-state continuity; query-layer sort behavior is still the F117 checkpoint. - Validation: `git diff --check`; `npm -w server run typecheck`. - **F110 — Search page URL-state client shell.** - Added `server/src/app/msp/search/SearchPageClient.tsx` and moved the route's rendered shell into it. - The results-page input is controlled from the URL's `q` value and debounces `router.replace()` for 200 ms on edits, preserving `type` and non-default `sort` while resetting cursor on new text input. - The server page still fetches initial data so cold `/msp/search?...` URLs render with results in SSR output. - Validation: `git diff --check`; `npm -w server run typecheck`. - **F111 — Results filter chips.** - Added anchor-based filter chips to `SearchPageClient`: `All` plus one chip for every object type present in the returned `groups` record. - Each chip shows a count badge and builds a shareable `/msp/search` URL preserving `q` and non-default `sort` while setting or clearing `type`. - Validation: `git diff --check`; `npm -w server run typecheck`. - **F112 — Grouped all-types results.** - When the active type filter is `All`, `SearchPageClient` now groups visible results by entity type and caps each rendered group at 10 rows. - Group headings use `search.groups.{objectType}` with a humanized fallback and show the corresponding group count from the server action result. - Validation: `git diff --check`; `npm -w server run typecheck`. - **F113 — Single-type flat results.** - The search route passes a valid `type` query parameter through to `searchAppAction`, so single-type URLs fetch only that entity type. - `SearchPageClient` renders the non-`All` branch as a flat list of result anchors instead of grouped sections. - Validation: covered by the F112 typecheck run; no code change required beyond recording the checkpoint. - **F114 — Cursor pagination controls.** - Added previous/next pagination links to `SearchPageClient` using the query layer's opaque `nextCursor`. - The page now accepts a lightweight `cursorStack` URL parameter so a previous link can reconstruct the prior cursor boundary while keeping the canonical `cursor` parameter as the active page boundary. - Text edits and filter changes intentionally drop cursor state so refreshed searches start from the first page. - Validation: `git diff --check`; `npm -w server run typecheck`. - **F115 — Loading and empty states.** - Added skeleton rows while the results-page input value differs from the URL-backed query and the 200 ms router update is pending. - Added an empty state for zero-result searches that echoes the query and suggests removing the type filter when one is active. - Pagination is hidden while loading or empty so stale cursor controls do not appear. - Validation: `git diff --check`; `npm -w server run typecheck`. - **F116 — Native-anchor result rows.** - The shared `renderResultRow()` helper in `SearchPageClient` renders every row as `` in both grouped and flat result modes. - This preserves browser-native new-tab and context-menu affordances on the full results page. - Validation: `git diff --check`; `npm -w server run typecheck`. - **F117 — Relevance/recent sort toggle.** - Added `sort?: 'relevance' | 'recent'` to the search action input and passed it from `/msp/search`. - `runSearchQuery()` now switches its keyset predicate and `ORDER BY`: relevance uses score/recency/object_id, while recent uses `source_updated_at DESC, object_id ASC`. - Added a results-page segmented anchor toggle that preserves `q` and `type` while resetting cursor state on sort changes. - Validation: `git diff --check`; `npm -w server run typecheck`. - **F118 — Search ARIA semantics.** - Added explicit combobox attributes to the sidebar search input: `role`, `aria-autocomplete`, `aria-expanded`, `aria-controls`, and `aria-activedescendant`. - Added stable list/option IDs for the typeahead popup and kept `/msp/search` exposed as an ARIA `region` in `SearchPageClient`. - Arrow-key state updates are handled in the next keyboard checkpoint (F119). - Validation: `git diff --check`; `npm -w server run typecheck`. - **F119 — Search keyboard navigation.** - Sidebar search now handles ArrowDown/ArrowUp with wrapping selection across suggestions plus the see-all row; `aria-activedescendant` tracks the selected option. - Enter opens the selected suggestion or submits to `/msp/search?q=...`; Escape dismisses the typeahead without trapping Tab behavior. - Results-page input handles Enter for immediate URL submission and Escape to restore the URL-backed query and blur. - Validation: `git diff --check`; `npm -w server run typecheck`. - **F120 — Stable search DOM IDs.** - Added `toDomIdPart()` helpers in the search UI components so dynamic type and record IDs are normalized to lowercase kebab-case-safe fragments. - Sidebar options, full-page result rows, filter chips, sort controls, pagination links, and empty-state controls now have stable IDs with sanitized dynamic portions. - Validation: `git diff --check`; `npm -w server run typecheck`. - **F121 — English search locale keys.** - Added the `search.*` namespace to `server/public/locales/en/msp/core.json`. - Covered placeholders, loading/help/error text, result summaries, empty states, filters/groups for all 27 object types, sort labels, pagination labels, and the typeahead see-all row. - Validation: `node -e "JSON.parse(require('fs').readFileSync('server/public/locales/en/msp/core.json','utf8'))"`; `git diff --check`; `npm -w server run typecheck`. - **F122 — Lang-pack propagation and validation.** - Ran `node scripts/generate-pseudo-locales.cjs`, which regenerated 86 pseudo-locale files from 43 English sources. - Ran `node scripts/validate-translations.cjs`; the first pass exposed missing real-locale `search.*` keys, so copied the English `search` namespace into `de/es/fr/it/nl/pl/pt` `msp/core.json` files and reran validation. - Final validation passed with 0 errors and 8 pre-existing Polish plural-form warnings unrelated to search. - Validation: `git diff --check`; `node scripts/validate-translations.cjs`; `npm -w server run typecheck`. - **F123 — Search UI translation wiring.** - Removed English `defaultValue` fallbacks from `SearchPalette` and `SearchPageClient` now that `search.*` locale keys exist. - Visible search UI text is resolved through `useTranslation('msp/core')`; the only remaining hardcoded strings in these components are non-UI route/status identifiers and telemetry-style log keys. - Validation: `rg "defaultValue:" server/src/components/search/SearchPalette.tsx server/src/app/msp/search/SearchPageClient.tsx` returns no matches; `git diff --check`; `npm -w server run typecheck`. - **F124 — SEARCH_INDEX_LIVE documentation/config.** - Added `SEARCH_INDEX_LIVE=false` to `.env.example` with rollout guidance: keep false through migration/backfill, then flip true for live incremental indexing. - Added `server.searchIndexLive` to `helm/values.yaml` and wired it into the main server deployment as the `SEARCH_INDEX_LIVE` environment variable. - Validation: `git diff --check`; `npm -w server run typecheck`. - **F125 — Deploy runbook.** - Added `docs/deployment/app-wide-search-runbook.md`. - The runbook covers migrate, deploy with `SEARCH_INDEX_LIVE=false`, run `npm run search:backfill`, flip live indexing on, roll server/workers, sample index health, and confirm `search:reconcile`. - Validation: `git diff --check`; `npm -w server run typecheck`. - **F126 — Search action telemetry.** - Added structured server logs for `search.query.count`, `search.query.empty`, and `search.query.latency_ms` in both `searchAppAction` and `searchAppTypeaheadAction`. - Each telemetry payload includes variant (`full` or `typeahead`), tenant, user ID, and latency value for the histogram-style metric. - `search.acl_drift` was already emitted by `verifyResultVisibility()` in F099 via server log plus optional Sentry capture. - Validation: `git diff --check`; `npm -w server run typecheck`. - **F127 — Per-user search rate limiting.** - Added in-memory `rate-limiter-flexible` guards at the server-action boundary. - Full search is limited to 10 requests/sec per `(tenant, user)`; typeahead is limited to 30 requests/sec per `(tenant, user)`. - Limit failures throw `SearchRateLimitError` with `status=429`, `code='SEARCH_RATE_LIMITED'`, and `retryAfterMs`. - Validation: `git diff --check`; `npm -w server run typecheck`. - **F128 — Ticket comment hash highlight.** - Main ticket comments now render with canonical `id="comment-{comment_id}"` DOM anchors to match search index URLs. - `CommentItem` detects a matching `#comment-{id}` hash on mount, scrolls the comment into view, and applies `.search-highlight` plus a short visual ring/background for about two seconds. - Validation: `git diff --check`; `npm -w @alga-psa/tickets run typecheck`; `npm -w server run typecheck`. ## Local DB availability The MCP `my-private-server` query tool resolves to `alga-psa-postgres-1` inside a docker network, but the local stack is stopped (`alga-test-postgres` exited 8w ago, no `alga-psa-postgres-1` container running). To use it during implementation: ```bash # bring the dev stack up docker compose -f docker-compose.base.yaml -f docker-compose.ce.yaml up -d postgres # verify docker ps --format '{{.Names}}\t{{.Status}}' | grep postgres ``` --- ## Schema reference (column-level, derived from migrations) The implementer should not need to grep migrations to know what to query. Below is a copy-pastable cheat sheet of the columns each indexer touches. If anything here diverges from reality at implementation time, the migration files are authoritative — but recheck rather than assume. ### Core entity columns ``` clients PK (tenant, client_id) client_id, client_name, email, phone_no, notes, url, properties (jsonb), ... (renamed from `companies`; see 20251003000001_company_to_client_migration.cjs) contacts PK (tenant, contact_name_id) contact_name_id, full_name, client_id, email, phone_number, role, is_client_admin, notes_document_id, created_at, updated_at users PK (tenant, user_id, email) user_id, username, first_name, last_name, email, user_type, hashed_password, auth_method, created_at, updated_at → user_type='internal' for MSP team members; 'client' for client-portal users tickets PK (tenant, ticket_id) ticket_id, ticket_number, title, channel_id (board), client_id, contact_name_id, assigned_to, status_id, priority_id, category_id, created_at, updated_at comments PK (tenant, comment_id) comment_id, ticket_id, user_id, contact_name_id, note, is_internal (boolean), is_resolution, is_initial_description, created_at, updated_at, metadata (jsonb) → `note` is plain text or markdown (NOT BlockNote) projects PK (tenant, project_id) project_id, project_name, description, client_id, status, contact_name_id, start_date, end_date, created_at, updated_at project_phases PK (tenant, phase_id) phase_id, project_id, phase_name, description, ... project_tasks PK (tenant, task_id) task_id, phase_id, task_name, description, assigned_to, ... project_task_comments PK (tenant, task_comment_id) task_comment_id, task_id, user_id, author_type, note (BlockNote JSON), markdown_content, created_at, updated_at, edited_at → prefer markdown_content; fall back to flattenBlockNote(note) assets PK (tenant, asset_id) asset_id, type_id, client_id, asset_tag, serial_number, name, status, location, attributes (jsonb), created_at, updated_at ``` ### Billing / invoicing ``` invoices PK (tenant, invoice_id) invoice_id, client_id, invoice_number, invoice_date, due_date, total_amount, status, custom_fields (jsonb), billing_period, created_at, updated_at invoice_items PK (tenant, item_id) item_id, invoice_id, service_id, description, quantity, unit_price, total_price invoice_annotations PK (tenant, annotation_id) annotation_id, invoice_id, user_id, content, is_internal, created_at contracts PK (tenant, contract_id) contract_id, contract_name, contract_description, billing_frequency, is_active, status, created_at, updated_at → status ∈ {'active','draft','terminated','expired'}; 'draft' = the "quote" tag (renamed/restructured by 20251008000001_rename_billing_to_contracts.cjs + 202510161430_add_contract_status_column.cjs) contract_lines PK (tenant, contract_line_id) contract_line_id, plan_name, description, billing_frequency, is_custom, plan_type, created_at, updated_at → formerly `billing_plans` client_contracts PK (tenant, client_contract_id) client_contract_id, client_id, contract_id, start_date, end_date, is_active, created_at, updated_at → join with contracts + clients to build a search title ``` ### Documents / KB / service ``` documents PK (tenant, document_id) document_id, document_name, type_id, user_id, contact_name_id, client_id, ticket_id, created_by, edited_by, entered_at, updated_at, content (BlockNote JSON), shared_type_id document_associations PK (tenant, association_id) association_id, document_id, entity_id, entity_type ∈ {'ticket','client', 'contact','schedule','project_task','quote','asset', ...}, created_at → NOT an internal user-share table. Used to attach docs to entities. document_share_links PK (tenant, share_id) share_id, document_id, token, share_type, password_hash, expires_at, max_downloads, is_revoked, created_by, created_at → EXTERNAL token-based shares only; NOT used for internal ACL. kb_articles PK (tenant, article_id) article_id, document_id (FK), ... → body comes through the FK to `documents.content` service_catalog PK (tenant, service_id) service_id, service_name, description, service_type, default_rate, unit_of_measure, category_id, attributes (jsonb), created_at, updated_at service_request_definitions PK (tenant, definition_id) definition_id, name, description, icon, category_id, form_schema (jsonb), execution_provider, visibility_provider, lifecycle_state, published_by, published_at, created_at, updated_at service_request_submissions PK (tenant, submission_id) submission_id, definition_id, definition_version_id, requester_user_id, client_id, contact_id, request_name, submitted_payload (jsonb), execution_status, created_ticket_id, created_at, updated_at ``` ### Workflow / activity ``` workflow_tasks PK (task_id) ← STRING ONLY, tenant is a column not in PK task_id (string), tenant (string), execution_id, event_id, task_definition_id, title, description, status, priority, due_date, context_data (jsonb), assigned_roles (jsonb), assigned_users (jsonb), created_at, updated_at, claimed_at, claimed_by, completed_at, completed_by, response_data (jsonb) → assigned_users is JSON array of user_ids; parse to uuid[] for visible_to_user_ids interactions PK (tenant, interaction_id) interaction_id, type_id, contact_name_id, client_id, user_id, ticket_id, title, notes (BlockNote JSON), interaction_date, duration, status_id, start_time, end_time, created_at, updated_at → `description` was renamed to `title` AND new `notes` column added in 20250530000000_improve_interactions_schema.cjs interaction_types PK (tenant, type_id) type_id, type_name, ... → join from interactions.type_id for the subtitle schedule_entries PK (tenant, entry_id) entry_id, title, work_item_id, work_item_type, user_id (owner), scheduled_start, scheduled_end, status, notes, created_at, updated_at time_entries PK (tenant, entry_id) entry_id, user_id (owner), start_time, end_time, notes, work_item_id, work_item_type, billable_duration, approval_status, created_at, updated_at → index ONLY when notes IS NOT NULL AND notes <> '' ``` ### Metadata / structural ``` boards PK (tenant, channel_id) channel_id, channel_name, ... → renamed from `channels` in 20250930000001_rename_channels_to_boards.cjs → column is still `channel_name` categories PK (tenant, category_id) category_id, category_name, description, ... → there is also `ticket_categories` (renamed from `service_categories`). Confirm which one the UI uses; default to `categories` for v1. tags PK (tenant, tag_id) tag_id, channel_id, tag_text, tagged_id, tagged_type ``` --- ## Code patterns the implementer needs ### Event bus Canonical event publish (used at action call sites): ```typescript import { publishEvent } from 'server/src/lib/eventBus/publishers'; // publishEvent omits id + timestamp; the publisher fills them in. await publishEvent({ eventType: 'CLIENT_UPDATED', payload: { tenant, client_id, changed_fields: [...] }, }); ``` Event types live in `packages/event-schemas/src/schemas/eventBusSchema.ts`. Each event has a matching Zod schema. To add a new event family: 1. Add the event type literal to the `EventTypeEnum` union. 2. Add a Zod payload schema for it. 3. Register the schema in the `EventPayloadSchemas` mapping. ### Subscriber registration Subscribers go under `server/src/lib/eventBus/subscribers/`. The new `searchIndexSubscriber.ts` registers in `server/src/lib/eventBus/initialize.ts` alongside the existing subscribers (`ticketEmailSubscriber`, `internalNotificationSubscriber`, etc.). Follow the pattern in those files. ### pg-boss job registration - Scheduler entry: `server/src/lib/jobs/jobScheduler.ts` - Handler registry: `server/src/lib/jobs/jobHandlerRegistry.ts` Pattern: ```typescript import { JobHandlerRegistry } from 'server/src/lib/jobs/jobHandlerRegistry'; import { JobScheduler } from 'server/src/lib/jobs/jobScheduler'; JobHandlerRegistry.register({ name: 'search:reconcile', handler: async (jobId, data: { tenantId: string }) => { // reconciliation logic per tenant }, retry: { maxAttempts: 3 }, }); const scheduler = await JobScheduler.getInstance(/* … */); await scheduler.scheduleRecurringJob('search:reconcile', '24 hours', { tenantId }); ``` ### withAuth pattern ```typescript import { withAuth } from '@alga-psa/auth'; import { createTenantKnex } from '@alga-psa/db'; export const searchAppAction = withAuth(async (user, { tenant }, input: SearchAppInput) => { const { knex } = await createTenantKnex(); // ... }); ``` Reference example: `server/src/app/msp/service-requests/actions.ts` lines 67–74. ### Tests - Runner: **Vitest** (config at `server/vitest.config.ts`) - Unit test example: `server/src/test/unit/workflowEmptyPayloadSchema.unit.test.ts` - Integration test example: `server/src/test/client-owned-contracts-resource-semantics.test.ts` - Commands (per CLAUDE.md): - `npm run test:unit` - `npm run test:integration` - `npm run test:e2e` - `npm run test:local` (all) ### i18n key convention `server/public/locales/en/msp/core.json` uses nested camelCase, e.g.: ```json { "nav": { "home": "Home", "tickets": "Tickets" }, "sidebar": { "searchPlaceholder": "Search" } } ``` For the search namespace, mirror the structure: ```json { "search": { "placeholder": "Search clients, tickets, documents…", "shortcutHint": "⌘K", "noResults": "No results for \"{{query}}\"", "loading": "Searching…", "seeAllResults": "See all {{count}} results", "filters": { "all": "All", "clients": "Clients", "tickets": "Tickets", "documents": "Documents", "..." }, "groups": { "...": "..." } } } ``` Run the lang-pack pipeline (`generate-pseudo-locales.cjs` + `validate-translations.cjs`) once after adding English keys to propagate to all locales. --- ## CE / EE extension — quick reference Full spec is in PRD §19. The short version for the implementer: - **CE owns the infrastructure.** `app_search_index` table, registry, subscriber, query builder, search action, UI all live in CE. EE never duplicates these. - **Registry merges two arrays:** `ceIndexers` (from `./indexers`) + `eeIndexers` (from `ee/server/src/lib/search/indexers`, stubbed to `[]` in CE). - **CE stub file** is created as part of F131. Match the existing repo CE/EE stub pattern — see `ee/server/src/lib/storage/providers/` or any other `ee/server/src/...` that already has a CE stub for the convention. The `ce-ee-stub-fixer` skill describes the build-time alias mechanism. - **`object_type` is `text`**, not an enum. Schema is identical CE↔EE. - **EE adds its own event types** in `packages/event-schemas` (or the EE event-schema extension point). The CE subscriber doesn't care — it dispatches by `object_type` via the merged registry. - **Orphan rows** (e.g., a CE deploy holding an old EE row): filtered out at query time via `object_type = ANY(registeredObjectTypes())`. Reconciliation also skips unregistered types — so it won't error trying to load an EE source row that doesn't exist in CE. - **What EE writes** (per entity): one indexer module + one event family + i18n keys for filter/group labels. EE does not touch CE files. Known likely EE entities (out of scope for CE v1): chat history (`ee/server/migrations/20260407163000_add_chat_history_search_indexes.cjs`), AI conversations/messages (`ee/server/migrations/202410291100_create_ai_schema.cjs`). EE planning is separate. --- ## Document ACL — v1 scope is intentional CE has **no internal per-user document permission mechanism**. Two related tables exist but neither is the right primitive: - `document_associations` — links a document to an entity (ticket, client, contact, …). Used for "show me docs attached to this entity," not for "user A can read this doc." - `document_share_links` — external token-based public shares with revoke/expiry. Not internal ACL. **Decision for v1:** documents are tenant-wide with `required_permission='document:read'` and optional `client_scope_id` derived from `documents.client_id`. The unused index columns `is_private` and `visible_to_user_ids` remain available for v2 if/when an internal share model is added — no schema change required at that time. --- ## Concrete deploy runbook ```bash # 1. Apply migration npm run migrate # 2. Deploy code with subscriber disabled # Set in env / helm values: # SEARCH_INDEX_LIVE=false # 3. Backfill all tenants npm run search:backfill # 4. Flip env to enable live indexing # SEARCH_INDEX_LIVE=true # Roll workers + server # 5. Reconciliation job (`search:reconcile`) runs daily at 6:00 AM per tenant # from launch; first run catches anything missed between (3) and (4). # 6. Enable the sidebar UI by merging the feature branch to main. ``` --- ## Implementation log - **2026-05-13 — T061 asset CRUD event contract.** Extended `searchEventPublishing.contract.test.ts` to assert asset actions emit `ASSET_CREATED`, `ASSET_UPDATED`, and `ASSET_DELETED`, covering search index incremental refresh for asset rows. Validation: `npx vitest run src/test/unit/searchEventPublishing.contract.test.ts --coverage=false` from `server/` passed. - **2026-05-13 — T062 invoice-family event contract.** Added a contract test for invoice header, item, and annotation create/update/delete event coverage. Filled the missing invoice item update publish in `packages/billing/src/models/invoice.ts` and added an annotation update helper that publishes `INVOICE_ANNOTATION_UPDATED`, matching the search indexer source events. Validation: `npx vitest run src/test/unit/searchEventPublishing.contract.test.ts --coverage=false` from `server/` passed; `npm -w @alga-psa/billing run typecheck` passed. - **2026-05-13 — T063 contract-family event contract.** Extended the source-publishing contract test to cover `CONTRACT_*` and `CLIENT_CONTRACT_*` CRUD events. Added `CLIENT_CONTRACT_DELETED` publishes when deleting a contract removes its client assignments, so client-contract search rows can be removed incrementally. Validation: `npx vitest run src/test/unit/searchEventPublishing.contract.test.ts --coverage=false` from `server/` passed; `npm -w @alga-psa/billing run typecheck` passed. - **2026-05-13 — T064 document update event contract.** Added source contract coverage for document content updates, association changes, and share-link create/revoke changes emitting `DOCUMENT_UPDATED`. Wired association and share-link changes to publish `DOCUMENT_UPDATED` so document search rows are reindexed when client scope or share state changes. Validation: `npx vitest run src/test/unit/searchEventPublishing.contract.test.ts --coverage=false` from `server/` passed; `npm -w @alga-psa/documents run typecheck` passed. - **2026-05-13 — T065 service catalog event contract.** Extended the publishing contract test to assert both API and billing action service-catalog CRUD paths emit `SERVICE_CATALOG_CREATED`, `SERVICE_CATALOG_UPDATED`, and `SERVICE_CATALOG_DELETED`. Added publishes to `packages/billing/src/actions/serviceActions.ts` so MSP UI mutations refresh service catalog search rows. Validation: `npx vitest run src/test/unit/searchEventPublishing.contract.test.ts --coverage=false` from `server/` passed; `npm -w @alga-psa/billing run typecheck` passed. - **2026-05-13 — T066 service-request event contract.** Added contract coverage for service-request definition/submission create, update, and delete search events. Existing create/update paths already published; added narrow delete helpers that emit `SERVICE_REQUEST_DEFINITION_DELETED` and `SERVICE_REQUEST_SUBMISSION_DELETED` so index rows can be removed when these records are physically deleted. Validation: `npx vitest run src/test/unit/searchEventPublishing.contract.test.ts --coverage=false` from `server/` passed; `npm -w server run typecheck` passed. - **2026-05-13 — T067 workflow task event contract.** Added contract coverage for workflow task create/update/delete and assignment-change search events. The model already published create/update; added model helpers for assignment replacement and deletion that emit `WORKFLOW_TASK_ASSIGNMENT_CHANGED` and `WORKFLOW_TASK_DELETED`. Validation: `npx vitest run src/test/unit/searchEventPublishing.contract.test.ts --coverage=false` from `server/` passed; `npm -w server run typecheck` passed. - **2026-05-13 — T068 remaining event-family contract.** Added aggregate contract coverage for interaction, schedule entry, time entry, board, category, and tag CRUD event publishes. Existing interaction/schedule/time/tag paths already published; added board/category publishes in ticket UI actions to match API/reference-data coverage. Validation: `npx vitest run src/test/unit/searchEventPublishing.contract.test.ts --coverage=false` from `server/` passed; `npm -w @alga-psa/tickets run typecheck` passed. - **2026-05-13 — T069 subscriber event-union contract.** Added unit coverage that the search index subscriber event list equals the union of every registered indexer's `sourceEvents`, and that the event resolver maps each event to all declaring indexers. Exposed a small read-only helper for the computed subscription event types so registration can stay registry-driven. Validation: `npx vitest run src/test/unit/searchIndexSubscriber.test.ts --coverage=false` from `server/` passed. - **2026-05-13 — T070 subscriber create-upsert behavior.** Added a fast behavior test for `CLIENT_CREATED` with live indexing enabled: the subscriber extracts tenant/object id, calls the client indexer's `loadOne`, and forwards the resulting `SearchDoc` to `upsertSearchDoc`. Exposed a test-only handler wrapper so the event handling path can be exercised without Redis. Validation: `npx vitest run src/test/unit/searchIndexSubscriber.test.ts src/test/unit/searchIndexSubscriber.behavior.test.ts --coverage=false` from `server/` passed. - **2026-05-13 — T071 subscriber delete behavior.** Extended the subscriber behavior suite so `CLIENT_DELETED` with live indexing enabled calls `deleteSearchDoc(knex, tenant, 'client', client_id)` and does not call `loadOne` or `upsertSearchDoc`. Validation: `npx vitest run src/test/unit/searchIndexSubscriber.behavior.test.ts --coverage=false` from `server/` passed. - **2026-05-13 — T072 live-index disabled behavior.** Added coverage that with `SEARCH_INDEX_LIVE=false`, the subscriber resolves and acknowledges the event but does not create a tenant knex, load the source row, upsert, or delete index rows. Validation: `npx vitest run src/test/unit/searchIndexSubscriber.behavior.test.ts --coverage=false` from `server/` passed. - **2026-05-13 — T073 live-index env flip behavior.** Confirmed the subscriber reads `SEARCH_INDEX_LIVE` per event rather than caching it at registration: a first `CLIENT_CREATED` while false performs no DB writes, then flipping the env var to true in the same process lets the next event upsert normally. Validation: `npx vitest run src/test/unit/searchIndexSubscriber.behavior.test.ts --coverage=false` from `server/` passed. - **2026-05-13 — T074 missing-source cleanup.** Fixed the subscriber race branch so an update/create event whose `loadOne` returns null now deletes the existing search index row for that `(tenant, object_type, object_id)` instead of only logging and leaving stale data. Added behavior coverage with `CLIENT_UPDATED`. Validation: `npx vitest run src/test/unit/searchIndexSubscriber.behavior.test.ts --coverage=false` from `server/` passed. - **2026-05-13 — T075 ticket-comment cascade.** Added subscriber behavior coverage for `TICKET_UPDATED`: after the ticket doc upsert, the subscriber queries the ticket's comment ids and re-loads/upserts each `ticket_comment` doc so parent-title denormalization stays fresh. Validation: `npx vitest run src/test/unit/searchIndexSubscriber.behavior.test.ts --coverage=false` from `server/` passed. - **2026-05-13 — T076 invoice child cascade.** Added subscriber behavior coverage for `INVOICE_UPDATED`: after the invoice doc upsert, the subscriber queries invoice item ids and annotation ids, then re-loads/upserts both child entity types with inherited invoice ACL context. Validation: `npx vitest run src/test/unit/searchIndexSubscriber.behavior.test.ts --coverage=false` from `server/` passed. - **2026-05-13 — T077 project child cascade.** Added subscriber behavior coverage for `PROJECT_UPDATED`: after the project doc upsert, the subscriber pages through phases, tasks, and task comments via their project-scoped queries and re-loads/upserts each child doc. Validation: `npx vitest run src/test/unit/searchIndexSubscriber.behavior.test.ts --coverage=false` from `server/` passed. - **2026-05-13 — T078 document association re-index.** Added subscriber behavior coverage for `DOCUMENT_ASSOCIATED`: association changes resolve to `documentIndexer.loadOne`, and the freshly loaded document doc (including updated `acl.clientScopeId`) is upserted. Validation: `npx vitest run src/test/unit/searchIndexSubscriber.behavior.test.ts --coverage=false` from `server/` passed. - **2026-05-13 — T079 user role visible-user reindex.** Added subscriber behavior coverage for `USER_ROLES_UPDATED`: after the user row is re-indexed, the subscriber enqueues `scheduleSearchVisibleUserReindexJob(tenant, userId)` so rows containing that user in `visible_to_user_ids` can refresh asynchronously. Validation: `npx vitest run src/test/unit/searchIndexSubscriber.behavior.test.ts --coverage=false` from `server/` passed. - **2026-05-13 — T080 tenant/type backfill.** Added unit coverage for `runSearchBackfill({ tenant, type: 'client' }, knex)`: it resolves only the client indexer, skips tenant catalog discovery, calls `clientIndexer.loadBatch(knex, tenant, null, 500)`, and upserts every returned doc. Validation: `npx vitest run src/test/unit/searchBackfill.test.ts --coverage=false` from `server/` passed. - **2026-05-13 — T081 backfill batching guard.** Added a synthetic 10k-row backfill test that returns exactly 500 docs for 20 pages, verifies the cursor advances by last object id, and confirms 10,000 upserts without materializing the full source set in one call. Validation: `npx vitest run src/test/unit/searchBackfill.test.ts --coverage=false` from `server/` passed. - **2026-05-13 — T082 backfill idempotency.** Added an in-memory upsert simulation and ran the same backfill twice, confirming the final row map is identical after the second run even though upserts execute again. Validation: `npx vitest run src/test/unit/searchBackfill.test.ts --coverage=false` from `server/` passed. - **2026-05-13 — T083 tenant catalog discovery.** Added backfill coverage with no `tenant` option: the script queries `tenants`, orders by tenant id, and runs the selected client indexer once per discovered tenant. Validation: `npx vitest run src/test/unit/searchBackfill.test.ts --coverage=false` from `server/` passed. - **2026-05-13 — T084 backfill npm script.** Added a static package-script contract that verifies root `package.json` wires `search:backfill` to `tsx server/src/scripts/search-backfill.ts`. Validation: `npx vitest run src/test/unit/searchBackfill.test.ts --coverage=false` from `server/` passed. - **2026-05-13 — T085 reconciliation watermark re-index.** Added unit coverage for `reindexRowsAfterWatermark`: with an index watermark at noon, an older source doc is skipped while a newer source doc is upserted and counted as reindexed. Validation: `npx vitest run src/test/unit/searchReconcile.test.ts --coverage=false` from `server/` passed. - **2026-05-13 — T086 reconciliation stale delete.** Added unit coverage for `deleteRowsMissingFromSource`: indexed ids are checked with `indexer.loadOne`, present sources are kept, and a missing source row causes `deleteSearchDoc(knex, tenant, objectType, objectId)`. Validation: `npx vitest run src/test/unit/searchReconcile.test.ts --coverage=false` from `server/` passed. - **2026-05-13 — T087 reconciliation missing insert.** Added unit coverage for `insertRowsMissingFromIndex`: source docs are compared against existing `app_search_index.object_id` rows, and only source docs absent from the index are upserted. Validation: `npx vitest run src/test/unit/searchReconcile.test.ts --coverage=false` from `server/` passed. - **2026-05-13 — T088 reconciliation job registration/schedule.** Added a static contract that the search reconcile handler is registered in `registerAllHandlers`, `scheduleSearchReconcileJob` uses `scheduleRecurringJob`, and scheduled-job startup calls it daily at `0 6 * * *`. Validation: `npx vitest run src/test/unit/searchReconcile.test.ts --coverage=false` from `server/` passed. - **2026-05-13 — T089 query length cap.** Added unit coverage that `parseQuery` rejects 201-character input with the typed `SearchQueryError` code `query_too_long`. Validation: `npx vitest run src/test/unit/searchQuery.test.ts --coverage=false` from `server/` passed. - **2026-05-13 — T090 query normalization.** Added parser coverage for whitespace collapse/trimming on text queries and identifier-like query normalization (`TIC-1023` -> `tic-1023`) while preserving non-identifier casing. Validation: `npx vitest run src/test/unit/searchQuery.test.ts --coverage=false` from `server/` passed. - **2026-05-13 — T091 FTS branch SQL.** Added query-builder coverage that `runSearchQuery` emits `websearch_to_tsquery('english', ?)` and filters with `s.search_vector @@ q.tsq` in the match branch. Validation: `npx vitest run src/test/unit/searchQuery.test.ts --coverage=false` from `server/` passed. - **2026-05-13 — T092 FTS ranking order.** Added query-builder coverage that relevance scoring includes `ts_rank_cd(s.search_vector, q.tsq)` and the default relevance sort orders by `score DESC, source_updated_at DESC, object_id ASC`. Validation: `npx vitest run src/test/unit/searchQuery.test.ts --coverage=false` from `server/` passed. - **2026-05-13 — T093 pg_trgm fallback row mapping.** Added query coverage that the fuzzy branch includes `s.title % q.raw` and `coalesce(s.subtitle, '') % q.raw`, and that a simulated `exhcange` result row maps back as a client hit for "Exchange Systems." Validation: `npx vitest run src/test/unit/searchQuery.test.ts --coverage=false` from `server/` passed. - **2026-05-13 — T094 trigram score contribution.** Added query-builder coverage that composite relevance includes `similarity(s.title, q.raw)`, subtitle similarity, and the v1 `* 0.4` trigram weight. Validation: `npx vitest run src/test/unit/searchQuery.test.ts --coverage=false` from `server/` passed. - **2026-05-13 — T095 ticket identifier pin.** Added query coverage for `TIC-1023`: parser binding lowercases the identifier to `tic-1023`, SQL checks `metadata->>'identifier'`, assigns exact matches score `1000`, and the mapped ticket hit remains first in the simulated result set. Validation: `npx vitest run src/test/unit/searchQuery.test.ts --coverage=false` from `server/` passed. - **2026-05-13 — T096 asset identifier pin.** Added asset-tag coverage for `LAP-0042`, verifying the same identifier exact-match SQL/binding path pins an asset result with score `1000`. Validation: `npx vitest run src/test/unit/searchQuery.test.ts --coverage=false` from `server/` passed. - **2026-05-13 — T097 time-decay ranking.** Added query-builder coverage that composite score multiplies relevance by `exp(-age/90d)` using `source_updated_at`, with default relevance ordering by score then recency so newer equivalent rows win. Validation: `npx vitest run src/test/unit/searchQuery.test.ts --coverage=false` from `server/` passed. - **2026-05-13 — T098 time-decay floor.** Added query-builder coverage that the time-decay multiplier is wrapped in `GREATEST(..., 0.05)` so very old rows retain the v1 minimum score multiplier. Validation: `npx vitest run src/test/unit/searchQuery.test.ts --coverage=false` from `server/` passed. - **2026-05-13 — T099 cursor round-trip.** Added unit coverage that `encodeSearchCursor` and `decodeSearchCursor` preserve score, ISO `updatedAt`, and object id for stable pagination boundaries. Validation: `npx vitest run src/test/unit/searchQuery.test.ts --coverage=false` from `server/` passed. - **2026-05-13 — T100 cursor pagination stability.** Added query coverage that a decoded cursor binds strict relevance/recency/object-id predicates and resets offset to zero, preventing page-one rows from reappearing on page two. Validation: `npx vitest run src/test/unit/searchQuery.test.ts --coverage=false` from `server/` passed. - **2026-05-13 — T101 snippet sentinel SQL.** Added query-builder coverage that snippet generation uses `ts_headline` with controlled `__SEARCH_MARK_START__` / `__SEARCH_MARK_STOP__` sentinels rather than raw HTML tags. Validation: `npx vitest run src/test/unit/searchQuery.test.ts --coverage=false` from `server/` passed. - **2026-05-13 — T102 snippet sanitizer.** Added direct sanitizer coverage showing arbitrary `