Skip to content

Persist signal entity links for People reconciliation - #839

Merged
jeevanpillay merged 3 commits into
mainfrom
feat/signal-entity-links-persistence
Jun 6, 2026
Merged

jeevanpillay merged 3 commits into
mainfrom
feat/signal-entity-links-persistence

Conversation

@jeevanpillay

@jeevanpillay jeevanpillay commented Jun 6, 2026 •

Copy link
Copy Markdown
Member

Summary

  • Add persistent Signal entity-link storage with idempotent per-signal replacement and conservative People resolution.
  • Reconcile unresolved Signal links when People are later indexed through classification or team-member sync.
  • Expose entityLinks through signal get APIs/MCP and render linked people on Signal detail.

Test Plan

  • pnpm check
  • pnpm typecheck
  • pnpm test

Summary by CodeRabbit

  • New Features
    • Signals now display associated linked people in the detail view with resolution details and confidence indicators
    • Unresolved linked people are marked with an "Unresolved" badge
    • Entity links are automatically reconciled when team members are synced and during signal classification workflows

@vercel

vercel Bot commented Jun 6, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
lightfast-app Ready Ready Preview, Comment Jun 6, 2026 12:36pm
lightfast-mcp Ready Ready Preview, Comment Jun 6, 2026 12:36pm
lightfast-platform Ready Ready Preview, Comment Jun 6, 2026 12:36pm
lightfast-www Ready Ready Preview, Comment Jun 6, 2026 12:36pm
lightfast-www-start Error Error Jun 6, 2026 12:36pm

Request Review

@coderabbitai

coderabbitai Bot commented Jun 6, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@jeevanpillay, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 14 minutes and 12 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 767f2c66-91f5-459a-9c8c-1e62e22e1e93

📥 Commits

Reviewing files that changed from the base of the PR and between fe31f7c and 9307bf1.

⛔ Files ignored due to path filters (3)
  • db/app/src/migrations/0026_luxuriant_wasp.sql is excluded by !db/**/migrations/**
  • db/app/src/migrations/meta/0026_snapshot.json is excluded by !db/**/migrations/**
  • db/app/src/migrations/meta/_journal.json is excluded by !db/**/migrations/**
📒 Files selected for processing (7)
  • api/app/src/__tests__/entity-index-workflow.test.ts
  • api/app/src/inngest/workflow/index-signal-entities.ts
  • apps/app/src/app/(app)/(pending-not-allowed)/[slug]/(workspace)/signals/_components/signal-detail-sheet.test.tsx
  • apps/app/src/app/(app)/(pending-not-allowed)/[slug]/(workspace)/signals/_components/signal-detail-sheet.tsx
  • db/app/src/__tests__/signal-entity-links.test.ts
  • db/app/src/index.ts
  • db/app/src/utils/signal-entity-links.ts
📝 Walkthrough

Walkthrough

This PR implements signal-to-person entity linking: signals extract and merge person mentions from input text, the system persists these links to a database table, resolves ambiguous names when people are classified or synced, and displays resolved links in the signal detail UI. The feature spans schema, business logic, workflows, APIs, and UI components.

Changes

Signal entity linking

Layer / File(s) Summary
API contract and database schema
packages/api-contract/src/schemas/signals.ts, packages/api-contract/src/index.ts, db/app/src/schema/tables/org-signal-entity-links.ts, db/app/src/schema/index.ts, db/app/src/schema/tables/index.ts, packages/api-contract/src/__tests__/signals.test.ts
New Zod schemas for entity links (mention kind, extraction method, resolved person, full link shape); getSignalOutput now includes entityLinks array. orgSignalEntityLinks MySQL table stores link metadata (anchor text, occurrence, confidence, rationale), optional person resolution fields, and unique index on (clerkOrgId, signalId, localEntityKey).
Signal entity link utilities
db/app/src/utils/signal-entity-links.ts, db/app/src/index.ts, db/app/src/__tests__/signal-entity-links.test.ts
buildSignalEntityLinkResolutionHints normalizes mentions (email → identity key, profile URLs → provider/handle, names → clamped string). replaceSignalEntityLinks transactionally deletes and inserts link records after resolving each candidate to a Person by identity-key then display-name matching. reconcileSignalEntityLinksForPeople uses batched cursor loop to iteratively resolve unresolved links. listSignalEntityLinksForSignal left-joins to people and maps rows to link details with computed confidence.
Candidate merging and deterministic keys
ai/src/signal-entity-linker/extract.ts, ai/src/__tests__/signal-entity-linker/extract.test.ts
mergeSignalEntityLinkCandidates now overwrites each merged candidate's localEntityKey with deterministic person_{index + 1} based on final order position. Tests verify full ordered sequence and individual key reassignment.
Signal indexing workflow
api/app/src/inngest/workflow/index-signal-entities.ts, api/app/src/__tests__/entity-index-workflow.test.ts
After merging candidates, indexSignalEntities calls replaceSignalEntityLinks to persist links and includes persistedLinks/resolvedLinks in output. Tests mock persistence and verify it is called on success but skipped when signal is missing/unclassified/invisible or AI fails.
People workflow and sync
api/app/src/inngest/workflow/classify-people.ts, api/app/src/services/team-members/people-sync.ts, api/app/src/__tests__/people-workflow.test.ts, api/app/src/__tests__/team-member-people-sync.test.ts
classify-people calls reconcileSignalEntityLinksForPeople after upserting people; syncTeamMembersForOrg calls it after sync completes. Workflows return entityLinksResolved count. Tests mock reconciliation and verify calls.
API endpoints returning entity links
api/app/src/orpc/router/signals.ts, api/app/src/router/(pending-not-allowed)/workspace-signals.ts, apps/app/src/app/(internal)/api/internal/mcp/signals/get/route.ts, apps/mcp/src/tools/execute.ts, api/app/src/__tests__/signal-orpc.test.ts, api/app/src/__tests__/workspace-signals-router.test.ts, apps/app/src/__tests__/app/api/internal/mcp-signals-route.test.ts, apps/mcp/src/__tests__/tools.test.ts
signalsRouter.get, workspaceSignalsRouter.get, and MCP signal GET routes call listSignalEntityLinksForSignal and include entityLinks in response. MCP tool execute interface and defaultDependencies wire the dependency. Tests mock listSignalEntityLinksForSignal, verify it is called with correct org/signal ids, and assert entityLinks in responses.
Signal detail UI
apps/app/src/app/(app)/(pending-not-allowed)/[slug]/(workspace)/signals/_components/signal-detail-content.tsx, apps/app/src/app/(app)/(pending-not-allowed)/[slug]/(workspace)/signals/_components/signal-detail-sheet.tsx, apps/app/src/app/(app)/(pending-not-allowed)/[slug]/(workspace)/signals/_components/signals-client.tsx, apps/app/src/app/(app)/(pending-not-allowed)/[slug]/signals-client.test.tsx, apps/app/src/app/(app)/(pending-not-allowed)/[slug]/(workspace)/signals/_components/signal-detail-content.test.tsx
SignalDetailContent adds EntityLinksSection to render "Linked people", mapping resolved links to /{slug}/people?person={id} and showing "Unresolved" badges for pending links. Requires slug prop. signal-detail-sheet accepts optional slug and forwards it; signals-client reads [slug] route param via useParams. Tests verify link rendering and href formatting.

🎯 4 (Complex) | ⏱️ ~60 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 2.78% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed Title follows conventional commits format with no prefix (implicit 'feat:'), is 53 characters, and accurately describes the main changeset: persistent storage and reconciliation of signal entity links for People.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/signal-entity-links-persistence
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch feat/signal-entity-links-persistence

Comment @coderabbitai help to get the list of available commands and usage tips.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fe31f7c807

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +104 to +109
const persisted = await step.run("persist entity links", () =>
replaceSignalEntityLinks(db, {
candidates,
clerkOrgId,
signalId,
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Skip persisting links for user-visible signals

When a classified signal remains visibilityScope: "user", shouldIndexSignalEntities still lets the workflow reach this new persist step because it only excludes needs_review; this writes rows into the org-scoped lightfast_org_signal_entity_links table. Those rows are later scanned by org-level reconciliation (reconcileSignalEntityLinksForPeople filters only by clerkOrgId), so private/user-scoped signal mentions become part of the shared org person-link graph. Guard this persist step to team-visible signals or store/enforce link visibility before writing.

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (2)
apps/app/src/app/(internal)/api/internal/mcp/signals/get/route.ts (1)

44-59: ⚖️ Poor tradeoff

Consider parallel fetching for signal and entity links.

Same opportunity as in the ORPC router: the signal and entity links queries execute sequentially. Fetching them concurrently would reduce latency:

     await assertHostedMcpOrgAccess(db, {
       orgId: parsed.data.actor.orgId,
       userId: parsed.data.actor.userId,
     });
-    const signal = await getVisibleSignalByPublicId(db, {
-      publicId: parsed.data.id,
-      clerkOrgId: parsed.data.actor.orgId,
-      createdByUserId: parsed.data.actor.userId,
-    });
-
-    if (!signal) {
-      return jsonError("not_found", "Signal not found.", 404);
-    }
-
-    const entityLinks = await listSignalEntityLinksForSignal(db, {
-      clerkOrgId: parsed.data.actor.orgId,
-      signalId: signal.publicId,
-    });
+    const [signal, entityLinks] = await Promise.all([
+      getVisibleSignalByPublicId(db, {
+        publicId: parsed.data.id,
+        clerkOrgId: parsed.data.actor.orgId,
+        createdByUserId: parsed.data.actor.userId,
+      }),
+      listSignalEntityLinksForSignal(db, {
+        clerkOrgId: parsed.data.actor.orgId,
+        signalId: parsed.data.id,
+      }),
+    ]);
+
+    if (!signal) {
+      return jsonError("not_found", "Signal not found.", 404);
+    }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/app/src/app/`(internal)/api/internal/mcp/signals/get/route.ts around
lines 44 - 59, The signal and entityLinks queries are being executed
sequentially which increases latency; update the handler to fetch the signal and
listSignalEntityLinksForSignal concurrently (use Promise.all or parallel awaits)
by initiating both promises (the call that yields `signal` and the call to
`listSignalEntityLinksForSignal(db, { clerkOrgId: parsed.data.actor.orgId,
signalId: signalIdOrPublicId })`) before awaiting results, then wait for both to
resolve and pass the resolved `signal` and `entityLinks` into
`getSignalOutput.parse` and the Response; ensure you preserve the existing
variables (`signal`, `entityLinks`, `db`, `parsed.data.actor.orgId`) and any
required typing/validation after both promises settle.
api/app/src/orpc/router/signals.ts (1)

57-67: ⚖️ Poor tradeoff

Consider parallel fetching for signal and entity links.

The signal and entity links queries execute sequentially, adding their latencies together. Since both are independent DB reads scoped to the same org, you could fetch them concurrently:

-    const signal = await getVisibleSignalByPublicId(db, {
-      publicId: getInput.id,
-      clerkOrgId: context.auth.identity.orgId,
-      createdByUserId: context.auth.identity.userId,
-    });
-
-    if (!signal) {
-      throw new ORPCError("NOT_FOUND", {
-        message: "Signal not found.",
-      });
-    }
-
-    const entityLinks = await listSignalEntityLinksForSignal(db, {
-      clerkOrgId: context.auth.identity.orgId,
-      signalId: signal.publicId,
-    });
+    const signal = await getVisibleSignalByPublicId(db, {
+      publicId: getInput.id,
+      clerkOrgId: context.auth.identity.orgId,
+      createdByUserId: context.auth.identity.userId,
+    });
+
+    if (!signal) {
+      throw new ORPCError("NOT_FOUND", {
+        message: "Signal not found.",
+      });
+    }
+
+    const entityLinks = await listSignalEntityLinksForSignal(db, {
+      clerkOrgId: context.auth.identity.orgId,
+      signalId: signal.publicId,
+    });

Wait, that's the same code. Let me fix:

-    const signal = await getVisibleSignalByPublicId(db, {
-      publicId: getInput.id,
-      clerkOrgId: context.auth.identity.orgId,
-      createdByUserId: context.auth.identity.userId,
-    });
-
-    if (!signal) {
-      throw new ORPCError("NOT_FOUND", {
-        message: "Signal not found.",
-      });
-    }
-
-    const entityLinks = await listSignalEntityLinksForSignal(db, {
-      clerkOrgId: context.auth.identity.orgId,
-      signalId: signal.publicId,
-    });
+    const [signal, entityLinks] = await Promise.all([
+      getVisibleSignalByPublicId(db, {
+        publicId: getInput.id,
+        clerkOrgId: context.auth.identity.orgId,
+        createdByUserId: context.auth.identity.userId,
+      }),
+      listSignalEntityLinksForSignal(db, {
+        clerkOrgId: context.auth.identity.orgId,
+        signalId: getInput.id,
+      }),
+    ]);
+
+    if (!signal) {
+      throw new ORPCError("NOT_FOUND", {
+        message: "Signal not found.",
+      });
+    }

This saves one round-trip's worth of latency.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@api/app/src/orpc/router/signals.ts` around lines 57 - 67, The signal fetch
(the code that produces the local variable signal) and the call to
listSignalEntityLinksForSignal are independent and should be executed in
parallel to avoid serialized DB latency; refactor so both promises are started
before awaiting (e.g., assign the signal fetch promise and the
listSignalEntityLinksForSignal(...) promise to variables, await them with
Promise.all, then use the resolved values to build the return object), keeping
references to the existing identifiers signal and entityLinks.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@apps/app/src/app/`(app)/(pending-not-allowed)/[slug]/(workspace)/signals/_components/signal-detail-sheet.tsx:
- Around line 29-34: SignalDetailSheet currently sets slug = "" which causes
SignalDetailContent to build broken hrefs like `/${slug}/people?person=${id}`
when slug is empty; make slug required instead of defaulting to "" by removing
the default value and updating the prop type (ensure callers pass a non-null
slug) so SignalDetailContent and any link construction always receive a valid
workspace slug (update SignalDetailSheet prop signature and any call sites to
provide the slug), or alternatively add a guard in SignalDetailContent to
suppress or fallback on rendering entity links when slug === "" (prefer option
1: require slug).

In `@db/app/src/utils/signal-entity-links.ts`:
- Around line 246-269: The loop over unresolvedLinks calls
resolveSignalEntityLinkRecord for each link, causing hundreds of serial DB
queries; instead, gather all unique identity keys and display names from
unresolvedLinks, run a single query against the People table (using inArray) to
fetch matching people, build a lookup map keyed by the identity key/display
name, then iterate unresolvedLinks and resolve each link from that map
(replacing calls to resolveSignalEntityLinkRecord), keeping the existing update
logic that sets resolvedAt/resolvedPersonId and uses getRowsAffected on the
update result.

---

Nitpick comments:
In `@api/app/src/orpc/router/signals.ts`:
- Around line 57-67: The signal fetch (the code that produces the local variable
signal) and the call to listSignalEntityLinksForSignal are independent and
should be executed in parallel to avoid serialized DB latency; refactor so both
promises are started before awaiting (e.g., assign the signal fetch promise and
the listSignalEntityLinksForSignal(...) promise to variables, await them with
Promise.all, then use the resolved values to build the return object), keeping
references to the existing identifiers signal and entityLinks.

In `@apps/app/src/app/`(internal)/api/internal/mcp/signals/get/route.ts:
- Around line 44-59: The signal and entityLinks queries are being executed
sequentially which increases latency; update the handler to fetch the signal and
listSignalEntityLinksForSignal concurrently (use Promise.all or parallel awaits)
by initiating both promises (the call that yields `signal` and the call to
`listSignalEntityLinksForSignal(db, { clerkOrgId: parsed.data.actor.orgId,
signalId: signalIdOrPublicId })`) before awaiting results, then wait for both to
resolve and pass the resolved `signal` and `entityLinks` into
`getSignalOutput.parse` and the Response; ensure you preserve the existing
variables (`signal`, `entityLinks`, `db`, `parsed.data.actor.orgId`) and any
required typing/validation after both promises settle.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: ca71d8eb-e1bf-4286-9501-1b6002d5a31b

📥 Commits

Reviewing files that changed from the base of the PR and between f6abd15 and fe31f7c.

⛔ Files ignored due to path filters (3)
  • db/app/src/migrations/0025_whole_mac_gargan.sql is excluded by !db/**/migrations/**
  • db/app/src/migrations/meta/0025_snapshot.json is excluded by !db/**/migrations/**
  • db/app/src/migrations/meta/_journal.json is excluded by !db/**/migrations/**
📒 Files selected for processing (34)
  • ai/src/__tests__/signal-entity-linker/extract.test.ts
  • ai/src/signal-entity-linker/extract.ts
  • api/app/src/__tests__/entity-index-workflow.test.ts
  • api/app/src/__tests__/people-workflow.test.ts
  • api/app/src/__tests__/signal-orpc.test.ts
  • api/app/src/__tests__/team-member-people-sync.test.ts
  • api/app/src/__tests__/workspace-signals-router.test.ts
  • api/app/src/inngest/workflow/classify-people.ts
  • api/app/src/inngest/workflow/index-signal-entities.ts
  • api/app/src/orpc/router/signals.ts
  • api/app/src/router/(pending-not-allowed)/workspace-signals.ts
  • api/app/src/services/team-members/people-sync.ts
  • apps/app/src/__tests__/app/(app)/(pending-not-allowed)/[slug]/signals-client.test.tsx
  • apps/app/src/__tests__/app/api/internal/mcp-signals-route.test.ts
  • apps/app/src/app/(app)/(pending-not-allowed)/[slug]/(workspace)/signals/_components/signal-detail-content.test.tsx
  • apps/app/src/app/(app)/(pending-not-allowed)/[slug]/(workspace)/signals/_components/signal-detail-content.tsx
  • apps/app/src/app/(app)/(pending-not-allowed)/[slug]/(workspace)/signals/_components/signal-detail-sheet.tsx
  • apps/app/src/app/(app)/(pending-not-allowed)/[slug]/(workspace)/signals/_components/signals-client.tsx
  • apps/app/src/app/(internal)/api/internal/mcp/signals/get/route.ts
  • apps/mcp/src/__tests__/app-signal-intake.test.ts
  • apps/mcp/src/__tests__/audit.test.ts
  • apps/mcp/src/__tests__/e2e-oauth-mcp.test.ts
  • apps/mcp/src/__tests__/tools.test.ts
  • apps/mcp/src/tools/execute.ts
  • db/app/src/__tests__/schema-conventions.test.ts
  • db/app/src/__tests__/signal-entity-links.test.ts
  • db/app/src/index.ts
  • db/app/src/schema/index.ts
  • db/app/src/schema/tables/index.ts
  • db/app/src/schema/tables/org-signal-entity-links.ts
  • db/app/src/utils/signal-entity-links.ts
  • packages/api-contract/src/__tests__/signals.test.ts
  • packages/api-contract/src/index.ts
  • packages/api-contract/src/schemas/signals.ts

Comment thread db/app/src/utils/signal-entity-links.ts Outdated
Comment thread db/app/src/utils/signal-entity-links.ts Outdated
…nks-persistence

# Conflicts:
#	db/app/src/migrations/meta/0025_snapshot.json
#	db/app/src/migrations/meta/_journal.json

This branch had an error being deployed

1 failed and 4 active deployments
Preview – lightfast-app — 9307bf14 Deployed Jun 6, 2026 by vercel[bot]
Preview – lightfast-www — 9307bf14 Deployed Jun 6, 2026 by vercel[bot]
Preview – lightfast-platform — 9307bf14 Deployed Jun 6, 2026 by vercel[bot]
Preview – lightfast-mcp — 9307bf14 Deployed Jun 6, 2026 by vercel[bot]
Preview – lightfast-www-start — 9307bf14 Deployed Jun 6, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant