@vellumai/vellum-gateway 0.10.11-dev.202607221933.baa2119 → 0.10.11-dev.202607222129.31c06e5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Tests for the shared binding-strength vocabulary: the display ladder and the
3
+ * enforcement order (demotion guard — LUM-2505).
4
+ */
5
+
6
+ import { describe, expect, test } from "bun:test";
7
+
8
+ import {
9
+ bindingStrengthForVerifiedVia,
10
+ isBindingDemotion,
11
+ knownStrengthRank,
12
+ VERIFIED_VIA_CHALLENGE,
13
+ VERIFIED_VIA_CHANNEL_CLAIM,
14
+ VERIFIED_VIA_MANUAL,
15
+ } from "../binding-strength-contract.js";
16
+
17
+ describe("knownStrengthRank", () => {
18
+ test("unverified (null / undefined / empty) ranks 0", () => {
19
+ expect(knownStrengthRank(null)).toBe(0);
20
+ expect(knownStrengthRank(undefined)).toBe(0);
21
+ expect(knownStrengthRank("")).toBe(0);
22
+ });
23
+
24
+ test("inbound channel claim ranks 1", () => {
25
+ expect(knownStrengthRank(VERIFIED_VIA_CHANNEL_CLAIM)).toBe(1);
26
+ });
27
+
28
+ test("workspace-vouched (manual) ranks 2", () => {
29
+ expect(knownStrengthRank(VERIFIED_VIA_MANUAL)).toBe(2);
30
+ });
31
+
32
+ test("proven / possession values all rank 3", () => {
33
+ expect(knownStrengthRank(VERIFIED_VIA_CHALLENGE)).toBe(3);
34
+ expect(knownStrengthRank("invite")).toBe(3);
35
+ expect(knownStrengthRank("voice")).toBe(3);
36
+ expect(knownStrengthRank("bootstrap")).toBe(3);
37
+ // Guardian auto-registration bindings (createGuardianBinding writers).
38
+ expect(knownStrengthRank("platform_auto_register")).toBe(3);
39
+ expect(knownStrengthRank("webhook_registration")).toBe(3);
40
+ });
41
+
42
+ test("unrecognized non-empty provenance is unknown (null)", () => {
43
+ expect(knownStrengthRank("guardian_approval")).toBeNull();
44
+ expect(knownStrengthRank("something_new")).toBeNull();
45
+ });
46
+ });
47
+
48
+ describe("bindingStrengthForVerifiedVia (display ladder)", () => {
49
+ test("maps the three introduction-card provenances", () => {
50
+ expect(bindingStrengthForVerifiedVia(VERIFIED_VIA_CHALLENGE)).toBe(
51
+ "verified_handshake",
52
+ );
53
+ expect(bindingStrengthForVerifiedVia(VERIFIED_VIA_MANUAL)).toBe(
54
+ "internal_workspace_match",
55
+ );
56
+ expect(bindingStrengthForVerifiedVia(VERIFIED_VIA_CHANNEL_CLAIM)).toBe(
57
+ "inbound_channel_claim",
58
+ );
59
+ });
60
+
61
+ test("returns null for provenance outside the ladder (card UI relies on this)", () => {
62
+ expect(bindingStrengthForVerifiedVia("invite")).toBeNull();
63
+ expect(bindingStrengthForVerifiedVia("bootstrap")).toBeNull();
64
+ expect(bindingStrengthForVerifiedVia("voice")).toBeNull();
65
+ expect(bindingStrengthForVerifiedVia(null)).toBeNull();
66
+ expect(bindingStrengthForVerifiedVia(undefined)).toBeNull();
67
+ });
68
+ });
69
+
70
+ describe("isBindingDemotion", () => {
71
+ test("a lower-strength source demoting a higher-strength binding is refused", () => {
72
+ // challenge (3) is the strongest — nothing weaker may overwrite it.
73
+ expect(isBindingDemotion(VERIFIED_VIA_CHALLENGE, VERIFIED_VIA_MANUAL)).toBe(
74
+ true,
75
+ );
76
+ expect(
77
+ isBindingDemotion(VERIFIED_VIA_CHALLENGE, VERIFIED_VIA_CHANNEL_CLAIM),
78
+ ).toBe(true);
79
+ // manual (2) may not be demoted to an inbound claim (1).
80
+ expect(
81
+ isBindingDemotion(VERIFIED_VIA_MANUAL, VERIFIED_VIA_CHANNEL_CLAIM),
82
+ ).toBe(true);
83
+ // A proven value demoted to null/unverified is a demotion too.
84
+ expect(isBindingDemotion(VERIFIED_VIA_CHALLENGE, null)).toBe(true);
85
+ // Guardian auto-registration bindings are proven — a later manual attest
86
+ // or channel claim can never demote them.
87
+ expect(
88
+ isBindingDemotion("platform_auto_register", VERIFIED_VIA_MANUAL),
89
+ ).toBe(true);
90
+ expect(
91
+ isBindingDemotion("webhook_registration", VERIFIED_VIA_CHANNEL_CLAIM),
92
+ ).toBe(true);
93
+ });
94
+
95
+ test("equal-tier writes and lateral proven swaps are not demotions", () => {
96
+ expect(isBindingDemotion(VERIFIED_VIA_CHALLENGE, "invite")).toBe(false);
97
+ expect(isBindingDemotion("invite", VERIFIED_VIA_CHALLENGE)).toBe(false);
98
+ expect(isBindingDemotion(VERIFIED_VIA_MANUAL, VERIFIED_VIA_MANUAL)).toBe(
99
+ false,
100
+ );
101
+ });
102
+
103
+ test("upgrades are not demotions", () => {
104
+ expect(
105
+ isBindingDemotion(VERIFIED_VIA_CHANNEL_CLAIM, VERIFIED_VIA_CHALLENGE),
106
+ ).toBe(false);
107
+ expect(isBindingDemotion(null, VERIFIED_VIA_CHANNEL_CLAIM)).toBe(false);
108
+ expect(isBindingDemotion(VERIFIED_VIA_MANUAL, VERIFIED_VIA_CHALLENGE)).toBe(
109
+ false,
110
+ );
111
+ });
112
+
113
+ test("unknown provenance on either side is never a demotion (fail-open)", () => {
114
+ expect(isBindingDemotion(VERIFIED_VIA_CHALLENGE, "guardian_approval")).toBe(
115
+ false,
116
+ );
117
+ expect(
118
+ isBindingDemotion("guardian_approval", VERIFIED_VIA_CHANNEL_CLAIM),
119
+ ).toBe(false);
120
+ expect(isBindingDemotion("unknown_a", "unknown_b")).toBe(false);
121
+ });
122
+ });
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Shared binding-strength vocabulary for the contact channel `verified_via`
3
+ * provenance column.
4
+ *
5
+ * Two distinct notions live here, both keyed on the free-text `verified_via`
6
+ * audit value:
7
+ *
8
+ * 1. The 3-tier *display* ladder (`BindingStrength` +
9
+ * `bindingStrengthForVerifiedVia`) the introduction card renders from. It
10
+ * covers only the three introduction-card provenances and returns `null`
11
+ * for everything else — the card UI depends on that exact shape.
12
+ *
13
+ * 2. The *enforcement* order (`knownStrengthRank` + `isBindingDemotion`) the
14
+ * gateway ACL write path uses to refuse demotions (LUM-2505): a total,
15
+ * conservative ranking over every real `verified_via` value, so a
16
+ * lower-strength source can never silently overwrite a higher-strength
17
+ * active binding.
18
+ *
19
+ * This lives in the shared package — not `assistant/` — because the gateway
20
+ * owns the contact ACL and is where the demotion guard runs. The assistant
21
+ * re-exports the display helpers from `runtime/introduction-policy.ts`, so its
22
+ * card/provenance call sites are unchanged.
23
+ *
24
+ * No import from `assistant/` or `gateway/`: pure vocabulary.
25
+ */
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // verified_via constants
29
+ // ---------------------------------------------------------------------------
30
+
31
+ /** `verified_via` written by the gateway when a verification code is redeemed. */
32
+ export const VERIFIED_VIA_CHALLENGE = "challenge";
33
+
34
+ /** `verified_via` written when the guardian direct-trusts a workspace-vouched identity. */
35
+ export const VERIFIED_VIA_MANUAL = "manual";
36
+
37
+ /** `verified_via` written when the guardian trusts an identity the platform is not vouching for. */
38
+ export const VERIFIED_VIA_CHANNEL_CLAIM = "manual_channel_claim";
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Display ladder (introduction card)
42
+ // ---------------------------------------------------------------------------
43
+
44
+ /**
45
+ * Binding-strength ladder the introduction card renders:
46
+ *
47
+ * verified_handshake > internal_workspace_match > inbound_channel_claim
48
+ *
49
+ * - `verified_handshake` — the contact proved control of the channel by
50
+ * returning a verification code (`verified_via: "challenge"`).
51
+ * - `internal_workspace_match` — the platform authenticated the identity inside
52
+ * the guardian's own workspace; the guardian vouches directly
53
+ * (`verified_via: "manual"`).
54
+ * - `inbound_channel_claim` — the guardian trusts an identity the platform is
55
+ * NOT vouching for (`verified_via: "manual_channel_claim"`).
56
+ */
57
+ export type BindingStrength =
58
+ | "verified_handshake"
59
+ | "internal_workspace_match"
60
+ | "inbound_channel_claim";
61
+
62
+ /**
63
+ * Derive the display binding strength for a `verified_via`. Returns `null` for
64
+ * provenance outside the introduction ladder (e.g. `invite`, `bootstrap`),
65
+ * which predate it and keep their own audit meaning — the introduction card UI
66
+ * relies on this exact shape.
67
+ *
68
+ * This is the DISPLAY ladder, not the enforcement order: use
69
+ * {@link knownStrengthRank} / {@link isBindingDemotion} for the demotion guard.
70
+ */
71
+ export function bindingStrengthForVerifiedVia(
72
+ verifiedVia: string | null | undefined,
73
+ ): BindingStrength | null {
74
+ switch (verifiedVia) {
75
+ case VERIFIED_VIA_CHALLENGE:
76
+ return "verified_handshake";
77
+ case VERIFIED_VIA_MANUAL:
78
+ return "internal_workspace_match";
79
+ case VERIFIED_VIA_CHANNEL_CLAIM:
80
+ return "inbound_channel_claim";
81
+ default:
82
+ return null;
83
+ }
84
+ }
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // Enforcement order (demotion guard — LUM-2505)
88
+ // ---------------------------------------------------------------------------
89
+
90
+ /**
91
+ * `verified_via` values where the actor demonstrably controls the channel (a
92
+ * code/token/possession proof) or the binding is a guardian binding (bootstrap
93
+ * or platform/webhook auto-registration). They share the top enforcement rank:
94
+ * none may be silently demoted, and a lateral swap between them is not a
95
+ * demotion. Every real production writer of `verified_via` that outranks
96
+ * `manual` must appear here, or the demotion guard would treat it as unknown
97
+ * (incomparable) and let a `manual` re-attest quietly demote it.
98
+ */
99
+ const PROVEN_VERIFIED_VIA: ReadonlySet<string> = new Set([
100
+ VERIFIED_VIA_CHALLENGE, // returned a verification code
101
+ "invite", // redeemed an invite code / token
102
+ "voice", // returned a voice (DTMF) code
103
+ "bootstrap", // guardian binding (also protected by the guardian-downgrade guard)
104
+ "platform_auto_register", // guardian binding auto-registered by the platform
105
+ "webhook_registration", // guardian binding registered via a provider webhook
106
+ ]);
107
+
108
+ /**
109
+ * Total, conservative strength rank for a `verified_via`, used by the gateway
110
+ * ACL write path to refuse demotions. Higher = stronger. Grouped into coarse
111
+ * tiers that hold under ANY intra-tier ordering the pending Slack-permissions
112
+ * PRD (D4) may land on:
113
+ *
114
+ * 3 proven / possession — {@link PROVEN_VERIFIED_VIA}.
115
+ * 2 workspace-vouched — the platform authenticated the identity but the
116
+ * actor did not actively prove control here (`manual`).
117
+ * 1 inbound channel claim — nobody vouches; the guardian trusted an
118
+ * unproven claim (`manual_channel_claim`).
119
+ * 0 unverified — no verification at all (`null` / empty).
120
+ *
121
+ * Returns `null` for an unrecognized non-empty provenance ("unknown"). The
122
+ * demotion guard treats unknown as incomparable and never refuses on it, so a
123
+ * forward-compatible new `verified_via` value is never mistaken for a demotion.
124
+ */
125
+ export function knownStrengthRank(
126
+ verifiedVia: string | null | undefined,
127
+ ): number | null {
128
+ if (verifiedVia == null || verifiedVia === "") return 0;
129
+ if (PROVEN_VERIFIED_VIA.has(verifiedVia)) return 3;
130
+ if (verifiedVia === VERIFIED_VIA_MANUAL) return 2;
131
+ if (verifiedVia === VERIFIED_VIA_CHANNEL_CLAIM) return 1;
132
+ return null;
133
+ }
134
+
135
+ /**
136
+ * Whether writing `incomingVia` over an existing `existingVia` binding would
137
+ * LOWER its strength — the demotion the gateway ACL write path refuses
138
+ * (LUM-2505).
139
+ *
140
+ * True only when BOTH provenances are known and the incoming rank is strictly
141
+ * lower than the existing. Unknown provenance on either side is incomparable
142
+ * and never counts as a demotion (fail-open), so the guard blocks only provable
143
+ * downgrades and never breaks a new/unrecognized `verified_via`. Equal ranks
144
+ * (including lateral swaps within the proven tier) and upgrades are not
145
+ * demotions.
146
+ */
147
+ export function isBindingDemotion(
148
+ existingVia: string | null | undefined,
149
+ incomingVia: string | null | undefined,
150
+ ): boolean {
151
+ const existing = knownStrengthRank(existingVia);
152
+ const incoming = knownStrengthRank(incomingVia);
153
+ if (existing == null || incoming == null) return false;
154
+ return incoming < existing;
155
+ }
@@ -321,3 +321,17 @@ export type {
321
321
  ResolvedChannelPermission,
322
322
  RiskThreshold,
323
323
  } from "./channel-permission-contract.js";
