Skip to content

feat(gateway): mirror invite createInvite from daemon → gateway DB (Track B PR-B-1)#30509

Closed
vellum-apollo-bot[bot] wants to merge 3 commits into
mainfrom
apollo/track-b-1-mirror-invite-store
Closed

feat(gateway): mirror invite createInvite from daemon → gateway DB (Track B PR-B-1)#30509
vellum-apollo-bot[bot] wants to merge 3 commits into
mainfrom
apollo/track-b-1-mirror-invite-store

Conversation

@vellum-apollo-bot
Copy link
Copy Markdown
Contributor

@vellum-apollo-bot vellum-apollo-bot Bot commented May 13, 2026

Track B PR-B-1 — daemon → gateway mirror dual-write for invite creation

Companion to Track A in the Gateway Security Migration workstream. Track A made POST /v1/contacts gateway-native and dual-wrote back to the daemon DB. This PR plumbs the invite-side equivalent — but in the reverse direction (daemon writes first, gateway mirror catches up).

Full design doc: memory/concepts/workstreams/track-b-invite-redemption.md.

Why reverse direction (vs Track A's gateway-first)

Track A could migrate the contacts business logic to the gateway in one PR because the logic was small. Invite creation isn't:

  • Daemon createIngressInvite generates the long token, the 6-digit invite code, and (for voice invites) a 6-digit voice code.
  • It resolves channel adapters from getInviteAdapterRegistry() and calls ensureTelegramBotUsernameResolved() for Telegram bot username discovery.
  • It generates the LLM-driven guardian-instruction text via generateInviteInstruction(...).
  • It builds share payloads per channel type.

Moving all that to the gateway is well past PR-B-1 scope. So PR-B-1 keeps the daemon as authoritative writer and adds a best-effort mirror dual-write to the gateway. PR-B-2 (redemption gateway-native) will flip direction back to Track A's gateway-first pattern, since redemption logic is small enough to migrate.

Per Vargas's principle (May 13 14:11 UTC): "Gateway DB is the future source of truth, assistant DB is the present-day source of truth, so we need dual writes everywhere contact related." Direction is a function of where the heavy logic lives.

Changes

File Change
gateway/src/db/schema.ts Extend ingressInvites to mirror the full daemon shape: add tokenHash (notNull, primary token-redemption lookup), sourceConversationId, expectedExternalUserId, voiceCodeHash, voiceCodeDigits, friendName, guardianName. Make inviteCodeHash nullable (was notNull; daemon treats it as nullable for voice invites). Add idx_ingress_invites_token_lookup on tokenHash and idx_ingress_invites_voice_lookup on (voiceCodeHash, sourceChannel) — both needed for PR-B-2 redemption lookups.
gateway/src/db/invite-store.ts (NEW) InviteStore.mirrorCreate(invite), idempotent on id so a daemon retry after a transient gateway failure converges.
gateway/src/ipc/invite-handlers.ts (NEW) mirror_invite_create IPC method with zod-validated params. Logs on failure and re-raises; the daemon-side call is the one that swallows.
gateway/src/index.ts Import inviteRoutes and spread into the IpcServer route registry.
assistant/src/runtime/invite-service.ts After the authoritative createInvite() row insert in createIngressInvite, fire ipcCall("mirror_invite_create", ...). Wrapped in try/catch → log.warn on failure; never throws back to the route handler.

Schema migration safety

Drizzle's pushSQLiteSchema (already running on gateway startup via gateway/src/db/connection.ts's TTY-spoofing wrapper) handles all of these changes:

  • Additive columns → simple ALTER TABLE ADD COLUMN, no risk.
  • inviteCodeHash NOT NULL → NULL → SQLite forces a table rebuild, but the table is dormant: nothing on main reads or writes existing rows (only gateway/src/db/schema.ts references it). PR feat(gateway): mirror assistant_ingress_invites schema in gateway DB #29890 added the schema; nothing has populated it.
  • New indexes → CREATE INDEX IF NOT EXISTS.

Track B sequence

Vargas's locked answers (May 13 14:11 UTC)

  1. Option pick: Option 2 staged across 3 PRs.
  2. Linear tickets: none for Track B PRs.
  3. Voice-code failure reasons: less vague is OK (applies to PR-B-2).
  4. Dual-write policy: dual writes everywhere contact related; gateway = future SoT, assistant = present-day SoT.
  5. hashVoiceCode location: move to gateway when possible (applies to PR-B-2).

