Reply Pattern Tracking & DSL Filtering

Implementation plan · researched against prod readonly TimescaleDB + codebase · 2026-07-06 · for Harshit
TL;DR
Store pattern usage on lifecycle events, exactly the way macro usage is already stored (metadata.macroId + partial index — shipped and proven). Both the goal-agent path and the interactive composer path emit DRAFT_REPLY_CREATED with metadata.patternIds; AI_REPLY_DRAFT_ACCEPTED additionally carries patternIds as the outcome marker. One tiny partial GIN index makes the DSL filter free at any scale, because it grows with pattern usage, not table volume. Hypertable conversion is not needed (and would hurt this query shape).

1Why lifecycle events (and the shipped precedent)

Every reply path in prod already lands on "ActionableInsightsLifecycleEvent":

PathEvent todayMetadata already present
Goal agent → RUN_MACRO DRAFT_REPLY sub-event macroId, sourceActionType:"RUN_MACRO", actionReviewId, sendDecision, goalId (run-macro.ts:200-214)
Goal agent → free DRAFT_REPLY DRAFT_REPLY_CREATED actionReviewId, actor info
Human sends / approves AI draft AI_REPLY_DRAFT_ACCEPTED source (composer_generate / review), originalAiDraft, finalText, wasEdited, reviewId
Human "generate a reply" (composer) nothing at generation time — (only ACCEPTED fires later, if sent)

Because each reply is its own event row with its own actionReviewId, the multi-replies-per-action property falls out for free: reply 1 on an action can carry pattern X, reply 2 pattern Y, each independently queryable. A dedicated ReplyPatternUsage join table would duplicate what the event log already models.

The precedent that proves the model
Macro usage is read back through idx_lifecycle_macro_usage, a partial expression index on metadata->>'macroId'. In prod it is 16 kB and has served 5,610 scans. Partial indexes only contain rows with the metadata key, so the pattern filter's cost is proportional to how many replies used patterns, not to total event volume. Uber taking us to 70M events/month does not touch this index at all.

2Prod scale measurements (readonly TimescaleDB)

5.77M
rows in lifecycle table
4.2 GB
total (2.8 GB is 16 indexes)
~650k/mo
growth, climbing (183k/mo 8mo ago)
8.5 ms
thread⋈events semi-join, biggest org

3The event contract

GOAL AGENT PATH                          INTERACTIVE PATH (composer)
───────────────                          ──────────────────────────
agent drafts reply                       human clicks "generate a reply"
  │                                        │  (Mastra stream relayed via
  │                                        │   apps/web .../api/ai/agent/route.ts)
  ▼                                        ▼
DRAFT_REPLY_CREATED / RUN_MACRO          DRAFT_REPLY_CREATED           ← NEW emission
  metadata.patternIds: [...]               metadata.patternIds: [...]
  metadata.actionReviewId                  metadata.source: "composer_generate"
  │                                        │  (emitted SERVER-SIDE at
  │                                        │   generation-complete)
  ▼                                        ▼
patternIds snapshotted onto              generate response returns patternIds
ActionReview.metadata                    → client holds them with the draft
  │                                        │
  ▼                                        ▼
human approves review                    human sends (input.aiReplyDraft
  │                                        echoes patternIds back)
  ▼                                        ▼
AI_REPLY_DRAFT_ACCEPTED                  AI_REPLY_DRAFT_ACCEPTED
  metadata.patternIds  (from review)       metadata.patternIds  (from echo)
  metadata.wasEdited                       metadata.wasEdited

Rules

  1. Creation events are the primary store. They are the only complete usage log: they cover rejected drafts, abandoned/regenerated drafts, and goal-agent auto-sends (no human in the loop → ACCEPTED never fires there).
  2. ACCEPTED also carries patternIds so "accepted replies that used pattern X" needs no event-to-event join. It's nearly free: the review path already snapshots draft info onto ActionReview metadata (that's how aiReplyDraftSource reaches the approval handler), and the composer path already sends {source, originalAiDraft} from the client — one more field.
  3. Never infer the link by recency. ACCEPTED must not "read the last DRAFT_REPLY_CREATED" — with regenerations, multiple drafters, or agent+human drafts coexisting, "last" mis-attributes silently. The correlation channel is explicit: review metadata (review path) or the UI echo (composer path). The UI is a courier for server-issued values, same as originalAiDraft today.
  4. patternIds is an array — one reply can draw on several patterns.
  5. Always set orgId on new events (see §6 — 28% of 2026 writes still omit it and silently vanish from org-scoped filters).

Why emit DRAFT_REPLY_CREATED on the interactive path (new write)

4The index

CREATE INDEX idx_lifecycle_pattern_usage
ON "ActionableInsightsLifecycleEvent"
USING gin ((metadata->'patternIds') jsonb_path_ops)
WHERE metadata ? 'patternIds';

5DSL wiring — two surfaces, both with shipped precedents

5a. pattern: filter on the actions/messages row sources

"Find all cases where a reply used pattern X" — the main filter.

  1. Register the field in packages/core/filter-registry/src/dsl-field-registry.ts (canonical name, aliases, operators, rowSources).
  2. Add a closure in packages/data/timescale-db/src/models/dashboard/field-sql-closures.ts, registered in FILTER_SQL_REGISTRY, emitting an EXISTS subquery against the lifecycle log correlated on the action id — exactly how tag:, theme:, and caseType: already filter via separate tables.
  3. Add the filterType to the FilterClause union in dsl-query-dispatcher.ts. It's auto-picked-up by tryRegistryDispatch.
  4. Boot-time validators (field-registries-validate.ts, registry-enforcement.test.ts) will fail if the field is declared without its closure wiring — run them.
  5. Agent-facing docs/allowlists: packages/core/agents/skills/sift-search/references/row-sources.md, packages/core/agents/DSL.md.
-- shape the closure emits (schematic)
EXISTS (
  SELECT 1 FROM "ActionableInsightsLifecycleEvent" e
  WHERE e."actionableInsightsThreadId" = ait.id
    AND e."orgId" = $org
    AND e.metadata->'patternIds' @> $patternIdJson
    -- optional stricter variant: AND e.type = 'AI_REPLY_DRAFT_ACCEPTED'
)

5b. patternId filter on the existing lifecycle_events catalog source

Per-reply granularity — "show each reply and which pattern made it". Also what lets the agent itself search past cases by pattern (the similar-case retrieval story).

The retrieval story this unlocks
"Find past cases where pattern X was used and the human didn't edit the draft" = type:AI_REPLY_DRAFT_ACCEPTED patternId:X wasEdited:false. That's a far stronger similar-case signal for prompting than draft-time usage alone — and it's exactly the "search past cases by pattern" idea from the Slack thread, without Supermemory.

6Hypertable verdict & the real scale work

Hypertable: no (for this)
Hypertables buy chunk exclusion on time-bounded queries, plus compression/retention. The DSL query here is "all actions that ever used pattern X in this org" — no time bound, so no chunk exclusion; instead every chunk's index gets probed, making untimebounded lookups slower than one global btree. We'd also have to rework the id PK to include createdAt and deal with the thread FK-cascade + notify trigger. Compression may matter for storage at 100x, but that's a separate problem from filtering.

What actually threatens the table at Uber scale, in order:

  1. NULL orgId — 2.29M rows (40%) have no orgId, and it's not legacy: 28% of 2026 writes still omit it (the CREATED / STATUS_CHANGED / QUEUE_CHANGED / ASSIGNED writers). Every org-scoped index and DSL filter silently misses those rows. Fix the writers (+ backfill — orgId is derivable from the thread).
  2. Index diet — 16 indexes = 16 index writes per insert at a future 70M inserts/month. Near-dead today: idx_lifecycle_event_workflow_execution (48 MB, 0 scans), idx_lifecycle_event_goal_type_created (375 MB, 124 scans), idx_lifecycle_assignee_org_created (324 MB, 135 scans), bare orgId index (56 MB, 12 scans). ~800 MB reclaimable.

Generalizing lifecycle-event filtering: this recipe (metadata key + partial index + EXISTS closure) holds for any selective event class. It is the wrong substrate for filters that would match most events (e.g. status-history filters at Uber scale) — those stay as materialized columns on the thread / ActionMetrics, like today.

7Implementation checklist

#ChangeWhere
1Stamp metadata.patternIds on goal-agent draft events (free DRAFT_REPLY + RUN_MACRO sub-event); always set orgId packages/core/action-manager/src/workflow/execute-goal-decision.ts, run-macro.ts
2Snapshot patternIds onto ActionReview metadata at review creation same paths, where aiReplyDraftSource/draft snapshots are written today
3Emit DRAFT_REPLY_CREATED (source:"composer_generate", patternIds) server-side at generation-complete; return patternIds to the client with the draft apps/web/src/app/api/ai/agent/route.ts (Mastra stream relay)
4Client echoes patternIds inside input.aiReplyDraft on send; server writes it into ACCEPTED composer send flow → apps/web/src/trpc/router/action/message-router.ts (~:238)
5Approval handler copies patternIds from review metadata into ACCEPTED apps/web/src/trpc/router/action/action-review/procedures-decisions.ts (~:296)
6Partial GIN index migration (§4) packages/data/timescale-db — pnpm create-migration
7pattern: row-source filter (§5a) dsl-field-registry.ts, field-sql-closures.ts, dsl-query-dispatcher.ts
8patternId on lifecycle_events source (§5b) lifecycle-events-search-field-planner.ts, dsl-field-registry.ts
9Exclude composer-generate creation events from case timeline / list queries lifecycle-events-search-field-planner.ts, list-actions.ts (precedent: ACCEPTED exclusions)
10Usage analytics reader (counts/daily series per pattern), mirroring getMacroUsageCounts / getMacroUsageDailySeries packages/data/timescale-db/src/models/actions/actionable-insights-lifecycle-event.ts

Independent follow-ups (not blockers, pre-Uber hygiene): fix NULL-orgId writers + backfill; drop the near-dead indexes.

8Open decisions