@elevasis/core 0.26.0 → 0.28.0

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.
Files changed (49) hide show
  1. package/dist/index.d.ts +162 -105
  2. package/dist/index.js +280 -174
  3. package/dist/knowledge/index.d.ts +43 -43
  4. package/dist/organization-model/index.d.ts +162 -105
  5. package/dist/organization-model/index.js +280 -174
  6. package/dist/test-utils/index.d.ts +20 -20
  7. package/dist/test-utils/index.js +184 -126
  8. package/package.json +3 -3
  9. package/src/_gen/__tests__/__snapshots__/contracts.md.snap +976 -1063
  10. package/src/business/acquisition/api-schemas.test.ts +1962 -1841
  11. package/src/business/acquisition/api-schemas.ts +1461 -1464
  12. package/src/business/acquisition/crm-next-action.test.ts +45 -25
  13. package/src/business/acquisition/crm-next-action.ts +227 -220
  14. package/src/business/acquisition/crm-priority.test.ts +41 -8
  15. package/src/business/acquisition/crm-priority.ts +365 -349
  16. package/src/business/acquisition/crm-state-actions.test.ts +208 -153
  17. package/src/business/acquisition/derive-actions.test.ts +90 -13
  18. package/src/business/acquisition/derive-actions.ts +8 -139
  19. package/src/business/acquisition/ontology-validation.ts +72 -158
  20. package/src/business/pdf/sections/investment.ts +1 -1
  21. package/src/business/pdf/sections/summary-investment.ts +1 -1
  22. package/src/execution/engine/tools/tool-maps.ts +872 -831
  23. package/src/organization-model/__tests__/cross-ref.test.ts +167 -0
  24. package/src/organization-model/__tests__/define-domain-record.test.ts +289 -0
  25. package/src/organization-model/__tests__/om-spine-doc-contract.test.ts +56 -0
  26. package/src/organization-model/__tests__/published-zero-leak.test.ts +60 -1
  27. package/src/organization-model/__tests__/resolve.test.ts +1 -1
  28. package/src/organization-model/__tests__/schema-refinements.test.ts +72 -0
  29. package/src/organization-model/cross-ref.ts +175 -0
  30. package/src/organization-model/domains/actions.ts +13 -0
  31. package/src/organization-model/domains/branding.ts +6 -6
  32. package/src/organization-model/domains/customers.ts +95 -78
  33. package/src/organization-model/domains/entities.ts +157 -144
  34. package/src/organization-model/domains/goals.ts +100 -83
  35. package/src/organization-model/domains/knowledge.ts +106 -93
  36. package/src/organization-model/domains/offerings.ts +88 -71
  37. package/src/organization-model/domains/policies.ts +115 -102
  38. package/src/organization-model/domains/roles.ts +109 -96
  39. package/src/organization-model/domains/sales.test.ts +104 -218
  40. package/src/organization-model/domains/sales.ts +212 -375
  41. package/src/organization-model/domains/statuses.ts +351 -339
  42. package/src/organization-model/domains/systems.ts +176 -164
  43. package/src/organization-model/helpers.ts +331 -306
  44. package/src/organization-model/index.ts +43 -0
  45. package/src/organization-model/published.ts +27 -2
  46. package/src/organization-model/schema-refinements.ts +667 -0
  47. package/src/organization-model/schema.ts +8 -715
  48. package/src/platform/constants/versions.ts +1 -1
  49. package/src/reference/_generated/contracts.md +1000 -1087
