Skip to content

feat(server)!: implement recursive control condition trees#115

Open
lan17 wants to merge 35 commits intomainfrom
codex/feature/sc-58429-condition-trees
Open

feat(server)!: implement recursive control condition trees#115
lan17 wants to merge 35 commits intomainfrom
codex/feature/sc-58429-condition-trees

Conversation

@lan17
Copy link
Contributor

@lan17 lan17 commented Mar 12, 2026

corresponding docs update: agentcontrol/docs-agent-control#28

Summary

  • replace flat control predicates with recursive condition trees across the shared models, engine, server, SDKs, and UI contracts
  • add pre-upgrade control migration tooling plus safe condition_trace metadata for composite evaluation
  • bump package majors and regenerate the TypeScript SDK/API types for the new contract

Scope

  • User-facing/API changes:
    • ControlDefinition now uses a top-level condition tree instead of top-level selector / evaluator
    • controls now support nested and / or / not composition with a hard API depth limit of 6
    • /api/v1 stays the same, but this is a major-version wire break for the canonical contract and old SDK majors are unsupported against the new server
    • added make server-migrate-control-conditions MIGRATE_ARGS=--dry-run and make server-migrate-control-conditions MIGRATE_ARGS=--apply
  • Internal changes:
    • recursive engine evaluation with short-circuiting and metadata.condition_trace
    • recursive server-side validation with nested field paths
    • Python SDK recursive leaf checks plus non-blocking server-version mismatch warnings
    • UI keeps the existing single-condition editor behavior for leaf conditions; composite trees are shown but preserved on save rather than fully edited in this PR
  • Rollout compatibility notes:
    • server/models still canonicalize legacy flat control payloads (selector + evaluator) into condition during rollout
    • strict runtime rejection of legacy flat payloads is deferred until after the migration window closes
  • Out of scope:
    • full condition-tree editing in the current UI modal
    • runtime strict rejection of legacy flat control payloads
    • drag-and-drop tree editing

Risk and Rollout

  • Risk level: high
  • Rollback plan:
    • take a database backup before running the migration apply step
    • if rollout fails before migration, revert to the previous server/SDK major
    • if rollout fails after migration, restore the pre-migration database snapshot before rolling back the server major

Testing

  • Added or updated automated tests
  • Ran make check
  • Manually verified behavior

make check passed, which covered:

  • make server-test
  • make engine-test
  • make sdk-test
  • make evaluators-test
  • repo linting
  • repo typechecking

Notes:

  • the run emitted a few existing third-party warnings (sqlglot[rs] deprecation, Strands resource/deprecation warnings), but the full target passed cleanly

Checklist

  • Linked issue/spec: sc-57582, sc-58429
  • Updated docs/examples for user-facing changes
  • Included any required follow-up tasks

lan17 added 2 commits March 11, 2026 22:03
…9-condition-trees

# Conflicts:
#	ui/src/core/page-components/agent-detail/modals/edit-control/edit-control-content.tsx
@lan17 lan17 changed the title feat!: implement recursive control condition trees feat(server)!: implement recursive control condition trees Mar 12, 2026
@lan17 lan17 marked this pull request as ready for review March 12, 2026 21:16
@namrataghadi-galileo
Copy link
Contributor

flowchart TD
  C["condition (root)"] --> A["and"]
  C --> O["or"]
  C --> N["not"]

  A --> L1["leaf: selector + evaluator"]
  A --> L2["leaf: selector + evaluator"]

  O --> L3["leaf: selector + evaluator"]
  O --> A2["and"]
  A2 --> L4["leaf: selector + evaluator"]
  A2 --> L5["leaf: selector + evaluator"]

  N --> L6["leaf: selector + evaluator"]

Loading

conditionEditingMessage: string | null;
};