324
+
325
+ // Binding-strength vocabulary for the contact `verified_via` provenance —
326
+ // display ladder (introduction card) + the enforcement order the gateway ACL
327
+ // write path uses to refuse demotions (LUM-2505).
328
+ export {
329
+ bindingStrengthForVerifiedVia,
330
+ isBindingDemotion,
331
+ knownStrengthRank,
332
+ VERIFIED_VIA_CHALLENGE,
333
+ VERIFIED_VIA_CHANNEL_CLAIM,
334
+ VERIFIED_VIA_MANUAL,
335
+ } from "./binding-strength-contract.js";
336
+
337
+ export type { BindingStrength } from "./binding-strength-contract.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/vellum-gateway",
3
- "version": "0.10.11-dev.202607221933.baa2119",
3
+ "version": "0.10.11-dev.202607222129.31c06e5",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -8,7 +8,15 @@
8
8
  * running daemon.
9
9
  */
10
10
 
11
- import { describe, test, expect, beforeAll, beforeEach, afterAll, mock } from "bun:test";
11
+ import {
12
+ describe,
13
+ test,
14
+ expect,
15
+ beforeAll,
16
+ beforeEach,
17
+ afterAll,
18
+ mock,
19
+ } from "bun:test";
12
20
 
13
21
  import "./test-preload.js";