@@ -1,153 +1,208 @@
1
- import { describe, expect, it } from 'vitest'
2
- // Phase 4 (D8): DEFAULT_ORGANIZATION_MODEL_SALES no longer exported — pipeline data moved to
3
- // system.content. CRM_PIPELINE_DEFINITION is retained as the Stateful pipeline shape
4
- // for CRM business logic (separate from the OM content nodes).
5
- import { CRM_PIPELINE_DEFINITION, LEAD_GEN_PIPELINE_DEFINITIONS } from '../../organization-model/domains/sales'
6
- import { ActivityEventSchema } from './activity-events'
7
- import { DealStageSchema, TransitionItemRequestSchema } from './api-schemas'
8
- import { deriveActions } from './derive-actions'
9
- import type { DealStage } from './types'
10
-
11
- const DEAL_STAGES = [
12
- 'interested',
13
- 'proposal',
14
- 'closing',
15
- 'closed_won',
16
- 'closed_lost',
17
- 'nurturing'
18
- ] as const satisfies readonly DealStage[]
19
-
20
- function deal(stageKey: string | null, stateKey: string | null = null): Parameters<typeof deriveActions>[0] {
21
- return { stage_key: stageKey, state_key: stateKey } as Parameters<typeof deriveActions>[0]
22
- }
23
-
24
- describe('CRM deal action derivation', () => {
25
- it('derives the exact base actions for interested deals', () => {
26
- expect(deriveActions(deal('interested'))).toEqual([
27
- { key: 'move_to_proposal', label: 'Move to Proposal' },
28
- { key: 'move_to_closed_lost', label: 'Close Lost' },
29
- { key: 'move_to_nurturing', label: 'Move to Nurturing' }
30
- ])
31
- })
32
-
33
- it.each([
34
- ['discovery_replied', { key: 'send_link', label: 'Send Booking Link' }],
35
- ['discovery_link_sent', { key: 'send_nudge', label: 'Send Nudge' }],
36
- ['discovery_booking_cancelled', { key: 'rebook', label: 'Rebook' }]
37
- ] as const)('adds the expected workflow action for interested/%s', (stateKey, expectedAction) => {
38
- const expected = [
39
- { key: 'move_to_proposal', label: 'Move to Proposal' },
40
- { key: 'move_to_closed_lost', label: 'Close Lost' },
41
- { key: 'move_to_nurturing', label: 'Move to Nurturing' },
42
- expectedAction
43
- ]
44
-
45
- expect(deriveActions(deal('interested', stateKey))).toEqual(expected)
46
- })
47
-
48
- it('adds nudge and no-show actions for interested/discovery_nudging', () => {
49
- expect(deriveActions(deal('interested', 'discovery_nudging'))).toEqual([
50
- { key: 'move_to_proposal', label: 'Move to Proposal' },
51
- { key: 'move_to_closed_lost', label: 'Close Lost' },
52
- { key: 'move_to_nurturing', label: 'Move to Nurturing' },
53
- { key: 'send_nudge', label: 'Send Nudge' },
54
- { key: 'mark_no_show', label: 'Mark No-Show' }
55
- ])
56
- })
57
-
58
- it('derives exact proposal and closing transitions', () => {
59
- expect(deriveActions(deal('proposal'))).toEqual([
60
- { key: 'move_to_closing', label: 'Move to Closing' },
61
- { key: 'move_to_closed_lost', label: 'Close Lost' },
62
- { key: 'move_to_nurturing', label: 'Move to Nurturing' }
63
- ])
64
-
65
- expect(deriveActions(deal('closing'))).toEqual([
66
- { key: 'move_to_closed_won', label: 'Close Won' },
67
- { key: 'move_to_closed_lost', label: 'Close Lost' },
68
- { key: 'move_to_nurturing', label: 'Move to Nurturing' }
69
- ])
70
- })
71
-
72
- it.each(['closed_won', 'closed_lost', 'nurturing', 'legacy_stage', null] as const)(
73
- 'derives no actions for terminal or non-canonical stage %s',
74
- (stageKey) => {
75
- expect(deriveActions(deal(stageKey))).toEqual([])
76
- }
77
- )
78
- })
79
-
80
- describe('ActivityEventSchema', () => {
81
- const timestamp = '2026-04-27T12:34:56.000Z'
82
-
83
- // Platform events only (8 members). Domain events (reply_received, booking_nudge_sent, etc.)
84
- // live in @repo/elevasis-operations as CrmDomainActivityEventSchema.
85
- // See Open Decision #5 in crm-action-system.mdx for rationale.
86
- it.each([
87
- [{ type: 'stage_change', timestamp, stageBefore: 'interested', stageAfter: 'proposal', reason: 'qualified' }],
88
- [{ type: 'state_change', timestamp, stateBefore: null, stateAfter: 'discovery_replied' }],
89
- [{ type: 'action_taken', timestamp, actionKey: 'send_link', payload: { channel: 'email' } }],
90
- [{ type: 'approval_created', timestamp, commandId: 'cmd_123', dealStageBefore: 'proposal' }],
91
- [{ type: 'approval_resolved', timestamp, commandId: 'cmd_123', resolution: 'superseded' }],
92
- [{ type: 'approval_stale', timestamp, commandId: 'cmd_123', dealStageAfter: 'closing' }],
93
- [{ type: 'task_created', timestamp, taskId: 'task_123' }],
94
- [{ type: 'deal_created', timestamp }]
95
- ])('accepts expected %s events', (event) => {
96
- expect(ActivityEventSchema.safeParse(event).success).toBe(true)
97
- })
98
-
99
- it.each([
100
- [{ type: 'deal_created', timestamp: 'not-a-date' }],
101
- [{ type: 'unknown_event', timestamp }],
102
- [{ type: 'stage_change', timestamp, stageBefore: 'interested' }],
103
- [{ type: 'approval_resolved', timestamp, commandId: 'cmd_123', resolution: 'approved' }],
104
- [{ type: 'booking_nudge_sent', timestamp, followupDay: '2' }],
105
- [{ type: 'action_taken', timestamp }]
106
- ])('rejects malformed event payload %o', (event) => {
107
- expect(ActivityEventSchema.safeParse(event).success).toBe(false)
108
- })
109
- })
110
-
111
- describe('CRM stage and transition vocabulary contracts', () => {
112
- // Phase 4 (D8): DEFAULT_ORGANIZATION_MODEL_SALES.pipelines removed.
113
- // CRM_PIPELINE_DEFINITION (StatefulPipelineDefinition) is the retained Stateful-trait
114
- // pipeline shape used by CRM business logic. stageKey → DealStageSchema alignment
115
- // is now verified against CRM_PIPELINE_DEFINITION.stages[*].stageKey.
116
- it('keeps DealStage, DealStageSchema, and CRM_PIPELINE_DEFINITION stages aligned', () => {
117
- const crmStageKeys = CRM_PIPELINE_DEFINITION.stages.map((stage) => stage.stageKey)
118
-
119
- expect(crmStageKeys).toEqual([...DEAL_STAGES])
120
- expect(DealStageSchema.options).toEqual([...DEAL_STAGES])
121
- })
122
-
123
- it('accepts every canonical CRM deal stage in transition requests', () => {
124
- for (const stageKey of DEAL_STAGES) {
125
- expect(
126
- TransitionItemRequestSchema.safeParse({
127
- pipelineKey: CRM_PIPELINE_DEFINITION.pipelineKey,
128
- stageKey,
129
- stateKey: null,
130
- expectedUpdatedAt: '2026-04-27T12:34:56.000Z'
131
- }).success
132
- ).toBe(true)
133
- }
134
- })
135
-
136
- it('accepts canonical lead-gen stage/state pairs in transition requests', () => {
137
- for (const pipelineDefinitions of Object.values(LEAD_GEN_PIPELINE_DEFINITIONS)) {
138
- for (const pipeline of pipelineDefinitions) {
139
- for (const stage of pipeline.stages) {
140
- for (const state of stage.states) {
141
- expect(
142
- TransitionItemRequestSchema.safeParse({
143
- pipelineKey: pipeline.pipelineKey,
144
- stageKey: stage.stageKey,
145
- stateKey: state.stateKey
146
- }).success
147
- ).toBe(true)
148
- }
149
- }
150
- }
151
- }
152
- })
153
- })
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ LEAD_GEN_PIPELINE_DEFINITIONS,
4
+ type StatefulPipelineDefinition
5
+ } from '../../organization-model/domains/sales'
6
+ import { ActivityEventSchema } from './activity-events'
7
+ import { DealStageSchema, TransitionItemRequestSchema } from './api-schemas'
8
+ import { deriveActions } from './derive-actions'
9
+ import type { DealStage } from './types'
10
+
11
+ const DEAL_STAGES = [
12
+ 'interested',
13
+ 'proposal',
14
+ 'closing',
15
+ 'closed_won',
16
+ 'closed_lost',
17
+ 'nurturing'
18
+ ] as const satisfies readonly DealStage[]
19
+
20
+ const CRM_PIPELINE_FIXTURE: StatefulPipelineDefinition = {
21
+ pipelineKey: 'crm',
22
+ label: 'CRM',
23
+ entityKey: 'crm.deal',
24
+ stages: DEAL_STAGES.map((stageKey, index) => ({
25
+ stageKey,
26
+ label: stageKey,
27
+ color: 'gray',
28
+ states:
29
+ index === 0
30
+ ? [
31
+ { stateKey: 'discovery_replied', label: 'Discovery Replied' },
32
+ { stateKey: 'discovery_link_sent', label: 'Discovery Link Sent' },
33
+ { stateKey: 'discovery_nudging', label: 'Discovery Nudging' },
34
+ { stateKey: 'discovery_booking_cancelled', label: 'Discovery Booking Cancelled' }
35
+ ]
36
+ : []
37
+ }))
38
+ }
39
+
40
+ const CRM_ACTION_FIXTURE = [
41
+ {
42
+ key: 'move_to_proposal',
43
+ label: 'Move to Proposal',
44
+ workflowId: 'move_to_proposal-workflow',
45
+ isAvailableFor: (deal: Parameters<typeof deriveActions>[0]) => deal.stage_key === 'interested'
46
+ },
47
+ {
48
+ key: 'move_to_closing',
49
+ label: 'Move to Closing',
50
+ workflowId: 'move_to_closing-workflow',
51
+ isAvailableFor: (deal: Parameters<typeof deriveActions>[0]) => deal.stage_key === 'proposal'
52
+ },
53
+ {
54
+ key: 'move_to_closed_won',
55
+ label: 'Close Won',
56
+ workflowId: 'move_to_closed_won-workflow',
57
+ isAvailableFor: (deal: Parameters<typeof deriveActions>[0]) => deal.stage_key === 'closing'
58
+ },
59
+ {
60
+ key: 'move_to_closed_lost',
61
+ label: 'Close Lost',
62
+ workflowId: 'move_to_closed_lost-workflow',
63
+ isAvailableFor: (deal: Parameters<typeof deriveActions>[0]) =>
64
+ deal.stage_key === 'interested' || deal.stage_key === 'proposal' || deal.stage_key === 'closing'
65
+ },
66
+ {
67
+ key: 'move_to_nurturing',
68
+ label: 'Move to Nurturing',
69
+ workflowId: 'move_to_nurturing-workflow',
70
+ isAvailableFor: (deal: Parameters<typeof deriveActions>[0]) =>
71
+ deal.stage_key === 'interested' || deal.stage_key === 'proposal' || deal.stage_key === 'closing'
72
+ },
73
+ {
74
+ key: 'send_link',
75
+ label: 'Send Booking Link',
76
+ workflowId: 'crm-send-booking-link-workflow',
77
+ isAvailableFor: (deal: Parameters<typeof deriveActions>[0]) =>
78
+ deal.stage_key === 'interested' && deal.state_key === 'discovery_replied'
79
+ },
80
+ {
81
+ key: 'send_nudge',
82
+ label: 'Send Nudge',
83
+ workflowId: 'crm-send-nudge-workflow',
84
+ isAvailableFor: (deal: Parameters<typeof deriveActions>[0]) =>
85
+ deal.stage_key === 'interested' &&
86
+ (deal.state_key === 'discovery_link_sent' || deal.state_key === 'discovery_nudging')
87
+ },
88
+ {
89
+ key: 'mark_no_show',
90
+ label: 'Mark No-Show',
91
+ workflowId: 'mark_no_show-workflow',
92
+ isAvailableFor: (deal: Parameters<typeof deriveActions>[0]) =>
93
+ deal.stage_key === 'interested' && deal.state_key === 'discovery_nudging'
94
+ },
95
+ {
96
+ key: 'rebook',
97
+ label: 'Rebook',
98
+ workflowId: 'crm-rebook-workflow',
99
+ isAvailableFor: (deal: Parameters<typeof deriveActions>[0]) =>
100
+ deal.stage_key === 'interested' && deal.state_key === 'discovery_booking_cancelled'
101
+ }
102
+ ]
103
+
104
+ function deal(stageKey: string | null, stateKey: string | null = null): Parameters<typeof deriveActions>[0] {
105
+ return { stage_key: stageKey, state_key: stateKey } as Parameters<typeof deriveActions>[0]
106
+ }
107
+
108
+ describe('CRM deal action derivation', () => {
109
+ it('derives the exact base actions for interested deals from caller-supplied actions', () => {
110
+ expect(deriveActions(deal('interested'), CRM_ACTION_FIXTURE)).toEqual([
111
+ { key: 'move_to_proposal', label: 'Move to Proposal' },
112
+ { key: 'move_to_closed_lost', label: 'Close Lost' },
113
+ { key: 'move_to_nurturing', label: 'Move to Nurturing' }
114
+ ])
115
+ })
116
+
117
+ it('derives no actions unless the caller supplies an action catalog', () => {
118
+ expect(deriveActions(deal('interested'))).toEqual([])
119
+ })
120
+
121
+ it.each([
122
+ ['discovery_replied', { key: 'send_link', label: 'Send Booking Link' }],
123
+ ['discovery_link_sent', { key: 'send_nudge', label: 'Send Nudge' }],
124
+ ['discovery_booking_cancelled', { key: 'rebook', label: 'Rebook' }]
125
+ ] as const)('adds the expected workflow action for interested/%s', (stateKey, expectedAction) => {
126
+ expect(deriveActions(deal('interested', stateKey), CRM_ACTION_FIXTURE)).toContainEqual(expectedAction)
127
+ })
128
+
129
+ it('adds nudge and no-show actions for interested/discovery_nudging', () => {
130
+ expect(deriveActions(deal('interested', 'discovery_nudging'), CRM_ACTION_FIXTURE)).toEqual([
131
+ { key: 'move_to_proposal', label: 'Move to Proposal' },
132
+ { key: 'move_to_closed_lost', label: 'Close Lost' },
133
+ { key: 'move_to_nurturing', label: 'Move to Nurturing' },
134
+ { key: 'send_nudge', label: 'Send Nudge' },
135
+ { key: 'mark_no_show', label: 'Mark No-Show' }
136
+ ])
137
+ })
138
+ })
139
+
140
+ describe('ActivityEventSchema', () => {
141
+ const timestamp = '2026-04-27T12:34:56.000Z'
142
+
143
+ it.each([
144
+ [{ type: 'stage_change', timestamp, stageBefore: 'interested', stageAfter: 'proposal', reason: 'qualified' }],
145
+ [{ type: 'state_change', timestamp, stateBefore: null, stateAfter: 'discovery_replied' }],
146
+ [{ type: 'action_taken', timestamp, actionKey: 'send_link', payload: { channel: 'email' } }],
147
+ [{ type: 'approval_created', timestamp, commandId: 'cmd_123', dealStageBefore: 'proposal' }],
148
+ [{ type: 'approval_resolved', timestamp, commandId: 'cmd_123', resolution: 'superseded' }],
149
+ [{ type: 'approval_stale', timestamp, commandId: 'cmd_123', dealStageAfter: 'closing' }],
150
+ [{ type: 'task_created', timestamp, taskId: 'task_123' }],
151
+ [{ type: 'deal_created', timestamp }]
152
+ ])('accepts expected %s events', (event) => {
153
+ expect(ActivityEventSchema.safeParse(event).success).toBe(true)
154
+ })
155
+
156
+ it.each([
157
+ [{ type: 'deal_created', timestamp: 'not-a-date' }],
158
+ [{ type: 'unknown_event', timestamp }],
159
+ [{ type: 'stage_change', timestamp, stageBefore: 'interested' }],
160
+ [{ type: 'approval_resolved', timestamp, commandId: 'cmd_123', resolution: 'approved' }],
161
+ [{ type: 'booking_nudge_sent', timestamp, followupDay: '2' }],
162
+ [{ type: 'action_taken', timestamp }]
163
+ ])('rejects malformed event payload %o', (event) => {
164
+ expect(ActivityEventSchema.safeParse(event).success).toBe(false)
165
+ })
166
+ })
167
+
168
+ describe('CRM stage and transition vocabulary contracts', () => {
169
+ it('keeps DealStage and DealStageSchema aligned with a caller-owned CRM fixture', () => {
170
+ const crmStageKeys = CRM_PIPELINE_FIXTURE.stages.map((stage) => stage.stageKey)
171
+
172
+ expect(crmStageKeys).toEqual([...DEAL_STAGES])
173
+ for (const stageKey of crmStageKeys) {
174
+ expect(DealStageSchema.safeParse(stageKey).success).toBe(true)
175
+ }
176
+ })
177
+
178
+ it('accepts every fixture CRM deal stage in transition requests', () => {
179
+ for (const stageKey of DEAL_STAGES) {
180
+ expect(
181
+ TransitionItemRequestSchema.safeParse({
182
+ pipelineKey: CRM_PIPELINE_FIXTURE.pipelineKey,
183
+ stageKey,
184
+ stateKey: null,
185
+ expectedUpdatedAt: '2026-04-27T12:34:56.000Z'
186
+ }).success
187
+ ).toBe(true)
188
+ }
189
+ })
190
+
191
+ it('accepts canonical lead-gen stage/state pairs in transition requests', () => {
192
+ for (const pipelineDefinitions of Object.values(LEAD_GEN_PIPELINE_DEFINITIONS)) {
193
+ for (const pipeline of pipelineDefinitions) {
194
+ for (const stage of pipeline.stages) {
195
+ for (const state of stage.states) {
196
+ expect(
197
+ TransitionItemRequestSchema.safeParse({
198
+ pipelineKey: pipeline.pipelineKey,
199
+ stageKey: stage.stageKey,
200
+ stateKey: state.stateKey
201
+ }).success
202
+ ).toBe(true)
203
+ }
204
+ }
205
+ }
206
+ }
207
+ })
208
+ })
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
  import type { AcqDealRow } from './types'
3
3
  import type { Action, ActionDef } from './derive-actions'
4
- import { DEFAULT_CRM_ACTIONS, SendReplyActionPayloadSchema, deriveActions } from './derive-actions'
4
+ import { SendReplyActionPayloadSchema, deriveActions as deriveActionsCore } from './derive-actions'
5
5
 
6
6
  // ---------------------------------------------------------------------------
7
7
  // Fixture builder
@@ -12,7 +12,7 @@ import { DEFAULT_CRM_ACTIONS, SendReplyActionPayloadSchema, deriveActions } from
12
12
  * Only stage_key and state_key affect availability logic; all other required
13
13
  * columns are filled with safe, inert defaults.
14
14
  */
15
- function makeDeal(
15
+ function makeDeal(
16
16
  overrides: Partial<AcqDealRow> & { ownership?: 'us' | 'them' | null; nextAction?: string | null } = {}
17
17
  ): AcqDealRow & { ownership?: 'us' | 'them' | null; nextAction?: string | null } {
18
18
  return {
@@ -51,7 +51,85 @@ function makeDeal(
51
51
  updated_at: '2026-01-01T00:00:00.000Z',
52
52
  ...overrides
53
53
  }
54
- }
54
+ }
55
+
56
+ const DEFAULT_CRM_ACTIONS: ActionDef[] = [
57
+ {
58
+ key: 'move_to_proposal',
59
+ label: 'Move to Proposal',
60
+ workflowId: 'move_to_proposal-workflow',
61
+ isAvailableFor: (deal) => deal.stage_key === 'interested'
62
+ },
63
+ {
64
+ key: 'move_to_closing',
65
+ label: 'Move to Closing',
66
+ workflowId: 'move_to_closing-workflow',
67
+ isAvailableFor: (deal) => deal.stage_key === 'proposal'
68
+ },
69
+ {
70
+ key: 'move_to_closed_won',
71
+ label: 'Close Won',
72
+ workflowId: 'move_to_closed_won-workflow',
73
+ isAvailableFor: (deal) => deal.stage_key === 'closing'
74
+ },
75
+ {
76
+ key: 'move_to_closed_lost',
77
+ label: 'Close Lost',
78
+ workflowId: 'move_to_closed_lost-workflow',
79
+ isAvailableFor: (deal) =>
80
+ deal.stage_key === 'interested' || deal.stage_key === 'proposal' || deal.stage_key === 'closing'
81
+ },
82
+ {
83
+ key: 'move_to_nurturing',
84
+ label: 'Move to Nurturing',
85
+ workflowId: 'move_to_nurturing-workflow',
86
+ isAvailableFor: (deal) =>
87
+ deal.stage_key === 'interested' || deal.stage_key === 'proposal' || deal.stage_key === 'closing'
88
+ },
89
+ {
90
+ key: 'send_reply',
91
+ label: 'Send Reply',
92
+ workflowId: 'crm-send-reply-workflow',
93
+ payloadSchema: SendReplyActionPayloadSchema,
94
+ isAvailableFor: (deal) =>
95
+ deal.stage_key === 'interested' &&
96
+ deal.nextAction === 'send_reply' &&
97
+ (deal.state_key === 'discovery_replied' ||
98
+ deal.state_key === 'discovery_link_sent' ||
99
+ deal.state_key === 'discovery_nudging')
100
+ },
101
+ {
102
+ key: 'send_link',
103
+ label: 'Send Booking Link',
104
+ workflowId: 'crm-send-booking-link-workflow',
105
+ isAvailableFor: (deal) => deal.stage_key === 'interested' && deal.state_key === 'discovery_replied'
106
+ },
107
+ {
108
+ key: 'send_nudge',
109
+ label: 'Send Nudge',
110
+ workflowId: 'crm-send-nudge-workflow',
111
+ isAvailableFor: (deal) =>
112
+ deal.stage_key === 'interested' &&
113
+ (deal.state_key === 'discovery_link_sent' || deal.state_key === 'discovery_nudging')
114
+ },
115
+ {
116
+ key: 'mark_no_show',
117
+ label: 'Mark No-Show',
118
+ workflowId: 'mark_no_show-workflow',
119
+ isAvailableFor: (deal) => deal.stage_key === 'interested' && deal.state_key === 'discovery_nudging'
120
+ },
121
+ {
122
+ key: 'rebook',
123
+ label: 'Rebook',
124
+ workflowId: 'crm-rebook-workflow',
125
+ isAvailableFor: (deal) =>
126
+ deal.stage_key === 'interested' && deal.state_key === 'discovery_booking_cancelled'
127
+ }
128
+ ]
129
+
130
+ function deriveActions(deal: Parameters<typeof deriveActionsCore>[0], actions: ActionDef[] = DEFAULT_CRM_ACTIONS): Action[] {
131
+ return deriveActionsCore(deal, actions)
132
+ }
55
133
 
56
134
  // ---------------------------------------------------------------------------
57
135
  // DEFAULT_CRM_ACTIONS static contract
@@ -321,11 +399,9 @@ describe('deriveActions()', () => {
321
399
  expect(deriveActions(makeDeal({ stage_key: 'interested' }), [])).toEqual([])
322
400
  })
323
401
 
324
- it('uses DEFAULT_CRM_ACTIONS when no second argument is provided', () => {
325
- const withDefaults = deriveActions(makeDeal({ stage_key: 'proposal' }))
326
- const withExplicit = deriveActions(makeDeal({ stage_key: 'proposal' }), DEFAULT_CRM_ACTIONS)
327
- expect(withDefaults).toEqual(withExplicit)
328
- })
402
+ it('core deriveActions returns [] when no action catalog is provided', () => {
403
+ expect(deriveActionsCore(makeDeal({ stage_key: 'proposal' }))).toEqual([])
404
+ })
329
405
  })
330
406
 
331
407
  // ---------------------------------------------------------------------------
@@ -514,11 +590,12 @@ describe('deriveActions() — multi-state discovery path coverage', () => {
514
590
  describe('deriveActions() - ownership-gated reply action', () => {
515
591
  it('lead asks us to respond: send_reply is available and ownership is our move', () => {
516
592
  const actions = deriveActions(
517
- makeDeal({
518
- stage_key: 'interested',
519
- state_key: 'discovery_replied',
520
- activity_log: [{ type: 'reply_received', timestamp: '2026-01-10T10:00:00Z' }]
521
- })
593
+ makeDeal({
594
+ stage_key: 'interested',
595
+ state_key: 'discovery_replied',
596
+ nextAction: 'send_reply',
597
+ activity_log: [{ type: 'reply_received', timestamp: '2026-01-10T10:00:00Z' }]
598
+ })
522
599
  )
523
600
 
524
601
  expect(actions).toContainEqual({ key: 'send_reply', label: 'Send Reply', payloadSchema: SendReplyActionPayloadSchema })