function getLeafConditionDetails(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Problem: This logic treats a condition as editable only when it is a top-level leaf (selector + evaluator), so any composite tree is considered unsupported by the editor.
Proposed change: Replace leaf-only detection with recursive tree-aware state handling (ConditionNode), so composite nodes are editable, not downgraded to read-only behavior.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

import type { ConditionNode, ControlDefinition } from '@/core/api/types';
import type { AnyEvaluatorDefinition } from '@/core/evaluators';
import { getEvaluator } from '@/core/evaluators';

export type NodeKind = 'leaf' | 'and' | 'or' | 'not';

export type LeafConditionDetails = {
  selectorPath: string;
  evaluatorName: string;
  evaluatorConfig: Record<string, unknown>;
};

export type ControlConditionState = {
  leafCondition: LeafConditionDetails | null;
  evaluatorId: string;
  evaluator: AnyEvaluatorDefinition | undefined;
  canEditLeafCondition: boolean;
  conditionEditingMessage: string | null;
};

export function getNodeKind(node: ConditionNode): NodeKind {
  if (node.selector && node.evaluator) return 'leaf';
  if (node.and) return 'and';
  if (node.or) return 'or';
  return 'not';
}

export function isLeafNode(node: ConditionNode): boolean {
  return getNodeKind(node) === 'leaf';
}

export function getChildren(node: ConditionNode): ConditionNode[] {
  if (node.and) return node.and;
  if (node.or) return node.or;
  if (node.not) return [node.not];
  return [];
}

function findFirstLeaf(node: ConditionNode): ConditionNode | null {
  if (isLeafNode(node)) return node;
  for (const child of getChildren(node)) {
    const found = findFirstLeaf(child);
    if (found) return found;
  }
  return null;
}

/**
 * Recursive/tree-aware replacement for old top-level leaf detection.
 * Old: only editable if condition.selector + condition.evaluator existed at root.
 * New: supports composite trees by finding leaf context recursively.
 */
function getLeafConditionDetails(condition: ConditionNode): LeafConditionDetails | null {
  const leaf = findFirstLeaf(condition);
  if (!leaf?.selector || !leaf.evaluator) return null;

  return {
    selectorPath: leaf.selector.path ?? '*',
    evaluatorName: leaf.evaluator.name,
    evaluatorConfig: leaf.evaluator.config,
  };
}

export function getControlConditionState(
  definition: ControlDefinition,
  conditionOverride?: ConditionNode
): ControlConditionState {
  const condition = conditionOverride ?? definition.condition;
  const leafCondition = getLeafConditionDetails(condition);
  const evaluatorId = leafCondition?.evaluatorName ?? '';
  const evaluator = getEvaluator(evaluatorId);

  return {
    leafCondition,
    evaluatorId,
    evaluator,
    canEditLeafCondition: Boolean(leafCondition && evaluator),
    conditionEditingMessage: null,
  };
}

This removes top-level leaf-only gating and makes condition parsing tree-aware, which is required to enable composite condition editing.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intentional for this PR. The UI work here is limited to keeping the existing single-condition/leaf editor working against the new condition contract. Composite trees are preserved on save, but not editable in the modal in this PR. I just updated the PR description to call that out explicitly.

selectorPath: string,
finalConfig: Record<string, unknown>
): ControlDefinition['condition'] {
if (!leafCondition) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Problem: When no leaf is detected, save behavior just returns the original definition.condition, which preserves composite trees but prevents any UI-driven updates to them.
Proposed change: Use editable tree state as the source of truth for condition and apply explicit tree actions (add/remove/convert/reorder). Keep the existing evaluator form only as a specialized editor when the current node is a leaf.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

import type { ConditionNode, ControlDefinition } from '@/core/api/types';
import type { AnyEvaluatorDefinition } from '@/core/evaluators';
import { getEvaluator } from '@/core/evaluators';

export type NodeKind = 'leaf' | 'and' | 'or' | 'not';
export type NodePath = number[];

export type LeafConditionDetails = {
  selectorPath: string;
  evaluatorName: string;
  evaluatorConfig: Record<string, unknown>;
  path: NodePath;
};

export type ControlConditionState = {
  leafCondition: LeafConditionDetails | null;
  evaluatorId: string;
  evaluator: AnyEvaluatorDefinition | undefined;
  canEditLeafCondition: boolean;
  conditionEditingMessage: string | null;
};

const DEFAULT_LEAF: ConditionNode = {
  selector: { path: '*' },
  evaluator: { name: 'regex', config: { pattern: '.+' } },
};

export function getNodeKind(node: ConditionNode): NodeKind {
  if (node.selector && node.evaluator) return 'leaf';
  if (node.and) return 'and';
  if (node.or) return 'or';
  return 'not';
}

export function isLeafNode(node: ConditionNode): boolean {
  return getNodeKind(node) === 'leaf';
}

export function getChildren(node: ConditionNode): ConditionNode[] {
  if (node.and) return node.and;
  if (node.or) return node.or;
  if (node.not) return [node.not];
  return [];
}

function withChildren(node: ConditionNode, children: ConditionNode[]): ConditionNode {
  const kind = getNodeKind(node);
  if (kind === 'and') return { and: children };
  if (kind === 'or') return { or: children };
  if (kind === 'not') return { not: children[0] ?? DEFAULT_LEAF };
  return node;
}

export function getNodeAtPath(root: ConditionNode, path: NodePath): ConditionNode {
  let current = root;
  for (const idx of path) {
    current = getChildren(current)[idx];
  }
  return current;
}

export function updateNodeAtPath(
  root: ConditionNode,
  path: NodePath,
  updater: (node: ConditionNode) => ConditionNode
): ConditionNode {
  if (path.length === 0) return updater(root);
  const [head, ...rest] = path;
  const children = getChildren(root);
  return withChildren(
    root,
    children.map((child, i) =>
      i === head ? updateNodeAtPath(child, rest, updater) : child
    )
  );
}

function findFirstLeaf(
  node: ConditionNode,
  path: NodePath = []
): { node: ConditionNode; path: NodePath } | null {
  if (isLeafNode(node)) return { node, path };
  const children = getChildren(node);
  for (let i = 0; i < children.length; i += 1) {
    const found = findFirstLeaf(children[i], [...path, i]);
    if (found) return found;
  }
  return null;
}

export function addChild(root: ConditionNode, path: NodePath, index?: number): ConditionNode {
  return updateNodeAtPath(root, path, (node) => {
    const kind = getNodeKind(node);
    if (kind === 'leaf') return node;
    if (kind === 'not') return { not: DEFAULT_LEAF };
    const children = [...getChildren(node)];
    const insertAt = typeof index === 'number' ? index : children.length;
    children.splice(insertAt, 0, DEFAULT_LEAF);
    return withChildren(node, children);
  });
}

export function removeNode(root: ConditionNode, path: NodePath): ConditionNode {
  if (path.length === 0) return DEFAULT_LEAF;
  const parentPath = path.slice(0, -1);
  const index = path[path.length - 1];

  return updateNodeAtPath(root, parentPath, (parent) => {
    const kind = getNodeKind(parent);
    if (kind === 'not') return DEFAULT_LEAF;
    const next = getChildren(parent).filter((_, i) => i !== index);
    if (next.length === 0) return DEFAULT_LEAF;
    if (next.length === 1 && (kind === 'and' || kind === 'or')) return next[0];
    return withChildren(parent, next);
  });
}

export function convertNodeType(node: ConditionNode, target: NodeKind): ConditionNode {
  const current = getNodeKind(node);
  if (current === target) return node;

  if (target === 'leaf') {
    const found = findFirstLeaf(node);
    return found?.node ?? DEFAULT_LEAF;
  }
  if (target === 'not') {
    const children = getChildren(node);
    return { not: children[0] ?? DEFAULT_LEAF };
  }
  if (target === 'and') {
    const children = getChildren(node);
    return { and: children.length > 0 ? children : [DEFAULT_LEAF] };
  }
  const children = getChildren(node);
  return { or: children.length > 0 ? children : [DEFAULT_LEAF] };
}

export function reorderChild(
  root: ConditionNode,
  parentPath: NodePath,
  from: number,
  to: number
): ConditionNode {
  return updateNodeAtPath(root, parentPath, (parent) => {
    const kind = getNodeKind(parent);
    if (kind !== 'and' && kind !== 'or') return parent;
    const children = [...getChildren(parent)];
    const [moved] = children.splice(from, 1);
    if (!moved) return parent;
    children.splice(to, 0, moved);
    return withChildren(parent, children);
  });
}

function getLeafConditionDetails(condition: ConditionNode): LeafConditionDetails | null {
  const found = findFirstLeaf(condition);
  if (!found?.node.selector || !found.node.evaluator) return null;

  return {
    selectorPath: found.node.selector.path ?? '*',
    evaluatorName: found.node.evaluator.name,
    evaluatorConfig: found.node.evaluator.config,
    path: found.path,
  };
}

export function getControlConditionState(
  definition: ControlDefinition,
  conditionOverride?: ConditionNode
): ControlConditionState {
  const condition = conditionOverride ?? definition.condition;
  const leafCondition = getLeafConditionDetails(condition);
  const evaluatorId = leafCondition?.evaluatorName ?? '';
  const evaluator = getEvaluator(evaluatorId);

  return {
    leafCondition,
    evaluatorId,
    evaluator,
    canEditLeafCondition: Boolean(leafCondition && evaluator),
    conditionEditingMessage: null,
  };
}

export function buildEditableCondition(
  conditionTree: ConditionNode,
  leafCondition: LeafConditionDetails | null,
  selectorPath: string,
  finalConfig: Record<string, unknown>
): ControlDefinition['condition'] {
  if (!leafCondition) return conditionTree;

  // Source of truth is the editable tree; only patch the selected leaf details.
  return updateNodeAtPath(conditionTree, leafCondition.path, (node) => ({
    ...node,
    selector: { path: selectorPath },
    evaluator: {
      name: leafCondition.evaluatorName,
      config: finalConfig,
    },
  }));
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed on what full support would require. We are intentionally not taking tree-state editing in this PR; the modal stays leaf-oriented and preserves composite conditions on save. I updated the PR description so that limitation is explicit.


export type ControlDefinitionFormProps = {
form: UseFormReturnType<ControlDefinitionFormValues>;
disableSelectorPath?: boolean;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is for line 537

Problem: The UI shows “Condition editing unavailable” for non-leaf conditions, blocking users from creating/updating composite logic in the modal.
Proposed change: Replace this alert-only path with a recursive ConditionTreeEditor component that supports full tree editing (and/or/not/leaf), while preserving current single-leaf UX via EvaluatorConfigSection for leaf nodes.

import { ConditionTreeEditor } from './condition-tree-editor';
import { isLeafNode } from './control-condition';

// ...

const [conditionTree, setConditionTree] =
  useState<ControlDefinition['condition']>(control.control.condition);

useEffect(() => {
  setConditionTree(control.control.condition);
}, [control.id, control.control.condition]);

// ...

<Grid.Col span={8}>
  {isLeafNode(conditionTree) && canEditLeafCondition ? (
    <EvaluatorConfigSection
      config={evaluatorConfig}
      evaluatorForm={evaluatorForm}
      formComponent={formComponent}
      height={EVALUATOR_CONFIG_HEIGHT}
      onConfigChange={syncJsonToForm}
      onValidateConfig={validateEvaluatorConfig}
    />
  ) : (
    <ConditionTreeEditor
      value={conditionTree}
      onChange={setConditionTree}
      onAddChild={(path) =>
        setConditionTree((prev) => addChild(prev, path))
      }
      onRemoveNode={(path) =>
        setConditionTree((prev) => removeNode(prev, path))
      }
      onConvertNode={(path, kind) =>
        setConditionTree((prev) =>
          updateNodeAtPath(prev, path, (node) => convertNodeType(node, kind))
        )
      }
      onReorderChild={(parentPath, from, to) =>
        setConditionTree((prev) =>
          reorderChild(prev, parentPath, from, to)
        )
      }
    />
  )}
</Grid.Col>

And in save path, conditionTree becomes the source of truth:

condition: buildEditableCondition(
  conditionTree,
  leafCondition,
  values.selector_path.trim(),
  finalConfig
),

So leaf controls still use EvaluatorConfigSection, while composite controls render full recursive tree editing.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same scope note here: we are not introducing a recursive ConditionTreeEditor in this PR. The goal for this UI pass is to keep the existing leaf editor working and make the composite-tree limitation explicit rather than pretending the modal can edit it.

_trace_warning_logged = False


def _primary_leaf_details(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Problem: _primary_leaf_details() returns (None, None) for composite conditions.
Proposed solution: replace with tree-wide identity helper matching server behavior.

def _control_observability_identity(
    control_def: ControlDefinition,
) -> tuple[str | None, str | None, int, list[str], list[str]]:
    all_evaluators: list[str] = []
    all_selector_paths: list[str] = []
    seen_evaluators: set[str] = set()
    seen_selectors: set[str] = set()
    leaf_count = 0

    for selector, evaluator in control_def.iter_condition_leaf_parts():
        leaf_count += 1
        selector_path = selector.path or "*"

        if evaluator.name not in seen_evaluators:
            seen_evaluators.add(evaluator.name)
            all_evaluators.append(evaluator.name)

        if selector_path not in seen_selectors:
            seen_selectors.add(selector_path)
            all_selector_paths.append(selector_path)

    primary_evaluator = all_evaluators[0] if all_evaluators else None
    primary_selector_path = all_selector_paths[0] if all_selector_paths else None
    return primary_selector_path, primary_evaluator, leaf_count, all_evaluators, all_selector_paths

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. This one is not an intentional scope cut like the UI tree editor. I updated the control observability identity to derive a deterministic representative from condition traversal order, so composite controls now populate top-level selector/evaluator dimensions instead of dropping them to null.

return
for match in matches:
ctrl = control_lookup.get(match.control_id)
selector_path, evaluator_name = (
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Problem: event emission only sets selector/evaluator for single-leaf controls.
Proposed solution: use helper output for all controls and enrich metadata.

selector_path, evaluator_name, leaf_count, all_evaluators, all_selector_paths = (
    _control_observability_identity(ctrl.control) if ctrl else (None, None, 0, [], [])
)

event_metadata = dict(match.result.metadata or {})
event_metadata.update(
    {
        "primary_evaluator": evaluator_name,
        "primary_selector_path": selector_path,
        "leaf_count": leaf_count,
        "all_evaluators": all_evaluators,
        "all_selector_paths": all_selector_paths,
    }
)

add_event(
    ControlExecutionEvent(
        ...
        evaluator_name=evaluator_name,
        selector_path=selector_path,
        metadata=event_metadata,
    )
)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. I updated event emission to keep the top-level selector/evaluator fields populated for composite controls and to enrich metadata with primary_*, leaf_count, all_evaluators, and all_selector_paths. I also added coverage for the composite case.

referencing_controls: list[tuple[str, str]] = [] # (control_name, evaluator)

for ctrl in controls:
evaluator_ref = ctrl.control.evaluator.name
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Problem: Appending per-leaf references creates duplicates for same control/evaluator pair.
Proposed Solution: Use a set for unique references while scanning leaves.

# before loop
referencing_control_set: set[tuple[str, str]] = set()

for ctrl in controls:
    for _, evaluator_spec in ctrl.control.iter_condition_leaf_parts():
        evaluator_ref = evaluator_spec.name
        if ":" not in evaluator_ref:
            continue
        ref_agent, ref_eval = evaluator_ref.split(":", 1)
        if ref_agent == agent.name and ref_eval in remove_evaluator_set:
            referencing_control_set.add((ctrl.name, ref_eval))

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. I changed the dependency scan to dedupe control/evaluator references before building the conflict response so composite controls with multiple matching leaves do not emit duplicate in_use errors.

# Check if this control references an evaluator we're removing
# AND it's scoped to this agent (by name match)
if ref_agent == agent.name and ref_eval in remove_evaluator_set:
referencing_controls.append((ctrl.name, ref_eval))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Problem: referencing_controls.append(...) duplicates entries.
Proposed Solution: Replace append with set insert and sort once before error response.

# remove append line and do:
referencing_control_set.add((ctrl.name, ref_eval))

# then before ConflictError:
referencing_controls = sorted(referencing_control_set, key=lambda x: (x[0], x[1]))

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in the same change: the conflict payload is now built from a sorted unique set of (control, evaluator) pairs for deterministic output without duplicates.

detail="Cannot remove evaluators: active controls reference them",
resource="Evaluator",
hint="Remove or update the controls that reference these evaluators first.",
errors=[
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Problem: Error list is built from duplicated tuples.
Proposed Solution: Build errors from sorted unique list.

errors=[
    ValidationErrorItem(
        resource="Control",
        field="evaluator.name",
        code="in_use",
        message=f"Control '{ctrl}' uses evaluator '{ev}'",
    )
    for ctrl, ev in referencing_controls  # already deduped + sorted
]

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also addressed in the same fix. The validation errors now come from the deduped, sorted control/evaluator list instead of the per-leaf append path.

tags: list[str] = Field(default_factory=list, description="Tags for categorization")

@classmethod
def canonicalize_payload(cls, data: Any) -> Any:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Problem: canonicalize_payload enables runtime legacy flat payload acceptance.
Proposed Solution (strict major wire break): Remove legacy canonicalization path and fail validation if selector/evaluator provided at top level.

@classmethod
def canonicalize_payload(cls, data: Any) -> Any:
    if not isinstance(data, dict):
        return data

    has_condition = "condition" in data
    has_selector = "selector" in data
    has_evaluator = "evaluator" in data

    if has_selector or has_evaluator:
        raise ValueError(
            "Legacy selector/evaluator fields are no longer supported. "
            "Use top-level 'condition' only."
        )
    if not has_condition:
        raise ValueError("Control definition must include top-level 'condition'.")
    return data

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We are intentionally keeping the canonicalization path during rollout. The current implementation and tests rely on that compatibility for legacy flat payloads, so I do not want to turn this PR into strict runtime rejection. I updated the PR description to make the rollout compatibility explicit.


@model_validator(mode="before")
@classmethod
def canonicalize_legacy_condition_shape(cls, data: Any) -> Any:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"""
    Compatibility mode: accepts legacy flat selector/evaluator payloads during rollout.
    Remove in next major once migration window closes.
    """

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And add matching note in PR scope/README/changelog:

Runtime compatibility note: server/models still canonicalize legacy flat control payloads
(selector + evaluator) into condition during rollout; strict rejection is deferred.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. I updated the wording in the PR description to explicitly call this compatibility mode out instead of implying strict runtime rejection.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. I updated the PR scope text to note that legacy flat payloads are still canonicalized during rollout and that strict rejection is deferred.

@namrataghadi-galileo
Copy link
Contributor

One suggestion from Observability perspective:

Current issue:
- primary_leaf() returns None for composite conditions, so evaluator_name and selector_path become null in observability events.

Suggested change:
- Don’t use primary_leaf() for observability identity.
- Derive a deterministic representative from all leaves (first leaf in traversal order).
- Keep full composite context in metadata.

Proposed behavior:
- evaluator_name = first leaf evaluator
- selector_path = first leaf selector path
- add metadata:
leaf_count
all_evaluators (ordered, deduped)
all_selector_paths (ordered, deduped)
optionally representative_* naming to avoid semantic confusion

Why:
- Prevents null observability dimensions for composite controls.
- Preserves backward compatibility for dashboards filtering by evaluator/selector.
- Still keeps full truth about all leaves in metadata.

@lan17
Copy link
Contributor Author

lan17 commented Mar 14, 2026

Following up on the broader observability note: I aligned server and SDK event emission so composite conditions use a deterministic representative leaf for top-level selector_path / evaluator_name, while preserving the full composite context in metadata (primary_*, leaf_count, ordered/deduped evaluator and selector lists).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants