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).
Every reply path in prod already lands on "ActionableInsightsLifecycleEvent":
| Path | Event today | Metadata 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.
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.
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
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.originalAiDraft today.patternIds is an array — one reply can draw on several patterns.orgId on new events (see §6 — 28% of 2026 writes still omit it and silently vanish from org-scoped filters).DRAFT_REPLY_CREATED enum value, distinguish with metadata.source: "composer_generate" (exactly how ACCEPTED distinguishes sources — avoids an enum migration).AI_REPLY_DRAFT_ACCEPTED is already excluded with type != … filters in lifecycle-events-search-field-planner.ts and list-actions.ts.CREATE INDEX idx_lifecycle_pattern_usage
ON "ActionableInsightsLifecycleEvent"
USING gin ((metadata->'patternIds') jsonb_path_ops)
WHERE metadata ? 'patternIds';
jsonb_path_ops because patternIds is an array → containment queries
(metadata->'patternIds' @> '"<patternId>"').WHERE metadata ? 'patternIds') → contains only pattern-stamped rows.
Stays kB-sized like the macro index regardless of total table growth.pattern: filter on the actions/messages row sources"Find all cases where a reply used pattern X" — the main filter.
rowSources).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.FilterClause union in
dsl-query-dispatcher.ts. It's auto-picked-up by tryRegistryDispatch.-- 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'
)
patternId filter on the existing lifecycle_events catalog sourcePer-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).
goalId, eventType, workflowId). Adding
patternId mirrors those.sqlStrategy: "savedSearchOnly" with no rowSources, like the other
audit fields (registry lines ~567-629) so the public parser accepts it without the aggregate DSL trying
to emit SQL for it.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.
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:
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).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.
| # | Change | Where |
|---|---|---|
| 1 | Stamp 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 |
| 2 | Snapshot patternIds onto ActionReview metadata at review creation |
same paths, where aiReplyDraftSource/draft snapshots are written today |
| 3 | Emit 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) |
| 4 | Client 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) |
| 5 | Approval handler copies patternIds from review metadata into ACCEPTED |
apps/web/src/trpc/router/action/action-review/procedures-decisions.ts (~:296) |
| 6 | Partial GIN index migration (§4) | packages/data/timescale-db — pnpm create-migration |
| 7 | pattern: row-source filter (§5a) |
dsl-field-registry.ts, field-sql-closures.ts, dsl-query-dispatcher.ts |
| 8 | patternId on lifecycle_events source (§5b) |
lifecycle-events-search-field-planner.ts, dsl-field-registry.ts |
| 9 | Exclude composer-generate creation events from case timeline / list queries | lifecycle-events-search-field-planner.ts, list-actions.ts (precedent: ACCEPTED exclusions) |
| 10 | Usage 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.
pattern: filter default to any-usage or accepted-only? Recommendation: any-usage by default, with the accepted-only variant expressed via the lifecycle_events source (type + wasEdited) until there's demand for a dedicated operator.