@unified-product-graph/core 0.28.0 → 0.30.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.
package/dist/index.js CHANGED
@@ -106,7 +106,8 @@ var UPG_DOMAINS = [
106
106
  "roadmap_item",
107
107
  "roadmap_theme",
108
108
  "changelog",
109
- "planning_cycle"
109
+ "planning_cycle",
110
+ "configuration_axis"
110
111
  ]
111
112
  },
112
113
  {
@@ -646,6 +647,7 @@ var UPG_ENTITY_META = [
646
647
  // it, carrying its occupants and the rule that arbitrates between them.
647
648
  // Enters `proposed` per the maturity rubric (precedent: planning_cycle 0.20.0).
648
649
  { name: "surface", type_id: "ent_359", maturity: "proposed", since: "0.27.0" },
650
+ { name: "configuration_axis", type_id: "ent_360", maturity: "proposed", since: "0.30.0" },
649
651
  { name: "journey_phase", type_id: "ent_330", maturity: "proposed", since: "0.2.0" },
650
652
  { name: "journey_action", type_id: "ent_331", maturity: "proposed", since: "0.2.0" },
651
653
  { name: "design_decision", type_id: "ent_319", maturity: "deprecated", since: "0.2.0", deprecated_in: "0.2.0", replacement: "decision" },
@@ -1047,6 +1049,29 @@ var DEFER_EDGE_PROPERTY_SCHEMA = {
1047
1049
  description: 'Target period the parked work is deferred to. Freeform, mirroring strategic_theme.time_horizon. @example "Q4 2026", "FY27", "next release".'
1048
1050
  }
1049
1051
  };
1052
+ var CONFIGURATION_VARIANCE_EDGE_PROPERTY_SCHEMA = {
1053
+ present_under: {
1054
+ type: "string[]",
1055
+ description: 'Values of the axis under which this surface exists. Every entry must name a member of the axis\'s closed `values` list, and an empty list is a modelling error rather than a way to say "never" (a surface that exists under no configuration should be deleted, not declared). Omitting the edge entirely is how a surface says it is present in every configuration. @example ["legacy_nav"]'
1056
+ }
1057
+ };
1058
+ var CONFIGURATION_QUALIFIER_EDGE_PROPERTY_SCHEMA = {
1059
+ active_when: {
1060
+ type: "object",
1061
+ description: 'Configuration condition under which this relationship holds. The edge is present in a projection that sets `axis` to one of `values`, and absent otherwise. Omit the key entirely for an invariant relationship: absence means "always", never "unknown".',
1062
+ properties: {
1063
+ axis: {
1064
+ type: "string",
1065
+ description: "Node id of the `configuration_axis` this condition reads."
1066
+ },
1067
+ values: {
1068
+ type: "string[]",
1069
+ description: "Values of that axis under which the relationship holds. Every entry must be a member of the axis's closed `values` list."
1070
+ }
1071
+ },
1072
+ required: ["axis", "values"]
1073
+ }
1074
+ };
1050
1075
  var UPG_EDGE_CATALOG = {
1051
1076
  // ── Part 1: Core Narrative Spine (Ring 1) ──────────────────────────────────
1052
1077
  // 1.1 User Domain
@@ -1573,7 +1598,7 @@ var UPG_EDGE_CATALOG = {
1573
1598
  // portfolio_shared, so the reading it names commonly lives in the
1574
1599
  // registry rather than in the product graph.
1575
1600
  // supersedes → semantic (prompt_version_supersedes_prompt_version).
1576
- surface_contains_surface: { forward_verb: "contains", reverse_verb: "belongs_to", classification: "hierarchy", source_type: "surface", target_type: "surface" },
1601
+ surface_contains_surface: { forward_verb: "contains", reverse_verb: "belongs_to", classification: "hierarchy", source_type: "surface", target_type: "surface", carries_properties: true, property_schema: CONFIGURATION_QUALIFIER_EDGE_PROPERTY_SCHEMA },
1577
1602
  surface_serves_job: { forward_verb: "serves", reverse_verb: "served_by", classification: "cross-domain", source_type: "surface", target_type: "job" },
1578
1603
  surface_governed_by_design_guideline: { forward_verb: "governed_by", reverse_verb: "governs", classification: "cross-domain", source_type: "surface", target_type: "design_guideline", cross_product_eligible: true },
1579
1604
  surface_renders_design_component: { forward_verb: "renders", reverse_verb: "rendered_on", classification: "hierarchy", source_type: "surface", target_type: "design_component", cross_product_eligible: true },
@@ -1608,11 +1633,53 @@ var UPG_EDGE_CATALOG = {
1608
1633
  // not cross_product_eligible → a deviation is a fact about one product's
1609
1634
  // code, and `surface` is not portfolio_shared in the first place.
1610
1635
  surface_deviates_via_technical_debt_item: { forward_verb: "deviates_via", reverse_verb: "causes_deviation_in", classification: "cross-domain", source_type: "surface", target_type: "technical_debt_item" },
1636
+ // ── surface, 0.30.0: composition that varies by configuration ───────────────
1637
+ // A product's surface tree is not one tree; it is a family of trees selected
1638
+ // by a configuration lever. The stored graph is the UNION of that family and
1639
+ // a single configuration is a PROJECTION of it, so these two edges are how a
1640
+ // graph says which member a fact belongs to. Silence still means "every
1641
+ // member", which is why a graph written before 0.30.0 needs no migration.
1642
+ //
1643
+ // varies_by — CONDITIONAL EXISTENCE. The surface exists only under the
1644
+ // values in `present_under`. An edge qualifier cannot say
1645
+ // this: the reported case was a set of named chips REPLACED
1646
+ // by one generic badge, where which surface EXISTS changes,
1647
+ // and the two carry different capacities and occupants.
1648
+ // alternates — "one of these, depending" as distinct from "both,
1649
+ // together". Semantic, matching its sibling
1650
+ // `surface_supersedes_surface`: neither surface owns the
1651
+ // other, and a hierarchy classification would demand a
1652
+ // UPG_VALID_CHILDREN pair that would be false. Explicit and
1653
+ // validator-checked (same axis, disjoint present_under);
1654
+ // never auto-derived, because disjointness is necessary for
1655
+ // alternation and nowhere near sufficient.
1656
+ //
1657
+ // Direction on `alternates_with` is a CONVENTION, not an enforcement: declare
1658
+ // it once, sourced from the surface present under the axis's `default_value`.
1659
+ // An axis with no default is legal modelling, so a check keyed on the
1660
+ // convention would fire on a correct graph, which is the one thing a check
1661
+ // must never do.
1662
+ // ── configuration_axis, 0.30.0: the lever itself ───────────────────────────
1663
+ // Three layers, three owners, and keeping them apart is what makes the model
1664
+ // honest: `engineering` owns the MECHANISM (`feature_flag`, with its key and
1665
+ // rollout percentage), `product_spec` owns the LEVER (the named dimension
1666
+ // along which what-you-ship differs), `ux_design` owns the PLACE (`surface`).
1667
+ // Two code flags that move together are ONE lever with two values.
1668
+ //
1669
+ // `defines` is hierarchy so the axis has a home in `get_tree`. It earns the
1670
+ // UPG_VALID_CHILDREN.product pair (guardrail G2b) on an honest verb: an axis
1671
+ // exists only within the product whose composition it varies. The rejected
1672
+ // alternative mirrors `classification_axis_owned_by_product`, a cross-domain
1673
+ // ownership edge that leaves the node unparented and invisible in every tree.
1674
+ product_defines_configuration_axis: { forward_verb: "defines", reverse_verb: "defined_by", classification: "hierarchy", source_type: "product", target_type: "configuration_axis" },
1675
+ feature_flag_drives_configuration_axis: { forward_verb: "drives", reverse_verb: "driven_by", classification: "cross-domain", source_type: "feature_flag", target_type: "configuration_axis" },
1676
+ surface_varies_by_configuration_axis: { forward_verb: "varies_by", reverse_verb: "varies", classification: "cross-domain", source_type: "surface", target_type: "configuration_axis", carries_properties: true, property_schema: CONFIGURATION_VARIANCE_EDGE_PROPERTY_SCHEMA },
1677
+ surface_alternates_with_surface: { forward_verb: "alternates_with", reverse_verb: "alternates_with", classification: "semantic", source_type: "surface", target_type: "surface" },
1611
1678
  // Inbound. `feature_occupies_surface` is the guest list: the edge that makes
1612
1679
  // "what else lives here?" answerable, and the one the contention anti-pattern
1613
1680
  // counts. cross-domain (product_spec → ux_design), like screen_surfaces_feature
1614
1681
  // running the other way.
1615
- feature_occupies_surface: { forward_verb: "occupies", reverse_verb: "occupied_by", classification: "cross-domain", source_type: "feature", target_type: "surface" },
1682
+ feature_occupies_surface: { forward_verb: "occupies", reverse_verb: "occupied_by", classification: "cross-domain", source_type: "feature", target_type: "surface", carries_properties: true, property_schema: CONFIGURATION_QUALIFIER_EDGE_PROPERTY_SCHEMA },
1616
1683
  screen_renders_surface: { forward_verb: "renders", reverse_verb: "rendered_by", classification: "hierarchy", source_type: "screen", target_type: "surface" },
1617
1684
  decision_affects_surface: { forward_verb: "affects", reverse_verb: "affected_by", classification: "cross-domain", source_type: "decision", target_type: "surface" },
1618
1685
  // The finer-grained sibling of journey_step_shown_on_screen: a step happens on
@@ -3678,6 +3745,10 @@ var UPG_VALID_CHILDREN = {
3678
3745
  "roadmap",
3679
3746
  "roadmap_theme",
3680
3747
  "planning_cycle",
3748
+ // Configuration (0.30.0): the levers whose values select which surface tree
3749
+ // the product renders. Few per product, and top-level because an axis is a
3750
+ // product-wide fact rather than a child of any one feature or surface.
3751
+ "configuration_axis",
3681
3752
  // Engineering
3682
3753
  "bounded_context",
3683
3754
  "code_repository",
@@ -4301,6 +4372,289 @@ function isContainmentFreeType(entityType) {
4301
4372
  return UPG_CONTAINMENT_FREE_TYPES.has(entityType);
4302
4373
  }
4303
4374
 
4375
+ // src/grammar/projection.ts
4376
+ var VARIES_BY_EDGE = "surface_varies_by_configuration_axis";
4377
+ var PRESENT_UNDER_PROPERTY = "present_under";
4378
+ var ACTIVE_WHEN_PROPERTY = "active_when";
4379
+ var QUALIFIABLE_EDGE_TYPES = [
4380
+ "surface_contains_surface",
4381
+ "feature_occupies_surface"
4382
+ ];
4383
+ function readStringArray(properties, key) {
4384
+ const raw = properties?.[key];
4385
+ if (!Array.isArray(raw)) return void 0;
4386
+ return raw.filter((v) => typeof v === "string");
4387
+ }
4388
+ function readActiveWhen(edge) {
4389
+ const raw = edge.properties?.[ACTIVE_WHEN_PROPERTY];
4390
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
4391
+ const axis = raw.axis;
4392
+ const values = readStringArray(raw, "values");
4393
+ if (typeof axis !== "string" || axis.length === 0 || !values || values.length === 0) {
4394
+ return void 0;
4395
+ }
4396
+ return { axis, values };
4397
+ }
4398
+ function projectGraph(nodes, edges, configuration) {
4399
+ const axes = Object.keys(configuration);
4400
+ if (axes.length === 0) {
4401
+ return {
4402
+ nodes: [...nodes],
4403
+ edges: [...edges],
4404
+ excluded_node_ids: [],
4405
+ deactivated_edge_count: 0,
4406
+ dangling_edge_count: 0
4407
+ };
4408
+ }
4409
+ const excluded = /* @__PURE__ */ new Set();
4410
+ for (const edge of edges) {
4411
+ if (edge.type !== VARIES_BY_EDGE) continue;
4412
+ const chosen = configuration[edge.target];
4413
+ if (chosen === void 0) continue;
4414
+ const presentUnder = readStringArray(edge.properties, PRESENT_UNDER_PROPERTY);
4415
+ if (!presentUnder) continue;
4416
+ if (!presentUnder.includes(chosen)) excluded.add(edge.source);
4417
+ }
4418
+ const survivingNodes = excluded.size === 0 ? [...nodes] : nodes.filter((n) => !excluded.has(n.id));
4419
+ let deactivated = 0;
4420
+ let dangling = 0;
4421
+ const survivingEdges = [];
4422
+ for (const edge of edges) {
4423
+ const qualifier = readActiveWhen(edge);
4424
+ if (qualifier) {
4425
+ const chosen = configuration[qualifier.axis];
4426
+ if (chosen !== void 0 && !qualifier.values.includes(chosen)) {
4427
+ deactivated++;
4428
+ continue;
4429
+ }
4430
+ }
4431
+ if (excluded.has(edge.source) || excluded.has(edge.target)) {
4432
+ dangling++;
4433
+ continue;
4434
+ }
4435
+ survivingEdges.push(edge);
4436
+ }
4437
+ return {
4438
+ nodes: survivingNodes,
4439
+ edges: survivingEdges,
4440
+ excluded_node_ids: [...excluded].sort(),
4441
+ deactivated_edge_count: deactivated,
4442
+ dangling_edge_count: dangling
4443
+ };
4444
+ }
4445
+ function enumerateProjections(nodes) {
4446
+ const out = [
4447
+ { configuration: {} }
4448
+ ];
4449
+ for (const node of nodes) {
4450
+ if (node.type !== "configuration_axis") continue;
4451
+ const values = readStringArray(node.properties, "values");
4452
+ if (!values) continue;
4453
+ for (const value of values) {
4454
+ out.push({ axis: node.id, value, configuration: { [node.id]: value } });
4455
+ }
4456
+ }
4457
+ return out;
4458
+ }
4459
+
4460
+ // src/grammar/configuration-drift.ts
4461
+ var ALTERNATES_EDGE = "surface_alternates_with_surface";
4462
+ var PARENT_EDGE_TYPES = /* @__PURE__ */ new Set(["surface_contains_surface", "screen_renders_surface"]);
4463
+ function edgeLabel(edge) {
4464
+ return edge.id ?? `${edge.source} -> ${edge.target}`;
4465
+ }
4466
+ function checkConfigurationDrift(nodes, edges) {
4467
+ const findings = [];
4468
+ const axisValues = /* @__PURE__ */ new Map();
4469
+ for (const node of nodes) {
4470
+ if (node.type !== "configuration_axis") continue;
4471
+ const values = readStringArray(node.properties, "values");
4472
+ if (!values || values.length === 0) {
4473
+ findings.push({
4474
+ kind: "axis_values_empty",
4475
+ severity: "error",
4476
+ node_id: node.id,
4477
+ axis_id: node.id,
4478
+ message: "Configuration axis declares no values. An axis with no values selects nothing, so no projection can be taken along it."
4479
+ });
4480
+ axisValues.set(node.id, []);
4481
+ continue;
4482
+ }
4483
+ axisValues.set(node.id, values);
4484
+ const rawDefault = node.properties?.default_value;
4485
+ if (typeof rawDefault === "string" && rawDefault.length > 0 && !values.includes(rawDefault)) {
4486
+ findings.push({
4487
+ kind: "axis_default_not_a_value",
4488
+ severity: "error",
4489
+ node_id: node.id,
4490
+ axis_id: node.id,
4491
+ message: `default_value "${rawDefault}" is not one of the axis values [${values.join(", ")}]. It names a value of this axis, so it has to be one the axis declares.`
4492
+ });
4493
+ }
4494
+ }
4495
+ for (const edge of edges) {
4496
+ if (edge.type !== VARIES_BY_EDGE) continue;
4497
+ const values = axisValues.get(edge.target);
4498
+ const presentUnder = readStringArray(edge.properties, PRESENT_UNDER_PROPERTY);
4499
+ if (!presentUnder || presentUnder.length === 0) {
4500
+ findings.push({
4501
+ kind: "present_under_empty",
4502
+ severity: "error",
4503
+ edge_id: edge.id,
4504
+ node_id: edge.source,
4505
+ axis_id: edge.target,
4506
+ message: `${edgeLabel(edge)} declares variance without a non-empty present_under. A surface that exists under no configuration should be deleted rather than declared; a surface that exists under all of them should carry no varies_by edge at all.`
4507
+ });
4508
+ continue;
4509
+ }
4510
+ if (!values) continue;
4511
+ for (const value of presentUnder) {
4512
+ if (!values.includes(value)) {
4513
+ findings.push({
4514
+ kind: "present_under_unknown_value",
4515
+ severity: "error",
4516
+ edge_id: edge.id,
4517
+ node_id: edge.source,
4518
+ axis_id: edge.target,
4519
+ message: `present_under names "${value}", which the axis does not declare. Its values are [${values.join(", ")}].`
4520
+ });
4521
+ }
4522
+ }
4523
+ }
4524
+ for (const edge of edges) {
4525
+ const hasQualifierKey = edge.properties !== void 0 && Object.prototype.hasOwnProperty.call(edge.properties, ACTIVE_WHEN_PROPERTY);
4526
+ if (!hasQualifierKey) continue;
4527
+ if (edge.type !== void 0 && !QUALIFIABLE_EDGE_TYPES.includes(edge.type)) {
4528
+ findings.push({
4529
+ kind: "qualifier_on_illegal_edge",
4530
+ severity: "error",
4531
+ edge_id: edge.id,
4532
+ message: `active_when is not legal on ${edge.type}. The qualifier is scoped to [${QUALIFIABLE_EDGE_TYPES.join(", ")}] deliberately: conditional composition is the question this release answers, and a general modality system is not.`
4533
+ });
4534
+ continue;
4535
+ }
4536
+ const qualifier = readActiveWhen(edge);
4537
+ if (!qualifier) {
4538
+ const rawQualifier = edge.properties?.[ACTIVE_WHEN_PROPERTY];
4539
+ const rawValues = rawQualifier && typeof rawQualifier === "object" && !Array.isArray(rawQualifier) ? rawQualifier.values : void 0;
4540
+ if (Array.isArray(rawValues) && rawValues.length === 0) {
4541
+ findings.push({
4542
+ kind: "qualifier_values_empty",
4543
+ severity: "error",
4544
+ edge_id: edge.id,
4545
+ message: `${edgeLabel(edge)} carries an active_when with an empty values list. Read literally that would remove the relationship from every configuration, which is a way to delete an edge by writing a property; it is treated as absent instead. To say a relationship never holds, delete the edge.`
4546
+ });
4547
+ continue;
4548
+ }
4549
+ findings.push({
4550
+ kind: "qualifier_axis_unresolved",
4551
+ severity: "error",
4552
+ edge_id: edge.id,
4553
+ message: `${edgeLabel(edge)} carries a malformed active_when. It must be an object with an axis id and a non-empty values array; anything else reads as absent, which silently makes the relationship invariant.`
4554
+ });
4555
+ continue;
4556
+ }
4557
+ const values = axisValues.get(qualifier.axis);
4558
+ if (!values) {
4559
+ findings.push({
4560
+ kind: "qualifier_axis_unresolved",
4561
+ severity: "error",
4562
+ edge_id: edge.id,
4563
+ axis_id: qualifier.axis,
4564
+ message: `active_when names axis "${qualifier.axis}", which is not a configuration_axis node in this graph.`
4565
+ });
4566
+ continue;
4567
+ }
4568
+ for (const value of qualifier.values) {
4569
+ if (!values.includes(value)) {
4570
+ findings.push({
4571
+ kind: "qualifier_unknown_value",
4572
+ severity: "error",
4573
+ edge_id: edge.id,
4574
+ axis_id: qualifier.axis,
4575
+ message: `active_when names value "${value}", which axis "${qualifier.axis}" does not declare. Its values are [${values.join(", ")}].`
4576
+ });
4577
+ }
4578
+ }
4579
+ }
4580
+ const varianceBySource = /* @__PURE__ */ new Map();
4581
+ for (const edge of edges) {
4582
+ if (edge.type !== VARIES_BY_EDGE) continue;
4583
+ const values = readStringArray(edge.properties, PRESENT_UNDER_PROPERTY) ?? [];
4584
+ const list = varianceBySource.get(edge.source) ?? [];
4585
+ list.push({ axis: edge.target, values });
4586
+ varianceBySource.set(edge.source, list);
4587
+ }
4588
+ for (const edge of edges) {
4589
+ if (edge.type !== ALTERNATES_EDGE) continue;
4590
+ const left = varianceBySource.get(edge.source) ?? [];
4591
+ const right = varianceBySource.get(edge.target) ?? [];
4592
+ if (left.length === 0 || right.length === 0) {
4593
+ findings.push({
4594
+ kind: "alternation_axis_mismatch",
4595
+ severity: "error",
4596
+ edge_id: edge.id,
4597
+ message: `${edgeLabel(edge)} declares an alternation, but at least one endpoint declares no variance at all. Two surfaces that are both always present are not alternatives; they co-exist.`
4598
+ });
4599
+ continue;
4600
+ }
4601
+ const sharedAxes = left.filter((l) => right.some((r) => r.axis === l.axis)).map((l) => l.axis);
4602
+ if (sharedAxes.length === 0) {
4603
+ findings.push({
4604
+ kind: "alternation_axis_mismatch",
4605
+ severity: "error",
4606
+ edge_id: edge.id,
4607
+ message: `${edgeLabel(edge)} declares an alternation between surfaces that vary on different axes. Alternatives are selected by ONE lever; surfaces on different levers can both be present at once.`
4608
+ });
4609
+ continue;
4610
+ }
4611
+ for (const axis of sharedAxes) {
4612
+ const leftValues = left.find((l) => l.axis === axis)?.values ?? [];
4613
+ const rightValues = right.find((r) => r.axis === axis)?.values ?? [];
4614
+ const overlap = leftValues.filter((v) => rightValues.includes(v));
4615
+ if (overlap.length > 0) {
4616
+ findings.push({
4617
+ kind: "alternation_overlap",
4618
+ severity: "error",
4619
+ edge_id: edge.id,
4620
+ axis_id: axis,
4621
+ message: `${edgeLabel(edge)} declares an alternation, but both surfaces are present under [${overlap.join(", ")}] on axis "${axis}". They co-exist there, so the graph is asserting "one of these" and "both, together" about the same pair.`
4622
+ });
4623
+ }
4624
+ }
4625
+ }
4626
+ const parentsByChild = /* @__PURE__ */ new Map();
4627
+ for (const edge of edges) {
4628
+ if (edge.type && PARENT_EDGE_TYPES.has(edge.type)) {
4629
+ parentsByChild.set(edge.target, (parentsByChild.get(edge.target) ?? 0) + 1);
4630
+ }
4631
+ }
4632
+ for (const [axisId, values] of axisValues) {
4633
+ for (const value of values) {
4634
+ const projected = projectGraph(nodes, edges, { [axisId]: value });
4635
+ const survivingParents = /* @__PURE__ */ new Map();
4636
+ for (const edge of projected.edges) {
4637
+ if (edge.type && PARENT_EDGE_TYPES.has(edge.type)) {
4638
+ survivingParents.set(edge.target, (survivingParents.get(edge.target) ?? 0) + 1);
4639
+ }
4640
+ }
4641
+ for (const node of projected.nodes) {
4642
+ if (node.type !== "surface") continue;
4643
+ if ((parentsByChild.get(node.id) ?? 0) === 0) continue;
4644
+ if ((survivingParents.get(node.id) ?? 0) > 0) continue;
4645
+ findings.push({
4646
+ kind: "orphaned_under_projection",
4647
+ severity: "warning",
4648
+ node_id: node.id,
4649
+ axis_id: axisId,
4650
+ message: `Surface is present under ${axisId} = "${value}" but every containment parent it has is absent there. Either it needs a parent in that configuration, or it should declare that it is absent from it too.`
4651
+ });
4652
+ }
4653
+ }
4654
+ }
4655
+ return findings;
4656
+ }
4657
+
4304
4658
  // src/grammar/lifecycles.ts
4305
4659
  var PRODUCT_LIFECYCLE = {
4306
4660
  entity_type: "product",
@@ -6551,6 +6905,10 @@ var UPG_LIFECYCLE_FREE_TYPES = /* @__PURE__ */ new Set([
6551
6905
  "market_segment",
6552
6906
  "classification_axis",
6553
6907
  "classification_value",
6908
+ // ── Product Specification (1): a configuration_axis is a structural
6909
+ // DECLARATION (a lever and its closed value set), not a thing that
6910
+ // progresses through states. Same reading as classification_axis above.
6911
+ "configuration_axis",
6554
6912
  // ── User Research (5 of 8): participants, quotes, observations,
6555
6913
  // clusters, survey responses are immutable data points ────────────────
6556
6914
  "participant",
@@ -11060,6 +11418,12 @@ var UPG_PROPERTY_SCHEMA = {
11060
11418
  regulation: { type: "string", enum: ["gdpr", "ccpa", "hipaa", "soc2", "iso27001", "pci_dss", "other"], description: "Regulation or standard this requirement derives from" },
11061
11419
  compliance_status: { type: "string", enum: ["compliant", "non_compliant", "in_progress", "not_applicable"], description: "Current compliance posture" }
11062
11420
  },
11421
+ // ConfigurationAxisProperties: A named dimension along which the product's composition differs.
11422
+ configuration_axis: {
11423
+ values: { type: "string[]", description: "The closed set of values this axis can take. Required: an axis with no values selects nothing and cannot be projected along. Two values is the common case and three is not unusual (a plan ladder). Order is not significant; the axis is categorical, not ordinal. Where the ordering does matter (an entitlement ladder in which each tier includes the one below), that is a classification question, and `classification_axis` with `axis_kind: 'ordinal'` is the instrument for it." },
11424
+ default_value: { type: "string", description: "The value this axis is understood to sit at when nobody says otherwise. Must be a member of `values`. NOTHING APPLIES IT AUTOMATICALLY. Omitting `configuration` on a read returns the UNION, not this projection, because the union is the honest answer to an unqualified question: it is every configuration at once, and silently substituting one of them would hide the others from a reader who did not know to ask. What the field does carry is the declaration convention for `surface_alternates_with_surface` (declare the edge from the surface present under the default) and a documented anchor for tools that later want to offer a starting configuration. It is a claim about the model, not about deployment: it says which value most of the graph was written against, not which configuration most customers are on." },
11425
+ kind: { type: "string", enum: ["feature_flag", "plan_tier", "permission_level", "beta_program", "other"], description: "What kind of lever this is. Names the mechanism family." }
11426
+ },
11063
11427
  // ConstraintProperties: Constraint: a named limitation or boundary on product creation.
11064
11428
  constraint: {
11065
11429
  constraint_kind: { type: "string", enum: ["resource", "technical", "regulatory", "temporal", "compliance", "other"], description: "Limitation category" },
@@ -12679,7 +13043,8 @@ var UPG_PROPERTY_SCHEMA = {
12679
13043
  health_status: { type: "string", enum: ["on_track", "at_risk", "off_track"], description: "Overall health" },
12680
13044
  url: { type: "string", description: "Where the product lives. Marketing site, app store URL, etc.", modifier: "volatile" },
12681
13045
  logo_url: { type: "string", description: "Logo or icon URL. Used to render product cards and lists.", modifier: "volatile" },
12682
- launched_at: { type: "string", description: "When the product became generally available (ISO 8601)" }
13046
+ launched_at: { type: "string", description: "When the product became generally available (ISO 8601)" },
13047
+ described_configuration: { type: "string", description: `The product configuration this graph describes, named in plain language. A product's composition often differs by feature flag, plan tier, permission level or beta programme: surfaces appear, disappear, or are replaced by different surfaces with different occupants. A graph that models one of those without saying which is qualified by a condition nobody wrote down, and every fact in it inherits that silence. NAMING, NOT CONFIGURING. This is a label for readers. Nothing reads it to gate, filter, or alter how a tool interprets the graph, and it makes no claim about which configuration most customers are on. @example "Enterprise plan, split-navigation flag on" @example "Default: free tier, all flags off"` }
12683
13048
  },
12684
13049
  // ProductAreaProperties: ProductArea entity.
12685
13050
  product_area: {
@@ -13207,7 +13572,7 @@ var UPG_PROPERTY_SCHEMA = {
13207
13572
  surface_kind: { type: "string", enum: ["shell", "tool", "pane", "region", "slot", "gutter", "action_bar", "overlay", "ambient"], description: "Structural kind. Determines what may legally nest inside this surface." },
13208
13573
  persistence: { type: "string", enum: ["always", "conditional", "on_demand", "transient"], description: "How reliably the surface is present." },
13209
13574
  visibility_condition: { type: "string", description: 'When the surface appears, in plain language. Pairs with `persistence: \'conditional\'`, which states *that* it is conditional; this states *what* the condition is. @example "A node is selected", "Only for workspace admins"' },
13210
- capacity: { type: "number", description: 'How many occupants ONE INSTANCE of the surface holds at once, as a non-negative count. Count the instances themselves with `cardinality`. CAPACITY IS ALWAYS INTENT, NEVER OBSERVED BEHAVIOUR. It records the cap the design asserts, so a banner region declared `capacity: 1` keeps saying 1 even after someone finds it rendering four. Reality that has drifted from the declared intent is recorded as a trackable, assignable debt item on `surface_deviates_via_technical_debt_item`, not by quietly editing this number up to match the bug. Editing it to match would destroy the only record that a gap exists. ABSENT MEANS UNBOUNDED. UPG has no union-typed property primitive (`PropertyDefinition.type` is a single scalar kind), so the proposed `integer | unbounded` shape is expressed as an optional number whose absence carries the "no cap" reading, rather than a magic sentinel value that every consumer would have to special-case. `0` is a real cap meaning "nothing may occupy this surface" (a reserved place), not "unbounded".' },
13575
+ capacity: { type: "number", description: 'How many occupants ONE INSTANCE of the surface holds at once, as a non-negative count. Count the instances themselves with `cardinality`. CAPACITY IS ALWAYS INTENT, NEVER OBSERVED BEHAVIOUR. It records the cap the design asserts, so a banner region declared `capacity: 1` keeps saying 1 even after someone finds it rendering four. Reality that has drifted from the declared intent is recorded as a trackable, assignable debt item on `surface_deviates_via_technical_debt_item`, not by quietly editing this number up to match the bug. Editing it to match would destroy the only record that a gap exists. ABSENT MEANS UNBOUNDED. UPG has no union-typed property primitive (`PropertyDefinition.type` is a single scalar kind), so the proposed `integer | unbounded` shape is expressed as an optional number whose absence carries the "no cap" reading, rather than a magic sentinel value that every consumer would have to special-case. `0` is a real cap meaning "nothing may occupy this surface" (a reserved place), not "unbounded". THREE STATES, AND THEY ARE ALL DIFFERENT. Absent = unbounded, no cap stated. `0` = a reserved place nothing may occupy. `null` = neither of those, and nothing this field defines; it is what you get by writing an explicit null rather than removing the key, and no consumer reads it as a cap. To return this property to ABSENT, remove the key with `update_node`\'s or `batch_update_nodes`\' `unset_properties`, since a property merge preserves anything you omit. UNBOUNDED IS NOT AN EXEMPTION FROM SCRUTINY. The contention detector reads an absent capacity as a threshold of 1, on the reasoning that a surface which states no limit has stated no answer either, so several occupants is exactly the unrecorded decision worth naming. Declaring a real capacity is therefore the way to quiet the check honestly, and the only way that also records something true.' },
13211
13576
  cardinality: { type: "string", enum: ["1", "0..1", "1..n", "0..n"], description: "How many instances of this surface exist. `capacity` counts occupants within one instance; this counts the instances." },
13212
13577
  instance_scope: { type: "string", enum: ["global", "per_parent"], description: 'What an instance is scoped to: one shared instance for the product, or one per containing surface. Decides whether "the product has this surface" is a true sentence or a per-parent one.' },
13213
13578
  composition_mode: { type: "string", enum: ["exclusive", "additive", "chained"], description: "How the occupants relate: one wins (`exclusive`), all coexist (`additive`), or each wraps the next (`chained`). Declaring `chained` exempts the surface from `contended-surface-without-arbitration`; leaving this unset does not." },
@@ -19927,6 +20292,44 @@ function validateValue(key, def, val) {
19927
20292
  }
19928
20293
  break;
19929
20294
  }
20295
+ case "string[]": {
20296
+ if (!Array.isArray(val)) {
20297
+ errors.push(`property "${key}" must be an array of strings`);
20298
+ break;
20299
+ }
20300
+ if (val.some((v) => typeof v !== "string")) {
20301
+ errors.push(`property "${key}" must contain only strings`);
20302
+ }
20303
+ if (val.length === 0) {
20304
+ errors.push(`property "${key}" must not be empty`);
20305
+ }
20306
+ break;
20307
+ }
20308
+ case "object": {
20309
+ if (typeof val !== "object" || val === null || Array.isArray(val)) {
20310
+ errors.push(`property "${key}" must be an object`);
20311
+ break;
20312
+ }
20313
+ const obj = val;
20314
+ for (const req2 of def.required ?? []) {
20315
+ if (obj[req2] === void 0 || obj[req2] === null) {
20316
+ errors.push(`property "${key}" is missing required key "${req2}"`);
20317
+ }
20318
+ }
20319
+ for (const [subKey, subDef] of Object.entries(def.properties ?? {})) {
20320
+ const subVal = obj[subKey];
20321
+ if (subVal === void 0 || subVal === null) continue;
20322
+ errors.push(...validateValue(`${key}.${subKey}`, subDef, subVal));
20323
+ }
20324
+ for (const presentKey of Object.keys(obj)) {
20325
+ if (def.properties && !(presentKey in def.properties)) {
20326
+ errors.push(
20327
+ `property "${key}" has unknown key "${presentKey}" (allowed: ${Object.keys(def.properties).join(", ")})`
20328
+ );
20329
+ }
20330
+ }
20331
+ break;
20332
+ }
19930
20333
  }
19931
20334
  return errors;
19932
20335
  }
@@ -20145,6 +20548,7 @@ var ENTITY_EMOJI = {
20145
20548
  pipeline_sales: "\u{1FA88}",
20146
20549
  pipeline_stage: "\u{1F9FD}",
20147
20550
  planning_cycle: "\u{1F5D3}\uFE0F",
20551
+ configuration_axis: "\u{1F39A}\uFE0F",
20148
20552
  playbook: "\u{1F4D2}",
20149
20553
  portfolio: "\u{1F9F8}",
20150
20554
  positioning: "\u{1F3AA}",
@@ -20839,6 +21243,7 @@ var STANDARD_LABELS = {
20839
21243
  roadmap_theme: { alt_labels: ["product theme", "roadmap theme"] },
20840
21244
  // UPG-660: renamed from bare 'theme' (N6 lineage)
20841
21245
  planning_cycle: { alt_labels: ["sprint", "iteration", "cycle", "program increment", "pi", "cadence", "time-box", "cooldown", "quarter"] },
21246
+ configuration_axis: { alt_labels: ["configuration", "config axis", "lever", "variant axis", "plan axis", "flag axis", "entitlement axis"] },
20842
21247
  // Engineering layer
20843
21248
  bounded_context: { alt_labels: ["context", "domain boundary", "module boundary"] },
20844
21249
  service: { alt_labels: ["microservice", "backend service", "api service"] },
@@ -23360,7 +23765,7 @@ var PRODUCT_SPEC_GUIDE = {
23360
23765
  // registered to product_spec but missing from the navigation order).
23361
23766
  // `changelog` lives here because it is a structural product-shipping
23362
23767
  // artefact; content domain references it only via cross-domain bridges.
23363
- creation_sequence: ["feature_area", "feature", "epic", "user_story", "acceptance_criterion", "task", "bug", "release", "roadmap", "roadmap_item", "roadmap_theme", "changelog", "planning_cycle"],
23768
+ creation_sequence: ["feature_area", "feature", "epic", "user_story", "acceptance_criterion", "task", "bug", "release", "roadmap", "roadmap_item", "roadmap_theme", "changelog", "planning_cycle", "configuration_axis"],
23364
23769
  patterns: [
23365
23770
  {
23366
23771
  name: "Feature Decomposition",
@@ -27049,7 +27454,7 @@ var UPG_ANTI_PATTERNS = [
27049
27454
  id: "contended-surface-without-arbitration",
27050
27455
  since: "0.27.0",
27051
27456
  name: "Contended surface without arbitration",
27052
- description: 'Features occupy surfaces in this graph, and at least one surface has no recorded answer to "who wins here, and why". Either it carries no arbitration_rule and has not declared itself `chained`, or it admits through arbitration_state that the answer is absent (`none`) or accidental (`safe_by_coincidence`). Left unrecorded, that answer lives in whoever remembers the last argument.',
27457
+ description: 'At least one surface in this graph has no recorded answer to "who wins here, and why". Either it holds more occupants than its stated `capacity` allows (or holds several while stating no limit at all) and carries no arbitration_rule, or it admits through arbitration_state that the answer is absent (`none`) or accidental (`safe_by_coincidence`). Left unrecorded, that answer lives in whoever remembers the last argument.',
27053
27458
  // 0.28.0 (feedback 852a9721, from a 30-surface field audit). Three changes,
27054
27459
  // each earned by a reported false positive or a reported blind spot:
27055
27460
  //
@@ -27087,6 +27492,68 @@ var UPG_ANTI_PATTERNS = [
27087
27492
  // attribution — making this family noisier on its first real deployment,
27088
27493
  // which is the exact failure (a) exists to fix. If field evidence later shows
27089
27494
  // it needs its own severity, mint it then.
27495
+ //
27496
+ // 0.29.0 (feedback af9ae4c2, the same reporter's measured follow-up: 43
27497
+ // surfaces, 10 flagged, 7 rightly and 3 wrongly). Branch (a) moved from an
27498
+ // aggregate presence count to the per-node `edge_count_vs_property` form.
27499
+ //
27500
+ // (d) CAPACITY IS NOW READ. The old branch was a pure edge count: any
27501
+ // surface with more than one occupant and no rule. That flags a header
27502
+ // row declaring room for four and holding exactly four, which is not
27503
+ // contended but PARTITIONED — everyone fits, by design, so nothing is
27504
+ // displaced and there is nothing to decide. All three of the reporter's
27505
+ // false positives were this, and all three had occupancy at or below a
27506
+ // stated capacity. The rule is now `occupancy > (capacity ?? 1)`.
27507
+ //
27508
+ // ABSENT CAPACITY BEHAVES AS 1, NOT AS INFINITY. Absence means
27509
+ // unbounded, and it is tempting to read unbounded as "never flag". The
27510
+ // opposite is right: a surface that states no limit has stated no
27511
+ // answer, so two or more occupants is precisely the unrecorded decision
27512
+ // this pattern names. That keeps all four of the reporter's unbounded
27513
+ // true positives (7, 7, 10 and 3 occupants) firing.
27514
+ //
27515
+ // The check had to become per-node rather than two ANDed aggregates:
27516
+ // "some surface is over capacity" and "some surface has no rule" are
27517
+ // both true when they are DIFFERENT surfaces, which would have kept
27518
+ // every false positive alive. See `EdgeCountVsPropertyCheck.node_filter`.
27519
+ //
27520
+ // ADJUDICATED: DOES A CAPACITY-SATISFIED `additive` SURFACE STILL NEED AN
27521
+ // ORDERING RULE? The reporter's capacity rule and their own earlier report
27522
+ // pull in opposite directions here: 0.28.0 documented that `additive`
27523
+ // occupants raise a question of ORDER rather than victory and that the order
27524
+ // belongs in `arbitration_rule`, while the capacity rule says a surface
27525
+ // where everyone fits has nothing to settle. Ruling: BOTH ARE RIGHT, because
27526
+ // they are about different failures, and this detector owns only one of them.
27527
+ //
27528
+ // - Contention is about DISPLACEMENT: who is not rendered. Capacity settles
27529
+ // it. If everyone fits, nobody is displaced, and "who wins" is genuinely
27530
+ // moot. That is why reading capacity removes real false positives rather
27531
+ // than merely quieting the check.
27532
+ // - Ordering is about ARRANGEMENT: in what sequence coexisting occupants
27533
+ // appear. Capacity says nothing about it. The reporter's own four-occupant
27534
+ // header row, positioned by runtime width measurement, has a live ordering
27535
+ // question and no displacement question at all.
27536
+ //
27537
+ // So this check reads capacity and stops at displacement. The ordering concern
27538
+ // stays documented on `composition_mode: 'additive'` as guidance, NOT as a
27539
+ // detector: minting `additive-surface-without-ordering` today would repeat the
27540
+ // 0.28.0 mistake of shipping a second detector on no field evidence, and it
27541
+ // would double-report against this one. Mint it when a graph shows unrecorded
27542
+ // ordering causing a real defect, with its own severity and remediation.
27543
+ //
27544
+ // Known consequence, recorded rather than papered over: `arbitration_rule` is
27545
+ // now visibly OVERLOADED. It holds a displacement rule for `exclusive`
27546
+ // surfaces and an ordering rule for `additive` ones, and this check reads it
27547
+ // for only the first. Splitting it (`arbitration_rule` vs `ordering_rule`) is
27548
+ // the natural move if the ordering detector is ever minted; it is not worth a
27549
+ // breaking property change before then.
27550
+ //
27551
+ // STILL OPEN (banked at 0.28.0, deliberately NOT changed here): a `chained`
27552
+ // slot that honestly declares `arbitration_state: 'none'` re-fires through
27553
+ // branch (c), which carries no chained exemption. Current guidance stands:
27554
+ // leave `arbitration_state` unset on chained surfaces. Changing (b)/(c) was
27555
+ // out of scope for this release and still awaits the field confirmation the
27556
+ // reporter will produce populating their 14 chained slots.
27090
27557
  structured_condition: {
27091
27558
  operator: "and",
27092
27559
  checks: [
@@ -27105,14 +27572,16 @@ var UPG_ANTI_PATTERNS = [
27105
27572
  checks: [
27106
27573
  {
27107
27574
  check: {
27108
- type: "entity_count",
27575
+ type: "edge_count_vs_property",
27109
27576
  entity_type: "surface",
27110
- filter: {
27111
- property: "arbitration_rule",
27112
- present: false,
27113
- except_property: "composition_mode",
27114
- except_value: "chained"
27115
- },
27577
+ edge_type: "feature_occupies_surface",
27578
+ direction: "inbound",
27579
+ property: "capacity",
27580
+ property_absent_default: 1,
27581
+ node_comparison: "gt",
27582
+ node_filter: { property: "arbitration_rule", present: false },
27583
+ except_property: "composition_mode",
27584
+ except_value: "chained",
27116
27585
  comparison: "nonzero"
27117
27586
  }
27118
27587
  },
@@ -27137,7 +27606,7 @@ var UPG_ANTI_PATTERNS = [
27137
27606
  ]
27138
27607
  },
27139
27608
  why_it_matters: "Contention over a UI place is settled every time it comes up, and an unrecorded settlement is re-argued at the next feature. The cost is paid in repeated decisions, not in a visible defect. A surface that is safe only by coincidence pays it all at once instead, the first time an occupant is added.",
27140
- remediation: "Read `arbitration_state` first: it says what the work actually is. `enforced_undocumented` means the rule already exists in code and needs transcribing into `arbitration_rule`: ten minutes, no meeting. `none` means nobody has decided and someone has to. `safe_by_coincidence` means nothing is enforcing anything and the surface only looks settled; that one wants a guard in code, not just prose. Record the constraint the rule works within (`capacity`, `dimensional_constraint`), and where a design system already answers it, link the source with `surface_governed_by_design_guideline`. Surfaces whose occupants wrap one another rather than compete should declare `composition_mode: 'chained'`, which exempts them: that is a factual claim about how the code composes, not a way to quiet the check.",
27609
+ remediation: "The violation names the offending surfaces in `target_node_ids`; start there rather than re-reading the whole roster. Read `arbitration_state` first: it says what the work actually is. `enforced_undocumented` means the rule already exists in code and needs transcribing into `arbitration_rule`: ten minutes, no meeting. `none` means nobody has decided and someone has to. `safe_by_coincidence` means nothing is enforcing anything and the surface only looks settled; that one wants a guard in code, not just prose. If a flagged surface genuinely holds everything it was designed to hold, the honest fix is to state its `capacity`, not to invent a rule: a surface whose occupancy is at or below a stated capacity is partitioned rather than contended, and it stops firing. Where a design system already answers the question, link the source with `surface_governed_by_design_guideline`. Surfaces whose occupants wrap one another rather than compete should declare `composition_mode: 'chained'`, which exempts them: that is a factual claim about how the code composes, not a way to quiet the check.",
27141
27610
  stages: ["build", "beta", "launch", "growth", "mature", "maintenance"],
27142
27611
  severity: "medium",
27143
27612
  source: { kind: "fundamental" }
@@ -27227,6 +27696,95 @@ var UPG_PRESENCE_EXCEPT_SPECS = (() => {
27227
27696
  function presenceExceptKey(property, exceptProperty, exceptValue) {
27228
27697
  return `${property}!${exceptProperty}=${exceptValue}`;
27229
27698
  }
27699
+ function edgeCountSpecKey(spec) {
27700
+ const filter = spec.node_filter ? `${spec.node_filter.property}=${spec.node_filter.present ? "present" : "absent"}` : "*";
27701
+ const except = spec.except_property !== void 0 && spec.except_value !== void 0 ? `${spec.except_property}=${spec.except_value}` : "*";
27702
+ return [
27703
+ spec.entity_type,
27704
+ spec.edge_type,
27705
+ spec.direction,
27706
+ spec.property,
27707
+ String(spec.property_absent_default),
27708
+ spec.node_comparison,
27709
+ filter,
27710
+ except
27711
+ ].join("|");
27712
+ }
27713
+ function entityFilterKey(entityType, filter) {
27714
+ const kind = classifyEntityFilter(filter);
27715
+ switch (kind) {
27716
+ case "presence":
27717
+ return `${entityType}|${filter.property}=${filter.present ? "present" : "absent"}`;
27718
+ case "value":
27719
+ return `${entityType}|${filter.property}=${filter.value}`;
27720
+ case "status":
27721
+ return `${entityType}|status=${filter.status}`;
27722
+ case "unrecognized":
27723
+ return `${entityType}|<unrecognized>`;
27724
+ }
27725
+ }
27726
+ function classifyEntityFilter(filter) {
27727
+ const hasProperty = typeof filter.property === "string";
27728
+ if (hasProperty && typeof filter.present === "boolean") return "presence";
27729
+ if (hasProperty && typeof filter.value === "string") return "value";
27730
+ if (typeof filter.status === "string") return "status";
27731
+ return "unrecognized";
27732
+ }
27733
+ function walkForEntityFilterSpecs(cond, out) {
27734
+ if ("operator" in cond) {
27735
+ for (const child of cond.checks) walkForEntityFilterSpecs(child, out);
27736
+ return;
27737
+ }
27738
+ const check = cond.check;
27739
+ if (check.type !== "entity_count" || !check.filter) return;
27740
+ const kind = classifyEntityFilter(check.filter);
27741
+ if (kind === "unrecognized") {
27742
+ throw new Error(
27743
+ `Unrecognized entity_count filter shape on "${check.entity_type}": ${JSON.stringify(check.filter)}. Recognised shapes are { property, present }, { property, value } and { status }. Teach classifyEntityFilter + the collector before declaring a new one.`
27744
+ );
27745
+ }
27746
+ const key = entityFilterKey(check.entity_type, check.filter);
27747
+ if (out.some((s) => entityFilterKey(s.entity_type, s.filter) === key)) return;
27748
+ out.push({ entity_type: check.entity_type, filter: check.filter, kind });
27749
+ }
27750
+ var UPG_ENTITY_FILTER_SPECS = (() => {
27751
+ const out = [];
27752
+ for (const ap of UPG_ANTI_PATTERNS) {
27753
+ if (ap.structured_condition) walkForEntityFilterSpecs(ap.structured_condition, out);
27754
+ }
27755
+ return out;
27756
+ })();
27757
+ function checkToEdgeCountSpec(check) {
27758
+ return {
27759
+ entity_type: check.entity_type,
27760
+ edge_type: check.edge_type,
27761
+ direction: check.direction ?? "inbound",
27762
+ property: check.property,
27763
+ property_absent_default: check.property_absent_default,
27764
+ node_comparison: check.node_comparison,
27765
+ node_filter: check.node_filter,
27766
+ except_property: check.except_property,
27767
+ except_value: check.except_value
27768
+ };
27769
+ }
27770
+ function walkForEdgeCountSpecs(cond, out) {
27771
+ if ("operator" in cond) {
27772
+ for (const child of cond.checks) walkForEdgeCountSpecs(child, out);
27773
+ return;
27774
+ }
27775
+ const check = cond.check;
27776
+ if (check.type !== "edge_count_vs_property") return;
27777
+ const spec = checkToEdgeCountSpec(check);
27778
+ const key = edgeCountSpecKey(spec);
27779
+ if (!out.some((s) => edgeCountSpecKey(s) === key)) out.push(spec);
27780
+ }
27781
+ var UPG_EDGE_COUNT_SPECS = (() => {
27782
+ const out = [];
27783
+ for (const ap of UPG_ANTI_PATTERNS) {
27784
+ if (ap.structured_condition) walkForEdgeCountSpecs(ap.structured_condition, out);
27785
+ }
27786
+ return out;
27787
+ })();
27230
27788
  function getAntiPatternById(id) {
27231
27789
  return UPG_ANTI_PATTERNS.find((ap) => ap.id === id);
27232
27790
  }
@@ -27303,7 +27861,11 @@ function evaluateAntiPatterns(inputs, options) {
27303
27861
  const concern = concernFor(ap.id);
27304
27862
  if (!concernEvaluatedFor(inputs.memberKind, concern)) continue;
27305
27863
  if (evaluateCondition(ap.structured_condition, inputs)) {
27306
- fires.push(buildViolation(ap, concern));
27864
+ const ids = /* @__PURE__ */ new Set();
27865
+ collectAttribution(ap.structured_condition, inputs, ids);
27866
+ const violation = buildViolation(ap, concern);
27867
+ if (ids.size > 0) violation.target_node_ids = [...ids].sort();
27868
+ fires.push(violation);
27307
27869
  }
27308
27870
  }
27309
27871
  fires.sort((a, b) => {
@@ -27313,6 +27875,25 @@ function evaluateAntiPatterns(inputs, options) {
27313
27875
  });
27314
27876
  return fires;
27315
27877
  }
27878
+ function collectAttribution(cond, inputs, out) {
27879
+ if ("operator" in cond) {
27880
+ for (const child of cond.checks) {
27881
+ if (cond.operator === "or" && !evaluateCondition(child, inputs)) continue;
27882
+ collectAttribution(child, inputs, out);
27883
+ }
27884
+ return;
27885
+ }
27886
+ const check = cond.check;
27887
+ if (check.type === "edge_count_vs_property") {
27888
+ const key = edgeCountSpecKey(checkToEdgeCountSpec(check));
27889
+ for (const id of inputs.nodesByEdgeCountSpec?.[key] ?? []) out.add(id);
27890
+ return;
27891
+ }
27892
+ if (check.type === "entity_count" && check.filter) {
27893
+ const key = entityFilterKey(check.entity_type, check.filter);
27894
+ for (const id of inputs.nodesByEntityFilter?.[key] ?? []) out.add(id);
27895
+ }
27896
+ }
27316
27897
  function evaluateCondition(cond, inputs) {
27317
27898
  if ("check" in cond) {
27318
27899
  return evaluateLeaf(cond.check, inputs);
@@ -27332,6 +27913,8 @@ function evaluateLeaf(check, inputs) {
27332
27913
  switch (check.type) {
27333
27914
  case "entity_count":
27334
27915
  return evaluateEntityCount(check, inputs);
27916
+ case "edge_count_vs_property":
27917
+ return evaluateEdgeCountVsProperty(check, inputs);
27335
27918
  case "relationship":
27336
27919
  return evaluateRelationship(check, inputs);
27337
27920
  case "benchmark":
@@ -27392,6 +27975,18 @@ function evaluateEntityCount(check, inputs) {
27392
27975
  }
27393
27976
  return compareNumber(count, check.comparison, check.threshold);
27394
27977
  }
27978
+ function evaluateEdgeCountVsProperty(check, inputs) {
27979
+ const key = edgeCountSpecKey(checkToEdgeCountSpec(check));
27980
+ const matched = inputs.nodesByEdgeCountSpec?.[key];
27981
+ if (matched === void 0) {
27982
+ return compareNumber(
27983
+ inputs.countsByType[check.entity_type] ?? 0,
27984
+ check.comparison,
27985
+ check.threshold
27986
+ );
27987
+ }
27988
+ return compareNumber(matched.length, check.comparison, check.threshold);
27989
+ }
27395
27990
  function relationshipKey(source_type, edge_type, target_type) {
27396
27991
  return `${source_type}|${edge_type}|${target_type}`;
27397
27992
  }
@@ -27474,7 +28069,7 @@ function collectTargetEntities(cond) {
27474
28069
  function walk(c) {
27475
28070
  if ("check" in c) {
27476
28071
  const leaf = c.check;
27477
- if (leaf.type === "entity_count" || leaf.type === "benchmark") {
28072
+ if (leaf.type === "entity_count" || leaf.type === "benchmark" || leaf.type === "edge_count_vs_property") {
27478
28073
  types.add(leaf.entity_type);
27479
28074
  } else if (leaf.type === "relationship") {
27480
28075
  types.add(leaf.source_type);
@@ -28585,6 +29180,11 @@ var UPG_REGIONS = [
28585
29180
  type: "planning_cycle",
28586
29181
  role: "container",
28587
29182
  notes: "self-nesting cadence axis"
29183
+ },
29184
+ {
29185
+ type: "configuration_axis",
29186
+ role: "leaf",
29187
+ notes: "the configuration lever whose values select which surface tree the product renders"
28588
29188
  }
28589
29189
  ],
28590
29190
  anchor: {
@@ -30501,7 +31101,7 @@ function checkHeaderSealText(text) {
30501
31101
  }
30502
31102
 
30503
31103
  // src/index.ts
30504
- var UPG_VERSION = "0.28.0";
31104
+ var UPG_VERSION = "0.30.0";
30505
31105
  var UPG_FORMAT_VERSION = "0.4.0";
30506
31106
  var MARKDOWN_FORMAT_VERSION = "0.1";
30507
31107
  var UPG_TYPES = getTypes();
@@ -30565,11 +31165,14 @@ var UPG_DOMAIN_COUNT = UPG_DOMAINS.length;
30565
31165
  var UPG_EDGE_COUNT = UPG_EDGE_TYPES.length;
30566
31166
  var UPG_META_COUNT = UPG_ENTITY_META.length;
30567
31167
  export {
31168
+ ACTIVE_WHEN_PROPERTY,
30568
31169
  ANALYTICS_DATA_PLAYBOOK,
30569
31170
  BUSINESS_GROWTH_METRIC_DRIVEN_PLAYBOOK,
30570
31171
  BUSINESS_GTM_GROWTH_PLAYBOOK,
30571
31172
  BUSINESS_MARKETING_AUDIENCE_FIRST_PLAYBOOK,
30572
31173
  CLASSIFICATION_EDGE_PROPERTY_SCHEMA,
31174
+ CONFIGURATION_QUALIFIER_EDGE_PROPERTY_SCHEMA,
31175
+ CONFIGURATION_VARIANCE_EDGE_PROPERTY_SCHEMA,
30573
31176
  CONTENT_DEPTH_WARNING_RULES,
30574
31177
  COVERAGE_ANTI_PATTERNS,
30575
31178
  DEFAULT_ENTITY_EMOJI,
@@ -30584,10 +31187,12 @@ export {
30584
31187
  MARKDOWN_FORMAT_VERSION,
30585
31188
  MARKET_COMPETITIVE_PLAYBOOK,
30586
31189
  OPERATIONS_QUALITY_PLAYBOOK,
31190
+ PRESENT_UNDER_PROPERTY,
30587
31191
  PRODUCT_DELIVERY_PLAYBOOK,
30588
31192
  PROPERTY_MODIFIERS,
30589
31193
  PROPERTY_MODIFIER_SEMANTICS,
30590
31194
  PROPERTY_SCALE_MAP,
31195
+ QUALIFIABLE_EDGE_TYPES,
30591
31196
  RECORD_ENTITY_TYPES,
30592
31197
  REFLECT_MODES,
30593
31198
  REGISTRY_PRODUCT_ID,
@@ -30615,10 +31220,12 @@ export {
30615
31220
  UPG_DOMAIN_RINGS,
30616
31221
  UPG_EDGE_CATALOG,
30617
31222
  UPG_EDGE_COUNT,
31223
+ UPG_EDGE_COUNT_SPECS,
30618
31224
  UPG_EDGE_MIGRATIONS,
30619
31225
  UPG_EDGE_PAIR_MAP,
30620
31226
  UPG_EDGE_TYPES,
30621
31227
  UPG_ENTITY_COUNT,
31228
+ UPG_ENTITY_FILTER_SPECS,
30622
31229
  UPG_ENTITY_META,
30623
31230
  UPG_ENTITY_META_BY_ID,
30624
31231
  UPG_ENTITY_META_BY_NAME,
@@ -30668,13 +31275,18 @@ export {
30668
31275
  UPG_WILDCARD_ENDPOINT,
30669
31276
  USERS_NEEDS_PLAYBOOK,
30670
31277
  UnknownEntityTypeError,
31278
+ VARIES_BY_EDGE,
30671
31279
  backfillSlug,
30672
31280
  buildTypeAliases,
30673
31281
  canBeChildOf,
31282
+ checkConfigurationDrift,
30674
31283
  checkHeaderSeal,
30675
31284
  checkHeaderSealText,
31285
+ checkToEdgeCountSpec,
31286
+ classifyEntityFilter,
30676
31287
  coerceProductStage,
30677
31288
  collectSlugsForType,
31289
+ collectTargetEntities,
30678
31290
  compareVersions,
30679
31291
  computeBodyChecksum,
30680
31292
  concernEvaluatedFor,
@@ -30686,6 +31298,9 @@ export {
30686
31298
  deriveSingleCounts,
30687
31299
  describeTreePattern,
30688
31300
  edgeCarriesProperties,
31301
+ edgeCountSpecKey,
31302
+ entityFilterKey,
31303
+ enumerateProjections,
30689
31304
  evaluateAntiPatterns,
30690
31305
  findInvalidStatusMigrationTargets,
30691
31306
  formatUpgText,
@@ -30777,6 +31392,9 @@ export {
30777
31392
  parseUpg,
30778
31393
  pickCanonicalEdge,
30779
31394
  presenceExceptKey,
31395
+ projectGraph,
31396
+ readActiveWhen,
31397
+ readStringArray,
30780
31398
  resolveAllEdges,
30781
31399
  resolveContainmentEdge,
30782
31400
  resolveEntityType,