Journey Round Trip Analysis
Executive Summary
This document analyzes the journey round trip logic in TDX CLI, explaining how data flows from API → YAML (pull) → API (push) and identifying potential issues, particularly around activations and local segments.
What is Journey Round Trip?
A "journey round trip" refers to the ability to:
- Pull journeys from Treasure AI CDP API to local YAML files
- Push those YAML files back to the API
- Maintain stability - pulling and immediately pushing should result in minimal/no changes
Key Challenge
Journeys contain embedded segments and embedded activations with auto-generated IDs that need to be:
- Converted to human-readable names during pull
- Matched back to server IDs during push
- Kept stable across multiple pull/push cycles
Architecture Overview
Data Flow
PULL: API → YAML
┌─────────────────┐
│ Journey API │
│ ├─ id: 123 │
│ ├─ entryCriteria│
│ │ └─ segmentId │──────────┐
│ │ "456" │ │
│ └─ steps │ │
│ └─ activation│ │
│ Id: "789" │──────┐ │
└─────────────────┘ │ │
│ │ │
│ Resolve IDs │ │
│ to names │ │
▼ │ │
┌─────────────────┐ │ │
│ journey.yml │ │ │
│ segments: │◀─────┘ │
│ entry_72ee: │ │
│ rule: ... │ │
│ activations: │◀─────────┘
│ to_td: │
│ connection │
└─────────────────┘
PUSH: YAML → API
┌─────────────────┐
│ journey.yml │
│ segments: │
│ entry_72ee: │
│ rule: {...} │──────────┐
│ activations: │ │
│ to_td: {...} │──────┐ │
└─────────────────┘ │ │
│ │ │
│ Match by │ │
│ content │ │
▼ │ │
┌─────────────────┐ │ │
│ Journey API │ │ │
│ ├─ entryCriteria│◀─────┘ │
│ │ └─ segmentId │ │
│ │ "456" │ │
│ └─ steps │◀─────────┘
│ └─ activation│
│ Id: "789" │
└─────────────────┘Core Components
1. Embedded Segments (Journey-Local Segments)
Characteristics:
- Have
kind=3(SEGMENT_KIND_JOURNEY) - Auto-generated names:
journey_entry_criteria_72ee903f-0644-42f5-a927-118442868213 - Used for: entry criteria, decision branches, wait conditions, goals
Pull Logic (src/sdk/segment/pull-workflow.ts:385-387):
const finalYaml = buildMergedJourneyDef(versionDetails, existingRuleToName);- Detects journey-local segments via pattern matching
- Generates clean names:
entry_criteria_72ee(type + short hash) - Matches segments by rule content to preserve names across pulls
- Embeds segment definitions in YAML
segmentssection
Push Logic (src/sdk/segment/push-workflow.ts:1322-1386):
export function buildLocalExpandedRuleMappings(journey: JourneyDef)- Builds expanded rule mappings from local YAML
- Compares server segment rules (expanded) with local rules
- Matches by content hash (not name)
- Creates new segments only if no match found
Matching Strategy:
- Expanded rule comparison - Rules are recursively expanded to include referenced segment content
- Normalization - YAML → API → YAML to ensure identical structure
- Content hash - Deterministic hash of expanded rule for matching
2. Embedded Activations (Journey-Local Activations)
Characteristics:
- Created via
/entities/journeys/{journeyId}/activations - Referenced in activation steps within journey stages
- Must be created AFTER journey exists (need journeyId)
Pull Logic (src/sdk/segment/pull-workflow.ts:546-580):
resolveActivation: async (activationId: string) => {
const definition = journeyActivationStepToYaml(activationStep, ...);
return { name: finalName, isJourneyLocal: true, definition };
}Push Logic (src/sdk/segment/push-workflow.ts:2799-2841):
resolveEmbeddedActivationId: async (name: string, definition: ActivationDef) => {
// Match by name first
const byName = journeyActivationNameToId.get(definition.name);
if (byName) return byName;
// Match by signature (content)
const signature = activationSignature(definition);
const bySignature = activationSignatureToId?.get(signature);
if (bySignature) return bySignature;
// Create new activation
const created = await deps.journey.createActivation(journeyId, createRequest);
}Matching Strategy (src/sdk/segment/push-workflow.ts:227-241):
function activationSignature(definition: ActivationDef): string {
const normalized = {
connection: definition.connection,
all_columns: definition.all_columns,
columns: definition.columns,
behavior: definition.behavior,
schedule: definition.schedule,
notification: definition.notification,
connector_config: definition.connector_config,
activation_template_id: definition.activation_template_id,
};
return stableStringify(normalized);
}- Signature matching - Ignores
namefield for rename stability - Content-based - Compares connection, config, columns, etc.
3. The 3-Step Journey Creation Workflow
Problem: New journeys with embedded resources have circular dependencies:
- Entry criteria references segment IDs that don't exist yet
- Segments need journeyId to be created
- Activations need journeyId to be created
Solution: 3-step workflow (src/sdk/segment/push-workflow.ts:2994-3104)
Step 1: Create Journey with Placeholders
// Create journey without entry criteria
const createRequest = JSON.parse(JSON.stringify(apiRequest));
stripPendingIds(createRequest); // Remove __pending_*__ placeholders
const createdJourney = await deps.journey.create(createRequest);- Removes all
__pending_segment__and__pending_act_*__placeholders - Creates journey shell without entry criteria
- Gets
journeyIdfor step 2
Step 2: Create Embedded Resources
// Create embedded segments
for (const [name, { definition }] of pendingEmbeddedSegments) {
const uniqueSegmentName = generateDeterministicJourneySegmentName(...);
const segmentId = await deps.segment.createJourneySegment({
name: uniqueSegmentName,
journeyId, // Now we have this!
rule: yamlRuleToApi(resolvedRule, true),
});
embeddedSegmentNameToId.set(name, segmentId);
}
// Create embedded activations
for (const [name, definition] of pendingEmbeddedActivations) {
const created = await deps.journey.createActivation(journeyId, createReq);
embeddedActivationNameToId.set(name, created.id);
}- Creates segments with journeyId association
- Creates activations with journeyId
- Builds mapping from local names to real IDs
Step 3: Update Journey with Real IDs
resolvePendingIdsInRequest(
updateRequest,
apiRequest,
stageNameToId,
embeddedSegmentNameToId,
embeddedActivationNameToId,
);
await deps.journey.update(journeyId, updateRequest);- Resolves all pending IDs to real IDs
- Updates journey with entry criteria and activation references
Consolidation Logic (Multi-Version Journeys)
Problem
Journeys can have multiple versions (v1, v2, v3) with potentially identical segments/activations. Without consolidation, we'd get: to_td, to_td_2, to_td_3 (duplicates)
Solution (src/sdk/segment/journey-utils.ts:383-438)
Segment Consolidation
function consolidateSegmentsByContent(
versionSegments: Array<Record<string, EmbeddedSegmentDef>>,
existingRuleToName?: Map<string, string[]>,
): ConsolidatedSegments {
for (const [name, segment] of Object.entries(segments)) {
const contentKey = serializeRule(segment.rule);
const existingName = contentKeyToName.get(contentKey);
if (existingName) {
// Map to existing
nameMapping.set(name, existingName);
} else {
// Prefer name from existing file if available
const canonicalName = existingRuleToName?.get(contentKey)?.[0] || name;
merged[canonicalName] = segment;
nameMapping.set(name, canonicalName);
}
}
}Activation Consolidation
function consolidateActivationsByContent(
versionActivations: Array<Record<string, ActivationDef>>,
): ConsolidatedActivations {
const contentKey = JSON.stringify({
name: activation.name,
connection: activation.connection,
connector_config: activation.connector_config,
});
if (contentKeyToName.has(contentKey)) {
nameMapping.set(name, contentKeyToName.get(contentKey));
} else {
merged[name] = activation;
contentKeyToName.set(contentKey, name);
}
}Reference Updates
After consolidation, all references must be updated:
entry_criteria.segmentreferencesdecision_pointbranch segment referenceswaitcondition segment referencesactivationstep referencesgoal.segmentreferences- Segment-to-segment references (include/exclude)
Potential Issues
🔴 CRITICAL: Wait Condition Segments Not Resolved in 3-Step Workflow
Location: src/sdk/segment/push-workflow.ts:3144-3277
Problem: The resolvePendingIdsInRequest() function resolves pending IDs for:
- ✅ Goal segments (line 3166-3178)
- ✅ Entry criteria (line 3189-3197)
- ✅ Exit criteria (line 3200-3212)
- ✅ Milestones (line 3215-3223)
- ✅ Decision point branches (line 3258-3273)
- ✅ Activation steps (line 3237-3256)
- ❌ Wait condition segments - MISSING!
Code:
function resolvePendingIdsInRequest(...) {
// ... resolves goal, entry, exit, milestone, decision points, activations
// MISSING: Wait condition resolution
// if (step.type === 'WaitStep' && step.waitCondition?.segmentId) {
// const actualId = resolvePendingSegment(step.waitCondition.segmentId, ...);
// if (actualId) step.waitCondition.segmentId = actualId;
// }
}Impact:
- New journeys with wait conditions referencing embedded segments will fail
- The pending ID
__pending_segment__won't be resolved - API will reject the request or create invalid wait conditions
Evidence:
- Wait conditions ARE stripped in
stripPendingIds()(line 3110-3139) - but NOT EXPLICITLY, likely missed - Wait conditions ARE collected during segment reference collection (journey-yaml.ts:556-558, 624-626, 785-787, 880-882)
- Wait conditions ARE converted during YAML→API (journey-yaml.ts:1710)
Fix Required: Add wait condition resolution in resolvePendingIdsInRequest():
// After decision point handling, before closing step loop
if (step.type === 'WaitStep' && step.waitCondition?.segmentId) {
if (originalStep?.waitCondition?.segmentId &&
isPendingSegmentId(originalStep.waitCondition.segmentId)) {
const actualId = resolvePendingSegment(
originalStep.waitCondition.segmentId,
`stage '${stage.name}' wait condition`,
);
if (actualId) {
step.waitCondition.segmentId = actualId;
}
}
}🟡 MEDIUM: Activation Signature Doesn't Include name Field
Location: src/sdk/segment/push-workflow.ts:227-241
Problem: The activation signature intentionally excludes the name field for "rename stability", but this could cause issues:
function activationSignature(definition: ActivationDef): string {
const normalized = {
connection: definition.connection,
// NOTE: Excludes definition.name!
...
};
}Scenario:
- User has activation:
{ name: "Send to TD", connection: "td-connection", ... } - User renames it in YAML:
{ name: "Send to Treasure", connection: "td-connection", ... } - Push workflow matches by signature → finds existing activation
- Result: Activation is NOT updated with new name
Is this intentional?
- Pro: Prevents creating duplicate activations when user renames in YAML
- Con: Actual activation name on server won't match YAML name
- Verdict: This seems intentional based on comment "rename stability", but could be confusing
Recommendation:
- Document this behavior clearly
- OR: Include name in signature but add explicit rename detection
🟡 MEDIUM: Activation Consolidation Uses Different Keys Than Signature
Location:
- Consolidation:
src/sdk/segment/journey-utils.ts:116-120 - Signature:
src/sdk/segment/push-workflow.ts:227-241
Problem: Consolidation uses:
const contentKey = JSON.stringify({
name: activation.name, // INCLUDED
connection: activation.connection,
connector_config: activation.connector_config,
});Signature uses:
const normalized = {
connection: definition.connection,
all_columns: definition.all_columns, // INCLUDED
columns: definition.columns, // INCLUDED
behavior: definition.behavior, // INCLUDED
schedule: definition.schedule, // INCLUDED
notification: definition.notification, // INCLUDED
connector_config: definition.connector_config,
activation_template_id: definition.activation_template_id, // INCLUDED
};Impact:
- Consolidation might merge activations that signature matching wouldn't
- Consolidation includes
namebut signature doesn't - Consolidation only checks 3 fields, signature checks 8 fields
Example:
- Activation A:
{ name: "to_td", connection: "td", columns: ["email"] } - Activation B:
{ name: "to_td", connection: "td", columns: ["user_id"] } - Consolidation → MERGES (only checks name, connection, connector_config)
- Signature → DIFFERENT (checks columns field)
Recommendation:
- Unify the matching logic
- Use the same fields for both consolidation and signature matching
🟡 MEDIUM: Goal Segment Reference Only Updated in Top-Level
Location: src/sdk/segment/journey-utils.ts:416-423
Code:
// Update goal.segment reference if present
let goal = firstYaml.goal;
if (goal?.segment) {
const consolidated = segmentNameMapping.get(goal.segment);
if (consolidated) {
goal = { ...goal, segment: consolidated };
}
}Problem: Goals can also be defined per-journey-version (not just top-level):
journeys:
- version: v1
goal:
segment: entry_criteria_abc # This goal is NOT updated!
- version: v2
goal:
segment: entry_criteria_defEvidence:
- Journey entry can have
goalfield (types/journey-def.ts:JourneyEntryDef) - Goal references are collected from entries (journey-yaml.ts:569-571)
- Goal references are replaced in entries (journey-yaml.ts:637-639)
- But consolidation only updates top-level goal!
Impact:
- Per-version goal segment references won't be consolidated
- Could reference wrong segment name after consolidation
Fix Required:
// Update goal references in journey entries
for (const journey of journeys) {
if (journey.goal?.segment) {
const consolidated = segmentNameMapping.get(journey.goal.segment);
if (consolidated) {
journey.goal.segment = consolidated;
}
}
}🟢 LOW: Segment Reference Resolution in Rules Could Miss Edge Cases
Location: src/sdk/segment/child-segment-yaml.ts:257-264, 324-330
Code:
// Pull: Only resolve if it looks like an ID (numeric)
if (/^\d+$/.test(ref.segment)) {
const name = await resolveSegmentName(ref.segment);
return { ...ref, segment: name };
}
// Push: Only resolve if it looks like a name (not numeric)
if (!/^\d+$/.test(ref.segment)) {
const id = await resolveSegmentId(ref.segment);
return { ...ref, segment: id };
}Problem: What if a user names their segment with only numbers? "12345"
- Pull: Would try to resolve as ID (might fail or resolve wrong segment)
- Push: Wouldn't resolve (thinks it's already an ID)
Impact:
- Low probability (users unlikely to use all-numeric names)
- Would cause confusion if it happens
Recommendation:
- Use a more robust detection method
- Check context (embedded segment vs. reference)
- Or: Always pass type hint to resolution functions
🟢 LOW: Stage Name Matching is Case-Insensitive But Could Be More Robust
Location: Multiple places use stage.name.toLowerCase() for matching
Examples:
src/sdk/segment/push-workflow.ts:3024src/sdk/segment/push-workflow.ts:2857-2858
Code:
stageNameToId.set(stage.name.toLowerCase(), stage.id);
const stage = targetJourney.attributes.journeyStages.find(
(s) => s.name.toLowerCase() === stageName.toLowerCase(),
);Problem:
- What if stage names differ in whitespace?
"Stage One"vs"Stage One" - What if special characters?
"Stage-1"vs"Stage 1"
Impact:
- Very low (users unlikely to have such similar names)
- Would cause stage resolution failures
Recommendation:
- Normalize stage names (trim, collapse whitespace)
- Or: Use more robust comparison (slugify)
Round-Trip Stability Mechanisms
✅ Content-Based Matching
- Segments matched by expanded rule content (not names)
- Prevents spurious changes when names differ
✅ Deterministic Naming
- Local segment names preserved via
existingRuleToNamemapping - Short hash from UUID ensures uniqueness
✅ Consolidation
- Duplicates across versions merged using content keys
- Prevents
to_td,to_td_2,to_td_3proliferation
✅ Placeholder IDs
- Pending resources marked with
__pending_*__prefix - Allows YAML→API conversion before resources exist
✅ Email/Connection Resolution
- User IDs converted to emails for readability
- Connection IDs converted to names
Testing Recommendations
Unit Tests Needed
Wait Condition Resolution
- Test: New journey with wait condition segment → 3-step workflow
- Verify: Wait condition segmentId is resolved in step 3
Activation Signature Consistency
- Test: Same activation content → same signature
- Test: Different columns → different signature
Consolidation Logic
- Test: Multiple versions with same segment → consolidated
- Test: Multiple versions with same activation → consolidated
- Test: Goal references updated in journey entries
Edge Cases
- Test: All-numeric segment names
- Test: Stage names with special characters/whitespace
- Test: Journey with all types of segment references (entry, decision, wait, goal)
Integration Tests Needed
Full Round Trip
- Pull journey → Modify nothing → Push
- Verify: No changes detected
Complex Journey
- Journey with: entry criteria, decision points, wait conditions, activations, goal
- Verify: All references resolved correctly
Multi-Version Journey
- Journey with 3 versions, duplicate segments
- Verify: Segments consolidated, references updated
Key Files Reference
Core Logic
src/sdk/segment/pull-workflow.ts- Pull orchestration (lines 183-596)src/sdk/segment/push-workflow.ts- Push orchestration (lines 2578-3277)src/sdk/segment/journey-yaml.ts- API ↔ YAML conversionsrc/sdk/segment/journey-utils.ts- Journey utilities and consolidation
Supporting
src/sdk/journey.ts- Journey SDK APIsrc/sdk/segment/child-segment-yaml.ts- Segment/activation conversionsrc/sdk/segment/types/journey-def.ts- Type definitions
Documentation
docs/design-notes/push-workflow.md- Architecture overview
Summary
The journey round trip logic is sophisticated and handles many complex scenarios:
- ✅ Embedded segments and activations
- ✅ Multi-version journeys
- ✅ Content-based matching
- ✅ 3-step creation workflow
- ✅ Consolidation of duplicates
Issues Fixed
✅ RESOLVED: Wait condition segments in 3-step workflow
- Commit: 2ffe951
- Added wait condition segment resolution in
resolvePendingIdsInRequest() - Added wait condition stripping in
stripPendingIds()
✅ RESOLVED: Goal references in journey entries
- Commit: 2ffe951
- Updated
buildMergedJourneyDef()to consolidate per-version goal.segment refs
✅ RESOLVED: Activation signature vs consolidation mismatch
- Commit: 6f83bf2
- Unified activation matching using
stableStringify()for consistent key ordering - Both consolidation and signature now use same 8 fields
✅ RESOLVED: Non-deterministic activation name suffixes
- Commit: 0229da2
- Removed
_2suffix logic fromresolveActivation()in both pull and push - Let consolidation handle all deduplication deterministically
✅ RESOLVED: Rule normalization for stable condition ordering
- Commit: 6f83bf2
- Apply
deepSortObject()to rules during consolidation - Ensures consistent condition order in stored YAML
Remaining Issues
🟡 TODO: Segment description mismatches
- Different versions may have different descriptions
- Need to handle description consolidation or selection
🟡 TODO: Segment matching edge cases
- Some segments not matching correctly between versions
- Example:
excluded_10cavsexcluded_4587duplication
🟢 LOW: Numeric segment name edge case
- Segments named with only numbers might confuse ID vs name detection
🟢 LOW: Stage name matching robustness
- Could normalize whitespace and special characters for better matching