TODO before marking ready

  • Gateway tests: invite-store.mirrorCreate upsert idempotence + mirror_invite_create IPC handler success + failure paths.
  • Assistant tests: createIngressInvite calls ipcCall("mirror_invite_create", ...) with the correct payload; survives mirror failure.
  • Verify schema push on startup in sandbox (drizzle-kit auto-applies new columns; table rebuild for inviteCodeHash notNull→nullable).

vellum-apollo-bot Bot and others added 2 commits May 13, 2026 14:20
Track B PR-B-1 in the Gateway Security Migration. After Track A made
`POST /v1/contacts` gateway-native + dual-write, this PR plumbs the
companion dual-write for invite creation in the reverse direction
(daemon → gateway).

Why the reverse direction?
- Daemon owns invite creation today: it generates the token / voice-code /
  invite-code, resolves channel adapters (Telegram bot username), and
  runs an LLM-driven guardian-instruction builder. Moving all that to
  the gateway is well past PR-B-1 scope.
- Vargas's principle (May 13 14:11 UTC): gateway DB is the future source
  of truth, assistant DB is the present-day source of truth — we need
  dual writes everywhere contact related. Direction is a function of
  where the heavy logic lives.
- PR-B-2 (redemption gateway-native) will flip back to Track A's
  pattern: gateway-first writes (use-count bump, status flip,
  channel-write), daemon mirror.

Changes:
- gateway/src/db/schema.ts: extend `ingressInvites` to mirror the full
  daemon shape (tokenHash, sourceConversationId, expectedExternalUserId,
  voiceCodeHash, voiceCodeDigits, friendName, guardianName); make
  inviteCodeHash nullable (was notNull, but daemon treats it as
  nullable for voice invites); add token + voice-code lookup indexes.
- gateway/src/db/invite-store.ts: new `InviteStore.mirrorCreate(invite)`,
  idempotent on id.
- gateway/src/ipc/invite-handlers.ts: new `mirror_invite_create` IPC
  handler with zod validation; wired into the gateway IpcServer route
  registry from gateway/src/index.ts.
- assistant/src/runtime/invite-service.ts: after the authoritative
  `createInvite()` write, fire an `ipcCall("mirror_invite_create")` to
  the gateway. Best-effort: failures log a warn and continue.

Doc: memory/concepts/workstreams/track-b-invite-redemption.md (vargas's
5 decisions locked May 13 14:11 UTC).

Tests follow.
Three new test files for PR-B-1:

* gateway/src/__tests__/invite-store.test.ts (6 tests)
  – mirrorCreate inserts on first call, updates on repeat (idempotent
    on id), round-trips voice + invite-code shapes, supports injected
    db for unit isolation.

* gateway/src/__tests__/ipc-invite-routes.test.ts (4 tests)
  – mirror_invite_create writes a row via the real GatewayIpcServer,
    is idempotent on id, rejects malformed params (zod), surfaces
    FK violations to the daemon as an error.

* assistant/src/runtime/__tests__/mirror-invite-to-gateway.test.ts (3 tests)
  – Helper fires ipcCall with the full IngressInvite payload,
    swallows IPC errors (best-effort dual-write contract),
    forwards voice-invite fields when present.

mirrorInviteToGateway is now exported so the daemon-side best-effort
behavior can be pinned directly instead of indirectly through the
heavy createIngressInvite chain.
@vellum-apollo-bot vellum-apollo-bot Bot marked this pull request as ready for review May 13, 2026 14:28
Copy link
Copy Markdown

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

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: 7cc1c7910f

ℹ️ About Codex in GitHub

Codex has been enabled to automatically 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 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

// present-day source of truth, and the daemon owns invite creation today
// (LLM-generated guardian-instruction + channel-adapter resolution stay
// daemon-side for now). PR-B-2 will flip redemption gateway-native.
await mirrorInviteToGateway(invite);
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 Avoid awaiting best-effort mirror in invite creation path

createIngressInvite now waits for the gateway mirror call before returning, so invite creation latency is coupled to gateway IPC health even though the mirror is documented as best-effort. The one-shot IPC client can wait on connect/call timeouts before returning undefined (see assistant/src/ipc/gateway-client.ts and packages/gateway-client/src/ipc-client.ts), which means a slow or wedged gateway can add multi-second delay to a user-facing invite create that should succeed from the daemon write alone.