14
22
 
@@ -80,10 +88,7 @@ const mirrorIpcCalls: { method: string; body: unknown }[] = [];
80
88
  mock.module("../ipc/assistant-client.js", () => ({
81
89
  IpcHandlerError: class extends Error {},
82
90
  IpcTransportError: class extends Error {},
83
- ipcCallAssistant: async (
84
- method: string,
85
- params?: { body?: unknown },
86
- ) => {
91
+ ipcCallAssistant: async (method: string, params?: { body?: unknown }) => {
87
92
  mirrorIpcCalls.push({ method, body: params?.body });
88
93
  return { ok: true };
89
94
  },
@@ -164,7 +169,13 @@ function seedContact(id: string, role: "guardian" | "contact" = "guardian") {
164
169
  .run();
165
170
  }
166
171
 
167
- function seedChannel(opts: { id: string; contactId: string; status?: string }) {
172
+ function seedChannel(opts: {
173
+ id: string;
174
+ contactId: string;
175
+ status?: string;
176
+ verifiedVia?: string | null;
177
+ verifiedAt?: number | null;
178
+ }) {
168
179
  const now = Date.now();
169
180
  getGatewayDb()
170
181
  .insert(contactChannels)
@@ -176,6 +187,8 @@ function seedChannel(opts: { id: string; contactId: string; status?: string }) {
176
187
  isPrimary: false,
177
188
  status: opts.status ?? "unverified",
178
189
  policy: "allow",
190
+ verifiedAt: opts.verifiedAt ?? null,
191
+ verifiedVia: opts.verifiedVia ?? null,
179
192
  interactionCount: 0,
180
193
  createdAt: now,
181
194
  updatedAt: now,
@@ -457,7 +470,7 @@ describe("upsert_verified_channel IPC handler", () => {
457
470
  expect(row!.status).toBe("blocked");
458
471
  });
459
472
 
460
- test("round-trips verifiedVia=\"invite\" (free string, not the restricted enum)", async () => {
473
+ test('round-trips verifiedVia="invite" (free string, not the restricted enum)', async () => {
461
474
  const res = (await upsertVerifiedChannelHandler({
462
475
  type: "vellum",
463
476
  address: "addr-invite",
@@ -477,3 +490,92 @@ describe("upsert_verified_channel IPC handler", () => {
477
490
  expect(row!.verifiedVia).toBe("invite");
478
491
  });
479
492
  });
493
+
494
+ describe("upsert_verified_channel binding-strength guard (LUM-2505)", () => {
495
+ test("refuses to demote an active challenge binding to a manual_channel_claim", async () => {
496
+ seedContact("c1", "contact");
497
+ seedChannel({
498
+ id: "ch1",
499
+ contactId: "c1",
500
+ status: "active",
501
+ verifiedVia: "challenge",
502
+ verifiedAt: 500,
503
+ });
504
+
505
+ const res = (await upsertVerifiedChannelHandler({
506
+ type: "vellum",
507
+ address: "addr-ch1",
508
+ externalChatId: "chat-1",
509
+ verifiedVia: "manual_channel_claim",
510
+ })) as { ok: boolean; verified: boolean; channel: { verifiedVia: string } };
511
+
512
+ // The write still succeeds, but the stronger provenance is preserved.
513
+ expect(res.ok).toBe(true);
514
+ expect(res.verified).toBe(true);
515
+ expect(res.channel.verifiedVia).toBe("challenge");
516
+
517
+ const row = getGatewayDb()
518
+ .select()
519
+ .from(contactChannels)
520
+ .where(eq(contactChannels.id, "ch1"))
521
+ .get();
522
+ expect(row!.verifiedVia).toBe("challenge");
523
+ expect(row!.verifiedAt).toBe(500);
524
+ expect(row!.status).toBe("active");
525
+ });
526
+
527
+ test("refuses to demote an active challenge binding to a manual attest", async () => {
528
+ seedContact("c1", "contact");
529
+ seedChannel({
530
+ id: "ch1",
531
+ contactId: "c1",
532
+ status: "active",
533
+ verifiedVia: "challenge",
534
+ verifiedAt: 500,
535
+ });
536
+
537
+ const res = (await upsertVerifiedChannelHandler({
538
+ type: "vellum",
539
+ address: "addr-ch1",
540
+ externalChatId: "chat-1",
541
+ verifiedVia: "manual",
542
+ })) as { ok: boolean; channel: { verifiedVia: string } };
543
+
544
+ expect(res.channel.verifiedVia).toBe("challenge");
545
+
546
+ const row = getGatewayDb()
547
+ .select()
548
+ .from(contactChannels)
549
+ .where(eq(contactChannels.id, "ch1"))
550
+ .get();
551
+ expect(row!.verifiedVia).toBe("challenge");
552
+ });
553
+
554
+ test("still applies an upgrade (manual_channel_claim -> challenge)", async () => {
555
+ seedContact("c1", "contact");
556
+ seedChannel({
557
+ id: "ch1",
558
+ contactId: "c1",
559
+ status: "active",
560
+ verifiedVia: "manual_channel_claim",
561
+ verifiedAt: 500,
562
+ });
563
+
564
+ const res = (await upsertVerifiedChannelHandler({
565
+ type: "vellum",
566
+ address: "addr-ch1",
567
+ externalChatId: "chat-1",
568
+ verifiedVia: "challenge",
569
+ })) as { ok: boolean; verified: boolean; channel: { verifiedVia: string } };
570
+
571
+ expect(res.verified).toBe(true);
572
+ expect(res.channel.verifiedVia).toBe("challenge");
573
+
574
+ const row = getGatewayDb()
575
+ .select()
576
+ .from(contactChannels)
577
+ .where(eq(contactChannels.id, "ch1"))
578
+ .get();
579
+ expect(row!.verifiedVia).toBe("challenge");
580
+ });
581
+ });
@@ -270,7 +270,7 @@ describe("ContactStore.markChannelVerified", () => {
270
270
  expect(result!.channel.verifiedVia).toBe("manual");
271
271
  });
272
272
 
273
- test("upgrades a previously challenge-verified channel to manual", async () => {
273
+ test("refuses to demote a challenge-verified channel to manual (LUM-2505)", async () => {
274
274
  seedContact("c1");
275
275
  seedChannel({
276
276
  id: "ch1",
@@ -280,12 +280,33 @@ describe("ContactStore.markChannelVerified", () => {
280
280
  verifiedVia: "challenge",
281
281
  });
282
282
 
283
- const before = Date.now();
283
+ // A `manual` attest (e.g. a roster "Link account") must not lower the
284
+ // stronger `challenge` proof on an active binding — the existing binding
285
+ // stands as an idempotent no-op.
284
286
  const result = await new ContactStore().markChannelVerified("ch1");
285
287
  expect(result).not.toBeNull();
288
+ expect(result!.didWrite).toBe(false);
289
+ expect(result!.channel.verifiedVia).toBe("challenge");
290
+ expect(result!.channel.verifiedAt).toBe(500);
291
+ });
292
+
293
+ test("applies an upgrade: manual -> challenge on an active channel", async () => {
294
+ seedContact("c1");
295
+ seedChannel({
296
+ id: "ch1",
297
+ contactId: "c1",
298
+ status: "active",
299
+ verifiedAt: 500,
300
+ verifiedVia: "manual",
301
+ });
302
+
303
+ const result = await new ContactStore().markChannelVerified(
304
+ "ch1",
305
+ "challenge",
306
+ );
307
+ expect(result).not.toBeNull();
286
308
  expect(result!.didWrite).toBe(true);
287
- expect(result!.channel.verifiedVia).toBe("manual");
288
- expect(result!.channel.verifiedAt!).toBeGreaterThanOrEqual(before);
309
+ expect(result!.channel.verifiedVia).toBe("challenge");
289
310
  });
290
311
 
291
312
  test("re-activates a non-active channel that previously had verifiedAt", async () => {
@@ -511,9 +532,7 @@ describe("ContactStore.markChannelVerified", () => {
511
532
 
512
533
  // Exactly one gateway channel row: the existing gateway row is verified in
513
534
  // place, keyed by (type,address).
514
- expect(
515
- getGatewayDb().select().from(contactChannels).all().length,
516
- ).toBe(1);
535
+ expect(getGatewayDb().select().from(contactChannels).all().length).toBe(1);
517
536
  });
518
537
 
519
538
  test("legacy cross-contact: resolves the gateway row by (type,address) even under a different contact", async () => {
@@ -548,6 +567,75 @@ describe("ContactStore.markChannelVerified", () => {
548
567
  });
549
568
  });
550
569
 
570
+ describe("ContactStore.upsertContact binding-strength guard (LUM-2505)", () => {
571
+ test("does not demote an active challenge channel via a weaker verifiedVia upsert", async () => {
572
+ seedContact("c1", "contact");
573
+ seedChannel({
574
+ id: "ch1",
575
+ contactId: "c1",
576
+ status: "active",
577
+ verifiedAt: 500,
578
+ verifiedVia: "challenge",
579
+ address: "addr-ch1",
580
+ });
581
+
582
+ await new ContactStore().upsertContact({
583
+ id: "c1",
584
+ channels: [
585
+ {
586
+ type: "vellum",
587
+ address: "addr-ch1",
588
+ status: "active",
589
+ verifiedVia: "manual_channel_claim",
590
+ verifiedAt: 999,
591
+ },
592
+ ],
593
+ });
594
+
595
+ const row = getGatewayDb()
596
+ .select()
597
+ .from(contactChannels)
598
+ .where(eq(contactChannels.id, "ch1"))
599
+ .get();
600
+ expect(row!.verifiedVia).toBe("challenge");
601
+ expect(row!.verifiedAt).toBe(500);
602
+ expect(row!.status).toBe("active");
603
+ });
604
+
605
+ test("applies an upgrade via upsert (manual_channel_claim -> challenge)", async () => {
606
+ seedContact("c1", "contact");
607
+ seedChannel({
608
+ id: "ch1",
609
+ contactId: "c1",
610
+ status: "active",
611
+ verifiedAt: 500,
612
+ verifiedVia: "manual_channel_claim",
613
+ address: "addr-ch1",
614
+ });
615
+
616
+ await new ContactStore().upsertContact({
617
+ id: "c1",
618
+ channels: [
619
+ {
620
+ type: "vellum",
621
+ address: "addr-ch1",
622
+ status: "active",
623
+ verifiedVia: "challenge",
624
+ verifiedAt: 999,
625
+ },
626
+ ],
627
+ });
628
+
629
+ const row = getGatewayDb()
630
+ .select()
631
+ .from(contactChannels)
632
+ .where(eq(contactChannels.id, "ch1"))
633
+ .get();
634
+ expect(row!.verifiedVia).toBe("challenge");
635
+ expect(row!.verifiedAt).toBe(999);
636
+ });
637
+ });
638
+
551
639
  describe("ContactStore.markChannelRevoked", () => {
552
640
  test("revokes a channel by its gateway id", async () => {
553
641
  seedContact("c1", "contact");
@@ -588,9 +676,7 @@ describe("ContactStore.markChannelRevoked", () => {
588
676
 
589
677
  // Exactly one gateway channel row: the existing gateway row is revoked in
590
678
  // place, keyed by (type,address).
591
- expect(
592
- getGatewayDb().select().from(contactChannels).all().length,
593
- ).toBe(1);
679
+ expect(getGatewayDb().select().from(contactChannels).all().length).toBe(1);
594
680
  });
595
681
  });
596
682
 
@@ -2,6 +2,7 @@ import { type Database } from "bun:sqlite";
2
2
 
3
3
  import { and, desc, eq, gt, ne, sql } from "drizzle-orm";
4
4
 
5
+ import { isBindingDemotion } from "@vellumai/gateway-client";
5
6
  import {
6
7
  type AssistantContactMetadata,
7
8
  type ContactRead,
@@ -723,6 +724,25 @@ export class ContactStore {
723
724
  if (!gwChannel) return null;
724
725
  const gwChannelId = gwChannel.id;
725
726
 
727
+ // Binding-strength guard (LUM-2505): never demote an active binding's
728
+ // provenance. A lower-strength verifiedVia (e.g. a `manual` roster attest)
729
+ // over an already-active, higher-strength channel (e.g. `challenge`) is
730
+ // refused — the stronger existing binding stands, as an idempotent no-op.
731
+ if (
732
+ gwChannel.status === "active" &&
733
+ isBindingDemotion(gwChannel.verifiedVia, verifiedVia)
734
+ ) {
735
+ log.warn(
736
+ {
737
+ channelId: gwChannelId,
738
+ existingVerifiedVia: gwChannel.verifiedVia,
739
+ incomingVerifiedVia: verifiedVia,
740
+ },
741
+ "markChannelVerified: refusing binding-strength demotion; preserving stronger verified_via",
742
+ );
743
+ return { channel: gwChannel, didWrite: false };
744
+ }
745
+
726
746
  const now = Date.now();
727
747
  const raw = (this.db as unknown as { $client: Database }).$client;
728
748
  const result = raw
@@ -1545,9 +1565,27 @@ export class ContactStore {
1545
1565
  if (ch.blockedReason !== undefined)
1546
1566
  updateSet.blockedReason = ch.blockedReason;
1547
1567
  }
1548
- if (ch.verifiedAt !== undefined) updateSet.verifiedAt = ch.verifiedAt;
1549
- if (ch.verifiedVia !== undefined)
1550
- updateSet.verifiedVia = ch.verifiedVia;
1568
+ // Binding-strength guard (LUM-2505): a lower-strength verifiedVia must
1569
+ // not demote an active binding. When it would, preserve the stronger
1570
+ // existing verified_via/verified_at; benign field updates still apply.
1571
+ if (
1572
+ existing.status === "active" &&
1573
+ ch.verifiedVia !== undefined &&
1574
+ isBindingDemotion(existing.verifiedVia, ch.verifiedVia)
1575
+ ) {
1576
+ log.warn(
1577
+ {
1578
+ channelId: existing.id,
1579
+ existingVerifiedVia: existing.verifiedVia,
1580
+ incomingVerifiedVia: ch.verifiedVia,
1581
+ },
1582
+ "syncChannels: refusing binding-strength demotion; preserving stronger verified_via",
1583
+ );
1584
+ } else {
1585
+ if (ch.verifiedAt !== undefined) updateSet.verifiedAt = ch.verifiedAt;
1586
+ if (ch.verifiedVia !== undefined)
1587
+ updateSet.verifiedVia = ch.verifiedVia;
1588
+ }
1551
1589
  if (ch.inviteId !== undefined) updateSet.inviteId = ch.inviteId;
1552
1590
  this.db
1553
1591
  .update(contactChannels)
@@ -1,6 +1,15 @@
1
1
  {
2
2
  "version": 1,
3
3
  "flags": [
4
+ {
5
+ "id": "in-chat-onboarding-tour",
6
+ "scope": "client",
7
+ "key": "in-chat-onboarding-tour",
8
+ "label": "In-Chat Onboarding Tour",
9
+ "description": "Multivariate experiment for the eyes-led in-chat onboarding tour. control = straight to chat after research onboarding (current behavior); tour = the extended tour auto-plays on first workspace entry, and the header replay button shows. Targeted via LaunchDarkly (planned 70% tour / 30% control).",
10
+ "defaultEnabled": "control",
11
+ "values": ["control", "tour"]
12
+ },
4
13
  {
5
14
  "id": "user-hosted-enabled",
6
15
  "scope": "client",
@@ -15,6 +15,8 @@ import { existsSync } from "node:fs";
15
15
 
16
16
  import { and, eq, sql } from "drizzle-orm";
17
17
 
18
+ import { isBindingDemotion } from "@vellumai/gateway-client";
19
+
18
20
  import { getGatewayDb } from "../db/connection.js";
19
21
  import {
20
22
  contactChannels as gwContactChannels,
@@ -82,6 +84,10 @@ export async function findContactChannelByAddress(
82
84
  * legacy/unmirrored channel may carry a different gateway id than the
83
85
  * assistant id, and a blind id-keyed update would silently affect 0 rows.
84
86
  *
87
+ * `verifiedAt` defaults to `now`; the caller passes an explicit value to
88
+ * preserve an existing timestamp when the binding-strength guard keeps a
89
+ * stronger `verifiedVia` (see {@link applyVerifiedChannelGatewayWrites}).
90
+ *
85
91
  * Returns true if a gateway row was updated or inserted; false if the only
86
92
  * matching row is blocked/revoked (so nothing was written).
87
93
  */
@@ -93,6 +99,7 @@ function writeVerifiedGatewayChannel(params: {
93
99
  externalChatId: string;
94
100
  verifiedVia: string;
95
101
  now: number;
102
+ verifiedAt?: number;
96
103
  allowRevokedReactivation?: boolean;
97
104
  }): boolean {
98
105
  const {
@@ -103,6 +110,7 @@ function writeVerifiedGatewayChannel(params: {
103
110
  externalChatId,
104
111
  verifiedVia,
105
112
  now,
113
+ verifiedAt,
106
114
  allowRevokedReactivation,
107
115
  } = params;
108
116
  const gwDb = getGatewayDb();
@@ -111,7 +119,7 @@ function writeVerifiedGatewayChannel(params: {
111
119
  policy: "allow",
112
120
  address,
113
121
  externalChatId,
114
- verifiedAt: now,
122
+ verifiedAt: verifiedAt ?? now,
115
123
  verifiedVia,
116
124
  revokedReason: null,
117
125
  blockedReason: null,
@@ -523,8 +531,11 @@ export function applyVerifiedChannelGatewayWrites(params: {
523
531
  // The gateway is the source of truth: a blocked/revoked gateway row rejects
524
532
  // the verification, gating BOTH the existing-channel update and the
525
533
  // new-insert path so no active mirror is created for a blocked actor. A
526
- // missing gateway row is the legitimate happy path (legacy/unmirrored).
527
- const gwStatus = gatewayChannelStatus(sourceChannel, address);
534
+ // missing gateway row is the legitimate happy path (legacy/unmirrored). The
535
+ // same authoritative read carries the existing binding provenance for the
536
+ // demotion guard below.
537
+ const gwRow = getGatewayChannelByKey(sourceChannel, address);
538
+ const gwStatus = gwRow?.status ?? null;
528
539
  if (
529
540
  gwStatus === "blocked" ||
530
541
  (gwStatus === "revoked" && !allowRevokedReactivation)
@@ -536,6 +547,33 @@ export function applyVerifiedChannelGatewayWrites(params: {
536
547
  return { verified: false };
537
548
  }
538
549
 
550
+ // Binding-strength guard (LUM-2505): a lower-strength source must never
551
+ // demote the recorded provenance of an already-active binding. When the
552
+ // incoming write would weaken the ladder, preserve the stronger existing
553
+ // `verified_via` / `verified_at`; the write still refreshes identity/ACL
554
+ // fields, it just cannot lower the proof. Only active bindings are protected
555
+ // — reactivation from revoked is invite-gated (a proven, top-tier
556
+ // `verified_via`) and keeps the incoming provenance.
557
+ let effectiveVerifiedVia = verifiedVia;
558
+ let effectiveVerifiedAt: number | undefined;
559
+ if (
560
+ gwRow &&
561
+ gwRow.status === "active" &&
562
+ isBindingDemotion(gwRow.verifiedVia, verifiedVia)
563
+ ) {
564
+ effectiveVerifiedVia = gwRow.verifiedVia ?? verifiedVia;
565
+ effectiveVerifiedAt = gwRow.verifiedAt ?? undefined;
566
+ log.warn(
567
+ {
568
+ sourceChannel,
569
+ address,
570
+ existingVerifiedVia: gwRow.verifiedVia,
571
+ incomingVerifiedVia: verifiedVia,
572
+ },
573
+ "Refusing binding-strength demotion: preserving stronger existing verified_via",
574
+ );
575
+ }
576
+
539
577
  if (existing) {
540
578
  const row = existing;
541
579
 
@@ -575,8 +613,9 @@ export function applyVerifiedChannelGatewayWrites(params: {
575
613
  type: sourceChannel,
576
614
  address,
577
615
  externalChatId,
578
- verifiedVia,
616
+ verifiedVia: effectiveVerifiedVia,
579
617
  now,
618
+ verifiedAt: effectiveVerifiedAt,
580
619
  allowRevokedReactivation,
581
620
  });
582
621
  // The pre-check passed, so a write is expected. A false means a
@@ -651,8 +690,9 @@ export function applyVerifiedChannelGatewayWrites(params: {
651
690
  type: sourceChannel,
652
691
  address,
653
692
  externalChatId,
654
- verifiedVia,
693
+ verifiedVia: effectiveVerifiedVia,
655
694
  now,
695
+ verifiedAt: effectiveVerifiedAt,
656
696
  allowRevokedReactivation,
657
697
  });
658
698
  // A blocked/revoked gateway row appeared after the pre-check — reject