From 61d7affb23dd70afcccde9fc8accc469c4042547 Mon Sep 17 00:00:00 2001 From: Lars George Date: Thu, 21 May 2026 18:40:29 +0200 Subject: [PATCH] =?UTF-8?q?feat(directory):=20Phase=204=20=E2=80=94=20Work?= =?UTF-8?q?flow=20Designer=20custom=20principals=20+=20DC=20wizard=20wirin?= =?UTF-8?q?g?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stacks on the Lakebase/File providers (#416). Closes Phase 4 of the plan #375. Workflow Designer: - Notification + Approval step config panels each gain a "Custom principals" toggle. Off = today's role-only Select. On = adds a PrincipalPicker(multi, users+groups) alongside the role Select; the picks are joined into the existing recipients / approvers string field as comma-separated literals via the new joinRoleAndPrincipals helper. splitRoleAndPrincipals does the inverse so the designer hydrates the form from a persisted config losslessly (UUID-shaped tokens, ``requester``, ``owner``, legacy aliases, and ``business:`` are recognised as the role-slot; anything else falls into the picker side). - Toggle state is persisted on the step config (recipients_custom / approvers_custom) so the UI re-opens in the same shape. Backend (small, additive): - _resolve_role_to_users now comma-splits unconditionally when a comma is present and recurses on each segment, so the workflow step's "role + custom principals" string resolves each piece in isolation: role tokens still expand to users-in-role, emails and group names pass through as literals, dedup is enforced. The previous '@'-gated comma-split is gone; a single email still returns as one literal tuple (covered by tests). Data Contract Wizard: - Contract Owner Input -> PrincipalPicker(single, user). - Data Consumers (multi, user+group), Subject Matter Experts (multi, user), Read Access groups (multi, group), Write Access groups (multi, group), Primary Support Email (single, user) — all previously rendered but unwired. Wired through to both submit and draft payloads under ``consumers``, ``subjectMatterExperts``, ``accessControl.{readGroups,writeGroups}``, and ``support.primaryEmail``. Tests: - Backend test_resolve_role_to_users.py (12): single-token branches still resolve (requester / owner / email / unknown literal); comma-split mixed role+email+group; dedup; whitespace + blank segments; requester / owner tokens inside a list still expand; legacy single-email path returns one tuple (regression). - Frontend workflow-principals.test.ts (14): join + split round-trip identity; role-only / principals-only / mixed shapes; UUID / alias / business: detection; lossless hydration; extra role-shaped tokens after the first fall into the picker side. Totals: - Backend directory + resolver tests: 79 passed. - Frontend tests: 719 passed (14 new), 6 skipped, 0 failed. - Type-check clean; lint clean on touched files (the two pre-existing apostrophe errors in workflow-designer.tsx at lines 1605/2016 are not in this diff). --- src/backend/src/common/workflow_executor.py | 26 ++- .../tests/unit/test_resolve_role_to_users.py | 125 ++++++++++++ .../data-contract-wizard-dialog.tsx | 124 ++++++++---- .../workflows/workflow-designer.tsx | 183 +++++++++++++++--- .../src/lib/workflow-principals.test.ts | 126 ++++++++++++ src/frontend/src/lib/workflow-principals.ts | 84 ++++++++ 6 files changed, 600 insertions(+), 68 deletions(-) create mode 100644 src/backend/src/tests/unit/test_resolve_role_to_users.py create mode 100644 src/frontend/src/lib/workflow-principals.test.ts create mode 100644 src/frontend/src/lib/workflow-principals.ts diff --git a/src/backend/src/common/workflow_executor.py b/src/backend/src/common/workflow_executor.py index cfd1c7e9..099809fb 100644 --- a/src/backend/src/common/workflow_executor.py +++ b/src/backend/src/common/workflow_executor.py @@ -227,11 +227,34 @@ def _resolve_role_to_users( - '' → app role UUID - 'user@email.com' → direct email - Legacy aliases like 'domain_owners', 'admins', etc. + - **Comma-separated list of any of the above** → recurse on each + segment, flatten results. This is what the Workflow Designer's + "Custom principals" toggle emits: a role/literal token from the + Select plus any users / groups picked from the PrincipalPicker. """ from src.db_models.settings import AppRoleDb from src.db_models.business_roles import BusinessRoleDb from src.db_models.business_owners import BusinessOwnerDb + # Recursion gate for the mixed-shape list. We check for ``,`` first + # so each segment hits the rest of this function in isolation and + # role tokens / aliases / business: prefixes still resolve. + if ',' in role_spec: + out: List[tuple] = [] + seen: set = set() + for segment in role_spec.split(','): + seg = segment.strip() + if not seg: + continue + for identifier, role_uuid in _resolve_role_to_users(db, seg, context): + if not identifier: + continue + key = (identifier, role_uuid) + if key not in seen: + seen.add(key) + out.append((identifier, role_uuid)) + return out + # Map shorthand names to role names (legacy support) role_aliases = { 'domain_owners': 'DomainOwner', @@ -259,7 +282,8 @@ def _resolve_role_to_users( return [(role_name, role.id if role else None)] if '@' in role_spec: - return [(e.strip(), None) for e in role_spec.split(',')] + # Pre-comma-split single email -- treat as a direct recipient. + return [(role_spec, None)] # Business role: prefixed with "business:" if role_spec.startswith('business:'): diff --git a/src/backend/src/tests/unit/test_resolve_role_to_users.py b/src/backend/src/tests/unit/test_resolve_role_to_users.py new file mode 100644 index 00000000..49846caa --- /dev/null +++ b/src/backend/src/tests/unit/test_resolve_role_to_users.py @@ -0,0 +1,125 @@ +"""Unit tests for ``_resolve_role_to_users``. + +Pins the existing single-token behaviour and locks down the new +comma-split / recurse path the Workflow Designer's "Custom principals" +toggle relies on. +""" + +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +from src.common.workflow_executor import _resolve_role_to_users + + +def _ctx(*, user_email="alice@example.com", entity=None, entity_type=None, entity_id=None): + return SimpleNamespace( + user_email=user_email, + entity=entity or {}, + entity_type=entity_type, + entity_id=entity_id, + entity_name=None, + ) + + +@pytest.fixture +def db_with_no_roles(): + """A DB session whose AppRole queries return nothing. + + Sufficient for tests that only exercise the single-token + requester/owner/email/literal branches plus the new comma-split. + """ + + db = MagicMock() + db.query.return_value.filter.return_value.first.return_value = None + db.query.return_value.all.return_value = [] + return db + + +class TestSingleTokenBehaviour: + def test_requester_returns_user_email(self, db_with_no_roles): + out = _resolve_role_to_users(db_with_no_roles, "requester", _ctx(user_email="r@x")) + assert out == [("r@x", None)] + + def test_requester_returns_empty_when_no_email(self, db_with_no_roles): + out = _resolve_role_to_users(db_with_no_roles, "requester", _ctx(user_email=None)) + assert out == [] + + def test_owner_pulls_from_entity(self, db_with_no_roles): + out = _resolve_role_to_users( + db_with_no_roles, "owner", _ctx(entity={"owner": "o@x"}), + ) + assert out == [("o@x", None)] + + def test_single_email_returns_as_literal(self, db_with_no_roles): + out = _resolve_role_to_users(db_with_no_roles, "alice@x.com", _ctx()) + assert out == [("alice@x.com", None)] + + def test_unknown_role_token_falls_back_to_literal(self, db_with_no_roles): + out = _resolve_role_to_users(db_with_no_roles, "Producers", _ctx()) + # Unrecognised non-email token -- returned as literal so the + # downstream notification fan-out can still address it (e.g. + # group name). + assert out == [("Producers", None)] + + +class TestCommaSplit: + def test_mixed_role_email_group(self, db_with_no_roles): + # The Workflow Designer's "Custom principals" toggle emits + # something like this: a role-literal first, then any picked + # emails / group names. + out = _resolve_role_to_users( + db_with_no_roles, + "owner,alice@example.com,Producers", + _ctx(entity={"owner": "o@x"}), + ) + ids = [identifier for identifier, _ in out] + assert ids == ["o@x", "alice@example.com", "Producers"] + + def test_dedupes_repeats(self, db_with_no_roles): + out = _resolve_role_to_users( + db_with_no_roles, + "alice@x,alice@x,Producers,Producers", + _ctx(), + ) + ids = [identifier for identifier, _ in out] + assert ids == ["alice@x", "Producers"] + + def test_strips_whitespace_and_skips_blanks(self, db_with_no_roles): + out = _resolve_role_to_users( + db_with_no_roles, + " alice@x , , Producers ,", + _ctx(), + ) + ids = [identifier for identifier, _ in out] + assert ids == ["alice@x", "Producers"] + + def test_requester_token_inside_list_expands(self, db_with_no_roles): + # The role select can still drop ``requester`` in the string; + # the comma-split path must let it expand to the user email. + out = _resolve_role_to_users( + db_with_no_roles, + "requester,Producers", + _ctx(user_email="r@x"), + ) + ids = [identifier for identifier, _ in out] + assert ids == ["r@x", "Producers"] + + def test_owner_token_inside_list_expands(self, db_with_no_roles): + out = _resolve_role_to_users( + db_with_no_roles, + "owner,Producers", + _ctx(entity={"owner": "o@x"}), + ) + ids = [identifier for identifier, _ in out] + assert ids == ["o@x", "Producers"] + + def test_single_email_no_longer_splits_on_absence_of_comma(self, db_with_no_roles): + # The legacy comma-split-only-when-'@'-present path is gone; + # confirm a lone email still returns one tuple, not zero. + out = _resolve_role_to_users(db_with_no_roles, "alice@example.com", _ctx()) + assert out == [("alice@example.com", None)] + + def test_empty_list_returns_empty(self, db_with_no_roles): + assert _resolve_role_to_users(db_with_no_roles, ",,,", _ctx()) == [] diff --git a/src/frontend/src/components/data-contracts/data-contract-wizard-dialog.tsx b/src/frontend/src/components/data-contracts/data-contract-wizard-dialog.tsx index ae002e2d..76d8d3e3 100644 --- a/src/frontend/src/components/data-contracts/data-contract-wizard-dialog.tsx +++ b/src/frontend/src/components/data-contracts/data-contract-wizard-dialog.tsx @@ -8,6 +8,7 @@ import { Textarea } from '@/components/ui/textarea' import InferFromAssetDialog from './infer-from-asset-dialog' import type { InferredSchemaObject } from './infer-from-asset-dialog' import BusinessConceptsDisplay from '@/components/business-concepts/business-concepts-display' +import { PrincipalPicker } from '@/components/common/principal-picker' import { useDomains } from '@/hooks/use-domains' import { useToast } from '@/hooks/use-toast' @@ -108,6 +109,25 @@ export default function DataContractWizardDialog({ isOpen, onOpenChange, onSubmi const [descriptionPurpose, setDescriptionPurpose] = useState(initial?.descriptionPurpose || '') const [descriptionLimitations, setDescriptionLimitations] = useState(initial?.descriptionLimitations || '') + // Stakeholders / access groups / support contacts wired up in Phase 4 + // (PRD #335). Previously these step-4 fields rendered but their values + // were dropped at submit time; they now flow into the wizard payload. + const [consumers, setConsumers] = useState( + (initial as { consumers?: string[] } | undefined)?.consumers ?? [], + ) + const [subjectMatterExperts, setSubjectMatterExperts] = useState( + (initial as { subjectMatterExperts?: string[] } | undefined)?.subjectMatterExperts ?? [], + ) + const [readGroups, setReadGroups] = useState( + (initial as { readGroups?: string[] } | undefined)?.readGroups ?? [], + ) + const [writeGroups, setWriteGroups] = useState( + (initial as { writeGroups?: string[] } | undefined)?.writeGroups ?? [], + ) + const [primarySupportEmail, setPrimarySupportEmail] = useState( + (initial as { primarySupportEmail?: string } | undefined)?.primarySupportEmail ?? '', + ) + type Column = { name: string; physicalType?: string; @@ -372,6 +392,12 @@ export default function DataContractWizardDialog({ isOpen, onOpenChange, onSubmi tenant, dataProduct, description: { usage: descriptionUsage, purpose: descriptionPurpose, limitations: descriptionLimitations }, + // Phase 4 stakeholder / access / support fields — previously + // rendered but unwired in the wizard. + consumers, + subjectMatterExperts, + accessControl: { readGroups, writeGroups }, + support: { primaryEmail: primarySupportEmail }, // Include contract-level semantic assignments as authoritativeDefinitions authoritativeDefinitions: convertSemanticConcepts(contractSemanticConcepts), schema: schemaObjects.map((o) => ({ @@ -453,6 +479,12 @@ export default function DataContractWizardDialog({ isOpen, onOpenChange, onSubmi tenant, dataProduct, description: { usage: descriptionUsage, purpose: descriptionPurpose, limitations: descriptionLimitations }, + // Phase 4 stakeholder / access / support fields — previously + // rendered but unwired in the wizard. + consumers, + subjectMatterExperts, + accessControl: { readGroups, writeGroups }, + support: { primaryEmail: primarySupportEmail }, // Include contract-level semantic assignments as authoritativeDefinitions authoritativeDefinitions: convertSemanticConcepts(contractSemanticConcepts), schema: schemaObjects.map((o) => ({ @@ -599,13 +631,16 @@ export default function DataContractWizardDialog({ isOpen, onOpenChange, onSubmi
- setOwner(e.target.value)} - placeholder="e.g., data-team@company.com" - className="mt-1" - /> +
+ setOwner(next ?? '')} + placeholder="e.g., data-team@company.com" + aria-label="Contract Owner" + /> +
@@ -1535,28 +1570,32 @@ export default function DataContractWizardDialog({ isOpen, onOpenChange, onSubmi
-
-
Data Consumers
- -
-
- -
- Add stakeholders who will consume this data -
+
Data Consumers
+ +
+ Stakeholders who will consume this data.
-
-
Subject Matter Experts
- -
-
- -
- Domain experts for business context and validation -
+
Subject Matter Experts
+ +
+ Domain experts for business context and validation.
@@ -1569,19 +1608,26 @@ export default function DataContractWizardDialog({ isOpen, onOpenChange, onSubmi
Read Access
-
- - - -
+
Write Access
-
- - -
+
@@ -1635,7 +1681,15 @@ export default function DataContractWizardDialog({ isOpen, onOpenChange, onSubmi
- +
+ setPrimarySupportEmail(next ?? '')} + placeholder="data-support@company.com" + aria-label="Primary Support Email" + /> +
diff --git a/src/frontend/src/components/workflows/workflow-designer.tsx b/src/frontend/src/components/workflows/workflow-designer.tsx index 839e979a..9842bcd8 100644 --- a/src/frontend/src/components/workflows/workflow-designer.tsx +++ b/src/frontend/src/components/workflows/workflow-designer.tsx @@ -121,6 +121,11 @@ import { } from '@/lib/workflow-labels'; import { TriggerPicker, type TriggerTypeOption } from './trigger-picker'; import { EntityTypeMultiselect } from './entity-type-multiselect'; +import { PrincipalPicker } from '@/components/common/principal-picker'; +import { + joinRoleAndPrincipals, + splitRoleAndPrincipals, +} from '@/lib/workflow-principals'; // Node types registry (default = fallback for unknown step_type) const nodeTypes = { @@ -1619,22 +1624,81 @@ export default function WorkflowDesigner({ workflowId }: WorkflowDesignerProps) {selectedStep.step_type === 'notification' && ( <> -
- - -
+ {(() => { + const raw = (selectedStep.config as { recipients?: string })?.recipients || ''; + const split = splitRoleAndPrincipals(raw); + const customOn = + (selectedStep.config as { recipients_custom?: boolean })?.recipients_custom + ?? split.principals.length > 0; + const setRole = (role: string) => { + const next = joinRoleAndPrincipals(role, split.principals); + updateStep(selectedStep.step_id, { + config: { ...selectedStep.config, recipients: next }, + }); + }; + const setPrincipals = (principals: string[]) => { + const next = joinRoleAndPrincipals(split.roleToken, principals); + updateStep(selectedStep.step_id, { + config: { ...selectedStep.config, recipients: next }, + }); + }; + const toggleCustom = (on: boolean) => { + // Persist the flag on the step config so the + // toggle state survives reloads. Clearing the + // picks when turning off keeps the wire shape + // unsurprising. + if (!on) { + updateStep(selectedStep.step_id, { + config: { + ...selectedStep.config, + recipients_custom: false, + recipients: joinRoleAndPrincipals(split.roleToken, []), + }, + }); + } else { + updateStep(selectedStep.step_id, { + config: { ...selectedStep.config, recipients_custom: true }, + }); + } + }; + return ( + <> +
+ + +
+
+
+ +

+ Add specific users or groups alongside the role. +

+
+ +
+ {customOn && ( +
+ + +
+ )} + + ); + })()}
updateStep(selectedStep.step_id, { - config: { ...selectedStep.config, approvers: v } - })} - > - - - - - {renderGroupedRoles(availableRoles, { requester: true })} - - -
+ {(() => { + const raw = (selectedStep.config as { approvers?: string })?.approvers || ''; + const split = splitRoleAndPrincipals(raw); + const customOn = + (selectedStep.config as { approvers_custom?: boolean })?.approvers_custom + ?? split.principals.length > 0; + const setRole = (role: string) => { + const next = joinRoleAndPrincipals(role, split.principals); + updateStep(selectedStep.step_id, { + config: { ...selectedStep.config, approvers: next }, + }); + }; + const setPrincipals = (principals: string[]) => { + const next = joinRoleAndPrincipals(split.roleToken, principals); + updateStep(selectedStep.step_id, { + config: { ...selectedStep.config, approvers: next }, + }); + }; + const toggleCustom = (on: boolean) => { + if (!on) { + updateStep(selectedStep.step_id, { + config: { + ...selectedStep.config, + approvers_custom: false, + approvers: joinRoleAndPrincipals(split.roleToken, []), + }, + }); + } else { + updateStep(selectedStep.step_id, { + config: { ...selectedStep.config, approvers_custom: true }, + }); + } + }; + return ( + <> +
+ + +
+
+
+ +

+ Add specific users or groups alongside the role. +

+
+ +
+ {customOn && ( +
+ + +
+ )} + + ); + })()}
{ + it('returns the role alone when no principals are picked', () => { + expect(joinRoleAndPrincipals('DomainOwner', [])).toBe('DomainOwner'); + expect(joinRoleAndPrincipals('requester', [])).toBe('requester'); + }); + + it('returns the principals alone when no role is selected', () => { + expect(joinRoleAndPrincipals('', ['alice@x.com', 'Producers'])).toBe( + 'alice@x.com,Producers', + ); + expect(joinRoleAndPrincipals(null, ['alice@x.com'])).toBe('alice@x.com'); + }); + + it('joins role and principals with commas', () => { + expect( + joinRoleAndPrincipals('owner', ['alice@x.com', 'Producers']), + ).toBe('owner,alice@x.com,Producers'); + }); + + it('dedupes repeats', () => { + expect( + joinRoleAndPrincipals('alice@x.com', ['alice@x.com', 'Producers']), + ).toBe('alice@x.com,Producers'); + }); + + it('strips whitespace and skips empties', () => { + expect( + joinRoleAndPrincipals(' owner ', [' alice@x.com ', '', ' Producers']), + ).toBe('owner,alice@x.com,Producers'); + }); +}); + +describe('splitRoleAndPrincipals', () => { + it('returns empty fields for null / undefined / empty', () => { + expect(splitRoleAndPrincipals(null)).toEqual({ roleToken: '', principals: [] }); + expect(splitRoleAndPrincipals(undefined)).toEqual({ roleToken: '', principals: [] }); + expect(splitRoleAndPrincipals('')).toEqual({ roleToken: '', principals: [] }); + }); + + it('recognises role literals as the role token', () => { + expect(splitRoleAndPrincipals('requester')).toEqual({ + roleToken: 'requester', + principals: [], + }); + expect(splitRoleAndPrincipals('owner')).toEqual({ + roleToken: 'owner', + principals: [], + }); + }); + + it('recognises legacy role aliases', () => { + expect(splitRoleAndPrincipals('domain_owners')).toEqual({ + roleToken: 'domain_owners', + principals: [], + }); + }); + + it('recognises business: prefixed business roles', () => { + expect(splitRoleAndPrincipals('business:abc')).toEqual({ + roleToken: 'business:abc', + principals: [], + }); + }); + + it('recognises UUID-shaped role tokens', () => { + const uuid = '01234567-89ab-cdef-0123-456789abcdef'; + expect(splitRoleAndPrincipals(uuid)).toEqual({ + roleToken: uuid, + principals: [], + }); + }); + + it('puts non-role entries into principals', () => { + expect(splitRoleAndPrincipals('alice@x.com,Producers')).toEqual({ + roleToken: '', + principals: ['alice@x.com', 'Producers'], + }); + }); + + it('splits a role + principals string back out losslessly', () => { + expect(splitRoleAndPrincipals('owner,alice@x.com,Producers')).toEqual({ + roleToken: 'owner', + principals: ['alice@x.com', 'Producers'], + }); + }); + + it('round-trips identity for join->split->join', () => { + const cases: Array<[string, string[]]> = [ + ['DomainOwner', []], + ['', ['alice@x.com']], + ['owner', ['alice@x.com', 'Producers']], + ['requester', ['some-group']], + ]; + for (const [role, picks] of cases) { + const joined = joinRoleAndPrincipals(role, picks); + const split = splitRoleAndPrincipals(joined); + const rejoined = joinRoleAndPrincipals(split.roleToken, split.principals); + expect(rejoined).toBe(joined); + } + }); + + it('preserves extra role-shaped tokens as principal entries after the first', () => { + // If somehow two role-shaped tokens appear (e.g. legacy data) the + // first wins the slot and the rest fall into the picker side so + // the user can see and remove them. + expect(splitRoleAndPrincipals('requester,owner,alice@x.com')).toEqual({ + roleToken: 'requester', + principals: ['owner', 'alice@x.com'], + }); + }); +}); diff --git a/src/frontend/src/lib/workflow-principals.ts b/src/frontend/src/lib/workflow-principals.ts new file mode 100644 index 00000000..76eec913 --- /dev/null +++ b/src/frontend/src/lib/workflow-principals.ts @@ -0,0 +1,84 @@ +/** + * Helpers for joining the Workflow Designer's role-token (selected + * from the role Select) with the additional principals picked via + * the PrincipalPicker ("Custom principals" toggle). + * + * The backend's recipient / approver resolver accepts a single + * comma-separated string where each segment may be a role/literal + * token, a user email, or a group name. ``joinRoleAndPrincipals`` + * produces that wire string; ``splitRoleAndPrincipals`` is its + * inverse so the designer can hydrate the form from a persisted + * config value. + * + * "Known" role tokens (``requester``, ``owner``, role UUIDs and + * legacy aliases) are kept on the left side of the comma; anything + * else on the right is treated as a principal pick. + */ + +/** + * Tokens we recognise as "role-shaped" rather than principal picks. + * The legacy aliases mirror the backend's role_aliases map; the + * ``looksLikeRoleUuid`` heuristic catches UUID-shaped tokens emitted + * by the role Select. + */ +const KNOWN_ROLE_LITERALS = new Set([ + 'requester', + 'owner', + 'domain_owners', + 'project_owners', + 'data_stewards', + 'admins', +]); + +const UUID_RE = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/; + +function looksLikeRoleToken(token: string): boolean { + if (!token) return false; + if (KNOWN_ROLE_LITERALS.has(token)) return true; + if (token.startsWith('business:')) return true; + if (UUID_RE.test(token)) return true; + return false; +} + +export function joinRoleAndPrincipals( + roleToken: string | null | undefined, + principals: string[], +): string { + const segments: string[] = []; + const seen = new Set(); + + const role = (roleToken || '').trim(); + if (role) { + segments.push(role); + seen.add(role); + } + for (const p of principals) { + const v = p.trim(); + if (!v || seen.has(v)) continue; + seen.add(v); + segments.push(v); + } + return segments.join(','); +} + +export interface SplitRecipients { + roleToken: string; + principals: string[]; +} + +export function splitRoleAndPrincipals(value: string | null | undefined): SplitRecipients { + const out: SplitRecipients = { roleToken: '', principals: [] }; + if (!value) return out; + const segments = value.split(',').map((s) => s.trim()).filter(Boolean); + for (const seg of segments) { + if (!out.roleToken && looksLikeRoleToken(seg)) { + // First role-shaped token wins the slot; subsequent role-shaped + // entries fall into the picker side so users can still see and + // remove them. + out.roleToken = seg; + } else { + out.principals.push(seg); + } + } + return out; +}