Useful? React with 👍 / 👎.

invite: IngressInvite,
): Promise<void> {
try {
await ipcCall("mirror_invite_create", {
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 Detect undefined IPC mirror result before treating as success

This call assumes mirror failures will throw and be caught, but the IPC helper returns undefined for transport and handler errors instead of rejecting. As a result, normal mirror failures bypass the catch block and never emit the invite-scoped warning (inviteId/contactId), making dual-write drift harder to diagnose; you need an explicit result === undefined check after the call.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 2 potential issues.

View 3 additional findings in Devin Review.

Open in Devin Review

Comment on lines +81 to +86
} catch (err) {
log.warn(
{ err, inviteId: invite.id, contactId: invite.contactId },
"createIngressInvite: gateway mirror dual-write failed (best-effort)",
);
}
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.

🟡 mirrorInviteToGateway catch block is dead code — ipcCall never rejects

The catch block in mirrorInviteToGateway will never execute for standard IPC failures because the underlying one-shot ipcCall (from packages/gateway-client/src/ipc-client.ts:72-172) is explicitly designed to never reject — it resolves with undefined on every error path (socket error, timeout, gateway error response). The Promise constructor at packages/gateway-client/src/ipc-client.ts:81 only takes a resolve callback with no reject. Every other caller of ipcCall in the codebase correctly checks result === undefined (e.g., assistant/src/runtime/routes/conversation-routes.ts:1276, assistant/src/permissions/gateway-threshold-reader.ts:214) instead of using try/catch. As a result, the daemon-side log.warn with inviteId and contactId context at line 82-85 will never fire, making mirror failures unobservable from the daemon's perspective (the gateway-client's own generic warning logs still fire, but without invite-specific context).

Prompt for agents
The one-shot ipcCall from @vellumai/gateway-client never throws/rejects — it always resolves (with undefined on failure). The try/catch in mirrorInviteToGateway at assistant/src/runtime/invite-service.ts:57-86 is dead code for all standard IPC failure modes.

To fix: replace the try/catch pattern with a return-value check, consistent with how other callers use ipcCall (see assistant/src/permissions/gateway-threshold-reader.ts:210-215 and assistant/src/runtime/routes/conversation-routes.ts:1272-1277 for the established pattern).

The fix should look like:

  const result = await ipcCall("mirror_invite_create", { ...fields... });
  if (result === undefined) {
    log.warn(
      { inviteId: invite.id, contactId: invite.contactId },
      "createIngressInvite: gateway mirror dual-write failed (best-effort)",
    );
  }

Also update the test at assistant/src/runtime/__tests__/mirror-invite-to-gateway.test.ts:124-132 — the 'swallows IPC errors' test currently mocks ipcCall to throw, which doesn't match the real behavior. Instead, mock it to return undefined and verify the function still resolves normally.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

// present-day source of truth, and the daemon owns invite creation today
// (LLM-generated guardian-instruction + channel-adapter resolution stay
// daemon-side for now). PR-B-2 will flip redemption gateway-native.
await mirrorInviteToGateway(invite);
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.

🚩 mirrorInviteToGateway blocks invite creation on IPC round-trip

At assistant/src/runtime/invite-service.ts:287, await mirrorInviteToGateway(invite) is called synchronously in the createIngressInvite flow, meaning invite creation blocks until the gateway mirror write completes (or the IPC call times out — default 5 seconds from packages/gateway-client/src/ipc-client.ts:52). Since the mirror is documented as best-effort and the daemon's own write is the source of truth, this could be fire-and-forget (void mirrorInviteToGateway(invite)) to avoid adding latency to the invite creation hot path. The current await means a slow/down gateway adds up to 5 seconds of latency to every invite creation. This is a design choice but worth flagging given the PR's "best-effort" framing.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

invite: IngressInvite,
): Promise<void> {
try {
await ipcCall("mirror_invite_create", {
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.

No. We do not call any gateway write actions from the assistant daemon

// Invite operations
// ---------------------------------------------------------------------------

export async function createIngressInvite(params: {
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.

This is what this PR should focus on. Whatever consumes this method, we need to now keep in the gateway

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