@unified-product-graph/core 0.29.0 → 0.31.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
  {
@@ -393,7 +394,7 @@ var UPG_DOMAINS = [
393
394
  {
394
395
  id: "program_mgmt",
395
396
  label: "Program Management",
396
- description: "Coordinating delivery across teams and timelines. Programs are the highest container. Projects break programs down. Milestones mark key dates. Risk registers track threats to delivery. Change requests manage scope changes. Deliverables define what ships. Resource allocations assign effort. Status reports communicate progress. Connects Product Specification (what to deliver) to Team & Organisation (who delivers).",
397
+ description: "Coordinating delivery across teams and timelines. Programs are the highest container. Projects break programs down. Milestones mark key dates, and hang from either a project or, where a product owns the date outright with no program above it, from the product itself. Risk registers track threats to delivery. Change requests manage scope changes. Deliverables define what ships. Resource allocations assign effort. Status reports communicate progress. Connects Product Specification (what to deliver) to Team & Organisation (who delivers).",
397
398
  types: [
398
399
  "program",
399
400
  "project",
@@ -489,8 +490,8 @@ var UPG_DOMAINS = [
489
490
  {
490
491
  id: "workspace",
491
492
  label: "Workspace",
492
- description: "Spatial thinking spaces for arranging entities, debating decisions, and committing to the graph. Workspaces are transient canvases that sit alongside all other domains, letting you compose and explore relationships before they become permanent graph structure. A framework exercise is a structured workspace: one run of a framework (MoSCoW, RICE, Kano, \u2026) applied to a chosen set of entities, with each entity's result recorded on the exercise-to-entity edge rather than the entity itself.",
493
- types: ["workspace", "framework_exercise"]
493
+ description: "Spatial thinking spaces for arranging entities, debating decisions, and committing to the graph. Workspaces are transient canvases that sit alongside all other domains, letting you compose and explore relationships before they become permanent graph structure. A framework exercise is a structured workspace: one run of a framework (MoSCoW, RICE, Kano, \u2026) applied to a chosen set of entities, with each entity's result recorded on the exercise-to-entity edge rather than the entity itself. A composition is the durable other half: a named, published view assembled from a canvas, with a stable slug people link to and a revision history, whose frozen arrangement holds pointers rather than copied content.",
494
+ types: ["workspace", "framework_exercise", "composition"]
494
495
  },
495
496
  {
496
497
  id: "foundations",
@@ -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" },
@@ -976,7 +978,8 @@ var UPG_ENTITY_META = [
976
978
  { name: "partner_revenue_share", type_id: "ent_312", maturity: "stable", since: "0.1.0" },
977
979
  // ── Workspace ──
978
980
  { name: "workspace", type_id: "ent_328", maturity: "proposed", since: "0.2.0" },
979
- { name: "framework_exercise", type_id: "ent_350", maturity: "proposed", since: "0.8.4" }
981
+ { name: "framework_exercise", type_id: "ent_350", maturity: "proposed", since: "0.8.4" },
982
+ { name: "composition", type_id: "ent_361", maturity: "proposed", since: "0.31.0" }
980
983
  ];
981
984
  var DEFERRED_PROPOSED_BY_CONTRACT = {
982
985
  // user_story (UPG-571) and experiment_run (UPG-664) graduated to stable in
@@ -1047,6 +1050,47 @@ var DEFER_EDGE_PROPERTY_SCHEMA = {
1047
1050
  description: 'Target period the parked work is deferred to. Freeform, mirroring strategic_theme.time_horizon. @example "Q4 2026", "FY27", "next release".'
1048
1051
  }
1049
1052
  };
1053
+ var CONFIGURATION_VARIANCE_EDGE_PROPERTY_SCHEMA = {
1054
+ present_under: {
1055
+ type: "string[]",
1056
+ 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"]'
1057
+ }
1058
+ };
1059
+ var CONFIGURATION_QUALIFIER_EDGE_PROPERTY_SCHEMA = {
1060
+ active_when: {
1061
+ type: "object",
1062
+ 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".',
1063
+ properties: {
1064
+ axis: {
1065
+ type: "string",
1066
+ description: "Node id of the `configuration_axis` this condition reads."
1067
+ },
1068
+ values: {
1069
+ type: "string[]",
1070
+ description: "Values of that axis under which the relationship holds. Every entry must be a member of the axis's closed `values` list."
1071
+ }
1072
+ },
1073
+ required: ["axis", "values"]
1074
+ }
1075
+ };
1076
+ var WORKSPACE_ARRANGEMENT_EDGE_PROPERTY_SCHEMA = {
1077
+ x: {
1078
+ type: "number",
1079
+ description: "Horizontal canvas coordinate of the card, in the canvas's own units. Expected on every arrangement: a placement without coordinates is not a placement."
1080
+ },
1081
+ y: {
1082
+ type: "number",
1083
+ description: "Vertical canvas coordinate of the card, in the canvas's own units. Expected on every arrangement: a placement without coordinates is not a placement."
1084
+ },
1085
+ expanded: {
1086
+ type: "boolean",
1087
+ description: "Whether the card is drawn expanded rather than collapsed. Presentation state that survives a reload, unlike selection."
1088
+ },
1089
+ frame_id: {
1090
+ type: "string",
1091
+ description: "Id of the canvas frame this card sits inside. The frame itself is opaque furniture and lives in `workspace.properties.canvas.frames`, so this names a bag member rather than a graph node."
1092
+ }
1093
+ };
1050
1094
  var UPG_EDGE_CATALOG = {
1051
1095
  // ── Part 1: Core Narrative Spine (Ring 1) ──────────────────────────────────
1052
1096
  // 1.1 User Domain
@@ -1573,7 +1617,7 @@ var UPG_EDGE_CATALOG = {
1573
1617
  // portfolio_shared, so the reading it names commonly lives in the
1574
1618
  // registry rather than in the product graph.
1575
1619
  // 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" },
1620
+ 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
1621
  surface_serves_job: { forward_verb: "serves", reverse_verb: "served_by", classification: "cross-domain", source_type: "surface", target_type: "job" },
1578
1622
  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
1623
  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 +1652,53 @@ var UPG_EDGE_CATALOG = {
1608
1652
  // not cross_product_eligible → a deviation is a fact about one product's
1609
1653
  // code, and `surface` is not portfolio_shared in the first place.
1610
1654
  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" },
1655
+ // ── surface, 0.30.0: composition that varies by configuration ───────────────
1656
+ // A product's surface tree is not one tree; it is a family of trees selected
1657
+ // by a configuration lever. The stored graph is the UNION of that family and
1658
+ // a single configuration is a PROJECTION of it, so these two edges are how a
1659
+ // graph says which member a fact belongs to. Silence still means "every
1660
+ // member", which is why a graph written before 0.30.0 needs no migration.
1661
+ //
1662
+ // varies_by — CONDITIONAL EXISTENCE. The surface exists only under the
1663
+ // values in `present_under`. An edge qualifier cannot say
1664
+ // this: the reported case was a set of named chips REPLACED
1665
+ // by one generic badge, where which surface EXISTS changes,
1666
+ // and the two carry different capacities and occupants.
1667
+ // alternates — "one of these, depending" as distinct from "both,
1668
+ // together". Semantic, matching its sibling
1669
+ // `surface_supersedes_surface`: neither surface owns the
1670
+ // other, and a hierarchy classification would demand a
1671
+ // UPG_VALID_CHILDREN pair that would be false. Explicit and
1672
+ // validator-checked (same axis, disjoint present_under);
1673
+ // never auto-derived, because disjointness is necessary for
1674
+ // alternation and nowhere near sufficient.
1675
+ //
1676
+ // Direction on `alternates_with` is a CONVENTION, not an enforcement: declare
1677
+ // it once, sourced from the surface present under the axis's `default_value`.
1678
+ // An axis with no default is legal modelling, so a check keyed on the
1679
+ // convention would fire on a correct graph, which is the one thing a check
1680
+ // must never do.
1681
+ // ── configuration_axis, 0.30.0: the lever itself ───────────────────────────
1682
+ // Three layers, three owners, and keeping them apart is what makes the model
1683
+ // honest: `engineering` owns the MECHANISM (`feature_flag`, with its key and
1684
+ // rollout percentage), `product_spec` owns the LEVER (the named dimension
1685
+ // along which what-you-ship differs), `ux_design` owns the PLACE (`surface`).
1686
+ // Two code flags that move together are ONE lever with two values.
1687
+ //
1688
+ // `defines` is hierarchy so the axis has a home in `get_tree`. It earns the
1689
+ // UPG_VALID_CHILDREN.product pair (guardrail G2b) on an honest verb: an axis
1690
+ // exists only within the product whose composition it varies. The rejected
1691
+ // alternative mirrors `classification_axis_owned_by_product`, a cross-domain
1692
+ // ownership edge that leaves the node unparented and invisible in every tree.
1693
+ product_defines_configuration_axis: { forward_verb: "defines", reverse_verb: "defined_by", classification: "hierarchy", source_type: "product", target_type: "configuration_axis" },
1694
+ feature_flag_drives_configuration_axis: { forward_verb: "drives", reverse_verb: "driven_by", classification: "cross-domain", source_type: "feature_flag", target_type: "configuration_axis" },
1695
+ 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 },
1696
+ surface_alternates_with_surface: { forward_verb: "alternates_with", reverse_verb: "alternates_with", classification: "semantic", source_type: "surface", target_type: "surface" },
1611
1697
  // Inbound. `feature_occupies_surface` is the guest list: the edge that makes
1612
1698
  // "what else lives here?" answerable, and the one the contention anti-pattern
1613
1699
  // counts. cross-domain (product_spec → ux_design), like screen_surfaces_feature
1614
1700
  // running the other way.
1615
- feature_occupies_surface: { forward_verb: "occupies", reverse_verb: "occupied_by", classification: "cross-domain", source_type: "feature", target_type: "surface" },
1701
+ 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
1702
  screen_renders_surface: { forward_verb: "renders", reverse_verb: "rendered_by", classification: "hierarchy", source_type: "screen", target_type: "surface" },
1617
1703
  decision_affects_surface: { forward_verb: "affects", reverse_verb: "affected_by", classification: "cross-domain", source_type: "decision", target_type: "surface" },
1618
1704
  // The finer-grained sibling of journey_step_shown_on_screen: a step happens on
@@ -1901,7 +1987,40 @@ var UPG_EDGE_CATALOG = {
1901
1987
  // real edge, not just a string property — the domain's own anti-pattern is
1902
1988
  // "version prompts like code".
1903
1989
  prompt_version_supersedes_prompt_version: { forward_verb: "supersedes", reverse_verb: "superseded_by", classification: "semantic", source_type: "prompt_version", target_type: "prompt_version" },
1990
+ // The TYPED subject edge. Kept alongside the polymorphic `eval_benchmark_measures_node`
1991
+ // added in 0.31.0, on the same footing as `constraint_constrains_feature` beside
1992
+ // `node_constrains_node`: reach for THIS one when the subject is a feature, and for the
1993
+ // wildcard only when it is not. Two ways to say the same thing about a feature is the
1994
+ // cost of the pair, and it is paid deliberately rather than by accident.
1904
1995
  eval_benchmark_measures_feature: { forward_verb: "measures", reverse_verb: "measured_by", classification: "cross-domain", source_type: "eval_benchmark", target_type: "feature" },
1996
+ /*
1997
+ * 0.31.0 — the benchmark SUBJECT, polymorphic.
1998
+ *
1999
+ * An eval measures a tool, a document, a check, an importer, a feature. Those
2000
+ * are unrelated types, so the subject relation is genuinely wildcard-shaped and
2001
+ * follows the decision-to-anything family (`decision_influences_node`).
2002
+ *
2003
+ * THE WIDTH IS OVER-WIDE, AND THAT IS ON THE LABEL RATHER THAN LAUNDERED.
2004
+ * The decision family's warrant is that a decision really is about anything. A
2005
+ * benchmark is not: it measures a MEASURABLE thing, and this edge will happily
2006
+ * claim a benchmark can measure a persona, which is meaningless. It ships as the
2007
+ * honest deferral it is — the alternative was minting typed edges for subject
2008
+ * types the spec does not yet have, which would answer "what is a tool in the
2009
+ * graph" as a side effect of building an eval harness. That question is a stated
2010
+ * non-goal, and deferring it is the point of choosing the wildcard.
2011
+ *
2012
+ * Registered in UPG_POLYMORPHIC_EDGE_KEYS; Audit 05 errors on any wildcard that
2013
+ * is not.
2014
+ */
2015
+ eval_benchmark_measures_node: { forward_verb: "measures", reverse_verb: "measured_by", classification: "semantic", source_type: "eval_benchmark", target_type: "node" },
2016
+ /*
2017
+ * 0.31.0 — the labeled corpus a benchmark draws its cases from.
2018
+ *
2019
+ * `ai_dataset` reused rather than a new `corpus` type minted (Q5, ratified): the
2020
+ * existing type plus one join edge carries the need, and a corpus type would be
2021
+ * minted only if that demonstrably failed. It has not.
2022
+ */
2023
+ eval_benchmark_draws_cases_from_ai_dataset: { forward_verb: "draws_cases_from", reverse_verb: "provides_cases_to", classification: "semantic", source_type: "eval_benchmark", target_type: "ai_dataset" },
1905
2024
  ai_guardrail_enforces_security_policy: { forward_verb: "enforces", reverse_verb: "enforced_by", classification: "cross-domain", source_type: "ai_guardrail", target_type: "security_policy" },
1906
2025
  model_comparison_informs_decision: { forward_verb: "informs", reverse_verb: "informed_by", classification: "cross-domain", source_type: "model_comparison", target_type: "decision" },
1907
2026
  ai_cost_tracker_feeds_cost_structure: { forward_verb: "feeds", reverse_verb: "fed_by", classification: "cross-domain", source_type: "ai_cost_tracker", target_type: "cost_structure" },
@@ -1939,6 +2058,23 @@ var UPG_EDGE_CATALOG = {
1939
2058
  agent_hook_triggers_ci_pipeline: { forward_verb: "triggers", reverse_verb: "triggered_by", classification: "cross-domain", source_type: "agent_hook", target_type: "ci_pipeline" },
1940
2059
  workflow_template_defines_agent_task: { forward_verb: "defines", reverse_verb: "defined_by", classification: "hierarchy", source_type: "workflow_template", target_type: "agent_task" },
1941
2060
  agent_definition_spawns_agent_task: { forward_verb: "spawns", reverse_verb: "spawned_by", classification: "hierarchy", source_type: "agent_definition", target_type: "agent_task" },
2061
+ // The delegation bridge (tasks-workflows-2026-08 §5). Before this, the whole
2062
+ // automation domain could reach product work only at coarse grain —
2063
+ // `workflow_run_implements_initiative` (initiative), `agent_skill_extends_feature`
2064
+ // (feature), `agent_session_creates_decision` (decision),
2065
+ // `workflow_artifact_references_deliverable` (deliverable) — and NOTHING reached
2066
+ // `task`, the atomic unit of product work that `feature_decomposes_into_task` /
2067
+ // `epic_decomposes_into_task` make the delivery hierarchy's leaf. So "this agent
2068
+ // work item IS the execution of that product task" was inexpressible, and a
2069
+ // human-vs-agent assignee could not be read off the graph.
2070
+ // Cross-domain: agent_task lives in `automation`, task in product delivery.
2071
+ // NOT hierarchy — the agent_task does not CONTAIN the task; both are
2072
+ // independently-parented work items (`workflow_template_defines_agent_task` /
2073
+ // `agent_definition_spawns_agent_task` own the agent side, feature/epic the
2074
+ // product side), and the delegation is a lateral reference between them.
2075
+ // Verb `executes` mirrors the domain's own vocabulary (`workflow_template`
2076
+ // is `executed_as` a run); reverse reads "this task is executed_by that agent task".
2077
+ agent_task_executes_task: { forward_verb: "executes", reverse_verb: "executed_by", classification: "cross-domain", source_type: "agent_task", target_type: "task" },
1942
2078
  // ── Part 4: Scale Ring (Ring 4) ────────────────────────────────────────────
1943
2079
  // 4.1 Growth Domain
1944
2080
  product_measures_funnel: { forward_verb: "measures", reverse_verb: "belongs_to", classification: "hierarchy", source_type: "product", target_type: "funnel" },
@@ -2289,6 +2425,25 @@ var UPG_EDGE_CATALOG = {
2289
2425
  product_managed_via_program: { forward_verb: "managed_via", reverse_verb: "manages", classification: "hierarchy", source_type: "product", target_type: "program" },
2290
2426
  program_contains_project: { forward_verb: "contains", reverse_verb: "belongs_to", classification: "hierarchy", source_type: "program", target_type: "project" },
2291
2427
  project_targets_milestone: { forward_verb: "targets", reverse_verb: "targeted_by", classification: "hierarchy", source_type: "project", target_type: "milestone" },
2428
+ // Portfolio Phase 2 (ratified 2026-08-14): a milestone that a PRODUCT owns
2429
+ // outright, with no program/project above it. `milestone` had exactly one
2430
+ // inbound edge spec-wide (`project_targets_milestone`) and `project` was its
2431
+ // only declared parent, so a portfolio-grain milestone — which names a
2432
+ // product, not a project — was inexpressible. This is the 0.23.0 epic-twin
2433
+ // precedent: an existing leaf type gains a second, SHALLOWER parent, and the
2434
+ // name, verbs and classification are the deeper edge's, inherited unchanged
2435
+ // rather than invented.
2436
+ //
2437
+ // The mediated path was checked and rejected, not overlooked. The relation IS
2438
+ // reachable as product —managed_via→ program —contains→ project —targets→
2439
+ // milestone, but at three hops (past the 1-2-hop threshold), and the hop count
2440
+ // is not the real objection: mediation forces `program` and `project` nodes
2441
+ // into existence that the source data does not have.
2442
+ //
2443
+ // NOT cross_product_eligible: a milestone belongs to the graph of the product
2444
+ // that owns it. `UPG_VALID_CHILDREN.product` gains 'milestone' in step, which
2445
+ // the hierarchy-orphan guard requires.
2446
+ product_targets_milestone: { forward_verb: "targets", reverse_verb: "targeted_by", classification: "hierarchy", source_type: "product", target_type: "milestone" },
2292
2447
  project_produces_deliverable: { forward_verb: "produces", reverse_verb: "produced_by", classification: "hierarchy", source_type: "project", target_type: "deliverable" },
2293
2448
  program_tracked_via_risk_register: { forward_verb: "tracked_via", reverse_verb: "tracks", classification: "hierarchy", source_type: "program", target_type: "risk_register" },
2294
2449
  risk_register_contains_risk: { forward_verb: "contains", reverse_verb: "belongs_to", classification: "hierarchy", source_type: "risk_register", target_type: "risk" },
@@ -2435,6 +2590,50 @@ var UPG_EDGE_CATALOG = {
2435
2590
  // node) has no schema mechanism yet — deliberately deferred, see the WS3
2436
2591
  // proposal doc, ripple map "surprises" #3.
2437
2592
  workspace_produced_node: { forward_verb: "produced", reverse_verb: "produced_in", classification: "causal", source_type: "workspace", target_type: "node" },
2593
+ // Canvas arrangement: which entities are ON a workspace, and where. This is
2594
+ // the edge `WorkspaceProperties`' docstring promised for months without the
2595
+ // catalog having it; the promise is now true.
2596
+ //
2597
+ // Polymorphic per the enum-vs-polymorphism ADR (2026-06-16), on all three
2598
+ // arms: universality holds (any entity can be dragged onto a canvas, with no
2599
+ // principled stopping point), constraint value is nil (nobody could defend
2600
+ // "you may arrange features but not personas"), and there is NO STRUCTURAL
2601
+ // ROLE. That third arm is load-bearing rather than decorative.
2602
+ // `classification: 'cross-domain'` is therefore mandatory, not cosmetic: it
2603
+ // keeps the edge out of `UPG_VALID_CHILDREN` and `resolveContainmentEdge`, so
2604
+ // `get_tree` never walks it and a canvas placement can never masquerade as
2605
+ // containment. An arranged node keeps its real containment parent. Same
2606
+ // posture as `framework_exercise_includes_node`.
2607
+ //
2608
+ // The alternative was putting the whole canvas snapshot in one opaque node
2609
+ // property, which is cheaper and loses. A node id inside a blob is a foreign
2610
+ // key held as a scalar (the exact thing P14 forbids), the placed entities
2611
+ // become invisible to `get_node` and `query`, and a deleted node leaves a
2612
+ // stale reference that `repair_dangling_edges` cannot see. As an edge, that
2613
+ // guard is inherited for free. Only the furniture with no graph referent
2614
+ // stays opaque, in `workspace.properties.canvas`.
2615
+ //
2616
+ // NOT cross_product_eligible: deliberate scope limit mirroring the WS3
2617
+ // anchors, not an oversight.
2618
+ workspace_arranges_node: { forward_verb: "arranges", reverse_verb: "arranged_in", classification: "cross-domain", source_type: "workspace", target_type: "node", carries_properties: true, property_schema: WORKSPACE_ARRANGEMENT_EDGE_PROPERTY_SCHEMA },
2619
+ // What a published composition SHOWS. Passes the same three-arm ADR test the
2620
+ // same way, and carries no properties: a focus is a bare reference.
2621
+ //
2622
+ // It earns its place by making a composition legible to a tool that cannot
2623
+ // parse the publishing tool's URLs. "Which published views show this
2624
+ // persona?" is a LEARN question the graph exists to answer, and the edge is
2625
+ // what makes a composition's graph references repairable when a focused node
2626
+ // is deleted. Adding it in a later release would be a second blast-radius
2627
+ // event on the same track.
2628
+ //
2629
+ // POPULATION IS BEST-EFFORT and an empty focus set is VALID: a composition
2630
+ // whose member hrefs the writing tool cannot resolve to node ids is still a
2631
+ // well-formed composition. That keeps the spec complete without blocking on
2632
+ // app work, and it is why no check fires on an unfocused composition.
2633
+ //
2634
+ // NOT cross_product_eligible: a portfolio-level composition spanning several
2635
+ // product graphs is conceivable but unevidenced. Same YAGNI call as above.
2636
+ composition_focuses_node: { forward_verb: "focuses", reverse_verb: "focused_in", classification: "cross-domain", source_type: "composition", target_type: "node" },
2438
2637
  // ── P14 edges (replacing foreign-key properties) ─────────────────────────
2439
2638
  feature_addresses_job: { forward_verb: "addresses", reverse_verb: "addressed_by", classification: "cross-domain", source_type: "feature", target_type: "job" },
2440
2639
  feature_drives_key_result: { forward_verb: "drives", reverse_verb: "driven_by", classification: "cross-domain", source_type: "feature", target_type: "key_result" },
@@ -3479,6 +3678,9 @@ var UPG_POLYMORPHIC_EDGE_KEYS = [
3479
3678
  "framework_exercise_includes_node",
3480
3679
  // Universal classification (any node classified against a classification_value)
3481
3680
  "node_classified_as_classification_value",
3681
+ // Benchmark subject (0.31.0): an eval measures a tool, a doc, a check, an
3682
+ // importer, a feature. Over-wide by admission — see the definition's comment.
3683
+ "eval_benchmark_measures_node",
3482
3684
  // Work-item issue links (0.20.0): endpoint-polymorphic over the work-item set
3483
3685
  // {feature, epic, user_story, task, bug}. The `work_item_` key names the
3484
3686
  // intended semantic domain; the endpoints are the `node` wildcard.
@@ -3487,7 +3689,12 @@ var UPG_POLYMORPHIC_EDGE_KEYS = [
3487
3689
  "work_item_duplicates_work_item",
3488
3690
  // Workspace provenance (WS3, 2026-07-05): a workspace can produce any
3489
3691
  // entity type arranged in it. Widened from workspace_produced_decision.
3490
- "workspace_produced_node"
3692
+ "workspace_produced_node",
3693
+ // Canvas arrangement and published-view focus: any entity can be dragged
3694
+ // onto a canvas or shown in a published view, and neither carries a
3695
+ // structural role, so both collapse polymorphic rather than enumerating.
3696
+ "workspace_arranges_node",
3697
+ "composition_focuses_node"
3491
3698
  ];
3492
3699
  var _POLY_KEY_SET = new Set(UPG_POLYMORPHIC_EDGE_KEYS);
3493
3700
  function isPolymorphicEdge(key) {
@@ -3678,6 +3885,10 @@ var UPG_VALID_CHILDREN = {
3678
3885
  "roadmap",
3679
3886
  "roadmap_theme",
3680
3887
  "planning_cycle",
3888
+ // Configuration (0.30.0): the levers whose values select which surface tree
3889
+ // the product renders. Few per product, and top-level because an axis is a
3890
+ // product-wide fact rather than a child of any one feature or surface.
3891
+ "configuration_axis",
3681
3892
  // Engineering
3682
3893
  "bounded_context",
3683
3894
  "code_repository",
@@ -3768,7 +3979,12 @@ var UPG_VALID_CHILDREN = {
3768
3979
  "subscription",
3769
3980
  "forecast",
3770
3981
  // Program Management
3982
+ // `milestone` is a SECOND, shallower parent here (Portfolio Phase 2,
3983
+ // 2026-08-14), alongside its existing `project` parent: a product can own a
3984
+ // milestone outright with no program/project above it. Required by the
3985
+ // hierarchy-orphan guard now that `product_targets_milestone` exists.
3771
3986
  "program",
3987
+ "milestone",
3772
3988
  // Marketing
3773
3989
  "marketing_strategy",
3774
3990
  "press_release",
@@ -4295,12 +4511,303 @@ var UPG_CONTAINMENT_FREE_TYPES = /* @__PURE__ */ new Set([
4295
4511
  // containment. Modelling it as a product child would force a containment edge
4296
4512
  // and read against the whole point of the exercise model (relational, not
4297
4513
  // hierarchical). Same posture as `person`. See ADR 2026-06-02-framework-exercises.
4298
- "framework_exercise"
4514
+ "framework_exercise",
4515
+ // A composition has no structural parent for the same reason a
4516
+ // framework_exercise does not: it anchors to what it SHOWS, relationally, via
4517
+ // `composition_focuses_node`. Modelling it as a product child would force a
4518
+ // containment edge that reads against the point — a published view is not a
4519
+ // part of the product, it is a lens onto one. This is also what keeps the
4520
+ // type to two edges rather than three: no provenance edge is minted (that is
4521
+ // `workspace_produced_node`, reused) and no parent edge is needed.
4522
+ "composition"
4299
4523
  ]);
4300
4524
  function isContainmentFreeType(entityType) {
4301
4525
  return UPG_CONTAINMENT_FREE_TYPES.has(entityType);
4302
4526
  }
4303
4527
 
4528
+ // src/grammar/projection.ts
4529
+ var VARIES_BY_EDGE = "surface_varies_by_configuration_axis";
4530
+ var PRESENT_UNDER_PROPERTY = "present_under";
4531
+ var ACTIVE_WHEN_PROPERTY = "active_when";
4532
+ var QUALIFIABLE_EDGE_TYPES = [
4533
+ "surface_contains_surface",
4534
+ "feature_occupies_surface"
4535
+ ];
4536
+ function readStringArray(properties, key) {
4537
+ const raw = properties?.[key];
4538
+ if (!Array.isArray(raw)) return void 0;
4539
+ return raw.filter((v) => typeof v === "string");
4540
+ }
4541
+ function readActiveWhen(edge) {
4542
+ const raw = edge.properties?.[ACTIVE_WHEN_PROPERTY];
4543
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
4544
+ const axis = raw.axis;
4545
+ const values = readStringArray(raw, "values");
4546
+ if (typeof axis !== "string" || axis.length === 0 || !values || values.length === 0) {
4547
+ return void 0;
4548
+ }
4549
+ return { axis, values };
4550
+ }
4551
+ function projectGraph(nodes, edges, configuration) {
4552
+ const axes = Object.keys(configuration);
4553
+ if (axes.length === 0) {
4554
+ return {
4555
+ nodes: [...nodes],
4556
+ edges: [...edges],
4557
+ excluded_node_ids: [],
4558
+ deactivated_edge_count: 0,
4559
+ dangling_edge_count: 0
4560
+ };
4561
+ }
4562
+ const excluded = /* @__PURE__ */ new Set();
4563
+ for (const edge of edges) {
4564
+ if (edge.type !== VARIES_BY_EDGE) continue;
4565
+ const chosen = configuration[edge.target];
4566
+ if (chosen === void 0) continue;
4567
+ const presentUnder = readStringArray(edge.properties, PRESENT_UNDER_PROPERTY);
4568
+ if (!presentUnder) continue;
4569
+ if (!presentUnder.includes(chosen)) excluded.add(edge.source);
4570
+ }
4571
+ const survivingNodes = excluded.size === 0 ? [...nodes] : nodes.filter((n) => !excluded.has(n.id));
4572
+ let deactivated = 0;
4573
+ let dangling = 0;
4574
+ const survivingEdges = [];
4575
+ for (const edge of edges) {
4576
+ const qualifier = readActiveWhen(edge);
4577
+ if (qualifier) {
4578
+ const chosen = configuration[qualifier.axis];
4579
+ if (chosen !== void 0 && !qualifier.values.includes(chosen)) {
4580
+ deactivated++;
4581
+ continue;
4582
+ }
4583
+ }
4584
+ if (excluded.has(edge.source) || excluded.has(edge.target)) {
4585
+ dangling++;
4586
+ continue;
4587
+ }
4588
+ survivingEdges.push(edge);
4589
+ }
4590
+ return {
4591
+ nodes: survivingNodes,
4592
+ edges: survivingEdges,
4593
+ excluded_node_ids: [...excluded].sort(),
4594
+ deactivated_edge_count: deactivated,
4595
+ dangling_edge_count: dangling
4596
+ };
4597
+ }
4598
+ function enumerateProjections(nodes) {
4599
+ const out = [
4600
+ { configuration: {} }
4601
+ ];
4602
+ for (const node of nodes) {
4603
+ if (node.type !== "configuration_axis") continue;
4604
+ const values = readStringArray(node.properties, "values");
4605
+ if (!values) continue;
4606
+ for (const value of values) {
4607
+ out.push({ axis: node.id, value, configuration: { [node.id]: value } });
4608
+ }
4609
+ }
4610
+ return out;
4611
+ }
4612
+
4613
+ // src/grammar/configuration-drift.ts
4614
+ var ALTERNATES_EDGE = "surface_alternates_with_surface";
4615
+ var PARENT_EDGE_TYPES = /* @__PURE__ */ new Set(["surface_contains_surface", "screen_renders_surface"]);
4616
+ function edgeLabel(edge) {
4617
+ return edge.id ?? `${edge.source} -> ${edge.target}`;
4618
+ }
4619
+ function checkConfigurationDrift(nodes, edges) {
4620
+ const findings = [];
4621
+ const axisValues = /* @__PURE__ */ new Map();
4622
+ for (const node of nodes) {
4623
+ if (node.type !== "configuration_axis") continue;
4624
+ const values = readStringArray(node.properties, "values");
4625
+ if (!values || values.length === 0) {
4626
+ findings.push({
4627
+ kind: "axis_values_empty",
4628
+ severity: "error",
4629
+ node_id: node.id,
4630
+ axis_id: node.id,
4631
+ message: "Configuration axis declares no values. An axis with no values selects nothing, so no projection can be taken along it."
4632
+ });
4633
+ axisValues.set(node.id, []);
4634
+ continue;
4635
+ }
4636
+ axisValues.set(node.id, values);
4637
+ const rawDefault = node.properties?.default_value;
4638
+ if (typeof rawDefault === "string" && rawDefault.length > 0 && !values.includes(rawDefault)) {
4639
+ findings.push({
4640
+ kind: "axis_default_not_a_value",
4641
+ severity: "error",
4642
+ node_id: node.id,
4643
+ axis_id: node.id,
4644
+ 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.`
4645
+ });
4646
+ }
4647
+ }
4648
+ for (const edge of edges) {
4649
+ if (edge.type !== VARIES_BY_EDGE) continue;
4650
+ const values = axisValues.get(edge.target);
4651
+ const presentUnder = readStringArray(edge.properties, PRESENT_UNDER_PROPERTY);
4652
+ if (!presentUnder || presentUnder.length === 0) {
4653
+ findings.push({
4654
+ kind: "present_under_empty",
4655
+ severity: "error",
4656
+ edge_id: edge.id,
4657
+ node_id: edge.source,
4658
+ axis_id: edge.target,
4659
+ 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.`
4660
+ });
4661
+ continue;
4662
+ }
4663
+ if (!values) continue;
4664
+ for (const value of presentUnder) {
4665
+ if (!values.includes(value)) {
4666
+ findings.push({
4667
+ kind: "present_under_unknown_value",
4668
+ severity: "error",
4669
+ edge_id: edge.id,
4670
+ node_id: edge.source,
4671
+ axis_id: edge.target,
4672
+ message: `present_under names "${value}", which the axis does not declare. Its values are [${values.join(", ")}].`
4673
+ });
4674
+ }
4675
+ }
4676
+ }
4677
+ for (const edge of edges) {
4678
+ const hasQualifierKey = edge.properties !== void 0 && Object.prototype.hasOwnProperty.call(edge.properties, ACTIVE_WHEN_PROPERTY);
4679
+ if (!hasQualifierKey) continue;
4680
+ if (edge.type !== void 0 && !QUALIFIABLE_EDGE_TYPES.includes(edge.type)) {
4681
+ findings.push({
4682
+ kind: "qualifier_on_illegal_edge",
4683
+ severity: "error",
4684
+ edge_id: edge.id,
4685
+ 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.`
4686
+ });
4687
+ continue;
4688
+ }
4689
+ const qualifier = readActiveWhen(edge);
4690
+ if (!qualifier) {
4691
+ const rawQualifier = edge.properties?.[ACTIVE_WHEN_PROPERTY];
4692
+ const rawValues = rawQualifier && typeof rawQualifier === "object" && !Array.isArray(rawQualifier) ? rawQualifier.values : void 0;
4693
+ if (Array.isArray(rawValues) && rawValues.length === 0) {
4694
+ findings.push({
4695
+ kind: "qualifier_values_empty",
4696
+ severity: "error",
4697
+ edge_id: edge.id,
4698
+ 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.`
4699
+ });
4700
+ continue;
4701
+ }
4702
+ findings.push({
4703
+ kind: "qualifier_axis_unresolved",
4704
+ severity: "error",
4705
+ edge_id: edge.id,
4706
+ 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.`
4707
+ });
4708
+ continue;
4709
+ }
4710
+ const values = axisValues.get(qualifier.axis);
4711
+ if (!values) {
4712
+ findings.push({
4713
+ kind: "qualifier_axis_unresolved",
4714
+ severity: "error",
4715
+ edge_id: edge.id,
4716
+ axis_id: qualifier.axis,
4717
+ message: `active_when names axis "${qualifier.axis}", which is not a configuration_axis node in this graph.`
4718
+ });
4719
+ continue;
4720
+ }
4721
+ for (const value of qualifier.values) {
4722
+ if (!values.includes(value)) {
4723
+ findings.push({
4724
+ kind: "qualifier_unknown_value",
4725
+ severity: "error",
4726
+ edge_id: edge.id,
4727
+ axis_id: qualifier.axis,
4728
+ message: `active_when names value "${value}", which axis "${qualifier.axis}" does not declare. Its values are [${values.join(", ")}].`
4729
+ });
4730
+ }
4731
+ }
4732
+ }
4733
+ const varianceBySource = /* @__PURE__ */ new Map();
4734
+ for (const edge of edges) {
4735
+ if (edge.type !== VARIES_BY_EDGE) continue;
4736
+ const values = readStringArray(edge.properties, PRESENT_UNDER_PROPERTY) ?? [];
4737
+ const list = varianceBySource.get(edge.source) ?? [];
4738
+ list.push({ axis: edge.target, values });
4739
+ varianceBySource.set(edge.source, list);
4740
+ }
4741
+ for (const edge of edges) {
4742
+ if (edge.type !== ALTERNATES_EDGE) continue;
4743
+ const left = varianceBySource.get(edge.source) ?? [];
4744
+ const right = varianceBySource.get(edge.target) ?? [];
4745
+ if (left.length === 0 || right.length === 0) {
4746
+ findings.push({
4747
+ kind: "alternation_axis_mismatch",
4748
+ severity: "error",
4749
+ edge_id: edge.id,
4750
+ 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.`
4751
+ });
4752
+ continue;
4753
+ }
4754
+ const sharedAxes = left.filter((l) => right.some((r) => r.axis === l.axis)).map((l) => l.axis);
4755
+ if (sharedAxes.length === 0) {
4756
+ findings.push({
4757
+ kind: "alternation_axis_mismatch",
4758
+ severity: "error",
4759
+ edge_id: edge.id,
4760
+ 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.`
4761
+ });
4762
+ continue;
4763
+ }
4764
+ for (const axis of sharedAxes) {
4765
+ const leftValues = left.find((l) => l.axis === axis)?.values ?? [];
4766
+ const rightValues = right.find((r) => r.axis === axis)?.values ?? [];
4767
+ const overlap = leftValues.filter((v) => rightValues.includes(v));
4768
+ if (overlap.length > 0) {
4769
+ findings.push({
4770
+ kind: "alternation_overlap",
4771
+ severity: "error",
4772
+ edge_id: edge.id,
4773
+ axis_id: axis,
4774
+ 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.`
4775
+ });
4776
+ }
4777
+ }
4778
+ }
4779
+ const parentsByChild = /* @__PURE__ */ new Map();
4780
+ for (const edge of edges) {
4781
+ if (edge.type && PARENT_EDGE_TYPES.has(edge.type)) {
4782
+ parentsByChild.set(edge.target, (parentsByChild.get(edge.target) ?? 0) + 1);
4783
+ }
4784
+ }
4785
+ for (const [axisId, values] of axisValues) {
4786
+ for (const value of values) {
4787
+ const projected = projectGraph(nodes, edges, { [axisId]: value });
4788
+ const survivingParents = /* @__PURE__ */ new Map();
4789
+ for (const edge of projected.edges) {
4790
+ if (edge.type && PARENT_EDGE_TYPES.has(edge.type)) {
4791
+ survivingParents.set(edge.target, (survivingParents.get(edge.target) ?? 0) + 1);
4792
+ }
4793
+ }
4794
+ for (const node of projected.nodes) {
4795
+ if (node.type !== "surface") continue;
4796
+ if ((parentsByChild.get(node.id) ?? 0) === 0) continue;
4797
+ if ((survivingParents.get(node.id) ?? 0) > 0) continue;
4798
+ findings.push({
4799
+ kind: "orphaned_under_projection",
4800
+ severity: "warning",
4801
+ node_id: node.id,
4802
+ axis_id: axisId,
4803
+ 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.`
4804
+ });
4805
+ }
4806
+ }
4807
+ }
4808
+ return findings;
4809
+ }
4810
+
4304
4811
  // src/grammar/lifecycles.ts
4305
4812
  var PRODUCT_LIFECYCLE = {
4306
4813
  entity_type: "product",
@@ -6388,6 +6895,34 @@ var FRAMEWORK_EXERCISE_LIFECYCLE = {
6388
6895
  }
6389
6896
  ]
6390
6897
  };
6898
+ var COMPOSITION_LIFECYCLE = {
6899
+ entity_type: "composition",
6900
+ initial_phase: "draft",
6901
+ terminal_phases: ["archived"],
6902
+ phases: [
6903
+ {
6904
+ id: "draft",
6905
+ status_category: "started",
6906
+ label: "Draft",
6907
+ description: "Being arranged. It has a slug reserved and members placed, but has never been published, so nothing resolves at its address yet.",
6908
+ transitions_to: ["published", "archived"]
6909
+ },
6910
+ {
6911
+ id: "published",
6912
+ status_category: "completed",
6913
+ label: "Published",
6914
+ description: "Live at its slug and linkable. Republishing the same slug stays in this phase and bumps the revision rather than moving the composition.",
6915
+ transitions_to: ["archived", "draft"]
6916
+ },
6917
+ {
6918
+ id: "archived",
6919
+ status_category: "completed",
6920
+ label: "Archived",
6921
+ description: "Withdrawn from the published set but retained, so an existing link resolves to a view marked out of date rather than to nothing. Can be republished.",
6922
+ transitions_to: ["published", "draft"]
6923
+ }
6924
+ ]
6925
+ };
6391
6926
  var SPECIFICATION_LIFECYCLE = {
6392
6927
  entity_type: "specification",
6393
6928
  initial_phase: "draft",
@@ -6519,6 +7054,7 @@ var UPG_LIFECYCLES = [
6519
7054
  GROWTH_CAMPAIGN_LIFECYCLE,
6520
7055
  // Workspace
6521
7056
  FRAMEWORK_EXERCISE_LIFECYCLE,
7057
+ COMPOSITION_LIFECYCLE,
6522
7058
  // ── Template-generated lifecycles ─────────────────────────────────
6523
7059
  ...TEMPLATE_LIFECYCLES
6524
7060
  ];
@@ -6551,6 +7087,10 @@ var UPG_LIFECYCLE_FREE_TYPES = /* @__PURE__ */ new Set([
6551
7087
  "market_segment",
6552
7088
  "classification_axis",
6553
7089
  "classification_value",
7090
+ // ── Product Specification (1): a configuration_axis is a structural
7091
+ // DECLARATION (a lever and its closed value set), not a thing that
7092
+ // progresses through states. Same reading as classification_axis above.
7093
+ "configuration_axis",
6554
7094
  // ── User Research (5 of 8): participants, quotes, observations,
6555
7095
  // clusters, survey responses are immutable data points ────────────────
6556
7096
  "participant",
@@ -7406,8 +7946,7 @@ var UPG_MIGRATIONS = {
7406
7946
  {
7407
7947
  from: "metric_definition",
7408
7948
  to: "metric",
7409
- defaults: { has_implementation: false },
7410
- reason: "A metric definition is a metric without implementation. Lifecycle property, not a separate type."
7949
+ reason: "A metric definition is a metric without implementation. Lifecycle state, not a separate type. (Corrected in v0.26.0: the default previously stamped `has_implementation: false`, a property `metric` has never declared. Dropped rather than declared, because a metric with no live reading simply has no `current_value`, and a boolean restating that would immediately drift from it.)"
7411
7950
  },
7412
7951
  // ── Experiment consolidation ────────────────────────────────────────────
7413
7952
  {
@@ -7432,8 +7971,8 @@ var UPG_MIGRATIONS = {
7432
7971
  {
7433
7972
  from: "risk_item",
7434
7973
  to: "risk",
7435
- defaults: { risk_domain: "program" },
7436
- reason: "risk_item (Program Mgmt) is identical to risk (Legal). Consolidated with risk_domain property."
7974
+ defaults: { risk_type: "program" },
7975
+ reason: "risk_item (Program Mgmt) is identical to risk (Legal). Consolidated onto the single kind axis, `risk_type`. (Corrected in v0.26.0: the default previously named `risk_domain`, a property `risk` has never declared; `program` was added to the `risk_type` enum rather than giving risk a second, drift-prone classification vocabulary.)"
7437
7976
  },
7438
7977
  {
7439
7978
  from: "security_incident",
@@ -7444,8 +7983,8 @@ var UPG_MIGRATIONS = {
7444
7983
  {
7445
7984
  from: "defect_report",
7446
7985
  to: "support_ticket",
7447
- defaults: { ticket_designation: "defect" },
7448
- reason: "Defect report is a support ticket with ticket_designation=defect. Same signal interface."
7986
+ defaults: { ticket_type: "bug" },
7987
+ reason: "Defect report is a support ticket with ticket_type=bug. Same signal interface. (Corrected: the default previously named `ticket_designation`, a property `support_ticket` has never declared, so every migrated node was stamped with an undeclared field.)"
7449
7988
  },
7450
7989
  {
7451
7990
  from: "onboarding_flow",
@@ -10467,7 +11006,7 @@ var UPG_PROPERTY_SCHEMA = {
10467
11006
  acceptance_criterion: {
10468
11007
  condition: { type: "string", description: "Required condition (Given/When/Then or plain text)" },
10469
11008
  test_type: { type: "string", enum: ["manual", "automated"], description: "Test mode" },
10470
- pass_status: { type: "string", enum: ["untested", "pass", "fail"], description: "Current pass/fail" }
11009
+ pass_status: { type: "string", enum: ["untested", "pass", "fail", "regressed", "blocked"], description: "Current verification state of this criterion. `untested` means never attempted; `blocked` means attempted but not verifiable for an environmental reason; `regressed` means previously passing, now failing.", notes: '`blocked` is a distinct state from `untested` (a missing credential or an unreachable dependency is not the same as nobody having tried), which the earlier three-value enum conflated into a silent gap. `regressed` is a criterion-level state and is NOT derivable here, because `acceptance_criterion` stores current state only and keeps no result history. The history lives on the `test_case` to `test_result` series, where a single execution\'s outcome is `TestResultProperties.result_status` and can never itself be "regressed".' }
10471
11010
  },
10472
11011
  // AccessPolicyProperties: Access policy.
10473
11012
  access_policy: {
@@ -10914,14 +11453,14 @@ var UPG_PROPERTY_SCHEMA = {
10914
11453
  // ClassificationAxisProperties: ClassificationAxis: a dimension along which subjects are classified.
10915
11454
  classification_axis: {
10916
11455
  axis_kind: { type: "string", enum: ["categorical", "ordinal", "continuous"], description: "Structural kind of values on this axis. `categorical` = discrete, unordered (most common; CMS architectures). `ordinal` = discrete, ordered (maturity tiers, T-shirt sizes). `continuous` = numeric range (latency budget, price points)." },
10917
- cardinality: { type: "string", enum: ["single", "multi"], description: 'How many values a subject may hold on this axis at once (UPG 0.11.3). `single` (default) = a subject sits at exactly one value; a re-classification to a new value SUPERSEDES the prior one (the classify writer retires the old same-axis edge and records the move in the reclassification history). `multi` = a subject may legitimately hold several values at once (e.g. an axis like "supported frameworks"); re-classifying ADDS a value and does not supersede. A separate axis from `axis_kind`: an axis can be `categorical` (unordered) yet `single`-select, or `categorical` yet `multi`-select.' }
11456
+ cardinality: { type: "string", enum: ["single", "multi"], description: "How many values a subject may hold on this axis at once. `single` (the default) means re-classifying SUPERSEDES the prior value; `multi` means it ADDS one.", notes: 'Under `single` the classify writer retires the old same-axis edge and records the move in the reclassification history, so the change is traceable rather than silent. `multi` suits an axis like "supported frameworks", where holding several values at once is the truth. A separate axis from `axis_kind`: an axis can be `categorical` (unordered) yet single-select, or `categorical` yet multi-select. The two answer different questions and neither implies the other.' }
10918
11457
  },
10919
11458
  // ClassificationValueProperties: ClassificationValue: a value on a classification axis.
10920
11459
  classification_value: {
10921
11460
  rationale: { type: "string", description: "Short paragraph: why this value earns its own row/column. Longer narrative belongs in `summary_md` or attached `content_piece` nodes." },
10922
11461
  exemplars: { type: "string[]", description: "Free-text examples of products occupying this value. For queryable occupancy, prefer `competitor` nodes with `classified_as` edges. @example ['Nimbus', 'Larch', 'Prism']" },
10923
- commitments: { type: "object[]", description: "The N (typically 2\u20134) load-bearing commitments that define this category. Each commitment is a structural axis; removing it changes the category. Pairs with `capabilities`: commitments are the *load-bearing definition*; capabilities are the *surface offering*. @example [ { name: 'Typed content graph', description: 'Schemas in code; references as edges; content is structured data.' }, { name: 'Real-time backend', description: 'Live queries, CRDT collaboration, sub-second propagation.' }, { name: 'Embeddable studio', description: 'Editor is a library hosted inside the team app, not an external portal.' }, ]" },
10924
- capabilities: { type: "object[]", description: "Structured capability bullets across the six canonical surfaces. Each surface appears at most once per `classification_value`. Pairs with `commitments`: capabilities describe what the category *offers*; commitments describe what *defines* it. @example [ { surface: 'delivery', bullets: ['NQL', 'GraphQL', 'REST', 'Live Sync API', 'Asset CDN'] }, { surface: 'extensibility', bullets: ['Custom input components', 'editor plugins'] }, ]" }
11462
+ commitments: { type: "object[]", description: "The load-bearing commitments that define this category, typically two to four. Each is a structural axis: removing it changes the category.", notes: "Pairs with `capabilities`, and the distinction matters when deciding where a bullet belongs: commitments are the load-bearing DEFINITION, capabilities are the surface OFFERING. A capability can be dropped without the category changing; a commitment cannot. A worked set: [ { name: 'Typed content graph', description: 'Schemas in code; references as edges; content is structured data.' }, { name: 'Real-time backend', description: 'Live queries, CRDT collaboration, sub-second propagation.' }, { name: 'Embeddable studio', description: 'Editor is a library hosted inside the team app, not an external portal.' }, ]" },
11463
+ capabilities: { type: "object[]", description: "Structured capability bullets across the six canonical surfaces. Each surface appears at most once per `classification_value`. @example [ { surface: 'delivery', bullets: ['NQL', 'GraphQL', 'REST', 'Live Sync API', 'Asset CDN'] }, { surface: 'extensibility', bullets: ['Custom input components', 'editor plugins'] }, ]", notes: "Pairs with `commitments`, and the distinction decides where a bullet belongs: capabilities describe what the category OFFERS, commitments describe what DEFINES it. Dropping a capability leaves the category intact; dropping a commitment does not." }
10925
11464
  },
10926
11465
  // CodeRepositoryProperties: Code repository.
10927
11466
  code_repository: {
@@ -10956,7 +11495,7 @@ var UPG_PROPERTY_SCHEMA = {
10956
11495
  analysis_type: { type: "string", enum: ["feature_comparison", "positioning", "swot", "pricing"], description: "Type of analysis. `feature_comparison` = side-by-side matrix. `positioning` = competitor positioning relative to each other. `swot` = strengths, weaknesses, opportunities, threats. `pricing` = pricing structure comparison." },
10957
11496
  analysis_date: { type: "string", description: 'ISO date conducted. Competitive intelligence decays quickly; track snapshot age. @example "2026-03-15"' },
10958
11497
  framework_id: { type: "string", description: 'Framework ID (references `UPGFramework.id`). @example "porter-five-forces", "swot-analysis", "competitive-matrix"' },
10959
- empty_cells: { type: "object[]", description: "Empty cells in a 2-axis classification matrix that earn explicit commentary. Only the strategically interesting cells. Opportunity-kind cells are the most valuable; they identify unoccupied strategic space. Each entry references two `classification_value` nodes by id (one from each `classification_axis` child of this `competitive_analysis`). `validate_graph` enforces that the refs resolve to `classification_value` nodes whose parents are distinct `classification_axis` instances. See `ClassificationValueProperties.commitments` for the inverse \"occupied cell\" structural-definition shape. @example [ { axis_a_value_ref: 'val-git-based', axis_b_value_ref: 'val-structured-text', rationale_kind: 'opportunity', rationale_md: 'No technical reason; only adoption inertia. Watching brief.' }, { axis_a_value_ref: 'val-composable', axis_b_value_ref: 'val-wysiwyg', rationale_kind: 'structural', rationale_md: 'Composable rejects HTML blobs; WYSIWYG requires them.' }, ]" },
11498
+ empty_cells: { type: "object[]", description: "Empty cells in a two-axis classification matrix that earn explicit commentary, and only the strategically interesting ones. Opportunity-kind cells are the most valuable: they identify unoccupied strategic space.", notes: "Each entry references two `classification_value` nodes by id, one from each `classification_axis` child of this `competitive_analysis`. `validate_graph` enforces that the refs resolve to `classification_value` nodes whose parents are distinct `classification_axis` instances, so a cell cannot name two values from the same axis. See `ClassificationValueProperties.commitments` for the inverse \"occupied cell\" structural-definition shape. A worked pair, one of each rationale kind: [ { axis_a_value_ref: 'val-git-based', axis_b_value_ref: 'val-structured-text', rationale_kind: 'opportunity', rationale_md: 'No technical reason; only adoption inertia. Watching brief.' }, { axis_a_value_ref: 'val-composable', axis_b_value_ref: 'val-wysiwyg', rationale_kind: 'structural', rationale_md: 'Composable rejects HTML blobs; WYSIWYG requires them.' }, ]" },
10960
11499
  last_updated: { type: "string", description: 'Provenance: ISO date-time this record was last observed or refreshed. Distinct from `analysis_date` (when the analysis was conducted). @example "2026-06-13"' },
10961
11500
  source: { type: "string", description: 'Provenance: where this was observed. A changelog, pricing, or docs URL, an analyst report, or a research note. @example "https://docs.larch.example/changelog/"' },
10962
11501
  confidence: {
@@ -11026,7 +11565,7 @@ var UPG_PROPERTY_SCHEMA = {
11026
11565
  // CompetitorSignalProperties: Competitor signal: a dated competitor move mapped onto our portfolio.
11027
11566
  competitor_signal: {
11028
11567
  observed_at: { type: "string", description: 'ISO date-time the move was observed. @example "2026-06-10"' },
11029
- signal_type: { type: "string", enum: ["feature_launch", "pricing_change", "acquisition", "partnership", "market_entry", "reclassification"], description: "Kind of move. `feature_launch` = shipped a feature. `pricing_change` = a plan or tier change. `acquisition` / `partnership` / `market_entry` = strategic moves. `reclassification` = the competitor moved between classification cells on an axis (e.g. ai_integrated to ai_agentic); auto-emitted at the classify-write chokepoint and carries `axis` / `from_value` / `to_value` / `competitor`." },
11568
+ signal_type: { type: "string", enum: ["feature_launch", "pricing_change", "acquisition", "partnership", "market_entry", "reclassification"], description: "Kind of move: a shipped `feature_launch`, a `pricing_change`, the strategic `acquisition` / `partnership` / `market_entry`, or a `reclassification` when the competitor moves between classification cells on an axis.", notes: "`reclassification` is auto-emitted at the classify-write chokepoint rather than authored, and carries `axis`, `from_value`, `to_value` and `competitor` so the move is reconstructable without diffing two snapshots." },
11030
11569
  summary: { type: "string", description: "One-line factual summary of the move (what shipped, not marketing copy)." },
11031
11570
  impact: { type: "string", enum: ["high", "medium", "low"], description: "Expected impact on our position." },
11032
11571
  competitor: { type: "string", description: "Reclassification only. The qualified id of the competitor that moved, as the classify cross-edge source (e.g. `p_rival/n_acme`). Identifies both the subject and its owning product, so `diff_classification({ product })` can filter the history stream." },
@@ -11058,7 +11597,21 @@ var UPG_PROPERTY_SCHEMA = {
11058
11597
  // ComplianceRequirementProperties: Compliance requirement.
11059
11598
  compliance_requirement: {
11060
11599
  regulation: { type: "string", enum: ["gdpr", "ccpa", "hipaa", "soc2", "iso27001", "pci_dss", "other"], description: "Regulation or standard this requirement derives from" },
11061
- compliance_status: { type: "string", enum: ["compliant", "non_compliant", "in_progress", "not_applicable"], description: "Current compliance posture" }
11600
+ compliance_status: { type: "string", enum: ["compliant", "non_compliant", "in_progress", "not_applicable"], description: "Current compliance posture" },
11601
+ owner: { type: "string", description: "Accountable person or team. Promote to a `node_owned_by_person` edge if ownership must be queryable." }
11602
+ },
11603
+ // CompositionProperties
11604
+ composition: {
11605
+ members: { type: "object[]", description: "The frozen member arrangement, captured at publish. Layout and pointers only, never resolved content." },
11606
+ rev: { type: "number", description: "Monotonic revision, bumped on each republish of the same slug.", notes: "A published revision is a fact about the composition, user-visible as a new print from the same plate, so it is serialised. Worth distinguishing from a store's concurrency token, which shares the name in some backends but is a fact about the table rather than about the thing, and is not spec data." },
11607
+ published_at: { type: "string", description: "ISO timestamp of the most recent publish or republish." },
11608
+ published_by: { type: "string", description: "Publisher handle or email. Display scalar, same posture as `WorkspaceProperties.owner`." }
11609
+ },
11610
+ // ConfigurationAxisProperties: A named dimension along which the product's composition differs.
11611
+ configuration_axis: {
11612
+ 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. Every `present_under` and `active_when.values` entry must name one of them.", notes: "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." },
11613
+ 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: an unqualified read returns the union, not this projection.", notes: "The union is the honest answer to an unqualified question, because it is every configuration at once; 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." },
11614
+ kind: { type: "string", enum: ["feature_flag", "plan_tier", "permission_level", "beta_program", "other"], description: "What kind of lever this is. Names the mechanism family." }
11062
11615
  },
11063
11616
  // ConstraintProperties: Constraint: a named limitation or boundary on product creation.
11064
11617
  constraint: {
@@ -11073,7 +11626,7 @@ var UPG_PROPERTY_SCHEMA = {
11073
11626
  contact: {
11074
11627
  contact_role: { type: "string", description: "Job title or role within the account" },
11075
11628
  is_decision_maker: { type: "boolean", description: "Whether this person has purchasing authority" },
11076
- buying_role: { type: "string", enum: ["champion", "economic_buyer", "technical_evaluator", "end_user", "detractor", "influencer", "procurement", "legal", "security"], description: "Role this contact plays in the buying committee (the decision-making unit). The substrate of enterprise multi-threading and of qualification frameworks (MEDDICC/SPICED): a deal with no `champion` and no `economic_buyer` mapped is single-threaded and at risk. `is_decision_maker` becomes largely derivable (`buying_role = economic_buyer`) but is kept for back-compat. @example 'economic_buyer'" }
11629
+ buying_role: { type: "string", enum: ["champion", "economic_buyer", "technical_evaluator", "end_user", "detractor", "influencer", "procurement", "legal", "security"], description: "Role this contact plays in the buying committee, the decision-making unit. @example 'economic_buyer'", notes: "The substrate of enterprise multi-threading and of qualification frameworks such as MEDDICC and SPICED: a deal with no `champion` and no `economic_buyer` mapped is single-threaded and at risk, which is a fact the graph can answer rather than a judgement someone has to make. `is_decision_maker` becomes largely derivable from this (`buying_role = economic_buyer`) but is kept for back-compat." }
11077
11630
  },
11078
11631
  // ContentCalendarProperties: Content calendar.
11079
11632
  content_calendar: {
@@ -11194,7 +11747,8 @@ var UPG_PROPERTY_SCHEMA = {
11194
11747
  data_contract: {
11195
11748
  retention_period: { type: "string", description: "How long data is retained before deletion" },
11196
11749
  deletion_policy: { type: "string", description: "Policy governing data deletion" },
11197
- third_party_sharing: { type: "boolean", description: "Whether data is shared with third parties" }
11750
+ third_party_sharing: { type: "boolean", description: "Whether data is shared with third parties" },
11751
+ owner: { type: "string", description: "Owning person or team accountable for the contract. Promote to a `node_owned_by_team` edge if ownership must be queryable." }
11198
11752
  },
11199
11753
  // DataDomainProperties: DataDomain. A coarse-grained grouping of related data assets (sources,
11200
11754
  data_domain: {
@@ -11240,7 +11794,8 @@ var UPG_PROPERTY_SCHEMA = {
11240
11794
  // DataProductProperties: DataProduct entity.
11241
11795
  data_product: {
11242
11796
  data_product_type: { type: "string", enum: ["report", "dataset", "stream", "api", "ml_feature", "other"], description: "Classification of the data product (UPG-579 Option B)." },
11243
- sla_freshness: { type: "string", description: 'Freshness SLA commitment (e.g. "< 1 hour")' }
11797
+ sla_freshness: { type: "string", description: 'Freshness SLA commitment (e.g. "< 1 hour")' },
11798
+ owner: { type: "string", description: "Owning person or team. Sibling of `data_domain.steward`: use `owner` for the accountable party of a single data product, `steward` for a whole domain. Promote to a `node_owned_by_team` edge if ownership must be queryable." }
11244
11799
  },
11245
11800
  // DataQualityRuleProperties: DataQualityRule entity.
11246
11801
  data_quality_rule: {
@@ -11270,7 +11825,7 @@ var UPG_PROPERTY_SCHEMA = {
11270
11825
  deal_value: { type: "number", description: "Monetary value of the deal" },
11271
11826
  close_date: { type: "string", description: "Expected close date (ISO format)" },
11272
11827
  probability: { type: "number", description: "Likelihood of closing (0-100%)" },
11273
- deal_outcome: { type: "string", enum: ["won", "lost", "no_decision"], description: "Terminal result of the deal. An Event-axis outcome (the verdict on a one-time event), NOT a lifecycle phase, so it is `deal_outcome` and not `deal_status` per the status-convention Rule 3. Lifecycle (open/won/lost as phases) belongs on the base `status` slot; this records the win/loss verdict a closed deal carries and gives `deal_lost_to_competitor` / win-loss study derivations their anchor. @example 'won'" },
11828
+ deal_outcome: { type: "string", enum: ["won", "lost", "no_decision"], description: "Terminal result of the deal: the win or loss verdict a closed deal carries. @example 'won'", notes: "An Event-axis outcome (the verdict on a one-time event) and NOT a lifecycle phase, so it is `deal_outcome` rather than `deal_status`, per status-convention Rule 3. Lifecycle (open, won and lost as phases) belongs on the base `status` slot. This field is what gives `deal_lost_to_competitor` and win-loss study derivations their anchor." },
11274
11829
  deal_type: { type: "string", enum: ["new_business", "expansion", "renewal"], description: "Motion this deal belongs to. Mirrors `PipelineSales.pipeline_type` at the deal grain; `renewal` is the value `subscription_renews_via_deal` points at. @example 'expansion'" },
11275
11830
  next_step: { type: "string", description: "The single most-used CRM field: the next concrete action to move the deal. Free text on purpose (a coordination note, not a structured task). @example 'Send security questionnaire to procurement'" },
11276
11831
  next_step_date: { type: "string", description: "When the `next_step` is due (ISO format)." },
@@ -11286,7 +11841,8 @@ var UPG_PROPERTY_SCHEMA = {
11286
11841
  normalized: { type: "number", description: "Normalized 0-1 value for cross-tool comparison (optional)." }
11287
11842
  },
11288
11843
  required: ["value", "label"]
11289
- }
11844
+ },
11845
+ owner: { type: "string", description: "Accountable person or team carrying the deal. Promote to a `node_owned_by_person` edge if ownership must be queryable." }
11290
11846
  },
11291
11847
  // DecisionProperties: Decision record. Strategic, product, engineering, or design.
11292
11848
  decision: {
@@ -11499,7 +12055,8 @@ var UPG_PROPERTY_SCHEMA = {
11499
12055
  },
11500
12056
  // EpicProperties: A collection of related user stories that delivers a feature or capability.
11501
12057
  epic: {
11502
- estimate: { type: "string", description: 'Rough size estimate (e.g. "3 sprints", "L", "13 points")' },
12058
+ effort: { type: "string", description: 'Effort estimate (e.g. "2h", "1d", "3 points"). Use a consistent unit within your team. Canonical name for the work-item size family (`epic`, `user_story`, `task`).' },
12059
+ estimate: { type: "string", description: 'Rough size estimate (e.g. "3 sprints", "L", "13 points"). @deprecated STAGED, release number assigned at release prep (docket Track "Docket wave 2"). Use `effort`, the family-uniform name already carried by `user_story` and `task`. `estimate` was epic\'s lone divergent spelling of the same concept and it caused real misreads (the app read `estimate` on `user_story`, where only `effort` is declared). Kept (not removed) for back-compat; removal is a later major. Writers: emit `effort`. Readers: prefer `effort`, fall back to `estimate`.' },
11503
12060
  priority: { type: "string", enum: ["urgent", "high", "medium", "low", "none"], description: "Task-level priority" },
11504
12061
  owner: { type: "string", description: "Responsible person or team. Promote to a `node_owned_by_person` edge if ownership must be queryable." },
11505
12062
  start_date: { type: "string", description: "ISO date work begins" },
@@ -11516,7 +12073,7 @@ var UPG_PROPERTY_SCHEMA = {
11516
12073
  },
11517
12074
  // EvalBenchmarkProperties: Evaluation benchmark.
11518
12075
  eval_benchmark: {
11519
- benchmark_type: { type: "string", enum: ["accuracy", "latency", "cost", "safety", "custom"], description: "Measured dimension" },
12076
+ benchmark_type: { type: "string", enum: ["accuracy", "latency", "cost", "safety", "precision_recall", "task_success", "coherence", "custom"], description: "Measured dimension.", notes: 'Widened in 0.31.0 with the three dimensions an eval suite actually reports and this set could not express: `precision_recall` (two numbers, not one accuracy figure, and the pair is the point, because a detector that fires on everything scores perfectly on recall alone), `task_success` (did the agent achieve the goal), and `coherence` (rubric-graded, for multi-step runs). THIS ENUM IS A DIMENSION AXIS, AND IT SHOULD STAY ONE. An eval suite\'s families are a SUBJECT axis (what is under test), and a dimension cannot separate subjects, which is why several families legitimately share `task_success`. The subject is expressed on the EDGE, not here: `eval_benchmark_measures_node` names what a benchmark measures by pointing at it. So "which benchmarks cover the importer" is a traversal, and this property stays a clean answer to a different question, "what dimension does this benchmark report". The commissioning brief hoped the enum would carry both; splitting them across the enum and the edge is the better answer, and it needs no third mechanism. Corollary worth stating, since it is load-bearing: do NOT add subject-shaped values here. `tool_use` or `documentation` would encode on this axis what the edge already carries, and the two would drift the first time a benchmark measured something its enum value did not admit.' },
11520
12077
  test_case_count: { type: "number", description: "Test cases in the suite", modifier: "derived" },
11521
12078
  passing_threshold: { type: "number", description: "Minimum passing score" },
11522
12079
  last_run: { type: "string", description: "ISO date of the most recent run" }
@@ -11532,7 +12089,8 @@ var UPG_PROPERTY_SCHEMA = {
11532
12089
  output_token_count: { type: "number", description: "Output tokens" },
11533
12090
  cost: { type: "number", description: "Total run cost" },
11534
12091
  error_rate: { type: "number", description: "Percentage of test cases that errored" },
11535
- feedback_scores: { type: "string", description: "Feedback score summary (human or automated)" }
12092
+ feedback_scores: { type: "string", description: "Feedback score summary (human or automated)" },
12093
+ metric_scores: { type: "object[]", description: "Per-metric scores, one entry per measured metric.", notes: "Added in 0.31.0 because `score` is a single aggregate and the forcing case needs two numbers: precision and recall, reported separately. An aggregate hides the only interesting failure mode, which is a detector that catches everything by firing on everything. `sample_size` is not optional decoration, and as of 0.31.0 the type says so: it is a REQUIRED member of `MetricScore`. A sampled run reports its sample size beside its score, always, so a comparison across runs can never quietly compare different sample sizes. A number without its denominator is not a smaller measurement, it is not a measurement, and a remark saying `always` over a field typed optional is a contract only the careful reader honours. The entry shape is the exported `MetricScore` interface rather than an anonymous literal, so a consumer can name the thing it is building. This is one of two `object[]` properties in the spec; the other, `composition.members`, already exports `CompositionMember`. The rejected alternative was two runs per benchmark, which needs no spec change and makes every question about a benchmark's quality a join, encoding a measurement artifact as graph structure." }
11536
12094
  },
11537
12095
  // EventProperties: Event.
11538
12096
  event: {
@@ -11636,6 +12194,7 @@ var UPG_PROPERTY_SCHEMA = {
11636
12194
  },
11637
12195
  // ExperimentRunProperties: Execution evidence for a structured test of a hypothesis (UCS pattern P6: event-occurrence).
11638
12196
  experiment_run: {
12197
+ experiment_type: { type: "string", enum: ["ab_test", "growth", "pricing"], description: "What sort of experiment this run is: `ab_test` is a controlled split between variants, `growth` an acquisition or activation experiment, `pricing` a packaging or willingness-to-pay one. Distinct from `disposition`, which is the outcome axis.", notes: "Three retired types (`ab_test`, `growth_experiment`, `pricing_experiment`) collapsed into `experiment_run` precisely along this axis, and the migration defaults stamp it so the distinction survives consolidation. Extensible: further kinds may be added in a minor release." },
11639
12198
  actual_start_date: { type: "string", description: "ISO actual start date (may differ from the plan's `planned_start_date`)" },
11640
12199
  actual_end_date: { type: "string", description: "ISO actual end date" },
11641
12200
  actual_reach: { type: "number", description: "Observed reach: how many people the run actually touched" },
@@ -11803,7 +12362,8 @@ var UPG_PROPERTY_SCHEMA = {
11803
12362
  // FrameworkExerciseProperties: Framework exercise: one run of a framework over a set of entities.
11804
12363
  framework_exercise: {
11805
12364
  framework_id: { type: "string", description: "Which framework this exercise runs: a framework id (e.g. 'moscow', 'rice-scoring', 'kano-model'). Resolves against the framework catalog." },
11806
- inputs_snapshot: { type: "object", description: "Optional frozen copy of the framework's input spec at apply time, so a historical exercise still renders correctly if the framework definition later evolves (inputs added, removed, or rescaled)." }
12365
+ inputs_snapshot: { type: "object", description: "Optional frozen copy of the framework's input spec at apply time, so a historical exercise still renders correctly if the framework definition later evolves (inputs added, removed, or rescaled)." },
12366
+ input_weights: { type: "object", description: "Relative multiplier per framework input for this run, keyed by input id. @example const properties: FrameworkExerciseProperties = { framework_id: 'rice-scoring', input_weights: { reach: 1, impact: 2, confidence: 1, effort: 1.5 }, }", notes: "A weight belongs to the RUN, not to any entity it scores: one weight per input, shared by every scored entity, which is why it sits here and not on the `framework_exercise_includes_node` edge alongside the per-entity result. Named `input_weights` rather than a bare `weight` deliberately, and the collision is live rather than hypothetical: `getPropertyDefaultScale` keys on property NAME alone and ignores entity type, and `PROPERTY_SCALE_MAP` already maps `weight` to the `importance_5` ordinal. A bare `weight` here would silently resolve to a 1-5 assessment scale, which is the wrong type, range and meaning for a multiplier. The plural also matches the spec's own vocabulary: a framework declares *inputs*, not dimensions." }
11807
12367
  },
11808
12368
  // FunnelProperties: Funnel entity.
11809
12369
  funnel: {
@@ -11820,7 +12380,8 @@ var UPG_PROPERTY_SCHEMA = {
11820
12380
  // GlossaryTermProperties: GlossaryTerm entity.
11821
12381
  glossary_term: {
11822
12382
  term_definition: { type: "string", description: "Plain-language definition of the term" },
11823
- synonyms: { type: "string[]", description: "Alternative names or abbreviations for this term" }
12383
+ synonyms: { type: "string[]", description: "Alternative names or abbreviations for this term" },
12384
+ owner: { type: "string", description: "Person or team accountable for keeping the definition current. Promote to a `node_owned_by_person` edge if ownership must be queryable." }
11824
12385
  },
11825
12386
  // GrowthCampaignProperties: GrowthCampaign entity.
11826
12387
  growth_campaign: {
@@ -11893,7 +12454,7 @@ var UPG_PROPERTY_SCHEMA = {
11893
12454
  // HypothesisEvidenceProperties
11894
12455
  hypothesis_evidence: {
11895
12456
  evidence_type: { type: "string", enum: ["experiment_run", "observation", "quote", "metric_change", "market_data", "interview"], description: "Kind of evidence. Drives renderer + filter UI. The provenance edge (`derived_from_*`) carries the actual source node reference per P14; this enum is typing/UI metadata." },
11896
- direction: { type: "string", enum: ["supports", "refutes", "neutral"], description: "Direction relative to the parent claim. Canonical direction axis aligned to `Evidence.direction` so every direction-of-evidence property uses the same vocab. `supports` = paired with the `supports` edge. `refutes` = paired with the `refutes` edge. `neutral` = data insufficient or noisy. BREAKING in v0.4.0: legacy `'confirms'`, `'disconfirms'`, `'inconclusive'` no longer type-check. Migration: `confirms \u2192 supports`, `disconfirms \u2192 refutes`, `inconclusive \u2192 neutral`." },
12457
+ direction: { type: "string", enum: ["supports", "refutes", "neutral"], description: "Direction relative to the parent claim: `supports` and `refutes` pair with the edges of the same name, `neutral` means the data is insufficient or noisy. BREAKING in v0.4.0: `confirms`, `disconfirms` and `inconclusive` no longer type-check. Migration is one-to-one: confirms to supports, disconfirms to refutes, inconclusive to neutral.", notes: "Aligned to `Evidence.direction` so every direction-of-evidence property in the spec shares one vocabulary." },
11897
12458
  weight: {
11898
12459
  type: "assessment",
11899
12460
  scale_id: "importance_5",
@@ -11963,6 +12524,7 @@ var UPG_PROPERTY_SCHEMA = {
11963
12524
  novelty: { type: "string", enum: ["known", "surprising", "contradictory"], description: "Novelty against existing knowledge. `known` = confirms what we already believed. `surprising` = challenges or extends our understanding. `contradictory` = directly conflicts with a prior assumption." },
11964
12525
  actionability: { type: "string", enum: ["immediate", "needs_validation", "informational"], description: "Current actionability. `immediate` = clear action, no further research needed. `needs_validation` = promising but requires more evidence. `informational` = important context, no direct action." },
11965
12526
  source_method: { type: "string", description: 'Producing research method. @example "usability_study", "interview_series", "survey"' },
12527
+ source_domain: { type: "string", description: 'Which discipline the insight came out of. The second provenance axis, symmetrical with `source_method`: that one is HOW the insight was produced, this one is WHICH PRACTICE produced it, and neither substitutes for the other. @example "ux", "support", "sales", "data_science"', notes: "Declared in v0.26.0 so the retired `ux_insight` type's migration default (`source_domain: 'ux'`) lands in a typed field rather than being lost." },
11966
12528
  statement: { type: "string", description: 'Insight statement in plain language. Write as an active, present-tense assertion. @example "Users consistently skip the tutorial because they trust their ability to explore independently."' },
11967
12529
  implications: { type: "string", description: "Product implications. The so-what." }
11968
12530
  },
@@ -11984,8 +12546,8 @@ var UPG_PROPERTY_SCHEMA = {
11984
12546
  },
11985
12547
  // InternalDocProperties: Internal document.
11986
12548
  internal_doc: {
11987
- doc_type: { type: "string", enum: ["rfc", "runbook", "guide", "spec", "onboarding", "other"], description: "Classification of the document" },
11988
- url: { type: "string", description: "URL or path to the document" }
12549
+ doc_type: { type: "string", enum: ["rfc", "runbook", "guide", "spec", "onboarding", "other"], description: "Classification of the document. @deprecated use `document.document_type`" },
12550
+ url: { type: "string", description: "URL or path to the document. @deprecated use `document.source_url`" }
11989
12551
  },
11990
12552
  // InterviewGuideProperties: Interview guide document.
11991
12553
  interview_guide: {
@@ -12343,7 +12905,7 @@ var UPG_PROPERTY_SCHEMA = {
12343
12905
  range_max: { type: "number", description: "Maximum expected or ceiling value" },
12344
12906
  cadence: { type: "string", enum: ["continuous", "hourly", "daily", "weekly", "monthly", "quarterly", "yearly", "on_demand", "other"], description: "Measurement cadence. Canonical `Cadence` since v0.4.0. `'realtime'` migrates to `'continuous'`; all other values 1:1." },
12345
12907
  owner: { type: "string", description: "Person or team responsible for tracking this metric. Promote to a `node_owned_by_person` edge if ownership must be queryable." },
12346
- metric_health: { type: "string", enum: ["healthy", "at_risk", "unhealthy", "unknown"], description: "Universal health rollup. Orthogonal to lifecycle and `guardrail_status`. Applies to every metric regardless of `designation`. `healthy` = trending well against target / inside safe range. `at_risk` = drifting, approaching breach or target shortfall. `unhealthy` = missed target or breached; action needed. `unknown` = no current reading or not yet measured. For guardrails specifically, `guardrail_status` remains the breach-specific signal (`safe`/`warning`/`breached`)." },
12908
+ metric_health: { type: "string", enum: ["healthy", "at_risk", "unhealthy", "unknown"], description: "Universal health rollup, applying to every metric regardless of `designation`: `healthy` is inside range, `at_risk` is drifting toward a breach or shortfall, `unhealthy` has missed or breached, `unknown` has no current reading.", notes: "Orthogonal to lifecycle and to `guardrail_status`. For guardrails specifically, `guardrail_status` remains the breach-specific signal (`safe` / `warning` / `breached`), so a guardrail carries both: one says how it is trending, the other says whether it has been crossed." },
12347
12909
  guardrail_threshold_min: { type: "number", description: "Lower bound for guardrail safety (below this = breach)" },
12348
12910
  guardrail_threshold_max: { type: "number", description: "Upper bound for guardrail safety (above this = breach)" },
12349
12911
  guardrail_status: { type: "string", enum: ["safe", "warning", "breached"], description: "Current guardrail health state" }
@@ -12363,7 +12925,7 @@ var UPG_PROPERTY_SCHEMA = {
12363
12925
  },
12364
12926
  // MilestoneProperties: Milestone.
12365
12927
  milestone: {
12366
- milestone_order: { type: "number", description: "Display order of this milestone within its parent project (0-indexed). The scalar ordering convention shared with `journey_step.step_order` and `journey_action.action_order` (UPG-663 / UPG-674). Orders the delivery milestones a project moves through, independent of `due_date`." },
12928
+ milestone_order: { type: "number", description: "Display order of this milestone within its parent (0-indexed). The scalar ordering convention shared with `journey_step.step_order` and `journey_action.action_order` (UPG-663 / UPG-674). Orders the delivery milestones a parent moves through, independent of `due_date`.", notes: "The parent is whichever of the two milestone parents the graph actually uses: a `project` via `project_targets_milestone`, or a `product` directly via `product_targets_milestone` (Portfolio Phase 2, for milestones a product owns outright with no program or project above them). The order is scoped to that one parent, never global." },
12367
12929
  due_date: { type: "string", description: "Target due date (ISO format)" },
12368
12930
  met_on_time: { type: "boolean", description: "Whether the milestone was met on time" }
12369
12931
  },
@@ -12593,8 +13155,8 @@ var UPG_PROPERTY_SCHEMA = {
12593
13155
  motivation: { type: "string", description: 'Primary motivation or driving need @example "Making confident, evidence-based decisions"' },
12594
13156
  tech_comfort: { type: "string", enum: ["low", "medium", "high", "expert", "other"], description: "Tech comfort. Closed set so personas across products compare on the same axis. Free-text colour belongs in `context` or `motivation`." },
12595
13157
  domain_expertise: { type: "string", description: 'Industry or domain knowledge this persona brings @example "10+ years SaaS experience", "New to healthcare IT"' },
12596
- audience_role: { type: "string", enum: ["buyer", "user", "champion", "influencer", "partner"], description: 'Role in the buying / adoption decision (the decision-making-unit split): who signs (buyer) vs who uses (user) vs who advocates internally (champion) vs who shapes the choice (influencer) vs who delivers / implements (partner). A portfolio must separate the economic buyer from the practitioner user; they are distinct personas with distinct jobs. Closed set so roles compare across products. @example "buyer"' },
12597
- actor_kind: { type: "string", enum: ["human", "agent", "system"], description: 'Who performs the work: a human archetype (default), an autonomous AI agent (e.g. Content Agent), or a non-agentic platform service. Absent = `human`, so existing personas need no migration. An `agent` persona is a first-class actor that participates in journeys via the same persona machinery and that humans delegate to via `persona_delegates_to_persona`. Human-coverage / segmentation metrics count `human` only; agent/system are opt-in. @example "agent"' }
13158
+ audience_role: { type: "string", enum: ["buyer", "user", "champion", "influencer", "partner"], description: 'Role in the buying or adoption decision: who signs (`buyer`), who uses (`user`), who advocates internally (`champion`), who shapes the choice (`influencer`), and who delivers it (`partner`). A closed set, so roles compare across products. @example "buyer"', notes: "A portfolio must separate the economic buyer from the practitioner user: they are distinct personas with distinct jobs, and collapsing them is how a product ends up built for whoever was easiest to interview." },
13159
+ actor_kind: { type: "string", enum: ["human", "agent", "system"], description: 'Who performs the work: a human archetype, an autonomous AI agent, or a non-agentic platform service. Absent means `human`, so existing personas need no migration. @example "agent"', notes: "An `agent` persona is a first-class actor: it participates in journeys through the same persona machinery, and humans delegate to it via `persona_delegates_to_persona`. Human-coverage and segmentation metrics count `human` only, so agent and system personas are opt-in rather than silently inflating a coverage number." }
12598
13160
  },
12599
13161
  // PipelineSalesProperties: Sales pipeline.
12600
13162
  pipeline_sales: {
@@ -12680,7 +13242,7 @@ var UPG_PROPERTY_SCHEMA = {
12680
13242
  url: { type: "string", description: "Where the product lives. Marketing site, app store URL, etc.", modifier: "volatile" },
12681
13243
  logo_url: { type: "string", description: "Logo or icon URL. Used to render product cards and lists.", modifier: "volatile" },
12682
13244
  launched_at: { type: "string", description: "When the product became generally available (ISO 8601)" },
12683
- 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"` }
13245
+ described_configuration: { type: "string", description: 'The product configuration this graph describes, named in plain language. A label for readers: nothing reads it to gate, filter, or alter how a tool interprets the graph. @example "Enterprise plan, split-navigation flag on"', notes: "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. It makes no claim about which configuration most customers are on. Where a product genuinely ships several and the differences matter, declare a `configuration_axis` instead and let each fact say which values it holds under." }
12684
13246
  },
12685
13247
  // ProductAreaProperties: ProductArea entity.
12686
13248
  product_area: {
@@ -12732,7 +13294,10 @@ var UPG_PROPERTY_SCHEMA = {
12732
13294
  qa_session: {
12733
13295
  session_type: { type: "string", enum: ["exploratory", "regression", "smoke", "uat"], description: "Type of QA session" },
12734
13296
  duration_minutes: { type: "number", description: "Duration of the session in minutes" },
12735
- bugs_found: { type: "number", description: "Number of bugs found during the session" }
13297
+ bugs_found: { type: "number", description: "Number of bugs found during the session" },
13298
+ environment: { type: "string", enum: ["local", "ci", "staging", "sandbox", "production_mirror"], description: "Environment the session was run against. Single-valued, unlike `TestPlanProperties.environments` (a plan spans several); the enum is the same one, mirroring `TestEnvironmentProperties.env_type`." },
13299
+ outcome_summary: { type: "string", description: "Plain-English outcome of the session: what it established, not how many bugs it counted (`bugs_found`). Named `outcome_summary`, matching `ExperimentRunProperties.outcome_summary`, because a bare `outcome` would collide with the `outcome` entity type." },
13300
+ executed_at: { type: "string", description: "ISO timestamp of when the session was run. Mirrors `TestResultProperties.executed_at`." }
12736
13301
  },
12737
13302
  // QueueTopicProperties: Message queue or topic.
12738
13303
  queue_topic: {
@@ -12799,7 +13364,7 @@ var UPG_PROPERTY_SCHEMA = {
12799
13364
  },
12800
13365
  // ReleaseStrategyProperties: Release strategy.
12801
13366
  release_strategy: {
12802
- strategy_type: { type: "string", enum: ["blue_green", "canary", "rolling", "recreate", "feature_flag"], description: 'How a new version reaches production. `blue_green` = instant switch between two identical environments. `canary` = gradual percentage rollout. `rolling` = replace instances incrementally. `recreate` = take down old, bring up new. `feature_flag` = code ships, features gated. @example "canary" for gradual rollout, "blue_green" for instant switch with instant rollback' },
13367
+ strategy_type: { type: "string", enum: ["blue_green", "canary", "rolling", "recreate", "feature_flag"], description: 'How a new version reaches production. @example "canary" for gradual rollout, "blue_green" for instant switch with instant rollback', notes: "`blue_green` switches instantly between two identical environments. `canary` rolls out by percentage. `rolling` replaces instances incrementally. `recreate` takes the old down before bringing the new up. `feature_flag` ships the code with the features gated." },
12803
13368
  canary_percentage: { type: "number", description: "Traffic routed to canary. Applies when `strategy_type === 'canary'`. @example 5 (5% initial canary before full rollout)" },
12804
13369
  rollback_criteria: { type: "string", description: 'Rollback triggers, automatic or manual. @example "Error rate > 1% over 5 minutes", "Latency p99 > 2x baseline"' },
12805
13370
  bake_time: { type: "string", description: 'Soak time before promoting to full production. @example "30m", "2h", "24h"' },
@@ -12860,7 +13425,7 @@ var UPG_PROPERTY_SCHEMA = {
12860
13425
  },
12861
13426
  // RiskProperties: Risk.
12862
13427
  risk: {
12863
- risk_type: { type: "string", enum: ["technical", "business", "legal", "security", "operational"], description: "Domain the risk belongs to" },
13428
+ risk_type: { type: "string", enum: ["technical", "business", "legal", "security", "operational", "program"], description: "Domain the risk belongs to. The single kind axis for `risk`: there is no second classification vocabulary. `program` added in v0.26.0 so the retired `risk_item` (Program Management) type has a home on the canonical `risk` after consolidation, rather than a parallel `risk_domain` field free to drift from this one." },
12864
13429
  probability: {
12865
13430
  type: "assessment",
12866
13431
  scale_id: "confidence_5",
@@ -13008,7 +13573,8 @@ var UPG_PROPERTY_SCHEMA = {
13008
13573
  effective_date: { type: "string", description: "ISO effective date" },
13009
13574
  url: { type: "string", description: "Policy document URL" },
13010
13575
  policy_status: { type: "string", enum: ["draft", "active", "under_review", "retired"], description: "Lifecycle status" },
13011
- rule_strength: { type: "string", enum: ["must", "must_not", "exception", "warning", "guideline"], description: "Imperative force" }
13576
+ rule_strength: { type: "string", enum: ["must", "must_not", "exception", "warning", "guideline"], description: "Imperative force" },
13577
+ owner: { type: "string", description: "Owning person or team accountable for the policy. Promote to a `node_owned_by_team` edge if ownership must be queryable." }
13012
13578
  },
13013
13579
  // SecurityReviewProperties: Security review.
13014
13580
  security_review: {
@@ -13208,12 +13774,12 @@ var UPG_PROPERTY_SCHEMA = {
13208
13774
  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." },
13209
13775
  persistence: { type: "string", enum: ["always", "conditional", "on_demand", "transient"], description: "How reliably the surface is present." },
13210
13776
  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"' },
13211
- 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.' },
13777
+ capacity: { type: "number", description: "How many occupants ONE INSTANCE of the surface holds at once, as a non-negative count. Absent means unbounded, `0` means a reserved place nothing may occupy. Always the cap the design INTENDS, never what was observed rendering. Count the instances themselves with `cardinality`.", notes: "Intent, not observation: a banner region declared `capacity: 1` keeps saying 1 even after someone finds it rendering four. Reality that has drifted from the declared intent belongs on `surface_deviates_via_technical_debt_item` as a trackable, assignable debt item. Editing this number up to match the bug would destroy the only record that a gap exists. Why absence rather than a sentinel: UPG has no union-typed property primitive, so `integer | unbounded` is expressed as an optional number whose absence carries the \"no cap\" reading, instead of a magic value every consumer would have to special-case. Three states, all different. ABSENT is unbounded, no cap stated. `0` is a reserved place. `null` is neither, and nothing this field defines: it is what an explicit null write leaves behind, and no consumer reads it as a cap. To return the property to absent, remove the key with `update_node`'s or `batch_update_nodes`' `unset_properties`, since a property merge preserves anything omitted. Unbounded is not an exemption from scrutiny. The contention detector reads an absent capacity as a threshold of 1, because a surface that states no limit has stated no answer either, so several occupants is exactly the unrecorded decision worth naming. Declaring a real capacity is the way to quiet the check honestly, and the only way that also records something true." },
13212
13778
  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." },
13213
13779
  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.' },
13214
13780
  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." },
13215
- arbitration_rule: { type: "string", description: 'Who wins when more occupants want the surface than `capacity` allows, and why. ABSENCE IS MEANINGFUL: a null or empty `arbitration_rule` on a contested surface means nobody decided, which is exactly what the `contended-surface-without-arbitration` anti-pattern detects. Do not fill this in with a placeholder to silence the check. @example "Highest priority wins; ties break to the most recently updated."' },
13216
- arbitration_state: { type: "string", enum: ["enforced_documented", "enforced_undocumented", "safe_by_coincidence", "none"], description: 'Whether the arbitration answer is enforced, written down, both, or neither. Separates "enforced in code but never transcribed" (ten minutes of typing) from "never decided" (a design meeting) from "safe only because nothing has collided yet" (the dangerous one).' },
13781
+ arbitration_rule: { type: "string", description: 'Who wins when more occupants want the surface than `capacity` allows, and why. Absence is meaningful: on a contested surface it means nobody decided. @example "Highest priority wins; ties break to the most recently updated."', notes: "An empty or absent rule on a contested surface is exactly what `contended-surface-without-arbitration` detects. Do not fill it in with a placeholder to silence the check: the check exists to find the unrecorded decision, and a placeholder hides it without settling anything. The field is overloaded across composition modes, which is worth knowing before writing one. On an `exclusive` surface it records DISPLACEMENT (who is not rendered). On an `additive` surface everyone fits, so what it records is ORDER. Only the displacement reading is what the contention detector reads." },
13782
+ arbitration_state: { type: "string", enum: ["enforced_documented", "enforced_undocumented", "safe_by_coincidence", "none", "no_contention_by_design"], description: "Whether the arbitration answer is enforced, written down, both, neither, or not owed at all. Leaving the field unset means unassessed, and always did.", notes: 'The separations are the whole point of the field, because absence alone conflates four situations with four different remediations: "enforced in code but never transcribed" is ten minutes of typing, "never decided" is a design meeting, "safe only because nothing has collided yet" is the dangerous one, and "nothing to decide, the occupants never compete" is a chained surface, which owes no arbitration rule at all.' },
13217
13783
  extensibility: { type: "string", enum: ["closed", "plugin_registerable", "user_configurable"], description: "Who may add occupants to the surface." },
13218
13784
  mutates_content: { type: "boolean", description: "Whether occupying this surface can change the underlying content, as opposed to only selecting or revealing it. The selector-versus-mutator distinction: a gutter that toggles a value is a mutator, a gutter that jumps the cursor is not." },
13219
13785
  dimensional_constraint: { type: "string", description: 'The hard spatial budget the surface imposes on its occupants, in whatever unit the design system speaks. @example "292px wide", "25px per field", "two grid columns"' }
@@ -13381,7 +13947,7 @@ var UPG_PROPERTY_SCHEMA = {
13381
13947
  },
13382
13948
  // TestSuiteProperties: Test suite.
13383
13949
  test_suite: {
13384
- suite_type: { type: "string", enum: ["unit", "integration", "e2e", "performance", "security", "accessibility"], description: "Category of test suite" },
13950
+ suite_type: { type: "string", enum: ["unit", "integration", "e2e", "performance", "security", "accessibility", "visual"], description: "Category of test suite. The single canonical verification-method vocabulary in the spec: do not mint a second one at criterion level.", notes: "`visual` covers visual-regression and screenshot-diff suites, whose evidence is a rendered-output comparison rather than an assertion over behaviour. Before it existed, producers mapped visual suites onto `integration` as the least-wrong stock value, which made them indistinguishable from genuine integration coverage." },
13385
13951
  test_count: { type: "number", description: "Number of tests in the suite", modifier: "derived" },
13386
13952
  pass_rate: { type: "number", description: "Percentage of tests passing (0-100)", modifier: "snapshot" },
13387
13953
  last_run: { type: "string", description: "ISO date of last execution" },
@@ -13424,7 +13990,7 @@ var UPG_PROPERTY_SCHEMA = {
13424
13990
  },
13425
13991
  // ThreatModelProperties: Threat model.
13426
13992
  threat_model: {
13427
- methodology: { type: "string", enum: ["stride", "dread", "pasta", "attack_tree", "other"], description: 'Methodology. `stride` = Spoofing, Tampering, Repudiation, Information Disclosure, DoS, Elevation of Privilege (OWASP standard). `dread` = Damage, Reproducibility, Exploitability, Affected users, Discoverability (numeric scoring). `pasta` = Process for Attack Simulation and Threat Analysis (7-stage). `attack_tree` = hierarchical tree of attack paths. @example "stride" is the most widely used methodology for web applications' },
13993
+ methodology: { type: "string", enum: ["stride", "dread", "pasta", "attack_tree", "other"], description: 'Threat-modelling methodology: `stride`, `dread`, `pasta`, or `attack_tree`. @example "stride" is the most widely used methodology for web applications', notes: "`stride` is the OWASP standard (Spoofing, Tampering, Repudiation, Information Disclosure, Denial of Service, Elevation of Privilege). `dread` is numeric scoring (Damage, Reproducibility, Exploitability, Affected users, Discoverability). `pasta` is the seven-stage Process for Attack Simulation and Threat Analysis. `attack_tree` is a hierarchical tree of attack paths." },
13428
13994
  scope: { type: "string", description: 'What system, feature, or data flow is being analysed. @example "user authentication", "payment processing", "admin API"' },
13429
13995
  last_reviewed: { type: "string", description: 'ISO date last reviewed. Models become stale as systems evolve. @example "2026-03-01"' },
13430
13996
  participants: { type: "string", description: 'Participants in the exercise. Promote individuals to `node_owned_by_person` edges if participation must be queryable. @example "Alice Chen (security lead), Bob Park (backend engineer), Carol Liu (architect)"' },
@@ -13476,6 +14042,7 @@ var UPG_PROPERTY_SCHEMA = {
13476
14042
  },
13477
14043
  // UserFlowProperties: User flow.
13478
14044
  user_flow: {
14045
+ flow_type: { type: "string", description: 'What sort of flow this is. Free-form rather than a closed enum, because flow kinds are product-specific and the spec has no complete vocabulary for them. @example "onboarding", "checkout", "recovery", "upgrade"', notes: "`user_flow` otherwise carries only structure (`trigger`, `steps`, `success_state`, `failure_state`) and no classification. The retired `onboarding_flow` type collapsed into `user_flow` along this axis, and its migration default stamps the value so the distinction survives." },
13479
14046
  flow_order: { type: "number", description: "Display order of this flow among sibling flows (0-indexed). The scalar ordering convention shared with `journey_step.step_order` and `journey_action.action_order` (UPG-663 / UPG-674). The free-text `steps` array below still captures the within-flow narrative; this scalar makes the flow itself a deterministically orderable sibling." },
13480
14047
  trigger: { type: "string", description: "Initiating event" },
13481
14048
  steps: { type: "string[]", description: "Ordered steps" },
@@ -13538,7 +14105,8 @@ var UPG_PROPERTY_SCHEMA = {
13538
14105
  severity: {
13539
14106
  type: "assessment",
13540
14107
  scale_id: "severity_5",
13541
- description: "Categorical severity derived from CVSS. Impact severity (UPGAssessment on the `severity_5` scale). Score alone is insufficient for triage; severity drives filtering and prioritisation. Migrated from the inline `critical|high|medium|low|informational` enum (UPG-579 Option C): map `critical` -> 5, `high` -> 4, `medium` -> 3, `low` -> 2, `informational` -> 1; carry the old word in `label`. @example value 5, label 'Critical', scale_id 'severity_5' (a remotely exploitable, no-auth vuln)",
14108
+ description: "Categorical severity derived from CVSS, as a UPGAssessment on the `severity_5` scale. Score alone is insufficient for triage: severity is what drives filtering and prioritisation. @example value 5, label 'Critical', scale_id 'severity_5' (a remotely exploitable, no-auth vuln)",
14109
+ notes: "Migrated from the inline `critical|high|medium|low|informational` enum (UPG-579 Option C): map `critical` to 5, `high` to 4, `medium` to 3, `low` to 2, `informational` to 1, carrying the old word in `label` so the original vocabulary survives the move.",
13542
14110
  properties: {
13543
14111
  value: { type: "number", description: "The numeric value, used for computation." },
13544
14112
  label: { type: "string", description: "The qualitative label (what the assessor meant)." },
@@ -13610,12 +14178,14 @@ var UPG_PROPERTY_SCHEMA = {
13610
14178
  workspace: {
13611
14179
  visibility: { type: "string", enum: ["private", "shared", "public"], description: "Who can see this workspace" },
13612
14180
  purpose: { type: "string", description: "Free-text description. Pairs with the closed-enum `workspace_purpose`." },
13613
- workspace_purpose: { type: "string", enum: ["discovery", "planning", "retrospective", "design", "research", "strategy", "general"], description: "Closed-enum classifier. Drives template suggestions and surfaces in workspace browsers. `discovery` = persona/job/opportunity exploration. `planning` = roadmap and decision sessions. `retrospective` = reflection on shipped work. `design` = experience or UI exploration. `research` = organising study data and synthesis. `strategy` = high-level direction setting. `general` = catch-all." },
14181
+ workspace_purpose: { type: "string", enum: ["discovery", "planning", "retrospective", "design", "research", "strategy", "general"], description: "What the workspace is for. Drives template suggestions and surfaces in workspace browsers.", notes: "`discovery` is persona, job and opportunity exploration. `planning` covers roadmap and decision sessions. `retrospective` is reflection on shipped work. `design` is experience or UI exploration. `research` organises study data and synthesis. `strategy` is high-level direction setting. `general` is the catch-all." },
13614
14182
  owner: { type: "string", description: "Workspace owner (handle or email). Display label; canonical owner is `team_owns_workspace` or `persona_owns_workspace`." },
13615
14183
  member_count: { type: "number", description: "Snapshot count. `team_works_in_workspace` edges are the source of truth.", modifier: "derived" },
13616
14184
  archived: { type: "boolean", description: "Archived. Archived workspaces remain queryable but hidden from default views." },
13617
14185
  archived_at: { type: "string", description: "ISO timestamp archived. Pairs with `archived === true`." },
13618
- icon: { type: "string", description: "Display icon (emoji or icon name)" }
14186
+ icon: { type: "string", description: "Display icon (emoji or icon name)" },
14187
+ retention: { type: "string", enum: ["transient", "durable"], description: "Retention intent. Absent means `transient`.", notes: "A workspace is a free-form thinking space and is transient by default, so the spec makes both intents EXPRESSIBLE and takes no position on which reaches the file. Whether to write a transient canvas at all is a tool decision, and the recommended posture is not to: a scratch canvas nobody named has no business in a shared, git-tracked graph, where it lands in everyone's diff. Writing only `durable` workspaces is what keeps a canonical graph entity comfortable with a transient-by-default object." },
14188
+ canvas: { type: "object", description: "Opaque canvas furniture: the parts of a canvas with no graph referent. Preserved verbatim on round-trip and never interpreted. Tool extension keys are namespaced `<tool>:<key>` with a colon, and no consumer interprets a key it does not own.", notes: "The cut here is UPG principle P14 applied literally. Anything that is a REFERENCE TO A GRAPH NODE is an edge, which is why placed entities ride `workspace_arranges_node` and not this bag: a node id held as a scalar inside a blob is a foreign key in disguise, and a deleted node would leave a stale reference nothing can detect. Anything that is pure UI chrome with no graph referent stays here, because minting `annotation` and `frame` entity types would add catalog surface that is meaningless to every consumer outside the tool that drew it. WHY THE COLON, and why the rule is in the summary rather than buried here. An underscore key is indistinguishable from an ordinary property name, so a migration that targets namespaced keys cannot match it and no validator can detect one that should have been namespaced. That undetectability is why enforcement lives in the type and the documentation instead of in a check: a check would report clean on a bag full of underscore keys, which is worse than no check. Two conventions were already coexisting when this was ruled (`entopo_views` in a live writer, `entopo:view_blocks` in the contract test certifying preservation), which is how a cheap rule becomes a migration. PRESERVATION IS NOT PERMISSION TO RENDER. Preserving every byte says nothing about meaning: a field-measured canvas carried `excluded: true` tombstones, and a consumer that preserved them faithfully while rendering every entry showed the user images they had deleted." }
13619
14189
  }
13620
14190
  };
13621
14191
  function getPropertySchema(entityType) {
@@ -14659,134 +15229,384 @@ var UPG_FRAMEWORKS = [
14659
15229
  }
14660
15230
  },
14661
15231
  {
14662
- "id": "porter-five-forces",
14663
- "approach_ids": [
14664
- "inspect"
14665
- ],
14666
- "name": "Porter Five Forces",
15232
+ "id": "lean-canvas",
15233
+ "name": "Lean Canvas",
14667
15234
  "version": "1.0.0",
14668
- "description": "Analyse industry competitiveness through five forces: rivalry, new entrants, substitutes, buyer power, and supplier power.",
14669
- "category": "strategy",
15235
+ "description": "Startup-focused adaptation of the BMC. Replaces partners/resources with problem/solution/unfair advantage.",
15236
+ "category": "business_model",
14670
15237
  "origin": {
14671
15238
  "type": "practitioner",
14672
- "attribution": "Michael Porter",
14673
- "description": "Published in Competitive Strategy (Free Press). The foundational framework for industry analysis.",
14674
- "year": 1979,
14675
- "license": "public_domain"
15239
+ "attribution": "Ash Maurya",
15240
+ "description": "Adapted from BMC for startups. Published in Running Lean (O'Reilly). Replaces partners/resources with problem/solution.",
15241
+ "url": "https://leanstack.com/lean-canvas",
15242
+ "year": 2012,
15243
+ "license": "published_methodology"
14676
15244
  },
14677
15245
  "tags": [
14678
- "strategy",
14679
- "collection"
15246
+ "business_model",
15247
+ "matrix"
14680
15248
  ],
14681
15249
  "slots": [
14682
15250
  {
14683
- "label": "Competitive Rivalry",
14684
- "role": "competitive_rivalry",
14685
- "entityTypeId": "competitor",
14686
- "description": "Intensity of competition among existing players"
15251
+ "label": "Problem",
15252
+ "entityTypeId": "need",
15253
+ "description": "Top 3 problems your customers face"
14687
15254
  },
14688
15255
  {
14689
- "label": "Threat of New Entrants",
14690
- "role": "new_entrants",
14691
- "entityTypeId": "competitor",
14692
- "description": "How easy is it for new competitors to enter?"
15256
+ "label": "Solution",
15257
+ "entityTypeId": "solution",
15258
+ "description": "Top 3 features addressing each problem"
14693
15259
  },
14694
15260
  {
14695
- "label": "Threat of Substitutes",
14696
- "role": "substitutes",
14697
- "entityTypeId": "competitor",
14698
- "description": "Can customers switch to alternatives?"
15261
+ "label": "Key Metrics",
15262
+ "entityTypeId": "metric",
15263
+ "description": "Key numbers that tell you how your business is doing"
14699
15264
  },
14700
15265
  {
14701
- "label": "Buyer Power",
14702
- "role": "buyer_power",
14703
- "entityTypeId": "persona",
14704
- "description": "How much leverage do buyers have?"
15266
+ "label": "Unique Value Prop",
15267
+ "entityTypeId": "value_proposition",
15268
+ "description": "Single, clear, compelling message"
14705
15269
  },
14706
15270
  {
14707
- "label": "Supplier Power",
14708
- "role": "supplier_power",
15271
+ "label": "Unfair Advantage",
15272
+ "entityTypeId": "capability",
15273
+ "description": "Something that cannot be easily copied"
15274
+ },
15275
+ {
15276
+ "label": "Channels",
15277
+ "entityTypeId": "acquisition_channel",
15278
+ "description": "Path to customers"
15279
+ },
15280
+ {
15281
+ "label": "Customer Segments",
14709
15282
  "entityTypeId": "persona",
14710
- "description": "How much leverage do suppliers have?"
15283
+ "description": "Target customers"
15284
+ },
15285
+ {
15286
+ "label": "Existing Alternatives",
15287
+ "entityTypeId": "competitor",
15288
+ "description": "What do customers use today?"
15289
+ },
15290
+ {
15291
+ "label": "Early Adopters",
15292
+ "entityTypeId": "behavioral_segment",
15293
+ "description": "Your first target customers"
15294
+ },
15295
+ {
15296
+ "label": "Cost Structure",
15297
+ "entityTypeId": "cost_structure",
15298
+ "description": "Customer acquisition costs, hosting, etc."
15299
+ },
15300
+ {
15301
+ "label": "Revenue Streams",
15302
+ "entityTypeId": "revenue_stream",
15303
+ "description": "Revenue model, lifetime value, margins"
14711
15304
  }
14712
15305
  ],
14713
15306
  "data": {
14714
15307
  "entity_types": [
15308
+ {
15309
+ "type": "need",
15310
+ "role": "bucket"
15311
+ },
15312
+ {
15313
+ "type": "solution",
15314
+ "role": "bucket"
15315
+ },
15316
+ {
15317
+ "type": "value_proposition",
15318
+ "role": "bucket"
15319
+ },
14715
15320
  {
14716
15321
  "type": "competitor",
14717
- "role": "item"
15322
+ "role": "bucket"
14718
15323
  },
14719
15324
  {
14720
15325
  "type": "persona",
15326
+ "role": "bucket"
15327
+ },
15328
+ {
15329
+ "type": "behavioral_segment",
15330
+ "role": "bucket"
15331
+ },
15332
+ {
15333
+ "type": "metric",
15334
+ "role": "bucket"
15335
+ },
15336
+ {
15337
+ "type": "acquisition_channel",
15338
+ "role": "bucket"
15339
+ },
15340
+ {
15341
+ "type": "cost_structure",
15342
+ "role": "bucket"
15343
+ },
15344
+ {
15345
+ "type": "revenue_stream",
15346
+ "role": "bucket"
15347
+ },
15348
+ {
15349
+ "type": "capability",
14721
15350
  "role": "item"
14722
15351
  }
14723
15352
  ],
14724
15353
  "required_properties": {}
14725
15354
  },
14726
15355
  "structure": {
14727
- "pattern": "collection"
15356
+ "pattern": "matrix"
14728
15357
  },
14729
15358
  "presentation": {
14730
15359
  "layout": {
14731
- "type": "grid",
14732
- "groupBy": "type"
15360
+ "type": "matrix",
15361
+ "rows": 3,
15362
+ "cols": 4
14733
15363
  },
14734
15364
  "sort_by": {
14735
15365
  "property": "title",
14736
15366
  "direction": "asc"
14737
15367
  },
14738
- "colour_by": "type",
15368
+ "colour_by": "group",
14739
15369
  "card_fields": [
14740
15370
  "title",
14741
15371
  "description"
14742
15372
  ]
14743
15373
  },
14744
15374
  "education": {
14745
- "purpose": "Analyse the competitive dynamics of an industry through five structural forces that determine profitability, revealing where power lies and where threats emerge.",
14746
- "core_question": "What structural forces shape competition in our industry, and how do they affect our ability to capture value?",
15375
+ "purpose": "Capture an entire business model hypothesis on a single page so founders can test assumptions rapidly without writing a full business plan.",
15376
+ "core_question": "What are the riskiest assumptions in our business model, and how can we test them quickly?",
14747
15377
  "when_to_use": [
14748
- "You need to align the team on long-term direction",
14749
- "Market conditions are shifting and you need to reassess positioning",
14750
- "Leadership needs a structured view of strategic options"
15378
+ "You are designing or redesigning how the business creates and captures value",
15379
+ "You need to communicate the business model to stakeholders or investors",
15380
+ "You want to identify risks and assumptions in your business model"
14751
15381
  ],
14752
15382
  "when_not_to_use": [
14753
- "You are in pure execution mode with a clear strategy already set",
14754
- "The team is too early-stage to commit to strategic constraints"
15383
+ "The business model is mature and well-understood by all stakeholders",
15384
+ "You are focused on tactical execution rather than model design"
14755
15385
  ]
14756
15386
  }
14757
15387
  },
14758
15388
  {
14759
- "id": "swot-analysis",
15389
+ "id": "competitive-landscape",
14760
15390
  "approach_ids": [
14761
15391
  "inspect"
14762
15392
  ],
14763
- "name": "SWOT Analysis",
15393
+ "name": "Competitive Landscape",
14764
15394
  "version": "1.0.0",
14765
- "description": "Map Strengths, Weaknesses, Opportunities, and Threats in a 2x2 grid. Internal vs external, helpful vs harmful.",
14766
- "category": "strategy",
15395
+ "description": "Survey the whole competitive environment on one surface: who competes, what they ship, where the market is moving, and how you are positioned within it.",
15396
+ "category": "competitive",
14767
15397
  "origin": {
14768
- "type": "practitioner",
14769
- "description": "Widely attributed to Albert Humphrey at Stanford Research Institute. One of the most commonly used strategy frameworks.",
14770
- "license": "public_domain"
15398
+ "type": "custom",
15399
+ "attribution": "The Product Creator",
15400
+ "description": "Original to the Unified Product Graph. Where competitor-profile studies one rival in depth, the landscape holds the market-level view: the competitor set, their features, the trends acting on all of them, and your own positioning read against the whole. Assembled from four entity types the catalog already models rather than adapted from a published methodology.",
15401
+ "license": "open_attribution"
14771
15402
  },
14772
15403
  "tags": [
14773
- "strategy",
15404
+ "competitive",
14774
15405
  "matrix"
14775
15406
  ],
14776
15407
  "slots": [
14777
15408
  {
14778
- "label": "Strengths",
14779
- "entityTypeId": "capability",
14780
- "description": "Internal advantages and core competencies"
15409
+ "label": "Competitors",
15410
+ "entityTypeId": "competitor",
15411
+ "description": "The rivals in the market you are surveying"
14781
15412
  },
14782
15413
  {
14783
- "label": "Weaknesses",
14784
- "entityTypeId": "need",
14785
- "description": "Internal limitations and gaps"
15414
+ "label": "Competitor Features",
15415
+ "entityTypeId": "competitor_feature",
15416
+ "description": "What those competitors actually ship"
14786
15417
  },
14787
15418
  {
14788
- "label": "Opportunities",
14789
- "entityTypeId": "opportunity",
15419
+ "label": "Market Trends",
15420
+ "entityTypeId": "market_trend",
15421
+ "description": "Forces acting on the whole market, not on one rival"
15422
+ },
15423
+ {
15424
+ "label": "Positioning",
15425
+ "entityTypeId": "positioning",
15426
+ "description": "Where you stand relative to the field"
15427
+ }
15428
+ ],
15429
+ "data": {
15430
+ "entity_types": [
15431
+ {
15432
+ "type": "competitor",
15433
+ "role": "item"
15434
+ },
15435
+ {
15436
+ "type": "competitor_feature",
15437
+ "role": "item"
15438
+ },
15439
+ {
15440
+ "type": "market_trend",
15441
+ "role": "item"
15442
+ },
15443
+ {
15444
+ "type": "positioning",
15445
+ "role": "item"
15446
+ }
15447
+ ],
15448
+ "required_properties": {}
15449
+ },
15450
+ "structure": {
15451
+ "pattern": "matrix"
15452
+ },
15453
+ "presentation": {
15454
+ "layout": {
15455
+ "type": "matrix",
15456
+ "rows": 2,
15457
+ "cols": 2
15458
+ },
15459
+ "colour_by": "type",
15460
+ "card_fields": [
15461
+ "title",
15462
+ "description",
15463
+ "status"
15464
+ ]
15465
+ },
15466
+ "education": {
15467
+ "purpose": "Hold the market-level competitive picture in one place: the field of rivals, what they ship, the trends moving the whole category, and your own position read against all three.",
15468
+ "core_question": "What does the field look like from above, and where do we sit in it?",
15469
+ "when_to_use": [
15470
+ "You need the shape of a whole market before choosing which rival to study in depth",
15471
+ "Trends are moving the category and you need them beside the competitor set, not in a separate document",
15472
+ "A positioning statement needs the competitive evidence sitting next to it"
15473
+ ],
15474
+ "when_not_to_use": [
15475
+ "You already know which single rival matters and want depth on them: use competitor-profile",
15476
+ "You are comparing capabilities feature-by-feature against named rivals: use competitive-matrix",
15477
+ "You need to place rivals positionally on two scored axes: use positioning-map"
15478
+ ]
15479
+ }
15480
+ },
15481
+ {
15482
+ "id": "porter-five-forces",
15483
+ "approach_ids": [
15484
+ "inspect"
15485
+ ],
15486
+ "name": "Porter Five Forces",
15487
+ "version": "1.0.0",
15488
+ "description": "Analyse industry competitiveness through five forces: rivalry, new entrants, substitutes, buyer power, and supplier power.",
15489
+ "category": "strategy",
15490
+ "origin": {
15491
+ "type": "practitioner",
15492
+ "attribution": "Michael Porter",
15493
+ "description": "Published in Competitive Strategy (Free Press). The foundational framework for industry analysis.",
15494
+ "year": 1979,
15495
+ "license": "public_domain"
15496
+ },
15497
+ "tags": [
15498
+ "strategy",
15499
+ "collection"
15500
+ ],
15501
+ "slots": [
15502
+ {
15503
+ "label": "Competitive Rivalry",
15504
+ "role": "competitive_rivalry",
15505
+ "entityTypeId": "competitor",
15506
+ "description": "Intensity of competition among existing players"
15507
+ },
15508
+ {
15509
+ "label": "Threat of New Entrants",
15510
+ "role": "new_entrants",
15511
+ "entityTypeId": "competitor",
15512
+ "description": "How easy is it for new competitors to enter?"
15513
+ },
15514
+ {
15515
+ "label": "Threat of Substitutes",
15516
+ "role": "substitutes",
15517
+ "entityTypeId": "competitor",
15518
+ "description": "Can customers switch to alternatives?"
15519
+ },
15520
+ {
15521
+ "label": "Buyer Power",
15522
+ "role": "buyer_power",
15523
+ "entityTypeId": "persona",
15524
+ "description": "How much leverage do buyers have?"
15525
+ },
15526
+ {
15527
+ "label": "Supplier Power",
15528
+ "role": "supplier_power",
15529
+ "entityTypeId": "persona",
15530
+ "description": "How much leverage do suppliers have?"
15531
+ }
15532
+ ],
15533
+ "data": {
15534
+ "entity_types": [
15535
+ {
15536
+ "type": "competitor",
15537
+ "role": "item"
15538
+ },
15539
+ {
15540
+ "type": "persona",
15541
+ "role": "item"
15542
+ }
15543
+ ],
15544
+ "required_properties": {}
15545
+ },
15546
+ "structure": {
15547
+ "pattern": "collection"
15548
+ },
15549
+ "presentation": {
15550
+ "layout": {
15551
+ "type": "grid",
15552
+ "groupBy": "type"
15553
+ },
15554
+ "sort_by": {
15555
+ "property": "title",
15556
+ "direction": "asc"
15557
+ },
15558
+ "colour_by": "type",
15559
+ "card_fields": [
15560
+ "title",
15561
+ "description"
15562
+ ]
15563
+ },
15564
+ "education": {
15565
+ "purpose": "Analyse the competitive dynamics of an industry through five structural forces that determine profitability, revealing where power lies and where threats emerge.",
15566
+ "core_question": "What structural forces shape competition in our industry, and how do they affect our ability to capture value?",
15567
+ "when_to_use": [
15568
+ "You need to align the team on long-term direction",
15569
+ "Market conditions are shifting and you need to reassess positioning",
15570
+ "Leadership needs a structured view of strategic options"
15571
+ ],
15572
+ "when_not_to_use": [
15573
+ "You are in pure execution mode with a clear strategy already set",
15574
+ "The team is too early-stage to commit to strategic constraints"
15575
+ ]
15576
+ }
15577
+ },
15578
+ {
15579
+ "id": "swot-analysis",
15580
+ "approach_ids": [
15581
+ "inspect"
15582
+ ],
15583
+ "name": "SWOT Analysis",
15584
+ "version": "1.0.0",
15585
+ "description": "Map Strengths, Weaknesses, Opportunities, and Threats in a 2x2 grid. Internal vs external, helpful vs harmful.",
15586
+ "category": "strategy",
15587
+ "origin": {
15588
+ "type": "practitioner",
15589
+ "description": "Widely attributed to Albert Humphrey at Stanford Research Institute. One of the most commonly used strategy frameworks.",
15590
+ "license": "public_domain"
15591
+ },
15592
+ "tags": [
15593
+ "strategy",
15594
+ "matrix"
15595
+ ],
15596
+ "slots": [
15597
+ {
15598
+ "label": "Strengths",
15599
+ "entityTypeId": "capability",
15600
+ "description": "Internal advantages and core competencies"
15601
+ },
15602
+ {
15603
+ "label": "Weaknesses",
15604
+ "entityTypeId": "need",
15605
+ "description": "Internal limitations and gaps"
15606
+ },
15607
+ {
15608
+ "label": "Opportunities",
15609
+ "entityTypeId": "opportunity",
14790
15610
  "description": "External trends and openings"
14791
15611
  },
14792
15612
  {
@@ -14950,6 +15770,108 @@ var UPG_FRAMEWORKS = [
14950
15770
  ]
14951
15771
  }
14952
15772
  },
15773
+ {
15774
+ "id": "product-vision-board",
15775
+ "approach_ids": [
15776
+ "plan"
15777
+ ],
15778
+ "name": "Product Vision Board",
15779
+ "version": "1.0.0",
15780
+ "description": "Roman Pichler's one-page vision canvas. A vision at the top narrows through target group, needs, product and business goals, so the reason for building and the thing being built stay on the same page.",
15781
+ "category": "strategy",
15782
+ "origin": {
15783
+ "type": "practitioner",
15784
+ "attribution": "Roman Pichler",
15785
+ "description": 'Created by Roman Pichler as a one-page tool for capturing and sharing a product vision, and published in "Strategize" (2016). The board deliberately keeps the vision, the people it serves, their needs, the product and the business goals in a single vertical read, so a change to any one is visible against the other four.',
15786
+ "license": "published_methodology"
15787
+ },
15788
+ "tags": [
15789
+ "strategy",
15790
+ "matrix"
15791
+ ],
15792
+ "slots": [
15793
+ {
15794
+ "label": "Vision",
15795
+ "entityTypeId": "vision",
15796
+ "description": "The long-term change the product exists to bring about"
15797
+ },
15798
+ {
15799
+ "label": "Target Group",
15800
+ "entityTypeId": "persona",
15801
+ "description": "The customers and users the vision is for"
15802
+ },
15803
+ {
15804
+ "label": "Needs",
15805
+ "entityTypeId": "need",
15806
+ "description": "The problem the product solves or the benefit it provides for that group"
15807
+ },
15808
+ {
15809
+ "label": "Product",
15810
+ "entityTypeId": "solution",
15811
+ "description": "What the product is, and what makes it stand out"
15812
+ },
15813
+ {
15814
+ "label": "Business Goals",
15815
+ "entityTypeId": "outcome",
15816
+ "description": "How the product benefits the company that builds it"
15817
+ }
15818
+ ],
15819
+ "data": {
15820
+ "entity_types": [
15821
+ {
15822
+ "type": "vision",
15823
+ "role": "item"
15824
+ },
15825
+ {
15826
+ "type": "persona",
15827
+ "role": "item"
15828
+ },
15829
+ {
15830
+ "type": "need",
15831
+ "role": "item"
15832
+ },
15833
+ {
15834
+ "type": "solution",
15835
+ "role": "item"
15836
+ },
15837
+ {
15838
+ "type": "outcome",
15839
+ "role": "item"
15840
+ }
15841
+ ],
15842
+ "required_properties": {}
15843
+ },
15844
+ "structure": {
15845
+ "pattern": "matrix"
15846
+ },
15847
+ "presentation": {
15848
+ "layout": {
15849
+ "type": "matrix",
15850
+ "rows": 5,
15851
+ "cols": 1
15852
+ },
15853
+ "colour_by": "type",
15854
+ "card_fields": [
15855
+ "title",
15856
+ "description",
15857
+ "status"
15858
+ ]
15859
+ },
15860
+ "education": {
15861
+ "purpose": "Capture a product vision on one page together with the group it serves, their needs, the product itself and the business goals it advances.",
15862
+ "core_question": "What change are we trying to bring about, for whom, and how does building it pay off?",
15863
+ "when_to_use": [
15864
+ "A new product or a major new direction needs a shared vision before any roadmap exists",
15865
+ "The team can describe what it is building but not why anyone should want it",
15866
+ "Vision, target group and business goals are living in three different documents and drifting apart"
15867
+ ],
15868
+ "when_not_to_use": [
15869
+ "The vision is settled and the open question is sequencing: use goal-oriented-roadmap",
15870
+ "You need the causal chain from vision down to daily work: use strategic-cascade",
15871
+ "The question is which business model captures the value: use business-model-canvas"
15872
+ ]
15873
+ }
15874
+ },
14953
15875
  {
14954
15876
  "id": "three-horizons",
14955
15877
  "approach_ids": [
@@ -15535,25 +16457,95 @@ var UPG_FRAMEWORKS = [
15535
16457
  "label": "Must-haves",
15536
16458
  "role": "must_have",
15537
16459
  "entityTypeId": "feature",
15538
- "description": "Expected features: absence causes dissatisfaction"
16460
+ "description": "Expected features: absence causes dissatisfaction",
16461
+ "predicate": [
16462
+ {
16463
+ "scope": "framework",
16464
+ "property": "functional_response",
16465
+ "op": "in",
16466
+ "value": [
16467
+ "i_expect_it",
16468
+ "i_am_neutral",
16469
+ "i_can_tolerate_it"
16470
+ ]
16471
+ },
16472
+ {
16473
+ "scope": "framework",
16474
+ "property": "dysfunctional_response",
16475
+ "op": "eq",
16476
+ "value": "i_dislike_it"
16477
+ }
16478
+ ]
15539
16479
  },
15540
16480
  {
15541
16481
  "label": "Performance",
15542
16482
  "role": "performance",
15543
16483
  "entityTypeId": "feature",
15544
- "description": "More is better: linear satisfaction increase"
16484
+ "description": "More is better: linear satisfaction increase",
16485
+ "predicate": [
16486
+ {
16487
+ "scope": "framework",
16488
+ "property": "functional_response",
16489
+ "op": "eq",
16490
+ "value": "i_like_it"
16491
+ },
16492
+ {
16493
+ "scope": "framework",
16494
+ "property": "dysfunctional_response",
16495
+ "op": "eq",
16496
+ "value": "i_dislike_it"
16497
+ }
16498
+ ]
15545
16499
  },
15546
16500
  {
15547
16501
  "label": "Delighters",
15548
16502
  "role": "delighter",
15549
16503
  "entityTypeId": "feature",
15550
- "description": "Unexpected features: presence creates delight"
16504
+ "description": "Unexpected features: presence creates delight",
16505
+ "predicate": [
16506
+ {
16507
+ "scope": "framework",
16508
+ "property": "functional_response",
16509
+ "op": "eq",
16510
+ "value": "i_like_it"
16511
+ },
16512
+ {
16513
+ "scope": "framework",
16514
+ "property": "dysfunctional_response",
16515
+ "op": "in",
16516
+ "value": [
16517
+ "i_expect_it",
16518
+ "i_am_neutral",
16519
+ "i_can_tolerate_it"
16520
+ ]
16521
+ }
16522
+ ]
15551
16523
  },
15552
16524
  {
15553
16525
  "label": "Indifferent",
15554
16526
  "role": "indifferent",
15555
16527
  "entityTypeId": "feature",
15556
- "description": "Features users don't care about either way"
16528
+ "description": "Features users don't care about either way",
16529
+ "predicate": [
16530
+ {
16531
+ "scope": "framework",
16532
+ "property": "functional_response",
16533
+ "op": "in",
16534
+ "value": [
16535
+ "i_am_neutral",
16536
+ "i_can_tolerate_it"
16537
+ ]
16538
+ },
16539
+ {
16540
+ "scope": "framework",
16541
+ "property": "dysfunctional_response",
16542
+ "op": "in",
16543
+ "value": [
16544
+ "i_am_neutral",
16545
+ "i_can_tolerate_it"
16546
+ ]
16547
+ }
16548
+ ]
15557
16549
  }
15558
16550
  ],
15559
16551
  "data": {
@@ -15888,32 +16880,213 @@ var UPG_FRAMEWORKS = [
15888
16880
  "entityTypeId": "feature",
15889
16881
  "description": "Features or requirements sorted into the four MoSCoW buckets"
15890
16882
  }
15891
- ],
16883
+ ],
16884
+ "data": {
16885
+ "entity_types": [
16886
+ {
16887
+ "type": "feature",
16888
+ "role": "scored_item"
16889
+ }
16890
+ ],
16891
+ "required_properties": {
16892
+ "feature": [
16893
+ {
16894
+ "property": "moscow",
16895
+ "type": "enum",
16896
+ "required": true,
16897
+ "scope": "framework",
16898
+ "label": "MoSCoW priority",
16899
+ "description": "Which scope bucket this requirement falls into for the current release",
16900
+ "enum_values": [
16901
+ "must",
16902
+ "should",
16903
+ "could",
16904
+ "wont"
16905
+ ]
16906
+ }
16907
+ ]
16908
+ }
16909
+ },
16910
+ "structure": {
16911
+ "pattern": "table"
16912
+ },
16913
+ "presentation": {
16914
+ "layout": {
16915
+ "type": "table",
16916
+ "columns": [
16917
+ {
16918
+ "property": "title",
16919
+ "label": "Requirement",
16920
+ "sortable": true
16921
+ },
16922
+ {
16923
+ "property": "moscow",
16924
+ "label": "Priority",
16925
+ "sortable": true,
16926
+ "format": "badge"
16927
+ }
16928
+ ]
16929
+ },
16930
+ "sort_by": {
16931
+ "property": "moscow",
16932
+ "direction": "asc"
16933
+ },
16934
+ "colour_by": "type",
16935
+ "card_fields": [
16936
+ "title",
16937
+ "description",
16938
+ "status"
16939
+ ]
16940
+ },
16941
+ "education": {
16942
+ "purpose": "Categorise requirements into Must, Should, Could, and Won't buckets so stakeholders agree on scope boundaries before development begins.",
16943
+ "core_question": "Which requirements are truly non-negotiable for this release, and which can be deferred without blocking the goal?",
16944
+ "when_to_use": [
16945
+ "You have more ideas or features than capacity to build them",
16946
+ "Stakeholders disagree on what to build next",
16947
+ "You need a transparent, defensible prioritisation process"
16948
+ ],
16949
+ "when_not_to_use": [
16950
+ "You have a single obvious next step with no contention",
16951
+ "The backlog is small enough to sequence intuitively"
16952
+ ]
16953
+ }
16954
+ },
16955
+ {
16956
+ "id": "experiment-tracker",
16957
+ "name": "Experiment Tracker",
16958
+ "version": "1.0.0",
16959
+ "description": "Track experiments from design through execution to results. Velocity-focused.",
16960
+ "category": "validation",
16961
+ "origin": {
16962
+ "type": "practitioner",
16963
+ "description": "Common in growth and product teams running continuous experiments.",
16964
+ "license": "cc_by"
16965
+ },
16966
+ "tags": [
16967
+ "validation",
16968
+ "table"
16969
+ ],
16970
+ "relational": {
16971
+ "spine": "experiment_run",
16972
+ "columns": [
16973
+ {
16974
+ "kind": "field",
16975
+ "id": "experiment",
16976
+ "label": "Experiment",
16977
+ "property": "title",
16978
+ "sortable": true
16979
+ },
16980
+ {
16981
+ "kind": "projection",
16982
+ "id": "hypothesis",
16983
+ "label": "Hypothesis",
16984
+ "path": [
16985
+ {
16986
+ "edge": "experiment_plan_ran_as_experiment_run",
16987
+ "direction": "reverse"
16988
+ },
16989
+ {
16990
+ "edge": "hypothesis_requires_experiment_plan",
16991
+ "direction": "reverse"
16992
+ }
16993
+ ],
16994
+ "fields": [
16995
+ "title"
16996
+ ],
16997
+ "sortable": true
16998
+ },
16999
+ {
17000
+ "kind": "projection",
17001
+ "id": "success_metric",
17002
+ "label": "Success Metric",
17003
+ "path": [
17004
+ {
17005
+ "edge": "experiment_run_measures_metric",
17006
+ "direction": "forward"
17007
+ }
17008
+ ],
17009
+ "fields": [
17010
+ "title",
17011
+ "current_value",
17012
+ "target_value"
17013
+ ]
17014
+ },
17015
+ {
17016
+ "kind": "field",
17017
+ "id": "stage",
17018
+ "label": "Stage",
17019
+ "property": "status"
17020
+ },
17021
+ {
17022
+ "kind": "field",
17023
+ "id": "verdict",
17024
+ "label": "Verdict",
17025
+ "property": "disposition"
17026
+ },
17027
+ {
17028
+ "kind": "projection",
17029
+ "id": "result",
17030
+ "label": "Result",
17031
+ "path": [
17032
+ {
17033
+ "edge": "experiment_run_produces_learning",
17034
+ "direction": "forward"
17035
+ }
17036
+ ],
17037
+ "fields": [
17038
+ "title",
17039
+ "result_direction"
17040
+ ]
17041
+ },
17042
+ {
17043
+ "kind": "projection",
17044
+ "id": "evidence",
17045
+ "label": "Evidence",
17046
+ "path": [
17047
+ {
17048
+ "edge": "experiment_run_yields_evidence",
17049
+ "direction": "forward"
17050
+ }
17051
+ ],
17052
+ "fields": [
17053
+ "title"
17054
+ ]
17055
+ }
17056
+ ],
17057
+ "sort": {
17058
+ "column": "experiment",
17059
+ "direction": "asc"
17060
+ }
17061
+ },
15892
17062
  "data": {
15893
17063
  "entity_types": [
15894
17064
  {
15895
- "type": "feature",
15896
- "role": "scored_item"
17065
+ "type": "experiment_run",
17066
+ "role": "item"
17067
+ },
17068
+ {
17069
+ "type": "experiment_plan",
17070
+ "role": "item"
17071
+ },
17072
+ {
17073
+ "type": "hypothesis",
17074
+ "role": "item"
17075
+ },
17076
+ {
17077
+ "type": "metric",
17078
+ "role": "item"
17079
+ },
17080
+ {
17081
+ "type": "learning",
17082
+ "role": "item"
17083
+ },
17084
+ {
17085
+ "type": "evidence",
17086
+ "role": "item"
15897
17087
  }
15898
17088
  ],
15899
- "required_properties": {
15900
- "feature": [
15901
- {
15902
- "property": "moscow",
15903
- "type": "enum",
15904
- "required": true,
15905
- "scope": "framework",
15906
- "label": "MoSCoW priority",
15907
- "description": "Which scope bucket this requirement falls into for the current release",
15908
- "enum_values": [
15909
- "must",
15910
- "should",
15911
- "could",
15912
- "wont"
15913
- ]
15914
- }
15915
- ]
15916
- }
17089
+ "required_properties": {}
15917
17090
  },
15918
17091
  "structure": {
15919
17092
  "pattern": "table"
@@ -15921,22 +17094,10 @@ var UPG_FRAMEWORKS = [
15921
17094
  "presentation": {
15922
17095
  "layout": {
15923
17096
  "type": "table",
15924
- "columns": [
15925
- {
15926
- "property": "title",
15927
- "label": "Requirement",
15928
- "sortable": true
15929
- },
15930
- {
15931
- "property": "moscow",
15932
- "label": "Priority",
15933
- "sortable": true,
15934
- "format": "badge"
15935
- }
15936
- ]
17097
+ "columns": []
15937
17098
  },
15938
17099
  "sort_by": {
15939
- "property": "moscow",
17100
+ "property": "title",
15940
17101
  "direction": "asc"
15941
17102
  },
15942
17103
  "colour_by": "type",
@@ -15947,16 +17108,16 @@ var UPG_FRAMEWORKS = [
15947
17108
  ]
15948
17109
  },
15949
17110
  "education": {
15950
- "purpose": "Categorise requirements into Must, Should, Could, and Won't buckets so stakeholders agree on scope boundaries before development begins.",
15951
- "core_question": "Which requirements are truly non-negotiable for this release, and which can be deferred without blocking the goal?",
17111
+ "purpose": "Track all running and completed experiments in one place: hypothesis, method, status, result, learnings. The team builds a cumulative record of validated knowledge.",
17112
+ "core_question": "What experiments are running, what have we learned from completed experiments, and are we applying those learnings to new hypotheses?",
15952
17113
  "when_to_use": [
15953
- "You have more ideas or features than capacity to build them",
15954
- "Stakeholders disagree on what to build next",
15955
- "You need a transparent, defensible prioritisation process"
17114
+ "You have hypotheses about user needs or solutions that need testing",
17115
+ "You want to reduce risk before committing engineering resources",
17116
+ "The team is debating assumptions that can be tested empirically"
15956
17117
  ],
15957
17118
  "when_not_to_use": [
15958
- "You have a single obvious next step with no contention",
15959
- "The backlog is small enough to sequence intuitively"
17119
+ "The solution is already validated through real usage data",
17120
+ "Speed of shipping matters more than certainty about assumptions"
15960
17121
  ]
15961
17122
  }
15962
17123
  },
@@ -16286,6 +17447,117 @@ var UPG_FRAMEWORKS = [
16286
17447
  ]
16287
17448
  }
16288
17449
  },
17450
+ {
17451
+ "id": "threat-model-canvas",
17452
+ "approach_ids": [
17453
+ "inspect"
17454
+ ],
17455
+ "name": "Threat Model Canvas",
17456
+ "version": "1.0.0",
17457
+ "description": "One surface for a product's security posture: the threat models in play, the threats and vulnerabilities they name, and the controls, policies and reviews answering them.",
17458
+ "category": "security",
17459
+ "origin": {
17460
+ "type": "custom",
17461
+ "attribution": "The Product Creator",
17462
+ "description": "Original to the Unified Product Graph. Deliberately NOT a STRIDE record: STRIDE is a taxonomy of six threat categories (spoofing, tampering, repudiation, information disclosure, denial of service, elevation of privilege), whereas this canvas lays out the security lifecycle: models, then threats, then vulnerabilities, then the controls, policies and reviews that answer them. A STRIDE record would be a different framework with six slots of one type, and is not this one. Assembled from six entity types the catalog already models.",
17463
+ "license": "open_attribution"
17464
+ },
17465
+ "tags": [
17466
+ "security",
17467
+ "matrix"
17468
+ ],
17469
+ "slots": [
17470
+ {
17471
+ "label": "Threat Models",
17472
+ "entityTypeId": "threat_model",
17473
+ "description": "The modelling exercises themselves: scope, system, assumptions"
17474
+ },
17475
+ {
17476
+ "label": "Threats",
17477
+ "entityTypeId": "threat",
17478
+ "description": "What an adversary could attempt against the system"
17479
+ },
17480
+ {
17481
+ "label": "Vulnerabilities",
17482
+ "entityTypeId": "vulnerability",
17483
+ "description": "Weaknesses that make a threat realisable"
17484
+ },
17485
+ {
17486
+ "label": "Controls",
17487
+ "entityTypeId": "security_control",
17488
+ "description": "Technical and procedural measures that reduce risk"
17489
+ },
17490
+ {
17491
+ "label": "Policies",
17492
+ "entityTypeId": "security_policy",
17493
+ "description": "The standing rules the controls implement"
17494
+ },
17495
+ {
17496
+ "label": "Reviews",
17497
+ "entityTypeId": "security_review",
17498
+ "description": "Assessments that check the posture actually holds"
17499
+ }
17500
+ ],
17501
+ "data": {
17502
+ "entity_types": [
17503
+ {
17504
+ "type": "threat_model",
17505
+ "role": "item"
17506
+ },
17507
+ {
17508
+ "type": "threat",
17509
+ "role": "item"
17510
+ },
17511
+ {
17512
+ "type": "vulnerability",
17513
+ "role": "item"
17514
+ },
17515
+ {
17516
+ "type": "security_control",
17517
+ "role": "item"
17518
+ },
17519
+ {
17520
+ "type": "security_policy",
17521
+ "role": "item"
17522
+ },
17523
+ {
17524
+ "type": "security_review",
17525
+ "role": "item"
17526
+ }
17527
+ ],
17528
+ "required_properties": {}
17529
+ },
17530
+ "structure": {
17531
+ "pattern": "matrix"
17532
+ },
17533
+ "presentation": {
17534
+ "layout": {
17535
+ "type": "matrix",
17536
+ "rows": 2,
17537
+ "cols": 3
17538
+ },
17539
+ "colour_by": "type",
17540
+ "card_fields": [
17541
+ "title",
17542
+ "description",
17543
+ "status"
17544
+ ]
17545
+ },
17546
+ "education": {
17547
+ "purpose": "Hold a product's whole security posture on one surface, so a named threat and the control that answers it are read together rather than tracked in separate systems.",
17548
+ "core_question": "What could go wrong, what makes it possible, and what have we actually put in place against it?",
17549
+ "when_to_use": [
17550
+ "A threat modelling session needs somewhere to land that is not a document nobody reopens",
17551
+ "Controls and policies exist but nobody can point to the threats they answer",
17552
+ "An audit or review asks you to show posture, not just a list of findings"
17553
+ ],
17554
+ "when_not_to_use": [
17555
+ "You want STRIDE's six threat categories specifically. This canvas is a lifecycle, not that taxonomy",
17556
+ "You are triaging a live incident rather than modelling posture",
17557
+ "The work is scoring and ranking risks against each other rather than laying out what exists"
17558
+ ]
17559
+ }
17560
+ },
16289
17561
  {
16290
17562
  "id": "atomic-design",
16291
17563
  "approach_ids": [
@@ -17080,11 +18352,107 @@ var UPG_FRAMEWORKS = [
17080
18352
  "category": "go_to_market",
17081
18353
  "origin": {
17082
18354
  "type": "practitioner",
17083
- "attribution": "Wes Bush",
17084
- "description": 'Coined and formalised by Wes Bush in "Product-Led Growth" (2019). The framework describes how the product itself drives acquisition, activation, and expansion, reducing dependence on sales-led motions.',
17085
- "url": "https://www.productled.com/",
17086
- "year": 2019,
17087
- "license": "published_methodology"
18355
+ "attribution": "Wes Bush",
18356
+ "description": 'Coined and formalised by Wes Bush in "Product-Led Growth" (2019). The framework describes how the product itself drives acquisition, activation, and expansion, reducing dependence on sales-led motions.',
18357
+ "url": "https://www.productled.com/",
18358
+ "year": 2019,
18359
+ "license": "published_methodology"
18360
+ },
18361
+ "tags": [
18362
+ "go_to_market",
18363
+ "flow"
18364
+ ],
18365
+ "slots": [
18366
+ {
18367
+ "label": "Free Entry",
18368
+ "role": "free_entry",
18369
+ "entityTypeId": "gtm_strategy",
18370
+ "description": "How users access the product for free"
18371
+ },
18372
+ {
18373
+ "label": "Aha Moment",
18374
+ "role": "aha_moment",
18375
+ "entityTypeId": "gtm_strategy",
18376
+ "description": "First experience of core value"
18377
+ },
18378
+ {
18379
+ "label": "Expansion Motion",
18380
+ "role": "expansion_motion",
18381
+ "entityTypeId": "sales_motion",
18382
+ "description": "How usage spreads within accounts"
18383
+ },
18384
+ {
18385
+ "label": "Monetisation Trigger",
18386
+ "role": "monetisation_trigger",
18387
+ "entityTypeId": "gtm_strategy",
18388
+ "description": "When and how to convert to paid"
18389
+ },
18390
+ {
18391
+ "label": "Metric",
18392
+ "role": "metric",
18393
+ "entityTypeId": "metric",
18394
+ "description": "Key metric driving the PLG motion"
18395
+ }
18396
+ ],
18397
+ "data": {
18398
+ "entity_types": [
18399
+ {
18400
+ "type": "gtm_strategy",
18401
+ "role": "item"
18402
+ },
18403
+ {
18404
+ "type": "sales_motion",
18405
+ "role": "item"
18406
+ },
18407
+ {
18408
+ "type": "metric",
18409
+ "role": "item"
18410
+ }
18411
+ ],
18412
+ "required_properties": {}
18413
+ },
18414
+ "structure": {
18415
+ "pattern": "flow"
18416
+ },
18417
+ "presentation": {
18418
+ "layout": {
18419
+ "type": "flow",
18420
+ "direction": "LR"
18421
+ },
18422
+ "colour_by": "status",
18423
+ "card_fields": [
18424
+ "title",
18425
+ "description",
18426
+ "status"
18427
+ ]
18428
+ },
18429
+ "education": {
18430
+ "purpose": "Design the product itself as the primary growth driver (free tier, self-serve onboarding, in-product virality) to reduce dependence on sales-led acquisition.",
18431
+ "core_question": "Can our product acquire, activate, and expand users without human intervention, and where in the loop do we still need sales?",
18432
+ "when_to_use": [
18433
+ "You are launching a new product, feature, or entering a new market",
18434
+ "You need to coordinate cross-functional launch activities",
18435
+ "You want to define target customers, channels, and messaging"
18436
+ ],
18437
+ "when_not_to_use": [
18438
+ "The product is mature with established distribution channels",
18439
+ "You are iterating on an existing product for existing customers"
18440
+ ]
18441
+ }
18442
+ },
18443
+ {
18444
+ "id": "gtm-playbook",
18445
+ "approach_ids": [
18446
+ "plan"
18447
+ ],
18448
+ "name": "GTM Playbook",
18449
+ "version": "1.0.0",
18450
+ "description": "End-to-end go-to-market plan. Covers market analysis, ICP definition, positioning, messaging, channel strategy, launch plan, and success metrics in a sequential flow.",
18451
+ "category": "go_to_market",
18452
+ "origin": {
18453
+ "type": "practitioner",
18454
+ "description": "Synthesised from go-to-market practice across product marketing, sales, and customer success. The playbook format coordinates all GTM functions into a single executable plan.",
18455
+ "license": "cc_by"
17088
18456
  },
17089
18457
  "tags": [
17090
18458
  "go_to_market",
@@ -17092,34 +18460,34 @@ var UPG_FRAMEWORKS = [
17092
18460
  ],
17093
18461
  "slots": [
17094
18462
  {
17095
- "label": "Free Entry",
17096
- "role": "free_entry",
18463
+ "label": "Market Analysis",
17097
18464
  "entityTypeId": "gtm_strategy",
17098
- "description": "How users access the product for free"
18465
+ "description": "Market sizing and competitive landscape"
17099
18466
  },
17100
18467
  {
17101
- "label": "Aha Moment",
17102
- "role": "aha_moment",
17103
- "entityTypeId": "gtm_strategy",
17104
- "description": "First experience of core value"
18468
+ "label": "Ideal Customer Profile",
18469
+ "entityTypeId": "ideal_customer_profile",
18470
+ "description": "Ideal customer profile"
17105
18471
  },
17106
18472
  {
17107
- "label": "Expansion Motion",
17108
- "role": "expansion_motion",
17109
- "entityTypeId": "sales_motion",
17110
- "description": "How usage spreads within accounts"
18473
+ "label": "Positioning",
18474
+ "entityTypeId": "positioning",
18475
+ "description": "How you position against alternatives"
17111
18476
  },
17112
18477
  {
17113
- "label": "Monetisation Trigger",
17114
- "role": "monetisation_trigger",
17115
- "entityTypeId": "gtm_strategy",
17116
- "description": "When and how to convert to paid"
18478
+ "label": "Messaging",
18479
+ "entityTypeId": "messaging",
18480
+ "description": "Key messages by audience"
17117
18481
  },
17118
18482
  {
17119
- "label": "Metric",
17120
- "role": "metric",
17121
- "entityTypeId": "metric",
17122
- "description": "Key metric driving the PLG motion"
18483
+ "label": "Launch",
18484
+ "entityTypeId": "launch",
18485
+ "description": "Launch activities and timeline"
18486
+ },
18487
+ {
18488
+ "label": "Sales Motion",
18489
+ "entityTypeId": "sales_motion",
18490
+ "description": "How you will sell"
17123
18491
  }
17124
18492
  ],
17125
18493
  "data": {
@@ -17129,11 +18497,23 @@ var UPG_FRAMEWORKS = [
17129
18497
  "role": "item"
17130
18498
  },
17131
18499
  {
17132
- "type": "sales_motion",
18500
+ "type": "ideal_customer_profile",
17133
18501
  "role": "item"
17134
18502
  },
17135
18503
  {
17136
- "type": "metric",
18504
+ "type": "positioning",
18505
+ "role": "item"
18506
+ },
18507
+ {
18508
+ "type": "messaging",
18509
+ "role": "item"
18510
+ },
18511
+ {
18512
+ "type": "launch",
18513
+ "role": "item"
18514
+ },
18515
+ {
18516
+ "type": "sales_motion",
17137
18517
  "role": "item"
17138
18518
  }
17139
18519
  ],
@@ -17155,8 +18535,8 @@ var UPG_FRAMEWORKS = [
17155
18535
  ]
17156
18536
  },
17157
18537
  "education": {
17158
- "purpose": "Design the product itself as the primary growth driver (free tier, self-serve onboarding, in-product virality) to reduce dependence on sales-led acquisition.",
17159
- "core_question": "Can our product acquire, activate, and expand users without human intervention, and where in the loop do we still need sales?",
18538
+ "purpose": "Orchestrate every go-to-market activity (messaging, channels, pricing, enablement, launch timeline) into a single executable plan.",
18539
+ "core_question": "Do all go-to-market functions (product, marketing, sales, CS) have a shared plan for how we'll bring this to market?",
17160
18540
  "when_to_use": [
17161
18541
  "You are launching a new product, feature, or entering a new market",
17162
18542
  "You need to coordinate cross-functional launch activities",
@@ -19369,6 +20749,173 @@ var UPG_FRAMEWORKS = [
19369
20749
  "You are in a delivery phase with clear requirements"
19370
20750
  ]
19371
20751
  }
20752
+ },
20753
+ {
20754
+ "id": "assumption-map",
20755
+ "approach_ids": [
20756
+ "reflect"
20757
+ ],
20758
+ "name": "Assumption Map",
20759
+ "version": "1.0.0",
20760
+ "description": "Plot assumptions on axes of uncertainty vs risk. Most risky + most uncertain = test first.",
20761
+ "category": "validation",
20762
+ "origin": {
20763
+ "type": "practitioner",
20764
+ "attribution": "David Bland",
20765
+ "description": "Published in Testing Business Ideas (Wiley). Prioritises which assumptions to validate first.",
20766
+ "url": "https://www.strategyzer.com/books/testing-business-ideas",
20767
+ "year": 2019,
20768
+ "license": "published_methodology"
20769
+ },
20770
+ "tags": [
20771
+ "validation",
20772
+ "quadrant"
20773
+ ],
20774
+ "slots": [
20775
+ {
20776
+ "label": "Test First",
20777
+ "entityTypeId": "assumption",
20778
+ "role": "test_first",
20779
+ "description": "High risk, low confidence: validate immediately",
20780
+ "predicate": [
20781
+ {
20782
+ "scope": "entity",
20783
+ "property": "risk_level",
20784
+ "op": "band",
20785
+ "value": [
20786
+ 4,
20787
+ 6
20788
+ ]
20789
+ },
20790
+ {
20791
+ "scope": "entity",
20792
+ "property": "confidence",
20793
+ "op": "band",
20794
+ "value": [
20795
+ 1,
20796
+ 4
20797
+ ]
20798
+ }
20799
+ ]
20800
+ },
20801
+ {
20802
+ "label": "Research",
20803
+ "entityTypeId": "assumption",
20804
+ "role": "research",
20805
+ "description": "Low risk, low confidence: learn more",
20806
+ "predicate": [
20807
+ {
20808
+ "scope": "entity",
20809
+ "property": "risk_level",
20810
+ "op": "band",
20811
+ "value": [
20812
+ 1,
20813
+ 4
20814
+ ]
20815
+ },
20816
+ {
20817
+ "scope": "entity",
20818
+ "property": "confidence",
20819
+ "op": "band",
20820
+ "value": [
20821
+ 1,
20822
+ 4
20823
+ ]
20824
+ }
20825
+ ]
20826
+ },
20827
+ {
20828
+ "label": "Monitor",
20829
+ "entityTypeId": "assumption",
20830
+ "role": "monitor",
20831
+ "description": "High risk but well understood: watch closely",
20832
+ "predicate": [
20833
+ {
20834
+ "scope": "entity",
20835
+ "property": "risk_level",
20836
+ "op": "band",
20837
+ "value": [
20838
+ 4,
20839
+ 6
20840
+ ]
20841
+ },
20842
+ {
20843
+ "scope": "entity",
20844
+ "property": "confidence",
20845
+ "op": "band",
20846
+ "value": [
20847
+ 4,
20848
+ 6
20849
+ ]
20850
+ }
20851
+ ]
20852
+ },
20853
+ {
20854
+ "label": "Accept",
20855
+ "entityTypeId": "assumption",
20856
+ "role": "accept",
20857
+ "description": "Low risk, high confidence: safe to proceed",
20858
+ "predicate": [
20859
+ {
20860
+ "scope": "entity",
20861
+ "property": "risk_level",
20862
+ "op": "band",
20863
+ "value": [
20864
+ 1,
20865
+ 4
20866
+ ]
20867
+ },
20868
+ {
20869
+ "scope": "entity",
20870
+ "property": "confidence",
20871
+ "op": "band",
20872
+ "value": [
20873
+ 4,
20874
+ 6
20875
+ ]
20876
+ }
20877
+ ]
20878
+ }
20879
+ ],
20880
+ "data": {
20881
+ "entity_types": [
20882
+ {
20883
+ "type": "assumption",
20884
+ "role": "item"
20885
+ }
20886
+ ],
20887
+ "required_properties": {}
20888
+ },
20889
+ "structure": {
20890
+ "pattern": "quadrant"
20891
+ },
20892
+ "presentation": {
20893
+ "layout": {
20894
+ "type": "quadrant",
20895
+ "x_axis": "confidence",
20896
+ "y_axis": "risk_level",
20897
+ "x_label": "Confidence",
20898
+ "y_label": "Risk"
20899
+ },
20900
+ "colour_by": "group",
20901
+ "card_fields": [
20902
+ "title",
20903
+ "description"
20904
+ ]
20905
+ },
20906
+ "education": {
20907
+ "purpose": "Plot assumptions on a risk/evidence matrix so teams test the most dangerous unknowns first instead of building on unvalidated beliefs.",
20908
+ "core_question": "Which assumptions carry the most risk and the least evidence? Where do we run experiments first?",
20909
+ "when_to_use": [
20910
+ "You have hypotheses about user needs or solutions that need testing",
20911
+ "You want to reduce risk before committing engineering resources",
20912
+ "The team is debating assumptions that can be tested empirically"
20913
+ ],
20914
+ "when_not_to_use": [
20915
+ "The solution is already validated through real usage data",
20916
+ "Speed of shipping matters more than certainty about assumptions"
20917
+ ]
20918
+ }
19372
20919
  }
19373
20920
  ];
19374
20921
  var UPG_FRAMEWORKS_BY_ID = Object.fromEntries(
@@ -19928,6 +21475,44 @@ function validateValue(key, def, val) {
19928
21475
  }
19929
21476
  break;
19930
21477
  }
21478
+ case "string[]": {
21479
+ if (!Array.isArray(val)) {
21480
+ errors.push(`property "${key}" must be an array of strings`);
21481
+ break;
21482
+ }
21483
+ if (val.some((v) => typeof v !== "string")) {
21484
+ errors.push(`property "${key}" must contain only strings`);
21485
+ }
21486
+ if (val.length === 0) {
21487
+ errors.push(`property "${key}" must not be empty`);
21488
+ }
21489
+ break;
21490
+ }
21491
+ case "object": {
21492
+ if (typeof val !== "object" || val === null || Array.isArray(val)) {
21493
+ errors.push(`property "${key}" must be an object`);
21494
+ break;
21495
+ }
21496
+ const obj = val;
21497
+ for (const req2 of def.required ?? []) {
21498
+ if (obj[req2] === void 0 || obj[req2] === null) {
21499
+ errors.push(`property "${key}" is missing required key "${req2}"`);
21500
+ }
21501
+ }
21502
+ for (const [subKey, subDef] of Object.entries(def.properties ?? {})) {
21503
+ const subVal = obj[subKey];
21504
+ if (subVal === void 0 || subVal === null) continue;
21505
+ errors.push(...validateValue(`${key}.${subKey}`, subDef, subVal));
21506
+ }
21507
+ for (const presentKey of Object.keys(obj)) {
21508
+ if (def.properties && !(presentKey in def.properties)) {
21509
+ errors.push(
21510
+ `property "${key}" has unknown key "${presentKey}" (allowed: ${Object.keys(def.properties).join(", ")})`
21511
+ );
21512
+ }
21513
+ }
21514
+ break;
21515
+ }
19931
21516
  }
19932
21517
  return errors;
19933
21518
  }
@@ -20001,6 +21586,7 @@ var ENTITY_EMOJI = {
20001
21586
  competitor_signal: "\u{1F4E1}",
20002
21587
  compliance_framework: "\u{1F3DF}\uFE0F",
20003
21588
  compliance_requirement: "\u26F2",
21589
+ composition: "\u{1F5BC}\uFE0F",
20004
21590
  constraint: "\u{1F512}",
20005
21591
  contact: "\u{1F4C7}",
20006
21592
  content_calendar: "\u{1F4C5}",
@@ -20146,6 +21732,7 @@ var ENTITY_EMOJI = {
20146
21732
  pipeline_sales: "\u{1FA88}",
20147
21733
  pipeline_stage: "\u{1F9FD}",
20148
21734
  planning_cycle: "\u{1F5D3}\uFE0F",
21735
+ configuration_axis: "\u{1F39A}\uFE0F",
20149
21736
  playbook: "\u{1F4D2}",
20150
21737
  portfolio: "\u{1F9F8}",
20151
21738
  positioning: "\u{1F3AA}",
@@ -20840,6 +22427,7 @@ var STANDARD_LABELS = {
20840
22427
  roadmap_theme: { alt_labels: ["product theme", "roadmap theme"] },
20841
22428
  // UPG-660: renamed from bare 'theme' (N6 lineage)
20842
22429
  planning_cycle: { alt_labels: ["sprint", "iteration", "cycle", "program increment", "pi", "cadence", "time-box", "cooldown", "quarter"] },
22430
+ configuration_axis: { alt_labels: ["configuration", "config axis", "lever", "variant axis", "plan axis", "flag axis", "entitlement axis"] },
20843
22431
  // Engineering layer
20844
22432
  bounded_context: { alt_labels: ["context", "domain boundary", "module boundary"] },
20845
22433
  service: { alt_labels: ["microservice", "backend service", "api service"] },
@@ -23361,7 +24949,7 @@ var PRODUCT_SPEC_GUIDE = {
23361
24949
  // registered to product_spec but missing from the navigation order).
23362
24950
  // `changelog` lives here because it is a structural product-shipping
23363
24951
  // artefact; content domain references it only via cross-domain bridges.
23364
- creation_sequence: ["feature_area", "feature", "epic", "user_story", "acceptance_criterion", "task", "bug", "release", "roadmap", "roadmap_item", "roadmap_theme", "changelog", "planning_cycle"],
24952
+ creation_sequence: ["feature_area", "feature", "epic", "user_story", "acceptance_criterion", "task", "bug", "release", "roadmap", "roadmap_item", "roadmap_theme", "changelog", "planning_cycle", "configuration_axis"],
23365
24953
  patterns: [
23366
24954
  {
23367
24955
  name: "Feature Decomposition",
@@ -24076,12 +25664,21 @@ var PORTFOLIO_GUIDE = {
24076
25664
  var WORKSPACE_GUIDE = {
24077
25665
  domain_id: "workspace",
24078
25666
  anchor_entity: "workspace",
24079
- creation_sequence: ["workspace", "framework_exercise"],
24080
- patterns: [],
25667
+ creation_sequence: ["workspace", "framework_exercise", "composition"],
25668
+ patterns: [
25669
+ {
25670
+ name: "Canvas to published view",
25671
+ description: "A workspace arranges the entities being thought about, produces the composition published from it, and the composition names what it focuses on so the published view stays answerable to the graph",
25672
+ entity_types: ["workspace", "composition"],
25673
+ edge_chain: ["workspace_arranges_node", "workspace_produced_node", "composition_focuses_node"]
25674
+ }
25675
+ ],
24081
25676
  required_bridges: [],
24082
25677
  anti_patterns: [
24083
25678
  { description: "Workspaces are containers for exploration: do not over-structure them" },
24084
- { description: "Workspace content is transient by default: promote discoveries to the graph, do not leave them in the workspace" }
25679
+ { description: "Workspace content is transient by default: promote discoveries to the graph, do not leave them in the workspace" },
25680
+ { description: "Treating a canvas arrangement as structure: a workspace_arranges_node edge says where a card sits, never what contains what. The arranged entity keeps its real parent" },
25681
+ { description: "Writing every scratch canvas into a shared graph: only a workspace someone deliberately kept (retention: durable) belongs in a file other people pull" }
24085
25682
  ]
24086
25683
  };
24087
25684
  var FOUNDATIONS_GUIDE = {
@@ -27050,7 +28647,7 @@ var UPG_ANTI_PATTERNS = [
27050
28647
  id: "contended-surface-without-arbitration",
27051
28648
  since: "0.27.0",
27052
28649
  name: "Contended surface without arbitration",
27053
- 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.',
28650
+ description: 'At least one surface 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) and carries no arbitration_rule, or it admits through arbitration_state that the answer is absent (`none`) or accidental (`safe_by_coincidence`). Unrecorded, that answer lives in whoever remembers the last argument.',
27054
28651
  // 0.28.0 (feedback 852a9721, from a 30-surface field audit). Three changes,
27055
28652
  // each earned by a reported false positive or a reported blind spot:
27056
28653
  //
@@ -27144,12 +28741,26 @@ var UPG_ANTI_PATTERNS = [
27144
28741
  // the natural move if the ordering detector is ever minted; it is not worth a
27145
28742
  // breaking property change before then.
27146
28743
  //
27147
- // STILL OPEN (banked at 0.28.0, deliberately NOT changed here): a `chained`
27148
- // slot that honestly declares `arbitration_state: 'none'` re-fires through
27149
- // branch (c), which carries no chained exemption. Current guidance stands:
27150
- // leave `arbitration_state` unset on chained surfaces. Changing (b)/(c) was
27151
- // out of scope for this release and still awaits the field confirmation the
27152
- // reporter will produce populating their 14 chained slots.
28744
+ // CLOSED (banked at 0.28.0, field-confirmed and resolved here): a `chained`
28745
+ // slot that honestly declared `arbitration_state: 'none'` used to re-fire
28746
+ // through branch (c), which carries no chained exemption. The field
28747
+ // confirmation arrived from a second reporter whose census left the state
28748
+ // unset on every one of its chained frames and named this branch as the
28749
+ // reason. THE FIX WAS THE VOCABULARY, NOT THE DETECTOR. Branch (c) matches
28750
+ // the literal string 'none', so the new fifth `SurfaceArbitrationState`
28751
+ // member `no_contention_by_design` never matches it and NOTHING in this
28752
+ // file, the evaluator, or the collector changed.
28753
+ //
28754
+ // Exempting `chained` from (c) was the alternative and it lost on both
28755
+ // correctness and cost. Correctness: `none` is defined as "nobody ever
28756
+ // decided", which is FALSE on a chained shell that has no decision to make,
28757
+ // and silencing a check on a false statement is worse than the empty field
28758
+ // it replaces. Cost: `evaluateEntityCount`'s except form covers the
28759
+ // PRESENCE filter only; the value-keyed path is a bare indexed lookup with
28760
+ // no exception arm, so giving it one means a parallel derived spec set, a
28761
+ // new collector aggregate keyed by (type, property, value, except_property,
28762
+ // except_value), the derivation walk, and its tests. That is the whole
28763
+ // 0.28.0 evaluator rider again, spent to make a false statement quiet.
27153
28764
  structured_condition: {
27154
28765
  operator: "and",
27155
28766
  checks: [
@@ -27202,7 +28813,23 @@ var UPG_ANTI_PATTERNS = [
27202
28813
  ]
27203
28814
  },
27204
28815
  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.",
27205
- 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.",
28816
+ // Remediation is deliberately the ACTIONABLE CORE only. It is read by
28817
+ // someone who has already been told there is a problem and now needs the
28818
+ // next move, so rationale in it is strictly in the way. The reasoning that
28819
+ // used to live here (why transcription is ten minutes rather than a
28820
+ // meeting, why safe_by_coincidence is the dangerous state, why declaring
28821
+ // capacity is the honest fix rather than inventing a rule, and why the
28822
+ // chained exemption is a factual claim rather than a mute button) is all
28823
+ // above in this comment block, which is where a reader who wants it looks.
28824
+ remediation: (
28825
+ // Remediation is read by someone already told there is a problem and now
28826
+ // wanting the action, so rationale is strictly in the way. The reasoning
28827
+ // trimmed out in 0.31.0: a surface at its designed capacity should state
28828
+ // that capacity rather than invent an arbitration rule it does not need,
28829
+ // and leaving the state empty on a chained surface reads as unassessed
28830
+ // rather than as "nothing to arbitrate".
28831
+ "Start from `target_node_ids`, then read `arbitration_state`: `enforced_undocumented` needs the rule transcribed into `arbitration_rule`, `none` needs a decision, `safe_by_coincidence` needs a guard in code. A surface holding all it was designed to should state its `capacity`. Occupants that wrap rather than compete declare `composition_mode: 'chained'` with `arbitration_state: 'no_contention_by_design'`."
28832
+ ),
27206
28833
  stages: ["build", "beta", "launch", "growth", "mature", "maintenance"],
27207
28834
  severity: "medium",
27208
28835
  source: { kind: "fundamental" }
@@ -28776,6 +30403,11 @@ var UPG_REGIONS = [
28776
30403
  type: "planning_cycle",
28777
30404
  role: "container",
28778
30405
  notes: "self-nesting cadence axis"
30406
+ },
30407
+ {
30408
+ type: "configuration_axis",
30409
+ role: "leaf",
30410
+ notes: "the configuration lever whose values select which surface tree the product renders"
28779
30411
  }
28780
30412
  ],
28781
30413
  anchor: {
@@ -30169,6 +31801,63 @@ var UPG_STRUCTURE_PATTERNS = [
30169
31801
  "flow"
30170
31802
  ];
30171
31803
 
31804
+ // src/frameworks/relational-paths.ts
31805
+ function resolveRelationalPath(spine, path, catalog = UPG_EDGE_CATALOG) {
31806
+ const steps = [];
31807
+ if (path.length === 0) {
31808
+ return { steps, endpoint: null, reason: "path is empty; a projection must declare at least one step", failedAt: 0 };
31809
+ }
31810
+ let current = spine;
31811
+ for (let i = 0; i < path.length; i++) {
31812
+ const step = path[i];
31813
+ const entry = catalog[step.edge];
31814
+ if (!entry) {
31815
+ return {
31816
+ steps,
31817
+ endpoint: null,
31818
+ failedAt: i,
31819
+ reason: `unknown edge type "${step.edge}" \u2014 not in the UPG edge catalog`
31820
+ };
31821
+ }
31822
+ if (step.direction !== "forward" && step.direction !== "reverse") {
31823
+ return {
31824
+ steps,
31825
+ endpoint: null,
31826
+ failedAt: i,
31827
+ reason: `step direction must be "forward" or "reverse", got "${String(step.direction)}"`
31828
+ };
31829
+ }
31830
+ const entersAt = step.direction === "forward" ? entry.source_type : entry.target_type;
31831
+ const landsOn = step.direction === "forward" ? entry.target_type : entry.source_type;
31832
+ if (entersAt !== current) {
31833
+ return {
31834
+ steps,
31835
+ endpoint: null,
31836
+ failedAt: i,
31837
+ reason: `step ${i} ("${step.edge}" ${step.direction}) enters at "${entersAt}" but the traversal is at "${current}". The edge is declared ${entry.source_type} -> ${entry.target_type}; traversing it ${step.direction} requires being at "${entersAt}".`
31838
+ };
31839
+ }
31840
+ steps.push({ from: current, to: landsOn });
31841
+ current = landsOn;
31842
+ }
31843
+ return { steps, endpoint: current, reason: null, failedAt: -1 };
31844
+ }
31845
+ function relationalCoveredEntityTypes(fw) {
31846
+ const covered = /* @__PURE__ */ new Set();
31847
+ const rel = fw.relational;
31848
+ if (!rel) return covered;
31849
+ covered.add(rel.spine);
31850
+ for (const column of rel.columns) {
31851
+ if (column.kind !== "projection") continue;
31852
+ const resolved = resolveRelationalPath(rel.spine, column.path);
31853
+ for (const step of resolved.steps) {
31854
+ covered.add(step.from);
31855
+ covered.add(step.to);
31856
+ }
31857
+ }
31858
+ return covered;
31859
+ }
31860
+
30172
31861
  // src/frameworks/validate.ts
30173
31862
  function validateUPGFramework(framework) {
30174
31863
  const errors = [];
@@ -30273,6 +31962,25 @@ function validateUPGFramework(framework) {
30273
31962
  }
30274
31963
  }
30275
31964
  }
31965
+ if (f.relational !== void 0) {
31966
+ if (typeof f.relational !== "object" || f.relational === null || Array.isArray(f.relational)) {
31967
+ errors.push('"relational" must be an object when present');
31968
+ } else {
31969
+ const rel = f.relational;
31970
+ errors.push(...validateRelationalSurface(rel));
31971
+ if (Array.isArray(f.slots) && f.slots.length > 0) {
31972
+ warnings.push(
31973
+ '"relational" and "slots" are both declared. `relational` is authoritative for a join surface; slots cannot express an edge path and will drift. Remove "slots" from this framework.'
31974
+ );
31975
+ }
31976
+ const presLayout = f.presentation?.layout;
31977
+ if (presLayout?.type === "table" && Array.isArray(presLayout.columns) && presLayout.columns.length > 0) {
31978
+ errors.push(
31979
+ `"presentation.layout.columns" declares ${presLayout.columns.length} columns, but "relational" is the authoritative roster for a join surface. Empty the presentation columns; do not maintain both.`
31980
+ );
31981
+ }
31982
+ }
31983
+ }
30276
31984
  if (!f.education || typeof f.education !== "object") {
30277
31985
  errors.push('"education" is required and must be an object');
30278
31986
  } else {
@@ -30290,8 +31998,144 @@ function validateUPGFramework(framework) {
30290
31998
  errors.push('"education.when_not_to_use" is required and must be an array');
30291
31999
  }
30292
32000
  }
32001
+ validateSlotPredicates(f.slots, errors);
30293
32002
  return { valid: errors.length === 0, errors, warnings };
30294
32003
  }
32004
+ var PREDICATE_SCOPES = ["entity", "framework"];
32005
+ var PREDICATE_OPS = ["eq", "in", "gte", "lt", "band"];
32006
+ function admits(atom) {
32007
+ switch (atom.op) {
32008
+ case "eq":
32009
+ return { kind: "discrete", values: [atom.value] };
32010
+ case "in":
32011
+ return { kind: "discrete", values: atom.value };
32012
+ case "gte":
32013
+ return { kind: "interval", lo: atom.value, hi: Number.POSITIVE_INFINITY };
32014
+ case "lt":
32015
+ return { kind: "interval", lo: Number.NEGATIVE_INFINITY, hi: atom.value };
32016
+ case "band":
32017
+ return { kind: "interval", lo: atom.value[0], hi: atom.value[1] };
32018
+ }
32019
+ }
32020
+ function atomsCannotBothHold(a, b) {
32021
+ const x = admits(a);
32022
+ const y = admits(b);
32023
+ if (x.kind === "discrete" && y.kind === "discrete") {
32024
+ return !x.values.some((v) => y.values.includes(v));
32025
+ }
32026
+ if (x.kind === "interval" && y.kind === "interval") {
32027
+ return !(x.lo < y.hi && y.lo < x.hi);
32028
+ }
32029
+ const set = x.kind === "discrete" ? x : y;
32030
+ const range = x.kind === "interval" ? x : y;
32031
+ return !set.values.some((v) => typeof v === "number" && v >= range.lo && v < range.hi);
32032
+ }
32033
+ function predicatesProvablyDisjoint(a, b) {
32034
+ for (const atomA of a) {
32035
+ for (const atomB of b) {
32036
+ if (atomA.scope === atomB.scope && atomA.property === atomB.property && atomsCannotBothHold(atomA, atomB)) {
32037
+ return true;
32038
+ }
32039
+ }
32040
+ }
32041
+ return false;
32042
+ }
32043
+ function parseAtom(atom, where, errors) {
32044
+ if (!atom || typeof atom !== "object" || Array.isArray(atom)) {
32045
+ errors.push(`${where} must be an object`);
32046
+ return null;
32047
+ }
32048
+ const a = atom;
32049
+ let ok = true;
32050
+ if (typeof a.scope !== "string" || !PREDICATE_SCOPES.includes(a.scope)) {
32051
+ errors.push(`${where}.scope must be one of: ${PREDICATE_SCOPES.join(", ")}`);
32052
+ ok = false;
32053
+ }
32054
+ if (typeof a.property !== "string" || a.property.length === 0) {
32055
+ errors.push(`${where}.property is required and must be a non-empty string`);
32056
+ ok = false;
32057
+ }
32058
+ if (typeof a.op !== "string" || !PREDICATE_OPS.includes(a.op)) {
32059
+ errors.push(`${where}.op must be one of: ${PREDICATE_OPS.join(", ")}`);
32060
+ return null;
32061
+ }
32062
+ switch (a.op) {
32063
+ case "eq":
32064
+ if (!["string", "number", "boolean"].includes(typeof a.value)) {
32065
+ errors.push(`${where}.value must be a string, number, or boolean for op "eq"`);
32066
+ ok = false;
32067
+ }
32068
+ break;
32069
+ case "in":
32070
+ if (!Array.isArray(a.value) || a.value.length === 0 || !a.value.every((v) => typeof v === "string" || typeof v === "number")) {
32071
+ errors.push(`${where}.value must be a non-empty array of strings or numbers for op "in"`);
32072
+ ok = false;
32073
+ }
32074
+ break;
32075
+ case "gte":
32076
+ case "lt":
32077
+ if (typeof a.value !== "number" || !Number.isFinite(a.value)) {
32078
+ errors.push(`${where}.value must be a finite number for op "${a.op}"`);
32079
+ ok = false;
32080
+ }
32081
+ break;
32082
+ case "band":
32083
+ if (!Array.isArray(a.value) || a.value.length !== 2 || !a.value.every((v) => typeof v === "number" && Number.isFinite(v))) {
32084
+ errors.push(`${where}.value must be a [min, max] pair of finite numbers for op "band"`);
32085
+ ok = false;
32086
+ } else if (a.value[0] >= a.value[1]) {
32087
+ errors.push(
32088
+ `${where}.value is an empty band: min (${a.value[0]}) must be less than max (${a.value[1]}). The interval is half-open [min, max)`
32089
+ );
32090
+ ok = false;
32091
+ }
32092
+ break;
32093
+ }
32094
+ return ok ? a : null;
32095
+ }
32096
+ function validateSlotPredicates(slots, errors) {
32097
+ if (!Array.isArray(slots)) return;
32098
+ const zones = [];
32099
+ slots.forEach((slot, i) => {
32100
+ if (!slot || typeof slot !== "object") return;
32101
+ const s = slot;
32102
+ if (s.predicate === void 0 || s.predicate === null) return;
32103
+ const label = typeof s.label === "string" && s.label.length > 0 ? ` ("${s.label}")` : "";
32104
+ const where = `"slots[${i}]${label}.predicate"`;
32105
+ if (!Array.isArray(s.predicate)) {
32106
+ errors.push(
32107
+ `${where} must be an array of atoms. A slot's membership rule is a conjunction, even when it has one atom`
32108
+ );
32109
+ return;
32110
+ }
32111
+ if (s.predicate.length === 0) {
32112
+ errors.push(
32113
+ `${where} must not be empty. A zero-atom predicate matches every entity, which is a catch-all cell \u2014 declare the property that decides membership`
32114
+ );
32115
+ return;
32116
+ }
32117
+ const atoms = s.predicate.map((atom, j) => parseAtom(atom, `${where}[${j}]`, errors));
32118
+ if (atoms.some((a) => a === null)) return;
32119
+ zones.push({
32120
+ where: `slots[${i}]${label}`,
32121
+ entityTypeId: typeof s.entityTypeId === "string" ? s.entityTypeId : null,
32122
+ predicate: atoms
32123
+ });
32124
+ });
32125
+ for (let i = 0; i < zones.length; i++) {
32126
+ for (let j = i + 1; j < zones.length; j++) {
32127
+ const a = zones[i];
32128
+ const b = zones[j];
32129
+ if (a.entityTypeId && b.entityTypeId && a.entityTypeId !== b.entityTypeId) continue;
32130
+ if (predicatesProvablyDisjoint(a.predicate, b.predicate)) continue;
32131
+ const shared = a.predicate.filter((x) => b.predicate.some((y) => y.scope === x.scope && y.property === x.property)).map((x) => `${x.scope}.${x.property}`);
32132
+ const hint = shared.length ? `They constrain the same properties (${[...new Set(shared)].join(", ")}) without separating on any of them` : `They constrain no property in common, so an entity can satisfy both`;
32133
+ errors.push(
32134
+ `"${a.where}" and "${b.where}" have predicates that are not provably disjoint, so an entity could land in both zones. ${hint}. Add an atom that separates them \u2014 for example, a value range on one property that the other excludes`
32135
+ );
32136
+ }
32137
+ }
32138
+ }
30295
32139
  function validateExpression(expr) {
30296
32140
  if (expr.trim().length === 0) {
30297
32141
  return "Expression must not be empty";
@@ -30311,6 +32155,122 @@ function validateExpression(expr) {
30311
32155
  }
30312
32156
  return null;
30313
32157
  }
32158
+ function validateRelationalSurface(rel) {
32159
+ const errors = [];
32160
+ const spine = rel.spine;
32161
+ if (!spine || typeof spine !== "string") {
32162
+ errors.push('"relational.spine" is required and must be a string (the entity type whose instances are the rows)');
32163
+ }
32164
+ if (!Array.isArray(rel.columns) || rel.columns.length === 0) {
32165
+ errors.push('"relational.columns" is required and must be a non-empty array');
32166
+ return errors;
32167
+ }
32168
+ const seenIds = /* @__PURE__ */ new Set();
32169
+ for (let i = 0; i < rel.columns.length; i++) {
32170
+ const col = rel.columns[i];
32171
+ const at = `"relational.columns[${i}]"`;
32172
+ if (!col || typeof col !== "object" || Array.isArray(col)) {
32173
+ errors.push(`${at} must be an object`);
32174
+ continue;
32175
+ }
32176
+ const c = col;
32177
+ if (!c.id || typeof c.id !== "string") {
32178
+ errors.push(`${at}.id is required and must be a string`);
32179
+ } else if (seenIds.has(c.id)) {
32180
+ errors.push(`${at}.id "${c.id}" is duplicated; column ids must be unique within a surface`);
32181
+ } else {
32182
+ seenIds.add(c.id);
32183
+ }
32184
+ if (!c.label || typeof c.label !== "string") {
32185
+ errors.push(`${at}.label is required and must be a string`);
32186
+ }
32187
+ switch (c.kind) {
32188
+ case "field": {
32189
+ if (!c.property || typeof c.property !== "string") {
32190
+ errors.push(`${at}.property is required on a field column and must be a string`);
32191
+ }
32192
+ if ("entityTypeId" in c) {
32193
+ errors.push(
32194
+ `${at} is a field column and must NOT declare "entityTypeId" \u2014 its entity is the spine by definition. A field column that names a type is the slot-forced shape this block replaces.`
32195
+ );
32196
+ }
32197
+ if ("path" in c) {
32198
+ errors.push(`${at} is a field column and must NOT declare "path"; use kind:"projection" to traverse edges`);
32199
+ }
32200
+ break;
32201
+ }
32202
+ case "computed": {
32203
+ if (!c.expression || typeof c.expression !== "string") {
32204
+ errors.push(`${at}.expression is required on a computed column and must be a string`);
32205
+ } else {
32206
+ const exprError = validateExpression(c.expression);
32207
+ if (exprError) errors.push(`${at}.expression: ${exprError}`);
32208
+ }
32209
+ break;
32210
+ }
32211
+ case "projection": {
32212
+ if (!Array.isArray(c.path) || c.path.length === 0) {
32213
+ errors.push(
32214
+ `${at}.path is required on a projection column and must be a non-empty array of { edge, direction } steps`
32215
+ );
32216
+ } else {
32217
+ let stepShapeOk = true;
32218
+ for (let s = 0; s < c.path.length; s++) {
32219
+ const step = c.path[s];
32220
+ if (!step || typeof step !== "object" || Array.isArray(step)) {
32221
+ errors.push(`${at}.path[${s}] must be an object { edge, direction }`);
32222
+ stepShapeOk = false;
32223
+ continue;
32224
+ }
32225
+ const st = step;
32226
+ if (!st.edge || typeof st.edge !== "string") {
32227
+ errors.push(`${at}.path[${s}].edge is required and must be a string`);
32228
+ stepShapeOk = false;
32229
+ }
32230
+ if (st.direction !== "forward" && st.direction !== "reverse") {
32231
+ errors.push(
32232
+ `${at}.path[${s}].direction is required and must be "forward" or "reverse" (never inferred)`
32233
+ );
32234
+ stepShapeOk = false;
32235
+ }
32236
+ }
32237
+ if (stepShapeOk && typeof spine === "string") {
32238
+ const resolved = resolveRelationalPath(spine, c.path);
32239
+ if (resolved.reason) {
32240
+ errors.push(`${at}.path does not compose from spine "${spine}": ${resolved.reason}`);
32241
+ }
32242
+ }
32243
+ }
32244
+ if (!Array.isArray(c.fields) || c.fields.length === 0) {
32245
+ errors.push(`${at}.fields is required on a projection column and must be a non-empty array of property keys`);
32246
+ }
32247
+ if (c.limit !== void 0 && (typeof c.limit !== "number" || !Number.isInteger(c.limit) || c.limit < 1)) {
32248
+ errors.push(`${at}.limit must be a positive integer when present (it truncates rendering, never membership)`);
32249
+ }
32250
+ break;
32251
+ }
32252
+ default:
32253
+ errors.push(`${at}.kind must be one of: field, computed, projection. Got "${String(c.kind)}"`);
32254
+ }
32255
+ }
32256
+ const sort = rel.sort;
32257
+ if (!sort || typeof sort !== "object" || Array.isArray(sort)) {
32258
+ errors.push('"relational.sort" is required and must be an object { column, direction }');
32259
+ } else {
32260
+ const s = sort;
32261
+ if (!s.column || typeof s.column !== "string") {
32262
+ errors.push('"relational.sort.column" is required and must be a string (a column id)');
32263
+ } else if (seenIds.size > 0 && !seenIds.has(s.column)) {
32264
+ errors.push(
32265
+ `"relational.sort.column" is "${s.column}", which is not a declared column id. Declared ids: ${[...seenIds].join(", ")}`
32266
+ );
32267
+ }
32268
+ if (s.direction !== "asc" && s.direction !== "desc") {
32269
+ errors.push('"relational.sort.direction" is required and must be "asc" or "desc"');
32270
+ }
32271
+ }
32272
+ return errors;
32273
+ }
30314
32274
 
30315
32275
  // src/format/canonical.ts
30316
32276
  import * as nodeCrypto from "crypto";
@@ -30441,12 +32401,13 @@ function canonicalProduct(product) {
30441
32401
  function effectiveRootProduct(doc) {
30442
32402
  const node = doc.nodes?.find((n) => n.type === "product" && n.id === doc.product.id);
30443
32403
  if (!node) return doc.product;
32404
+ const propStage = node.properties?.stage;
32405
+ const nodeStage = (typeof propStage === "string" ? propStage : void 0) ?? node.status;
30444
32406
  return {
30445
32407
  ...doc.product,
30446
32408
  title: node.title ?? doc.product.title,
30447
32409
  description: node.description ?? doc.product.description,
30448
- // A product's lifecycle status IS its stage, the same axis (grammar/lifecycles).
30449
- stage: node.status ?? doc.product.stage
32410
+ stage: nodeStage ?? doc.product.stage
30450
32411
  };
30451
32412
  }
30452
32413
  function singleBody(doc) {
@@ -30692,7 +32653,7 @@ function checkHeaderSealText(text) {
30692
32653
  }
30693
32654
 
30694
32655
  // src/index.ts
30695
- var UPG_VERSION = "0.29.0";
32656
+ var UPG_VERSION = "0.31.0";
30696
32657
  var UPG_FORMAT_VERSION = "0.4.0";
30697
32658
  var MARKDOWN_FORMAT_VERSION = "0.1";
30698
32659
  var UPG_TYPES = getTypes();
@@ -30756,11 +32717,14 @@ var UPG_DOMAIN_COUNT = UPG_DOMAINS.length;
30756
32717
  var UPG_EDGE_COUNT = UPG_EDGE_TYPES.length;
30757
32718
  var UPG_META_COUNT = UPG_ENTITY_META.length;
30758
32719
  export {
32720
+ ACTIVE_WHEN_PROPERTY,
30759
32721
  ANALYTICS_DATA_PLAYBOOK,
30760
32722
  BUSINESS_GROWTH_METRIC_DRIVEN_PLAYBOOK,
30761
32723
  BUSINESS_GTM_GROWTH_PLAYBOOK,
30762
32724
  BUSINESS_MARKETING_AUDIENCE_FIRST_PLAYBOOK,
30763
32725
  CLASSIFICATION_EDGE_PROPERTY_SCHEMA,
32726
+ CONFIGURATION_QUALIFIER_EDGE_PROPERTY_SCHEMA,
32727
+ CONFIGURATION_VARIANCE_EDGE_PROPERTY_SCHEMA,
30764
32728
  CONTENT_DEPTH_WARNING_RULES,
30765
32729
  COVERAGE_ANTI_PATTERNS,
30766
32730
  DEFAULT_ENTITY_EMOJI,
@@ -30775,10 +32739,12 @@ export {
30775
32739
  MARKDOWN_FORMAT_VERSION,
30776
32740
  MARKET_COMPETITIVE_PLAYBOOK,
30777
32741
  OPERATIONS_QUALITY_PLAYBOOK,
32742
+ PRESENT_UNDER_PROPERTY,
30778
32743
  PRODUCT_DELIVERY_PLAYBOOK,
30779
32744
  PROPERTY_MODIFIERS,
30780
32745
  PROPERTY_MODIFIER_SEMANTICS,
30781
32746
  PROPERTY_SCALE_MAP,
32747
+ QUALIFIABLE_EDGE_TYPES,
30782
32748
  RECORD_ENTITY_TYPES,
30783
32749
  REFLECT_MODES,
30784
32750
  REGISTRY_PRODUCT_ID,
@@ -30861,9 +32827,12 @@ export {
30861
32827
  UPG_WILDCARD_ENDPOINT,
30862
32828
  USERS_NEEDS_PLAYBOOK,
30863
32829
  UnknownEntityTypeError,
32830
+ VARIES_BY_EDGE,
32831
+ WORKSPACE_ARRANGEMENT_EDGE_PROPERTY_SCHEMA,
30864
32832
  backfillSlug,
30865
32833
  buildTypeAliases,
30866
32834
  canBeChildOf,
32835
+ checkConfigurationDrift,
30867
32836
  checkHeaderSeal,
30868
32837
  checkHeaderSealText,
30869
32838
  checkToEdgeCountSpec,
@@ -30884,6 +32853,7 @@ export {
30884
32853
  edgeCarriesProperties,
30885
32854
  edgeCountSpecKey,
30886
32855
  entityFilterKey,
32856
+ enumerateProjections,
30887
32857
  evaluateAntiPatterns,
30888
32858
  findInvalidStatusMigrationTargets,
30889
32859
  formatUpgText,
@@ -30974,11 +32944,17 @@ export {
30974
32944
  normalizeDocument,
30975
32945
  parseUpg,
30976
32946
  pickCanonicalEdge,
32947
+ predicatesProvablyDisjoint,
30977
32948
  presenceExceptKey,
32949
+ projectGraph,
32950
+ readActiveWhen,
32951
+ readStringArray,
32952
+ relationalCoveredEntityTypes,
30978
32953
  resolveAllEdges,
30979
32954
  resolveContainmentEdge,
30980
32955
  resolveEntityType,
30981
32956
  resolveLabel,
32957
+ resolveRelationalPath,
30982
32958
  resolveSlugCollision,
30983
32959
  resolveTreePatternEdges,
30984
32960
  ringOrderedDomainIds,