feat(gateway): mirror invite createInvite from daemon → gateway DB (Track B PR-B-1)#30509
feat(gateway): mirror invite createInvite from daemon → gateway DB (Track B PR-B-1)#30509vellum-apollo-bot[bot] wants to merge 3 commits into
Conversation
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.
There was a problem hiding this comment.
💡 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); |
There was a problem hiding this comment.
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", { |
There was a problem hiding this comment.
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 👍 / 👎.
| } catch (err) { | ||
| log.warn( | ||
| { err, inviteId: invite.id, contactId: invite.contactId }, | ||
| "createIngressInvite: gateway mirror dual-write failed (best-effort)", | ||
| ); | ||
| } |
There was a problem hiding this comment.
🟡 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.
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); |
There was a problem hiding this comment.
🚩 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.
Was this helpful? React with 👍 or 👎 to provide feedback.
| invite: IngressInvite, | ||
| ): Promise<void> { | ||
| try { | ||
| await ipcCall("mirror_invite_create", { |
There was a problem hiding this comment.
No. We do not call any gateway write actions from the assistant daemon
| // Invite operations | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| export async function createIngressInvite(params: { |
There was a problem hiding this comment.
This is what this PR should focus on. Whatever consumes this method, we need to now keep in the gateway
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/contactsgateway-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:
createIngressInvitegenerates the long token, the 6-digit invite code, and (for voice invites) a 6-digit voice code.getInviteAdapterRegistry()and callsensureTelegramBotUsernameResolved()for Telegram bot username discovery.generateInviteInstruction(...).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
gateway/src/db/schema.tsingressInvitesto mirror the full daemon shape: addtokenHash(notNull, primary token-redemption lookup),sourceConversationId,expectedExternalUserId,voiceCodeHash,voiceCodeDigits,friendName,guardianName. MakeinviteCodeHashnullable (was notNull; daemon treats it as nullable for voice invites). Addidx_ingress_invites_token_lookupontokenHashandidx_ingress_invites_voice_lookupon(voiceCodeHash, sourceChannel)— both needed for PR-B-2 redemption lookups.gateway/src/db/invite-store.ts(NEW)InviteStore.mirrorCreate(invite), idempotent onidso a daemon retry after a transient gateway failure converges.gateway/src/ipc/invite-handlers.ts(NEW)mirror_invite_createIPC method with zod-validated params. Logs on failure and re-raises; the daemon-side call is the one that swallows.gateway/src/index.tsinviteRoutesand spread into the IpcServer route registry.assistant/src/runtime/invite-service.tscreateInvite()row insert increateIngressInvite, fireipcCall("mirror_invite_create", ...). Wrapped in try/catch →log.warnon failure; never throws back to the route handler.Schema migration safety
Drizzle's
pushSQLiteSchema(already running on gateway startup viagateway/src/db/connection.ts's TTY-spoofing wrapper) handles all of these changes:ALTER TABLE ADD COLUMN, no risk.inviteCodeHashNOT NULL → NULL→ SQLite forces a table rebuild, but the table is dormant: nothing onmainreads or writes existing rows (onlygateway/src/db/schema.tsreferences it). PR feat(gateway): mirror assistant_ingress_invites schema in gateway DB #29890 added the schema; nothing has populated it.CREATE INDEX IF NOT EXISTS.Track B sequence
createInvite.POST /v1/contacts/invites/redeemgoes gateway-native. Redemption reads the gateway'singress_invites, writes the channel natively viaupsertVerifiedContactChannel, and dual-writes use-count + channel back to the daemon (Track A pattern). Also moveshashVoiceCodeto the gateway per Vargas's "move to gateway whenever possible" decision. Loosens voice-redemption failure reasons per Vargas's "can make failures less vague" decision.assistant/src/runtime/invite-redemption-service.ts+ daemoninvites_redeemroute + the wrappercontacts-write.ts:upsertContactChannel. Decommission, mirroring Track A PRs chore(assistant): remove POST /v1/contacts daemon route and contact_upsert skill tool (follow-up to #30141) #30278/chore(assistant): remove brokenassistant contacts upsertCLI subcommand (follow-up to #30278) #30281.Vargas's locked answers (May 13 14:11 UTC)
hashVoiceCodelocation: move to gateway when possible (applies to PR-B-2).TODO before marking ready
invite-store.mirrorCreateupsert idempotence +mirror_invite_createIPC handler success + failure paths.createIngressInvitecallsipcCall("mirror_invite_create", ...)with the correct payload; survives mirror failure.inviteCodeHashnotNull→nullable).