@unified-product-graph/core 0.30.0 → 0.32.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.cjs CHANGED
@@ -141,6 +141,7 @@ __export(index_exports, {
141
141
  USERS_NEEDS_PLAYBOOK: () => USERS_NEEDS_PLAYBOOK,
142
142
  UnknownEntityTypeError: () => UnknownEntityTypeError,
143
143
  VARIES_BY_EDGE: () => VARIES_BY_EDGE,
144
+ WORKSPACE_ARRANGEMENT_EDGE_PROPERTY_SCHEMA: () => WORKSPACE_ARRANGEMENT_EDGE_PROPERTY_SCHEMA,
144
145
  backfillSlug: () => backfillSlug,
145
146
  buildTypeAliases: () => buildTypeAliases,
146
147
  canBeChildOf: () => canBeChildOf,
@@ -256,14 +257,17 @@ __export(index_exports, {
256
257
  normalizeDocument: () => normalizeDocument,
257
258
  parseUpg: () => parseUpg,
258
259
  pickCanonicalEdge: () => pickCanonicalEdge,
260
+ predicatesProvablyDisjoint: () => predicatesProvablyDisjoint,
259
261
  presenceExceptKey: () => presenceExceptKey,
260
262
  projectGraph: () => projectGraph,
261
263
  readActiveWhen: () => readActiveWhen,
262
264
  readStringArray: () => readStringArray,
265
+ relationalCoveredEntityTypes: () => relationalCoveredEntityTypes,
263
266
  resolveAllEdges: () => resolveAllEdges,
264
267
  resolveContainmentEdge: () => resolveContainmentEdge,
265
268
  resolveEntityType: () => resolveEntityType,
266
269
  resolveLabel: () => resolveLabel,
270
+ resolveRelationalPath: () => resolveRelationalPath,
267
271
  resolveSlugCollision: () => resolveSlugCollision,
268
272
  resolveTreePatternEdges: () => resolveTreePatternEdges,
269
273
  ringOrderedDomainIds: () => ringOrderedDomainIds,
@@ -674,7 +678,7 @@ var UPG_DOMAINS = [
674
678
  {
675
679
  id: "program_mgmt",
676
680
  label: "Program Management",
677
- 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).",
681
+ 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).",
678
682
  types: [
679
683
  "program",
680
684
  "project",
@@ -770,8 +774,8 @@ var UPG_DOMAINS = [
770
774
  {
771
775
  id: "workspace",
772
776
  label: "Workspace",
773
- 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.",
774
- types: ["workspace", "framework_exercise"]
777
+ 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.",
778
+ types: ["workspace", "framework_exercise", "composition", "capture"]
775
779
  },
776
780
  {
777
781
  id: "foundations",
@@ -1258,7 +1262,11 @@ var UPG_ENTITY_META = [
1258
1262
  { name: "partner_revenue_share", type_id: "ent_312", maturity: "stable", since: "0.1.0" },
1259
1263
  // ── Workspace ──
1260
1264
  { name: "workspace", type_id: "ent_328", maturity: "proposed", since: "0.2.0" },
1261
- { name: "framework_exercise", type_id: "ent_350", maturity: "proposed", since: "0.8.4" }
1265
+ { name: "framework_exercise", type_id: "ent_350", maturity: "proposed", since: "0.8.4" },
1266
+ { name: "composition", type_id: "ent_361", maturity: "proposed", since: "0.31.0" },
1267
+ // A dated, hashed rendition of a node that is already in the graph. Enters
1268
+ // `proposed` per the maturity rubric (precedent: composition 0.31.0).
1269
+ { name: "capture", type_id: "ent_362", maturity: "proposed", since: "0.32.0" }
1262
1270
  ];
1263
1271
  var DEFERRED_PROPOSED_BY_CONTRACT = {
1264
1272
  // user_story (UPG-571) and experiment_run (UPG-664) graduated to stable in
@@ -1352,6 +1360,24 @@ var CONFIGURATION_QUALIFIER_EDGE_PROPERTY_SCHEMA = {
1352
1360
  required: ["axis", "values"]
1353
1361
  }
1354
1362
  };
1363
+ var WORKSPACE_ARRANGEMENT_EDGE_PROPERTY_SCHEMA = {
1364
+ x: {
1365
+ type: "number",
1366
+ 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."
1367
+ },
1368
+ y: {
1369
+ type: "number",
1370
+ 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."
1371
+ },
1372
+ expanded: {
1373
+ type: "boolean",
1374
+ description: "Whether the card is drawn expanded rather than collapsed. Presentation state that survives a reload, unlike selection."
1375
+ },
1376
+ frame_id: {
1377
+ type: "string",
1378
+ 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."
1379
+ }
1380
+ };
1355
1381
  var UPG_EDGE_CATALOG = {
1356
1382
  // ── Part 1: Core Narrative Spine (Ring 1) ──────────────────────────────────
1357
1383
  // 1.1 User Domain
@@ -1528,6 +1554,24 @@ var UPG_EDGE_CATALOG = {
1528
1554
  // the catalog's singular-source/target grammar (polymorphic targets
1529
1555
  // would be a separate catalog-mechanic change).
1530
1556
  competitive_analysis_dimensioned_by_classification_axis: { forward_verb: "dimensioned_by", reverse_verb: "dimensions", classification: "hierarchy", source_type: "competitive_analysis", target_type: "classification_axis" },
1557
+ // A product's own classification axes (0.32.0). Before this the only parent a
1558
+ // classification_axis could have was a competitive_analysis, so a taxonomy the
1559
+ // product uses on its OWN entities — the grouped-label case, one named group
1560
+ // over a set of values — had to hang off a competitive analysis it has nothing
1561
+ // to do with, or float parentless.
1562
+ //
1563
+ // Hierarchy, so the axis has a home in get_tree, on the same honest verb the
1564
+ // competitive_analysis edge already uses; and it earns its
1565
+ // UPG_VALID_CHILDREN.product pair the same way product_defines_configuration_axis
1566
+ // did at 0.30.0 — axes are few per product, and visibility of a mechanism
1567
+ // matters more than the tree cost.
1568
+ //
1569
+ // This is what makes one level of label grouping expressible with NO new label
1570
+ // surface: the group is the axis, the labels are its values, and carrying one
1571
+ // is node_classified_as_classification_value, all of it stable since 0.4.0.
1572
+ // Ungrouped freeform labels stay in `tags`. The line between them: if the
1573
+ // group has a name a person would filter by, it is an axis; otherwise a tag.
1574
+ product_dimensioned_by_classification_axis: { forward_verb: "dimensioned_by", reverse_verb: "dimensions", classification: "hierarchy", source_type: "product", target_type: "classification_axis" },
1531
1575
  classification_axis_includes_classification_value: { forward_verb: "includes", reverse_verb: "value_of", classification: "hierarchy", source_type: "classification_axis", target_type: "classification_value" },
1532
1576
  // 0.10.2: also dual-registered as a cross-edge (UPG_CROSS_EDGE_TYPES) so a
1533
1577
  // competitor can be classified directly against a `registry/{classification_value}`
@@ -1767,10 +1811,20 @@ var UPG_EDGE_CATALOG = {
1767
1811
  // iterations; a cycle contains its cooldown), mirroring team_contains_team.
1768
1812
  planning_cycle_contains_planning_cycle: { forward_verb: "contains", reverse_verb: "belongs_to", classification: "hierarchy", source_type: "planning_cycle", target_type: "planning_cycle" },
1769
1813
  // E3: scheduling work into a cycle is a deliberate authoring act, not a
1770
- // containment nesting — user_story keeps its feature/epic parent and is merely
1771
- // referenced by the cycle. deliberate_only so the generic-inference chokepoints
1772
- // never auto-materialise it.
1773
- planning_cycle_schedules_user_story: { forward_verb: "schedules", reverse_verb: "scheduled_in", classification: "semantic", source_type: "planning_cycle", target_type: "user_story", deliberate_only: true },
1814
+ // containment nesting — the work item keeps its feature/epic parent and is
1815
+ // merely referenced by the cycle. deliberate_only so the generic-inference
1816
+ // chokepoints never auto-materialise it.
1817
+ //
1818
+ // WIDENED AT 0.32.0, renamed from planning_cycle_schedules_user_story. The
1819
+ // story-only endpoint could not hold a real tracker import: the adapters
1820
+ // default an unrecognised issue to `task`, so the type a cycle most needed to
1821
+ // schedule was the one type it could not reach. Endpoint-polymorphic over the
1822
+ // work-item set {feature, epic, user_story, task, bug} by the same
1823
+ // construction as work_item_blocks_work_item in the same 0.20.0 batch — the
1824
+ // `work_item` token names the intended semantic domain, the endpoint is the
1825
+ // `node` wildcard. Three typed edges were the alternative and would have
1826
+ // re-opened a decision this vocabulary already took for the same entity set.
1827
+ planning_cycle_schedules_work_item: { forward_verb: "schedules", reverse_verb: "scheduled_in", classification: "semantic", source_type: "planning_cycle", target_type: "node", deliberate_only: true },
1774
1828
  // E2: the OKR cycle-scoping anchor. objective -> planning_cycle. planning_cycle
1775
1829
  // is portfolio_shared, so this passes the cross-scope gate and ships as
1776
1830
  // PROVISIONAL (allowed cross-product with a write-time warning, self-maintaining,
@@ -2248,7 +2302,40 @@ var UPG_EDGE_CATALOG = {
2248
2302
  // real edge, not just a string property — the domain's own anti-pattern is
2249
2303
  // "version prompts like code".
2250
2304
  prompt_version_supersedes_prompt_version: { forward_verb: "supersedes", reverse_verb: "superseded_by", classification: "semantic", source_type: "prompt_version", target_type: "prompt_version" },
2305
+ // The TYPED subject edge. Kept alongside the polymorphic `eval_benchmark_measures_node`
2306
+ // added in 0.31.0, on the same footing as `constraint_constrains_feature` beside
2307
+ // `node_constrains_node`: reach for THIS one when the subject is a feature, and for the
2308
+ // wildcard only when it is not. Two ways to say the same thing about a feature is the
2309
+ // cost of the pair, and it is paid deliberately rather than by accident.
2251
2310
  eval_benchmark_measures_feature: { forward_verb: "measures", reverse_verb: "measured_by", classification: "cross-domain", source_type: "eval_benchmark", target_type: "feature" },
2311
+ /*
2312
+ * 0.31.0 — the benchmark SUBJECT, polymorphic.
2313
+ *
2314
+ * An eval measures a tool, a document, a check, an importer, a feature. Those
2315
+ * are unrelated types, so the subject relation is genuinely wildcard-shaped and
2316
+ * follows the decision-to-anything family (`decision_influences_node`).
2317
+ *
2318
+ * THE WIDTH IS OVER-WIDE, AND THAT IS ON THE LABEL RATHER THAN LAUNDERED.
2319
+ * The decision family's warrant is that a decision really is about anything. A
2320
+ * benchmark is not: it measures a MEASURABLE thing, and this edge will happily
2321
+ * claim a benchmark can measure a persona, which is meaningless. It ships as the
2322
+ * honest deferral it is — the alternative was minting typed edges for subject
2323
+ * types the spec does not yet have, which would answer "what is a tool in the
2324
+ * graph" as a side effect of building an eval harness. That question is a stated
2325
+ * non-goal, and deferring it is the point of choosing the wildcard.
2326
+ *
2327
+ * Registered in UPG_POLYMORPHIC_EDGE_KEYS; Audit 05 errors on any wildcard that
2328
+ * is not.
2329
+ */
2330
+ eval_benchmark_measures_node: { forward_verb: "measures", reverse_verb: "measured_by", classification: "semantic", source_type: "eval_benchmark", target_type: "node" },
2331
+ /*
2332
+ * 0.31.0 — the labeled corpus a benchmark draws its cases from.
2333
+ *
2334
+ * `ai_dataset` reused rather than a new `corpus` type minted (Q5, ratified): the
2335
+ * existing type plus one join edge carries the need, and a corpus type would be
2336
+ * minted only if that demonstrably failed. It has not.
2337
+ */
2338
+ 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" },
2252
2339
  ai_guardrail_enforces_security_policy: { forward_verb: "enforces", reverse_verb: "enforced_by", classification: "cross-domain", source_type: "ai_guardrail", target_type: "security_policy" },
2253
2340
  model_comparison_informs_decision: { forward_verb: "informs", reverse_verb: "informed_by", classification: "cross-domain", source_type: "model_comparison", target_type: "decision" },
2254
2341
  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" },
@@ -2286,6 +2373,23 @@ var UPG_EDGE_CATALOG = {
2286
2373
  agent_hook_triggers_ci_pipeline: { forward_verb: "triggers", reverse_verb: "triggered_by", classification: "cross-domain", source_type: "agent_hook", target_type: "ci_pipeline" },
2287
2374
  workflow_template_defines_agent_task: { forward_verb: "defines", reverse_verb: "defined_by", classification: "hierarchy", source_type: "workflow_template", target_type: "agent_task" },
2288
2375
  agent_definition_spawns_agent_task: { forward_verb: "spawns", reverse_verb: "spawned_by", classification: "hierarchy", source_type: "agent_definition", target_type: "agent_task" },
2376
+ // The delegation bridge (tasks-workflows-2026-08 §5). Before this, the whole
2377
+ // automation domain could reach product work only at coarse grain —
2378
+ // `workflow_run_implements_initiative` (initiative), `agent_skill_extends_feature`
2379
+ // (feature), `agent_session_creates_decision` (decision),
2380
+ // `workflow_artifact_references_deliverable` (deliverable) — and NOTHING reached
2381
+ // `task`, the atomic unit of product work that `feature_decomposes_into_task` /
2382
+ // `epic_decomposes_into_task` make the delivery hierarchy's leaf. So "this agent
2383
+ // work item IS the execution of that product task" was inexpressible, and a
2384
+ // human-vs-agent assignee could not be read off the graph.
2385
+ // Cross-domain: agent_task lives in `automation`, task in product delivery.
2386
+ // NOT hierarchy — the agent_task does not CONTAIN the task; both are
2387
+ // independently-parented work items (`workflow_template_defines_agent_task` /
2388
+ // `agent_definition_spawns_agent_task` own the agent side, feature/epic the
2389
+ // product side), and the delegation is a lateral reference between them.
2390
+ // Verb `executes` mirrors the domain's own vocabulary (`workflow_template`
2391
+ // is `executed_as` a run); reverse reads "this task is executed_by that agent task".
2392
+ agent_task_executes_task: { forward_verb: "executes", reverse_verb: "executed_by", classification: "cross-domain", source_type: "agent_task", target_type: "task" },
2289
2393
  // ── Part 4: Scale Ring (Ring 4) ────────────────────────────────────────────
2290
2394
  // 4.1 Growth Domain
2291
2395
  product_measures_funnel: { forward_verb: "measures", reverse_verb: "belongs_to", classification: "hierarchy", source_type: "product", target_type: "funnel" },
@@ -2636,6 +2740,25 @@ var UPG_EDGE_CATALOG = {
2636
2740
  product_managed_via_program: { forward_verb: "managed_via", reverse_verb: "manages", classification: "hierarchy", source_type: "product", target_type: "program" },
2637
2741
  program_contains_project: { forward_verb: "contains", reverse_verb: "belongs_to", classification: "hierarchy", source_type: "program", target_type: "project" },
2638
2742
  project_targets_milestone: { forward_verb: "targets", reverse_verb: "targeted_by", classification: "hierarchy", source_type: "project", target_type: "milestone" },
2743
+ // Portfolio Phase 2 (ratified 2026-08-14): a milestone that a PRODUCT owns
2744
+ // outright, with no program/project above it. `milestone` had exactly one
2745
+ // inbound edge spec-wide (`project_targets_milestone`) and `project` was its
2746
+ // only declared parent, so a portfolio-grain milestone — which names a
2747
+ // product, not a project — was inexpressible. This is the 0.23.0 epic-twin
2748
+ // precedent: an existing leaf type gains a second, SHALLOWER parent, and the
2749
+ // name, verbs and classification are the deeper edge's, inherited unchanged
2750
+ // rather than invented.
2751
+ //
2752
+ // The mediated path was checked and rejected, not overlooked. The relation IS
2753
+ // reachable as product —managed_via→ program —contains→ project —targets→
2754
+ // milestone, but at three hops (past the 1-2-hop threshold), and the hop count
2755
+ // is not the real objection: mediation forces `program` and `project` nodes
2756
+ // into existence that the source data does not have.
2757
+ //
2758
+ // NOT cross_product_eligible: a milestone belongs to the graph of the product
2759
+ // that owns it. `UPG_VALID_CHILDREN.product` gains 'milestone' in step, which
2760
+ // the hierarchy-orphan guard requires.
2761
+ product_targets_milestone: { forward_verb: "targets", reverse_verb: "targeted_by", classification: "hierarchy", source_type: "product", target_type: "milestone" },
2639
2762
  project_produces_deliverable: { forward_verb: "produces", reverse_verb: "produced_by", classification: "hierarchy", source_type: "project", target_type: "deliverable" },
2640
2763
  program_tracked_via_risk_register: { forward_verb: "tracked_via", reverse_verb: "tracks", classification: "hierarchy", source_type: "program", target_type: "risk_register" },
2641
2764
  risk_register_contains_risk: { forward_verb: "contains", reverse_verb: "belongs_to", classification: "hierarchy", source_type: "risk_register", target_type: "risk" },
@@ -2782,6 +2905,60 @@ var UPG_EDGE_CATALOG = {
2782
2905
  // node) has no schema mechanism yet — deliberately deferred, see the WS3
2783
2906
  // proposal doc, ripple map "surprises" #3.
2784
2907
  workspace_produced_node: { forward_verb: "produced", reverse_verb: "produced_in", classification: "causal", source_type: "workspace", target_type: "node" },
2908
+ // Canvas arrangement: which entities are ON a workspace, and where. This is
2909
+ // the edge `WorkspaceProperties`' docstring promised for months without the
2910
+ // catalog having it; the promise is now true.
2911
+ //
2912
+ // Polymorphic per the enum-vs-polymorphism ADR (2026-06-16), on all three
2913
+ // arms: universality holds (any entity can be dragged onto a canvas, with no
2914
+ // principled stopping point), constraint value is nil (nobody could defend
2915
+ // "you may arrange features but not personas"), and there is NO STRUCTURAL
2916
+ // ROLE. That third arm is load-bearing rather than decorative.
2917
+ // `classification: 'cross-domain'` is therefore mandatory, not cosmetic: it
2918
+ // keeps the edge out of `UPG_VALID_CHILDREN` and `resolveContainmentEdge`, so
2919
+ // `get_tree` never walks it and a canvas placement can never masquerade as
2920
+ // containment. An arranged node keeps its real containment parent. Same
2921
+ // posture as `framework_exercise_includes_node`.
2922
+ //
2923
+ // The alternative was putting the whole canvas snapshot in one opaque node
2924
+ // property, which is cheaper and loses. A node id inside a blob is a foreign
2925
+ // key held as a scalar (the exact thing P14 forbids), the placed entities
2926
+ // become invisible to `get_node` and `query`, and a deleted node leaves a
2927
+ // stale reference that `repair_dangling_edges` cannot see. As an edge, that
2928
+ // guard is inherited for free. Only the furniture with no graph referent
2929
+ // stays opaque, in `workspace.properties.canvas`.
2930
+ //
2931
+ // NOT cross_product_eligible: deliberate scope limit mirroring the WS3
2932
+ // anchors, not an oversight.
2933
+ 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 },
2934
+ // What a published composition SHOWS. Passes the same three-arm ADR test the
2935
+ // same way, and carries no properties: a focus is a bare reference.
2936
+ //
2937
+ // It earns its place by making a composition legible to a tool that cannot
2938
+ // parse the publishing tool's URLs. "Which published views show this
2939
+ // persona?" is a LEARN question the graph exists to answer, and the edge is
2940
+ // what makes a composition's graph references repairable when a focused node
2941
+ // is deleted. Adding it in a later release would be a second blast-radius
2942
+ // event on the same track.
2943
+ //
2944
+ // POPULATION IS BEST-EFFORT and an empty focus set is VALID: a composition
2945
+ // whose member hrefs the writing tool cannot resolve to node ids is still a
2946
+ // well-formed composition. That keeps the spec complete without blocking on
2947
+ // app work, and it is why no check fires on an unfocused composition.
2948
+ //
2949
+ // NOT cross_product_eligible: a portfolio-level composition spanning several
2950
+ // product graphs is conceivable but unevidenced. Same YAGNI call as above.
2951
+ composition_focuses_node: { forward_verb: "focuses", reverse_verb: "focused_in", classification: "cross-domain", source_type: "composition", target_type: "node" },
2952
+ // What a capture is a picture OF (0.32.0). Polymorphic because anything in the
2953
+ // graph can be rendered — a surface, a screen, a report, a canvas — and being
2954
+ // captured carries no structural role, so the endpoint collapses to the
2955
+ // wildcard rather than enumerating a list that would never finish.
2956
+ //
2957
+ // This edge is also where the preservation-is-not-permission rule lands for
2958
+ // renditions: a consumer renders the captures the GRAPH says exist, never the
2959
+ // ones some tool's opaque bag still remembers. A capture that was deleted is a
2960
+ // node that was deleted, and the spec needs no second deletion vocabulary.
2961
+ capture_renders_node: { forward_verb: "renders", reverse_verb: "rendered_by", classification: "cross-domain", source_type: "capture", target_type: "node" },
2785
2962
  // ── P14 edges (replacing foreign-key properties) ─────────────────────────
2786
2963
  feature_addresses_job: { forward_verb: "addresses", reverse_verb: "addressed_by", classification: "cross-domain", source_type: "feature", target_type: "job" },
2787
2964
  feature_drives_key_result: { forward_verb: "drives", reverse_verb: "driven_by", classification: "cross-domain", source_type: "feature", target_type: "key_result" },
@@ -2898,6 +3075,23 @@ var UPG_EDGE_CATALOG = {
2898
3075
  node_owned_by_stakeholder: { forward_verb: "owned_by", reverse_verb: "owns", classification: "cross-domain", source_type: "node", target_type: "stakeholder" },
2899
3076
  node_owned_by_department: { forward_verb: "owned_by", reverse_verb: "owns", classification: "cross-domain", source_type: "node", target_type: "department", cross_product_eligible: true },
2900
3077
  node_owned_by_person: { forward_verb: "owned_by", reverse_verb: "owns", classification: "cross-domain", source_type: "node", target_type: "person" },
3078
+ // ── Cross-domain: Assignment (0.32.0) ───────────────────────────────────────
3079
+ // Assignment is NOT ownership, and 0.12.0 ruled otherwise. That ruling routed
3080
+ // task.assignee and bug.assignee into node_owned_by_person, and it is
3081
+ // superseded here rather than quietly widened, because the two differ in a way
3082
+ // the field measures.
3083
+ //
3084
+ // ASSIGNMENT HAS A TIME INTERVAL AND OWNERSHIP DOES NOT. A shipped ops plane
3085
+ // models assignment as a claim carrying claimed-at, released-at and a partial
3086
+ // unique index enforcing one live claim; nothing in the spec gives ownership
3087
+ // that shape. And a real 184-item board ran with 157 items (85%) unassigned
3088
+ // while every one of them was owned in the ordinary sense. One edge meaning
3089
+ // both cannot express "owned by the team, assigned to nobody", which is the
3090
+ // modal state of a working board.
3091
+ //
3092
+ // Not cross_product_eligible, matching node_owned_by_person: an assignee is a
3093
+ // person in this graph's own roster.
3094
+ node_assigned_to_person: { forward_verb: "assigned_to", reverse_verb: "assigned", classification: "cross-domain", source_type: "node", target_type: "person" },
2901
3095
  // ── Cross-domain: Architecture (DDD) ─────────────────────────────────────────
2902
3096
  // Service, domain_event, domain_entity, aggregate, read_model, api_contract,
2903
3097
  // value_object, command, and data_model are DDD building blocks that all
@@ -3826,6 +4020,9 @@ var UPG_POLYMORPHIC_EDGE_KEYS = [
3826
4020
  "framework_exercise_includes_node",
3827
4021
  // Universal classification (any node classified against a classification_value)
3828
4022
  "node_classified_as_classification_value",
4023
+ // Benchmark subject (0.31.0): an eval measures a tool, a doc, a check, an
4024
+ // importer, a feature. Over-wide by admission — see the definition's comment.
4025
+ "eval_benchmark_measures_node",
3829
4026
  // Work-item issue links (0.20.0): endpoint-polymorphic over the work-item set
3830
4027
  // {feature, epic, user_story, task, bug}. The `work_item_` key names the
3831
4028
  // intended semantic domain; the endpoints are the `node` wildcard.
@@ -3834,7 +4031,23 @@ var UPG_POLYMORPHIC_EDGE_KEYS = [
3834
4031
  "work_item_duplicates_work_item",
3835
4032
  // Workspace provenance (WS3, 2026-07-05): a workspace can produce any
3836
4033
  // entity type arranged in it. Widened from workspace_produced_decision.
3837
- "workspace_produced_node"
4034
+ "workspace_produced_node",
4035
+ // Canvas arrangement and published-view focus: any entity can be dragged
4036
+ // onto a canvas or shown in a published view, and neither carries a
4037
+ // structural role, so both collapse polymorphic rather than enumerating.
4038
+ "workspace_arranges_node",
4039
+ "composition_focuses_node",
4040
+ // Universal assignment (0.32.0): who is working on this, as distinct from who
4041
+ // owns it. Wildcard source for the same reason node_owned_by_person has one.
4042
+ "node_assigned_to_person",
4043
+ // Capture subject (0.32.0): anything in the graph can be rendered, and being
4044
+ // rendered carries no structural role.
4045
+ "capture_renders_node",
4046
+ // Cadence scheduling (0.32.0, widened from planning_cycle_schedules_user_story):
4047
+ // endpoint-polymorphic over the work-item set {feature, epic, user_story, task,
4048
+ // bug}. The `work_item` key token names the intended semantic domain; the
4049
+ // endpoint is the `node` wildcard.
4050
+ "planning_cycle_schedules_work_item"
3838
4051
  ];
3839
4052
  var _POLY_KEY_SET = new Set(UPG_POLYMORPHIC_EDGE_KEYS);
3840
4053
  function isPolymorphicEdge(key) {
@@ -4029,6 +4242,11 @@ var UPG_VALID_CHILDREN = {
4029
4242
  // the product renders. Few per product, and top-level because an axis is a
4030
4243
  // product-wide fact rather than a child of any one feature or surface.
4031
4244
  "configuration_axis",
4245
+ // Classification (0.32.0): the product's own taxonomies — the grouped-label
4246
+ // case, one named group over a set of values. Same reasoning as the
4247
+ // configuration axis above, and until now the only parent a
4248
+ // classification_axis could have was a competitive_analysis.
4249
+ "classification_axis",
4032
4250
  // Engineering
4033
4251
  "bounded_context",
4034
4252
  "code_repository",
@@ -4119,7 +4337,12 @@ var UPG_VALID_CHILDREN = {
4119
4337
  "subscription",
4120
4338
  "forecast",
4121
4339
  // Program Management
4340
+ // `milestone` is a SECOND, shallower parent here (Portfolio Phase 2,
4341
+ // 2026-08-14), alongside its existing `project` parent: a product can own a
4342
+ // milestone outright with no program/project above it. Required by the
4343
+ // hierarchy-orphan guard now that `product_targets_milestone` exists.
4122
4344
  "program",
4345
+ "milestone",
4123
4346
  // Marketing
4124
4347
  "marketing_strategy",
4125
4348
  "press_release",
@@ -4308,7 +4531,7 @@ var UPG_VALID_CHILDREN = {
4308
4531
  // contains iterations; a cycle contains its cooldown) via
4309
4532
  // planning_cycle_contains_planning_cycle. It does NOT contain user_story as a
4310
4533
  // child: scheduling work into a cycle is the semantic, deliberate-only
4311
- // planning_cycle_schedules_user_story edge, so a story keeps its feature/epic
4534
+ // planning_cycle_schedules_work_item edge, so the item keeps its feature/epic
4312
4535
  // containment parent and is merely referenced by the cycle.
4313
4536
  planning_cycle: ["planning_cycle"],
4314
4537
  // ── Engineering hierarchy ───────────────────────────────────────────────────
@@ -4646,7 +4869,20 @@ var UPG_CONTAINMENT_FREE_TYPES = /* @__PURE__ */ new Set([
4646
4869
  // containment. Modelling it as a product child would force a containment edge
4647
4870
  // and read against the whole point of the exercise model (relational, not
4648
4871
  // hierarchical). Same posture as `person`. See ADR 2026-06-02-framework-exercises.
4649
- "framework_exercise"
4872
+ "framework_exercise",
4873
+ // A composition has no structural parent for the same reason a
4874
+ // framework_exercise does not: it anchors to what it SHOWS, relationally, via
4875
+ // `composition_focuses_node`. Modelling it as a product child would force a
4876
+ // containment edge that reads against the point — a published view is not a
4877
+ // part of the product, it is a lens onto one. This is also what keeps the
4878
+ // type to two edges rather than three: no provenance edge is minted (that is
4879
+ // `workspace_produced_node`, reused) and no parent edge is needed.
4880
+ "composition",
4881
+ // A capture has no structural parent for the same reason: it anchors to what
4882
+ // it RENDERS, via `capture_renders_node`. Giving it a product parent would
4883
+ // claim a capture is part of the product rather than a picture of one, and
4884
+ // would mint a containment edge whose only job is to satisfy the hierarchy.
4885
+ "capture"
4650
4886
  ]);
4651
4887
  function isContainmentFreeType(entityType) {
4652
4888
  return UPG_CONTAINMENT_FREE_TYPES.has(entityType);
@@ -6752,7 +6988,8 @@ var WORK_ITEM_TEMPLATE = {
6752
6988
  initial_phase: "todo",
6753
6989
  terminal_phases: ["done", "cancelled"],
6754
6990
  phases: [
6755
- { id: "todo", status_category: "unstarted", label: "To Do", description: "Identified but not yet started.", transitions_to: ["in_progress", "cancelled"] },
6991
+ { id: "backlog", status_category: "backlog", label: "Backlog", description: "Accepted or set aside deliberately: real work, not scheduled. Distinct from `todo`, which is committed and merely not started.", transitions_to: ["todo", "in_progress", "cancelled"] },
6992
+ { id: "todo", status_category: "unstarted", label: "To Do", description: "Identified but not yet started.", transitions_to: ["in_progress", "backlog", "cancelled"] },
6756
6993
  { id: "in_progress", status_category: "started", label: "In Progress", description: "Actively being worked on.", transitions_to: ["in_review", "todo", "cancelled"] },
6757
6994
  { id: "in_review", status_category: "started", label: "In Review", description: "Work completed, awaiting review or acceptance.", transitions_to: ["done", "in_progress", "cancelled"] },
6758
6995
  { id: "done", status_category: "completed", label: "Done", description: "Completed and accepted.", transitions_to: [] },
@@ -7022,6 +7259,34 @@ var FRAMEWORK_EXERCISE_LIFECYCLE = {
7022
7259
  }
7023
7260
  ]
7024
7261
  };
7262
+ var COMPOSITION_LIFECYCLE = {
7263
+ entity_type: "composition",
7264
+ initial_phase: "draft",
7265
+ terminal_phases: ["archived"],
7266
+ phases: [
7267
+ {
7268
+ id: "draft",
7269
+ status_category: "started",
7270
+ label: "Draft",
7271
+ description: "Being arranged. It has a slug reserved and members placed, but has never been published, so nothing resolves at its address yet.",
7272
+ transitions_to: ["published", "archived"]
7273
+ },
7274
+ {
7275
+ id: "published",
7276
+ status_category: "completed",
7277
+ label: "Published",
7278
+ description: "Live at its slug and linkable. Republishing the same slug stays in this phase and bumps the revision rather than moving the composition.",
7279
+ transitions_to: ["archived", "draft"]
7280
+ },
7281
+ {
7282
+ id: "archived",
7283
+ status_category: "completed",
7284
+ label: "Archived",
7285
+ 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.",
7286
+ transitions_to: ["published", "draft"]
7287
+ }
7288
+ ]
7289
+ };
7025
7290
  var SPECIFICATION_LIFECYCLE = {
7026
7291
  entity_type: "specification",
7027
7292
  initial_phase: "draft",
@@ -7153,6 +7418,7 @@ var UPG_LIFECYCLES = [
7153
7418
  GROWTH_CAMPAIGN_LIFECYCLE,
7154
7419
  // Workspace
7155
7420
  FRAMEWORK_EXERCISE_LIFECYCLE,
7421
+ COMPOSITION_LIFECYCLE,
7156
7422
  // ── Template-generated lifecycles ─────────────────────────────────
7157
7423
  ...TEMPLATE_LIFECYCLES
7158
7424
  ];
@@ -7354,7 +7620,11 @@ var UPG_LIFECYCLE_FREE_TYPES = /* @__PURE__ */ new Set([
7354
7620
  // from experiment_runs (which have lifecycles); hypothesis_evidence was
7355
7621
  // lifecycle-free here but is deprecated at v0.4.0 ──────────────────────────────────────
7356
7622
  "evidence",
7357
- "learning"
7623
+ "learning",
7624
+ // ── Workspace (1 of 4): a capture is a fact about a moment, not a workflow.
7625
+ // It is taken, and from then on it either still matches its subject or it
7626
+ // does not — which is what `content_hash` answers, not a phase. ─────────
7627
+ "capture"
7358
7628
  ]);
7359
7629
  function isLifecycleFreeType(entityType) {
7360
7630
  return UPG_LIFECYCLE_FREE_TYPES.has(entityType);
@@ -8044,8 +8314,7 @@ var UPG_MIGRATIONS = {
8044
8314
  {
8045
8315
  from: "metric_definition",
8046
8316
  to: "metric",
8047
- defaults: { has_implementation: false },
8048
- reason: "A metric definition is a metric without implementation. Lifecycle property, not a separate type."
8317
+ 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.)"
8049
8318
  },
8050
8319
  // ── Experiment consolidation ────────────────────────────────────────────
8051
8320
  {
@@ -8070,8 +8339,8 @@ var UPG_MIGRATIONS = {
8070
8339
  {
8071
8340
  from: "risk_item",
8072
8341
  to: "risk",
8073
- defaults: { risk_domain: "program" },
8074
- reason: "risk_item (Program Mgmt) is identical to risk (Legal). Consolidated with risk_domain property."
8342
+ defaults: { risk_type: "program" },
8343
+ 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.)"
8075
8344
  },
8076
8345
  {
8077
8346
  from: "security_incident",
@@ -8082,8 +8351,8 @@ var UPG_MIGRATIONS = {
8082
8351
  {
8083
8352
  from: "defect_report",
8084
8353
  to: "support_ticket",
8085
- defaults: { ticket_designation: "defect" },
8086
- reason: "Defect report is a support ticket with ticket_designation=defect. Same signal interface."
8354
+ defaults: { ticket_type: "bug" },
8355
+ 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.)"
8087
8356
  },
8088
8357
  {
8089
8358
  from: "onboarding_flow",
@@ -8259,6 +8528,46 @@ function getDeprecatedTypes() {
8259
8528
  return deprecated;
8260
8529
  }
8261
8530
  var UPG_PROPERTY_MIGRATIONS = {
8531
+ "0.32.0": [
8532
+ // C3 — archive becomes a base-node axis. WorkspaceProperties.archived /
8533
+ // archived_at shipped the orthogonal-archive idea for exactly one type;
8534
+ // field data showed it is general (a tracker held 559 archived-completed
8535
+ // items beside 18 live-completed ones), so the pair moves to UPGBaseNode
8536
+ // and the workspace properties are @deprecated. Lift, not drop: the value
8537
+ // is real and its new home means the same thing.
8538
+ {
8539
+ kind: "lift_property_to_top_level",
8540
+ type: "workspace",
8541
+ from_property: "archived",
8542
+ to: "archived",
8543
+ reason: "0.32.0: archive generalised from WorkspaceProperties to UPGBaseNode.archived. Same fact, wider home; the workspace property is @deprecated."
8544
+ },
8545
+ {
8546
+ kind: "lift_property_to_top_level",
8547
+ type: "workspace",
8548
+ from_property: "archived_at",
8549
+ to: "archived_at",
8550
+ reason: "0.32.0: pairs with the archived lift above. UPGBaseNode.archived_at is the canonical home."
8551
+ },
8552
+ // C4 — the duplicate label surfaces. TaskProperties.labels and
8553
+ // BugProperties.labels were declared "applied uniformly across work item
8554
+ // types" and duplicated base-node `tags` with no consumer anywhere. Three
8555
+ // label surfaces (base tags, per-type tags, per-type labels) is two too
8556
+ // many; grouped labels are classification_axis + classification_value, and
8557
+ // ungrouped ones are `tags`.
8558
+ {
8559
+ kind: "drop_props",
8560
+ type: "task",
8561
+ drop_props: ["labels"],
8562
+ reason: "0.32.0: TaskProperties.labels duplicated base-node `tags` and had no consumers. Freeform labels live in `tags`; grouped labels are classification_axis + node_classified_as_classification_value."
8563
+ },
8564
+ {
8565
+ kind: "drop_props",
8566
+ type: "bug",
8567
+ drop_props: ["labels"],
8568
+ reason: "0.32.0: BugProperties.labels duplicated base-node `tags` and had no consumers. Same resolution as task."
8569
+ }
8570
+ ],
8262
8571
  // ── v0.21.0: UPG-690 D.1 — collapse residual *_status shadows into status ────
8263
8572
  //
8264
8573
  // Six `*_status` enum properties that re-encode their entity's own lifecycle
@@ -9394,6 +9703,48 @@ function getSplitMigrations(fromVersion, toVersion) {
9394
9703
  return result;
9395
9704
  }
9396
9705
  var UPG_SCALAR_TO_EDGE_MIGRATIONS = {
9706
+ "0.32.0": [
9707
+ // C2 — assignment gets its own edge, superseding the 0.12.0 rules that
9708
+ // routed task/bug `assignee` into node_owned_by_person.
9709
+ //
9710
+ // WHY THIS IS A REVERSAL AND NOT A GAP-FILL. 0.12.0 ruled that an assignee
9711
+ // is an owner. Assignment has a time interval and an exclusivity that
9712
+ // ownership does not, and a real board ran 85% unassigned while every item
9713
+ // was owned — a state one edge cannot express.
9714
+ //
9715
+ // WHY EXISTING EDGES ARE NOT REWRITTEN. A node_owned_by_person edge on a
9716
+ // task is indistinguishable by inspection from a genuine ownership edge, so
9717
+ // a blanket rename would silently reclassify real facts. Measured population
9718
+ // across every graph in the originating workspace was ~zero, which makes the
9719
+ // cost of leaving them nil and the cost of guessing a wrong fact.
9720
+ //
9721
+ // user_story is included here and was MISSING from 0.12.0 entirely, though
9722
+ // it has carried `assignee` since 0.20.0.
9723
+ {
9724
+ from_type: "task",
9725
+ scalar_property: "assignee",
9726
+ target_type: "person",
9727
+ edge_type: "node_assigned_to_person",
9728
+ drop_scalar: false,
9729
+ reason: 'P14 actor: the assignee is a first-class person ("every task assigned to this person"). Assignment is not ownership: it has an interval and an exclusivity that ownership lacks, so it links node_assigned_to_person, superseding the 0.12.0 node_owned_by_person rule. Display string kept.'
9730
+ },
9731
+ {
9732
+ from_type: "bug",
9733
+ scalar_property: "assignee",
9734
+ target_type: "person",
9735
+ edge_type: "node_assigned_to_person",
9736
+ drop_scalar: false,
9737
+ reason: "P14 actor: same as task. Supersedes the 0.12.0 node_owned_by_person rule. Display string kept."
9738
+ },
9739
+ {
9740
+ from_type: "user_story",
9741
+ scalar_property: "assignee",
9742
+ target_type: "person",
9743
+ edge_type: "node_assigned_to_person",
9744
+ drop_scalar: false,
9745
+ reason: "P14 actor: user_story has carried `assignee` since 0.20.0 and had no promotion rule at all; an asymmetry with task and bug that this closes. Display string kept."
9746
+ }
9747
+ ],
9397
9748
  "0.12.0": [
9398
9749
  // ── Bucket A1 — orphan scalars (no edge existed): add edge + migrate + drop ──
9399
9750
  {
@@ -9804,22 +10155,12 @@ var UPG_SCALAR_TO_EDGE_MIGRATIONS = {
9804
10155
  drop_scalar: false,
9805
10156
  reason: "P14 actor: the release owner is a first-class person; link node_owned_by_person. Display string kept."
9806
10157
  },
9807
- {
9808
- from_type: "task",
9809
- scalar_property: "assignee",
9810
- target_type: "person",
9811
- edge_type: "node_owned_by_person",
9812
- drop_scalar: false,
9813
- reason: 'P14 actor: the assignee is a first-class person ("every task assigned to this person"); link node_owned_by_person. Display string kept.'
9814
- },
9815
- {
9816
- from_type: "bug",
9817
- scalar_property: "assignee",
9818
- target_type: "person",
9819
- edge_type: "node_owned_by_person",
9820
- drop_scalar: false,
9821
- reason: "P14 actor: the assignee is a first-class person; link node_owned_by_person. Display string kept."
9822
- },
10158
+ // NOTE (0.32.0): the task/bug `assignee` rules that lived here are SUPERSEDED
10159
+ // by the '0.32.0' block below, which routes them to node_assigned_to_person.
10160
+ // They are removed rather than left to double-run: two rules over one scalar
10161
+ // would mint the person once and link it twice, under two verbs that now mean
10162
+ // different things. Graphs promoted before 0.32.0 keep the ownership edges
10163
+ // they were given — see the 0.32.0 block for why those are not rewritten.
9823
10164
  {
9824
10165
  from_type: "roadmap",
9825
10166
  scalar_property: "owner",
@@ -9915,6 +10256,14 @@ function getScalarToEdgeMigrations(fromVersion, toVersion) {
9915
10256
  return result;
9916
10257
  }
9917
10258
  var UPG_EDGE_MIGRATIONS = {
10259
+ "0.32.0": [
10260
+ // C5 — the cadence scheduling edge widens from user_story to the work-item
10261
+ // set. No requires_target_type: the destination's target_type is the `node`
10262
+ // wildcard, and endpoint guards are checked against the catalog's declared
10263
+ // type rather than the concrete instance (same reasoning as the 0.22.0
10264
+ // workspace_produced_node rename below). Gate only on the unwidened source.
10265
+ { kind: "rename", from: "planning_cycle_schedules_user_story", to: "planning_cycle_schedules_work_item", requires_source_type: "planning_cycle", reason: "0.32.0: a cycle could only schedule a user_story, while tracker imports produce `task` by default; the type a cadence most needed to hold was the one it could not reach. Widened to the polymorphic work-item endpoint; no flip, source unchanged. Measured live population of the old type at release: zero (no .upg in the repo or the dogfood graph carried it), so this rename is a vocabulary change, not a data migration." }
10266
+ ],
9918
10267
  // (target version TBD at release ceremony — pencilled at 0.22.0, the next
9919
10268
  // breaking slot after 0.21.0 per the docket; adjust the key if Wave 4
9920
10269
  // claims it first.) WS3 commit-provenance collapse (2026-07-05, ratified):
@@ -11105,7 +11454,7 @@ var UPG_PROPERTY_SCHEMA = {
11105
11454
  acceptance_criterion: {
11106
11455
  condition: { type: "string", description: "Required condition (Given/When/Then or plain text)" },
11107
11456
  test_type: { type: "string", enum: ["manual", "automated"], description: "Test mode" },
11108
- pass_status: { type: "string", enum: ["untested", "pass", "fail"], description: "Current pass/fail" }
11457
+ 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".' }
11109
11458
  },
11110
11459
  // AccessPolicyProperties: Access policy.
11111
11460
  access_policy: {
@@ -11468,9 +11817,9 @@ var UPG_PROPERTY_SCHEMA = {
11468
11817
  priority: { type: "string", enum: ["urgent", "high", "medium", "low", "none"], description: "Urgency relative to other work. Independent of `bug_severity` (a critical bug can have low priority if rare)." },
11469
11818
  assignee: { type: "string", description: "Assigned person. Promote to a `node_owned_by_person` edge if ownership must be queryable." },
11470
11819
  due_date: { type: "string", description: "ISO date due. Often tied to a release gate or SLA." },
11471
- labels: { type: "string[]", description: "Free-form classification tags. Applied uniformly across work item types." },
11820
+ labels: { type: "string[]", description: "Free-form classification tags. @deprecated since 0.32.0. Use base-node `tags` for ungrouped labels, and `classification_axis` + `classification_value` + `node_classified_as_classification_value` when the labels belong to a named group. This field duplicated `tags` and had no consumers; three parallel label surfaces (base `tags`, per-type `tags`, per-type `labels`) were two too many. `UPG_PROPERTY_MIGRATIONS['0.32.0']` drops it." },
11472
11821
  workflow_state: { type: "string", description: 'The source tool\'s raw custom workflow state, verbatim and opaque (e.g. "In Review", "QA", "Needs Triage"). Non-canonical and never reasoned over: it exists to round-trip an import losslessly. Map it onto a canonical bucket with `workflow_state_category`; canonical `status` stays the sole reasoning axis.' },
11473
- workflow_state_category: { type: "string", description: 'Canonical bucket the raw `workflow_state` maps onto for reasoning (a source "In Review" and "QA" might both map to a verification phase). Optional companion to `workflow_state`: it lets a graph reason over an imported custom workflow without promoting the raw label to `status`.' }
11822
+ workflow_state_category: { type: "string", enum: ["triage", "backlog", "unstarted", "started", "completed", "cancelled"], description: 'Canonical bucket the raw `workflow_state` maps onto for reasoning: a source "In Review" and a source "QA" may both map to a verification phase. Optional companion to `workflow_state`; canonical `status` remains the sole reasoning axis.', notes: "It exists so a graph can reason over an imported custom workflow WITHOUT promoting the source's raw label to `status`. The raw label keeps its own field and stays verbatim; this one says what that label means in the six-bucket vocabulary every major tracker converges on. NARROWED FROM `string` AT 0.32.0. The field exists to carry exactly the vocabulary, and typing it as an open string meant nothing enforced the one thing it was for; an importer could write any word here and no consumer would know it had. A graph carrying a free string now fails to type-check rather than failing to be understood." }
11474
11823
  },
11475
11824
  // BuildArtifactProperties: Build artifact.
11476
11825
  build_artifact: {
@@ -11501,6 +11850,16 @@ var UPG_PROPERTY_SCHEMA = {
11501
11850
  allocated: { type: "number", description: "Capacity already allocated to work" },
11502
11851
  available: { type: "number", description: "Remaining unallocated capacity" }
11503
11852
  },
11853
+ // CaptureProperties: Capture: a dated, hashed rendition of something already in the graph.
11854
+ capture: {
11855
+ capture_uri: { type: "string", description: "Where the bytes are. `https://` for hosted, `file://` or a relative path for local. Required: a capture with no location renders nothing." },
11856
+ content_hash: { type: "string", description: "Content hash of the bytes. The regeneration signal; see the type's `@remarks` for why mtime and size are not." },
11857
+ hash_algorithm: { type: "string", enum: ["sha256", "sha1", "md5"], description: "Hash algorithm. Absent means `sha256`." },
11858
+ captured_at: { type: "string", description: "ISO timestamp the capture was taken." },
11859
+ media_type: { type: "string", description: 'IANA media type of the bytes. @example "image/png"' },
11860
+ fidelity: { type: "string", enum: ["exact", "approximate", "not_applicable"], description: "How faithfully this rendition represents its subject. `approximate` covers a capture taken in a stand-in state or at the wrong viewport." },
11861
+ capture_status: { type: "string", enum: ["captured", "blocked", "skipped"], description: "Whether the capture succeeded.", notes: "A `blocked` capture is a real record rather than a missing one: it says the subject exists and could not be rendered, which is what stops the next run rediscovering the same obstacle. `skipped` is the deliberate exclusion." }
11862
+ },
11504
11863
  // CeremonyProperties: Ceremony entity.
11505
11864
  ceremony: {
11506
11865
  ceremony_type: { type: "string", enum: ["standup", "planning", "review", "retro", "sync", "demo", "other"], description: "Kind of recurring meeting" },
@@ -11552,14 +11911,14 @@ var UPG_PROPERTY_SCHEMA = {
11552
11911
  // ClassificationAxisProperties: ClassificationAxis: a dimension along which subjects are classified.
11553
11912
  classification_axis: {
11554
11913
  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)." },
11555
- 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.' }
11914
+ 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.' }
11556
11915
  },
11557
11916
  // ClassificationValueProperties: ClassificationValue: a value on a classification axis.
11558
11917
  classification_value: {
11559
11918
  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." },
11560
11919
  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']" },
11561
- 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.' }, ]" },
11562
- 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'] }, ]" }
11920
+ 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.' }, ]" },
11921
+ 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." }
11563
11922
  },
11564
11923
  // CodeRepositoryProperties: Code repository.
11565
11924
  code_repository: {
@@ -11594,7 +11953,7 @@ var UPG_PROPERTY_SCHEMA = {
11594
11953
  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." },
11595
11954
  analysis_date: { type: "string", description: 'ISO date conducted. Competitive intelligence decays quickly; track snapshot age. @example "2026-03-15"' },
11596
11955
  framework_id: { type: "string", description: 'Framework ID (references `UPGFramework.id`). @example "porter-five-forces", "swot-analysis", "competitive-matrix"' },
11597
- 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.' }, ]" },
11956
+ 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.' }, ]" },
11598
11957
  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"' },
11599
11958
  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/"' },
11600
11959
  confidence: {
@@ -11664,7 +12023,7 @@ var UPG_PROPERTY_SCHEMA = {
11664
12023
  // CompetitorSignalProperties: Competitor signal: a dated competitor move mapped onto our portfolio.
11665
12024
  competitor_signal: {
11666
12025
  observed_at: { type: "string", description: 'ISO date-time the move was observed. @example "2026-06-10"' },
11667
- 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`." },
12026
+ 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." },
11668
12027
  summary: { type: "string", description: "One-line factual summary of the move (what shipped, not marketing copy)." },
11669
12028
  impact: { type: "string", enum: ["high", "medium", "low"], description: "Expected impact on our position." },
11670
12029
  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." },
@@ -11696,12 +12055,22 @@ var UPG_PROPERTY_SCHEMA = {
11696
12055
  // ComplianceRequirementProperties: Compliance requirement.
11697
12056
  compliance_requirement: {
11698
12057
  regulation: { type: "string", enum: ["gdpr", "ccpa", "hipaa", "soc2", "iso27001", "pci_dss", "other"], description: "Regulation or standard this requirement derives from" },
11699
- compliance_status: { type: "string", enum: ["compliant", "non_compliant", "in_progress", "not_applicable"], description: "Current compliance posture" }
12058
+ compliance_status: { type: "string", enum: ["compliant", "non_compliant", "in_progress", "not_applicable"], description: "Current compliance posture" },
12059
+ owner: { type: "string", description: "Accountable person or team. Promote to a `node_owned_by_person` edge if ownership must be queryable." }
12060
+ },
12061
+ // CompositionProperties
12062
+ composition: {
12063
+ members: { type: "object[]", description: "The frozen member arrangement, captured at publish. Layout and pointers only, never resolved content." },
12064
+ 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." },
12065
+ published_at: { type: "string", description: "ISO timestamp of the most recent publish or republish." },
12066
+ published_by: { type: "string", description: "Publisher handle or email. Display scalar, same posture as `WorkspaceProperties.owner`." },
12067
+ member_query: { type: "object", description: "When present, membership is DERIVED: members are produced by running this query rather than authored by placement.", notes: "This is the portable statement of what the composition shows. `CompositionMember.href` remains the publishing tool's own resolved route and stays opaque to everyone else; a member may carry both, and then the href is a fast path while the query is the meaning. A consumer that cannot parse the href can still render the layer, which is the whole reason the declaration is here rather than in a tool-namespaced bag key. A composition with no `member_query` is authored, which is what every composition written before 0.32.0 is." },
12068
+ presentation: { type: "object", description: "Advisory rendering intent for the composition as a whole. A consumer may ignore it entirely and still be conformant." }
11700
12069
  },
11701
12070
  // ConfigurationAxisProperties: A named dimension along which the product's composition differs.
11702
12071
  configuration_axis: {
11703
- values: { type: "string[]", description: "The closed set of values this axis can take. Required: an axis with no values selects nothing and cannot be projected along. Two values is the common case and three is not unusual (a plan ladder). Order is not significant; the axis is categorical, not ordinal. Where the ordering does matter (an entitlement ladder in which each tier includes the one below), that is a classification question, and `classification_axis` with `axis_kind: 'ordinal'` is the instrument for it." },
11704
- default_value: { type: "string", description: "The value this axis is understood to sit at when nobody says otherwise. Must be a member of `values`. NOTHING APPLIES IT AUTOMATICALLY. Omitting `configuration` on a read returns the UNION, not this projection, because the union is the honest answer to an unqualified question: it is every configuration at once, and silently substituting one of them would hide the others from a reader who did not know to ask. What the field does carry is the declaration convention for `surface_alternates_with_surface` (declare the edge from the surface present under the default) and a documented anchor for tools that later want to offer a starting configuration. It is a claim about the model, not about deployment: it says which value most of the graph was written against, not which configuration most customers are on." },
12072
+ 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." },
12073
+ 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." },
11705
12074
  kind: { type: "string", enum: ["feature_flag", "plan_tier", "permission_level", "beta_program", "other"], description: "What kind of lever this is. Names the mechanism family." }
11706
12075
  },
11707
12076
  // ConstraintProperties: Constraint: a named limitation or boundary on product creation.
@@ -11717,7 +12086,7 @@ var UPG_PROPERTY_SCHEMA = {
11717
12086
  contact: {
11718
12087
  contact_role: { type: "string", description: "Job title or role within the account" },
11719
12088
  is_decision_maker: { type: "boolean", description: "Whether this person has purchasing authority" },
11720
- 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'" }
12089
+ 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." }
11721
12090
  },
11722
12091
  // ContentCalendarProperties: Content calendar.
11723
12092
  content_calendar: {
@@ -11838,7 +12207,8 @@ var UPG_PROPERTY_SCHEMA = {
11838
12207
  data_contract: {
11839
12208
  retention_period: { type: "string", description: "How long data is retained before deletion" },
11840
12209
  deletion_policy: { type: "string", description: "Policy governing data deletion" },
11841
- third_party_sharing: { type: "boolean", description: "Whether data is shared with third parties" }
12210
+ third_party_sharing: { type: "boolean", description: "Whether data is shared with third parties" },
12211
+ 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." }
11842
12212
  },
11843
12213
  // DataDomainProperties: DataDomain. A coarse-grained grouping of related data assets (sources,
11844
12214
  data_domain: {
@@ -11884,7 +12254,8 @@ var UPG_PROPERTY_SCHEMA = {
11884
12254
  // DataProductProperties: DataProduct entity.
11885
12255
  data_product: {
11886
12256
  data_product_type: { type: "string", enum: ["report", "dataset", "stream", "api", "ml_feature", "other"], description: "Classification of the data product (UPG-579 Option B)." },
11887
- sla_freshness: { type: "string", description: 'Freshness SLA commitment (e.g. "< 1 hour")' }
12257
+ sla_freshness: { type: "string", description: 'Freshness SLA commitment (e.g. "< 1 hour")' },
12258
+ 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." }
11888
12259
  },
11889
12260
  // DataQualityRuleProperties: DataQualityRule entity.
11890
12261
  data_quality_rule: {
@@ -11914,7 +12285,7 @@ var UPG_PROPERTY_SCHEMA = {
11914
12285
  deal_value: { type: "number", description: "Monetary value of the deal" },
11915
12286
  close_date: { type: "string", description: "Expected close date (ISO format)" },
11916
12287
  probability: { type: "number", description: "Likelihood of closing (0-100%)" },
11917
- 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'" },
12288
+ 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." },
11918
12289
  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'" },
11919
12290
  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'" },
11920
12291
  next_step_date: { type: "string", description: "When the `next_step` is due (ISO format)." },
@@ -11930,7 +12301,8 @@ var UPG_PROPERTY_SCHEMA = {
11930
12301
  normalized: { type: "number", description: "Normalized 0-1 value for cross-tool comparison (optional)." }
11931
12302
  },
11932
12303
  required: ["value", "label"]
11933
- }
12304
+ },
12305
+ owner: { type: "string", description: "Accountable person or team carrying the deal. Promote to a `node_owned_by_person` edge if ownership must be queryable." }
11934
12306
  },
11935
12307
  // DecisionProperties: Decision record. Strategic, product, engineering, or design.
11936
12308
  decision: {
@@ -12143,13 +12515,14 @@ var UPG_PROPERTY_SCHEMA = {
12143
12515
  },
12144
12516
  // EpicProperties: A collection of related user stories that delivers a feature or capability.
12145
12517
  epic: {
12146
- estimate: { type: "string", description: 'Rough size estimate (e.g. "3 sprints", "L", "13 points")' },
12518
+ 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`).' },
12519
+ 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`.' },
12147
12520
  priority: { type: "string", enum: ["urgent", "high", "medium", "low", "none"], description: "Task-level priority" },
12148
12521
  owner: { type: "string", description: "Responsible person or team. Promote to a `node_owned_by_person` edge if ownership must be queryable." },
12149
12522
  start_date: { type: "string", description: "ISO date work begins" },
12150
12523
  target_date: { type: "string", description: "ISO date work completes" },
12151
12524
  workflow_state: { type: "string", description: 'The source tool\'s raw custom workflow state, verbatim and opaque (e.g. "In Review", "QA", "Needs Triage"). Non-canonical and never reasoned over: it exists to round-trip an import losslessly. Map it onto a canonical bucket with `workflow_state_category`; canonical `status` stays the sole reasoning axis.' },
12152
- workflow_state_category: { type: "string", description: 'Canonical bucket the raw `workflow_state` maps onto for reasoning (a source "In Review" and "QA" might both map to a verification phase). Optional companion to `workflow_state`: it lets a graph reason over an imported custom workflow without promoting the raw label to `status`.' }
12525
+ workflow_state_category: { type: "string", enum: ["triage", "backlog", "unstarted", "started", "completed", "cancelled"], description: 'Canonical bucket the raw `workflow_state` maps onto for reasoning: a source "In Review" and a source "QA" may both map to a verification phase. Optional companion to `workflow_state`; canonical `status` remains the sole reasoning axis.', notes: "It exists so a graph can reason over an imported custom workflow WITHOUT promoting the source's raw label to `status`. The raw label keeps its own field and stays verbatim; this one says what that label means in the six-bucket vocabulary every major tracker converges on. NARROWED FROM `string` AT 0.32.0. The field exists to carry exactly the vocabulary, and typing it as an open string meant nothing enforced the one thing it was for; an importer could write any word here and no consumer would know it had. A graph carrying a free string now fails to type-check rather than failing to be understood." }
12153
12526
  },
12154
12527
  // ErrorBudgetProperties: Error budget.
12155
12528
  error_budget: {
@@ -12160,7 +12533,7 @@ var UPG_PROPERTY_SCHEMA = {
12160
12533
  },
12161
12534
  // EvalBenchmarkProperties: Evaluation benchmark.
12162
12535
  eval_benchmark: {
12163
- benchmark_type: { type: "string", enum: ["accuracy", "latency", "cost", "safety", "custom"], description: "Measured dimension" },
12536
+ 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.' },
12164
12537
  test_case_count: { type: "number", description: "Test cases in the suite", modifier: "derived" },
12165
12538
  passing_threshold: { type: "number", description: "Minimum passing score" },
12166
12539
  last_run: { type: "string", description: "ISO date of the most recent run" }
@@ -12176,7 +12549,8 @@ var UPG_PROPERTY_SCHEMA = {
12176
12549
  output_token_count: { type: "number", description: "Output tokens" },
12177
12550
  cost: { type: "number", description: "Total run cost" },
12178
12551
  error_rate: { type: "number", description: "Percentage of test cases that errored" },
12179
- feedback_scores: { type: "string", description: "Feedback score summary (human or automated)" }
12552
+ feedback_scores: { type: "string", description: "Feedback score summary (human or automated)" },
12553
+ 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." }
12180
12554
  },
12181
12555
  // EventProperties: Event.
12182
12556
  event: {
@@ -12280,6 +12654,7 @@ var UPG_PROPERTY_SCHEMA = {
12280
12654
  },
12281
12655
  // ExperimentRunProperties: Execution evidence for a structured test of a hypothesis (UCS pattern P6: event-occurrence).
12282
12656
  experiment_run: {
12657
+ 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." },
12283
12658
  actual_start_date: { type: "string", description: "ISO actual start date (may differ from the plan's `planned_start_date`)" },
12284
12659
  actual_end_date: { type: "string", description: "ISO actual end date" },
12285
12660
  actual_reach: { type: "number", description: "Observed reach: how many people the run actually touched" },
@@ -12331,7 +12706,7 @@ var UPG_PROPERTY_SCHEMA = {
12331
12706
  target_date: { type: "string", description: "ISO date work completes" },
12332
12707
  health: { type: "string", enum: ["on_track", "at_risk", "off_track"], description: "Delivery health" },
12333
12708
  workflow_state: { type: "string", description: 'The source tool\'s raw custom workflow state, verbatim and opaque (e.g. "In Review", "QA", "Needs Triage"). Non-canonical and never reasoned over: it exists to round-trip an import losslessly. Map it onto a canonical bucket with `workflow_state_category`; canonical `status` stays the sole reasoning axis.' },
12334
- workflow_state_category: { type: "string", description: 'Canonical bucket the raw `workflow_state` maps onto for reasoning (a source "In Review" and "QA" might both map to a verification phase). Optional companion to `workflow_state`: it lets a graph reason over an imported custom workflow without promoting the raw label to `status`.' }
12709
+ workflow_state_category: { type: "string", enum: ["triage", "backlog", "unstarted", "started", "completed", "cancelled"], description: 'Canonical bucket the raw `workflow_state` maps onto for reasoning: a source "In Review" and a source "QA" may both map to a verification phase. Optional companion to `workflow_state`; canonical `status` remains the sole reasoning axis.', notes: "It exists so a graph can reason over an imported custom workflow WITHOUT promoting the source's raw label to `status`. The raw label keeps its own field and stays verbatim; this one says what that label means in the six-bucket vocabulary every major tracker converges on. NARROWED FROM `string` AT 0.32.0. The field exists to carry exactly the vocabulary, and typing it as an open string meant nothing enforced the one thing it was for; an importer could write any word here and no consumer would know it had. A graph carrying a free string now fails to type-check rather than failing to be understood." }
12335
12710
  },
12336
12711
  // FeatureAreaProperties: A structural grouping of related features within a product.
12337
12712
  feature_area: {
@@ -12447,7 +12822,8 @@ var UPG_PROPERTY_SCHEMA = {
12447
12822
  // FrameworkExerciseProperties: Framework exercise: one run of a framework over a set of entities.
12448
12823
  framework_exercise: {
12449
12824
  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." },
12450
- 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)." }
12825
+ 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)." },
12826
+ 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." }
12451
12827
  },
12452
12828
  // FunnelProperties: Funnel entity.
12453
12829
  funnel: {
@@ -12464,7 +12840,8 @@ var UPG_PROPERTY_SCHEMA = {
12464
12840
  // GlossaryTermProperties: GlossaryTerm entity.
12465
12841
  glossary_term: {
12466
12842
  term_definition: { type: "string", description: "Plain-language definition of the term" },
12467
- synonyms: { type: "string[]", description: "Alternative names or abbreviations for this term" }
12843
+ synonyms: { type: "string[]", description: "Alternative names or abbreviations for this term" },
12844
+ 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." }
12468
12845
  },
12469
12846
  // GrowthCampaignProperties: GrowthCampaign entity.
12470
12847
  growth_campaign: {
@@ -12537,7 +12914,7 @@ var UPG_PROPERTY_SCHEMA = {
12537
12914
  // HypothesisEvidenceProperties
12538
12915
  hypothesis_evidence: {
12539
12916
  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." },
12540
- 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`." },
12917
+ 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." },
12541
12918
  weight: {
12542
12919
  type: "assessment",
12543
12920
  scale_id: "importance_5",
@@ -12607,6 +12984,7 @@ var UPG_PROPERTY_SCHEMA = {
12607
12984
  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." },
12608
12985
  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." },
12609
12986
  source_method: { type: "string", description: 'Producing research method. @example "usability_study", "interview_series", "survey"' },
12987
+ 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." },
12610
12988
  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."' },
12611
12989
  implications: { type: "string", description: "Product implications. The so-what." }
12612
12990
  },
@@ -12628,8 +13006,8 @@ var UPG_PROPERTY_SCHEMA = {
12628
13006
  },
12629
13007
  // InternalDocProperties: Internal document.
12630
13008
  internal_doc: {
12631
- doc_type: { type: "string", enum: ["rfc", "runbook", "guide", "spec", "onboarding", "other"], description: "Classification of the document" },
12632
- url: { type: "string", description: "URL or path to the document" }
13009
+ doc_type: { type: "string", enum: ["rfc", "runbook", "guide", "spec", "onboarding", "other"], description: "Classification of the document. @deprecated use `document.document_type`" },
13010
+ url: { type: "string", description: "URL or path to the document. @deprecated use `document.source_url`" }
12633
13011
  },
12634
13012
  // InterviewGuideProperties: Interview guide document.
12635
13013
  interview_guide: {
@@ -12987,7 +13365,7 @@ var UPG_PROPERTY_SCHEMA = {
12987
13365
  range_max: { type: "number", description: "Maximum expected or ceiling value" },
12988
13366
  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." },
12989
13367
  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." },
12990
- 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`)." },
13368
+ 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." },
12991
13369
  guardrail_threshold_min: { type: "number", description: "Lower bound for guardrail safety (below this = breach)" },
12992
13370
  guardrail_threshold_max: { type: "number", description: "Upper bound for guardrail safety (above this = breach)" },
12993
13371
  guardrail_status: { type: "string", enum: ["safe", "warning", "breached"], description: "Current guardrail health state" }
@@ -13007,7 +13385,7 @@ var UPG_PROPERTY_SCHEMA = {
13007
13385
  },
13008
13386
  // MilestoneProperties: Milestone.
13009
13387
  milestone: {
13010
- 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`." },
13388
+ 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." },
13011
13389
  due_date: { type: "string", description: "Target due date (ISO format)" },
13012
13390
  met_on_time: { type: "boolean", description: "Whether the milestone was met on time" }
13013
13391
  },
@@ -13237,8 +13615,8 @@ var UPG_PROPERTY_SCHEMA = {
13237
13615
  motivation: { type: "string", description: 'Primary motivation or driving need @example "Making confident, evidence-based decisions"' },
13238
13616
  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`." },
13239
13617
  domain_expertise: { type: "string", description: 'Industry or domain knowledge this persona brings @example "10+ years SaaS experience", "New to healthcare IT"' },
13240
- 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"' },
13241
- 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"' }
13618
+ 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." },
13619
+ 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." }
13242
13620
  },
13243
13621
  // PipelineSalesProperties: Sales pipeline.
13244
13622
  pipeline_sales: {
@@ -13324,7 +13702,8 @@ var UPG_PROPERTY_SCHEMA = {
13324
13702
  url: { type: "string", description: "Where the product lives. Marketing site, app store URL, etc.", modifier: "volatile" },
13325
13703
  logo_url: { type: "string", description: "Logo or icon URL. Used to render product cards and lists.", modifier: "volatile" },
13326
13704
  launched_at: { type: "string", description: "When the product became generally available (ISO 8601)" },
13327
- 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"` }
13705
+ 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." },
13706
+ key_prefix: { type: "string", description: 'Prefix for keys minted onto this product\'s nodes (e.g. `"LTN"`, giving `LTN-1`, `LTN-2`, \u2026). A product with no prefix mints no keys. @example "LTN"', notes: 'Pairs with `UPGBaseNode.key`, which holds the minted value. The prefix sits on the product because the key\'s uniqueness scope is the product, across entity types; the namespace a tracker calls a "team". Only the prefix is serialised: the next number is `max(existing) + 1`, derived from the graph, because a counter is a fact about a store rather than about the product.' }
13328
13707
  },
13329
13708
  // ProductAreaProperties: ProductArea entity.
13330
13709
  product_area: {
@@ -13376,7 +13755,10 @@ var UPG_PROPERTY_SCHEMA = {
13376
13755
  qa_session: {
13377
13756
  session_type: { type: "string", enum: ["exploratory", "regression", "smoke", "uat"], description: "Type of QA session" },
13378
13757
  duration_minutes: { type: "number", description: "Duration of the session in minutes" },
13379
- bugs_found: { type: "number", description: "Number of bugs found during the session" }
13758
+ bugs_found: { type: "number", description: "Number of bugs found during the session" },
13759
+ 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`." },
13760
+ 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." },
13761
+ executed_at: { type: "string", description: "ISO timestamp of when the session was run. Mirrors `TestResultProperties.executed_at`." }
13380
13762
  },
13381
13763
  // QueueTopicProperties: Message queue or topic.
13382
13764
  queue_topic: {
@@ -13443,7 +13825,7 @@ var UPG_PROPERTY_SCHEMA = {
13443
13825
  },
13444
13826
  // ReleaseStrategyProperties: Release strategy.
13445
13827
  release_strategy: {
13446
- 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' },
13828
+ 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." },
13447
13829
  canary_percentage: { type: "number", description: "Traffic routed to canary. Applies when `strategy_type === 'canary'`. @example 5 (5% initial canary before full rollout)" },
13448
13830
  rollback_criteria: { type: "string", description: 'Rollback triggers, automatic or manual. @example "Error rate > 1% over 5 minutes", "Latency p99 > 2x baseline"' },
13449
13831
  bake_time: { type: "string", description: 'Soak time before promoting to full production. @example "30m", "2h", "24h"' },
@@ -13504,7 +13886,7 @@ var UPG_PROPERTY_SCHEMA = {
13504
13886
  },
13505
13887
  // RiskProperties: Risk.
13506
13888
  risk: {
13507
- risk_type: { type: "string", enum: ["technical", "business", "legal", "security", "operational"], description: "Domain the risk belongs to" },
13889
+ 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." },
13508
13890
  probability: {
13509
13891
  type: "assessment",
13510
13892
  scale_id: "confidence_5",
@@ -13652,7 +14034,8 @@ var UPG_PROPERTY_SCHEMA = {
13652
14034
  effective_date: { type: "string", description: "ISO effective date" },
13653
14035
  url: { type: "string", description: "Policy document URL" },
13654
14036
  policy_status: { type: "string", enum: ["draft", "active", "under_review", "retired"], description: "Lifecycle status" },
13655
- rule_strength: { type: "string", enum: ["must", "must_not", "exception", "warning", "guideline"], description: "Imperative force" }
14037
+ rule_strength: { type: "string", enum: ["must", "must_not", "exception", "warning", "guideline"], description: "Imperative force" },
14038
+ 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." }
13656
14039
  },
13657
14040
  // SecurityReviewProperties: Security review.
13658
14041
  security_review: {
@@ -13852,12 +14235,12 @@ var UPG_PROPERTY_SCHEMA = {
13852
14235
  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." },
13853
14236
  persistence: { type: "string", enum: ["always", "conditional", "on_demand", "transient"], description: "How reliably the surface is present." },
13854
14237
  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"' },
13855
- 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.' },
14238
+ 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." },
13856
14239
  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." },
13857
14240
  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.' },
13858
14241
  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." },
13859
- 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."' },
13860
- 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).' },
14242
+ 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." },
14243
+ 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.' },
13861
14244
  extensibility: { type: "string", enum: ["closed", "plugin_registerable", "user_configurable"], description: "Who may add occupants to the surface." },
13862
14245
  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." },
13863
14246
  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"' }
@@ -13920,9 +14303,9 @@ var UPG_PROPERTY_SCHEMA = {
13920
14303
  effort: { type: "string", description: 'Effort estimate (e.g. "2h", "1d", "3 points"). Use a consistent unit within your team.' },
13921
14304
  priority: { type: "string", enum: ["urgent", "high", "medium", "low", "none"], description: "Relative importance against other tasks" },
13922
14305
  due_date: { type: "string", description: "ISO date due. Typically bounded by the containing story's due date." },
13923
- labels: { type: "string[]", description: "Free-form classification tags. Applied uniformly across work item types." },
14306
+ labels: { type: "string[]", description: "Free-form classification tags. @deprecated since 0.32.0. Use base-node `tags` for ungrouped labels, and `classification_axis` + `classification_value` + `node_classified_as_classification_value` when the labels belong to a named group. This field duplicated `tags` and had no consumers; three parallel label surfaces (base `tags`, per-type `tags`, per-type `labels`) were two too many. `UPG_PROPERTY_MIGRATIONS['0.32.0']` drops it." },
13924
14307
  workflow_state: { type: "string", description: 'The source tool\'s raw custom workflow state, verbatim and opaque (e.g. "In Review", "QA", "Needs Triage"). Non-canonical and never reasoned over: it exists to round-trip an import losslessly. Map it onto a canonical bucket with `workflow_state_category`; canonical `status` stays the sole reasoning axis.' },
13925
- workflow_state_category: { type: "string", description: 'Canonical bucket the raw `workflow_state` maps onto for reasoning (a source "In Review" and "QA" might both map to a verification phase). Optional companion to `workflow_state`: it lets a graph reason over an imported custom workflow without promoting the raw label to `status`.' }
14308
+ workflow_state_category: { type: "string", enum: ["triage", "backlog", "unstarted", "started", "completed", "cancelled"], description: 'Canonical bucket the raw `workflow_state` maps onto for reasoning: a source "In Review" and a source "QA" may both map to a verification phase. Optional companion to `workflow_state`; canonical `status` remains the sole reasoning axis.', notes: "It exists so a graph can reason over an imported custom workflow WITHOUT promoting the source's raw label to `status`. The raw label keeps its own field and stays verbatim; this one says what that label means in the six-bucket vocabulary every major tracker converges on. NARROWED FROM `string` AT 0.32.0. The field exists to carry exactly the vocabulary, and typing it as an open string meant nothing enforced the one thing it was for; an importer could write any word here and no consumer would know it had. A graph carrying a free string now fails to type-check rather than failing to be understood." }
13926
14309
  },
13927
14310
  // TeamProperties: Team entity.
13928
14311
  team: {
@@ -14025,7 +14408,7 @@ var UPG_PROPERTY_SCHEMA = {
14025
14408
  },
14026
14409
  // TestSuiteProperties: Test suite.
14027
14410
  test_suite: {
14028
- suite_type: { type: "string", enum: ["unit", "integration", "e2e", "performance", "security", "accessibility"], description: "Category of test suite" },
14411
+ 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." },
14029
14412
  test_count: { type: "number", description: "Number of tests in the suite", modifier: "derived" },
14030
14413
  pass_rate: { type: "number", description: "Percentage of tests passing (0-100)", modifier: "snapshot" },
14031
14414
  last_run: { type: "string", description: "ISO date of last execution" },
@@ -14068,7 +14451,7 @@ var UPG_PROPERTY_SCHEMA = {
14068
14451
  },
14069
14452
  // ThreatModelProperties: Threat model.
14070
14453
  threat_model: {
14071
- 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' },
14454
+ 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." },
14072
14455
  scope: { type: "string", description: 'What system, feature, or data flow is being analysed. @example "user authentication", "payment processing", "admin API"' },
14073
14456
  last_reviewed: { type: "string", description: 'ISO date last reviewed. Models become stale as systems evolve. @example "2026-03-01"' },
14074
14457
  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)"' },
@@ -14120,6 +14503,7 @@ var UPG_PROPERTY_SCHEMA = {
14120
14503
  },
14121
14504
  // UserFlowProperties: User flow.
14122
14505
  user_flow: {
14506
+ 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." },
14123
14507
  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." },
14124
14508
  trigger: { type: "string", description: "Initiating event" },
14125
14509
  steps: { type: "string[]", description: "Ordered steps" },
@@ -14143,7 +14527,7 @@ var UPG_PROPERTY_SCHEMA = {
14143
14527
  priority: { type: "string", enum: ["urgent", "high", "medium", "low", "none"], description: "Relative importance against other stories. Lifted onto user_story (0.20.0) so the story is a first-class plannable unit alongside task, matching Jira/Linear where the story/issue is the estimated-and-assigned atom." },
14144
14528
  due_date: { type: "string", description: "ISO date due. Typically bounded by the release or planning_cycle the story is scheduled into." },
14145
14529
  workflow_state: { type: "string", description: 'The source tool\'s raw custom workflow state, verbatim and opaque (e.g. "In Review", "QA", "Needs Triage"). Non-canonical and never reasoned over: it exists to round-trip an import losslessly. Map it onto a canonical bucket with `workflow_state_category`; canonical `status` stays the sole reasoning axis.' },
14146
- workflow_state_category: { type: "string", description: 'Canonical bucket the raw `workflow_state` maps onto for reasoning (a source "In Review" and "QA" might both map to a verification phase). Optional companion to `workflow_state`: it lets a graph reason over an imported custom workflow without promoting the raw label to `status`.' }
14530
+ workflow_state_category: { type: "string", enum: ["triage", "backlog", "unstarted", "started", "completed", "cancelled"], description: 'Canonical bucket the raw `workflow_state` maps onto for reasoning: a source "In Review" and a source "QA" may both map to a verification phase. Optional companion to `workflow_state`; canonical `status` remains the sole reasoning axis.', notes: "It exists so a graph can reason over an imported custom workflow WITHOUT promoting the source's raw label to `status`. The raw label keeps its own field and stays verbatim; this one says what that label means in the six-bucket vocabulary every major tracker converges on. NARROWED FROM `string` AT 0.32.0. The field exists to carry exactly the vocabulary, and typing it as an open string meant nothing enforced the one thing it was for; an importer could write any word here and no consumer would know it had. A graph carrying a free string now fails to type-check rather than failing to be understood." }
14147
14531
  },
14148
14532
  // ValueObjectProperties: DDD value object.
14149
14533
  value_object: {
@@ -14182,7 +14566,8 @@ var UPG_PROPERTY_SCHEMA = {
14182
14566
  severity: {
14183
14567
  type: "assessment",
14184
14568
  scale_id: "severity_5",
14185
- 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)",
14569
+ 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)",
14570
+ 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.",
14186
14571
  properties: {
14187
14572
  value: { type: "number", description: "The numeric value, used for computation." },
14188
14573
  label: { type: "string", description: "The qualitative label (what the assessor meant)." },
@@ -14254,12 +14639,14 @@ var UPG_PROPERTY_SCHEMA = {
14254
14639
  workspace: {
14255
14640
  visibility: { type: "string", enum: ["private", "shared", "public"], description: "Who can see this workspace" },
14256
14641
  purpose: { type: "string", description: "Free-text description. Pairs with the closed-enum `workspace_purpose`." },
14257
- 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." },
14642
+ 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." },
14258
14643
  owner: { type: "string", description: "Workspace owner (handle or email). Display label; canonical owner is `team_owns_workspace` or `persona_owns_workspace`." },
14259
14644
  member_count: { type: "number", description: "Snapshot count. `team_works_in_workspace` edges are the source of truth.", modifier: "derived" },
14260
- archived: { type: "boolean", description: "Archived. Archived workspaces remain queryable but hidden from default views." },
14261
- archived_at: { type: "string", description: "ISO timestamp archived. Pairs with `archived === true`." },
14262
- icon: { type: "string", description: "Display icon (emoji or icon name)" }
14645
+ archived: { type: "boolean", description: "Archived. Archived workspaces remain queryable but hidden from default views. @deprecated since 0.32.0. Use `UPGBaseNode.archived`, which generalises this pair to every entity type. Field data showed the archived/status split is not a workspace peculiarity: a tracker held 559 archived-completed items beside 18 live-completed ones, and one status field has nowhere to put the difference. `UPG_PROPERTY_MIGRATIONS['0.32.0']` lifts this value to the top level." },
14646
+ archived_at: { type: "string", description: "ISO timestamp archived. Pairs with `archived === true`. @deprecated since 0.32.0. Use `UPGBaseNode.archived_at`. Lifted by `UPG_PROPERTY_MIGRATIONS['0.32.0']`." },
14647
+ icon: { type: "string", description: "Display icon (emoji or icon name)" },
14648
+ 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." },
14649
+ 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." }
14263
14650
  }
14264
14651
  };
14265
14652
  function getPropertySchema(entityType) {
@@ -15303,159 +15690,220 @@ var UPG_FRAMEWORKS = [
15303
15690
  }
15304
15691
  },
15305
15692
  {
15306
- "id": "porter-five-forces",
15307
- "approach_ids": [
15308
- "inspect"
15309
- ],
15310
- "name": "Porter Five Forces",
15693
+ "id": "lean-canvas",
15694
+ "name": "Lean Canvas",
15311
15695
  "version": "1.0.0",
15312
- "description": "Analyse industry competitiveness through five forces: rivalry, new entrants, substitutes, buyer power, and supplier power.",
15313
- "category": "strategy",
15696
+ "description": "Startup-focused adaptation of the BMC. Replaces partners/resources with problem/solution/unfair advantage.",
15697
+ "category": "business_model",
15314
15698
  "origin": {
15315
15699
  "type": "practitioner",
15316
- "attribution": "Michael Porter",
15317
- "description": "Published in Competitive Strategy (Free Press). The foundational framework for industry analysis.",
15318
- "year": 1979,
15319
- "license": "public_domain"
15700
+ "attribution": "Ash Maurya",
15701
+ "description": "Adapted from BMC for startups. Published in Running Lean (O'Reilly). Replaces partners/resources with problem/solution.",
15702
+ "url": "https://leanstack.com/lean-canvas",
15703
+ "year": 2012,
15704
+ "license": "published_methodology"
15320
15705
  },
15321
15706
  "tags": [
15322
- "strategy",
15323
- "collection"
15707
+ "business_model",
15708
+ "matrix"
15324
15709
  ],
15325
15710
  "slots": [
15326
15711
  {
15327
- "label": "Competitive Rivalry",
15328
- "role": "competitive_rivalry",
15329
- "entityTypeId": "competitor",
15330
- "description": "Intensity of competition among existing players"
15712
+ "label": "Problem",
15713
+ "entityTypeId": "need",
15714
+ "description": "Top 3 problems your customers face"
15331
15715
  },
15332
15716
  {
15333
- "label": "Threat of New Entrants",
15334
- "role": "new_entrants",
15335
- "entityTypeId": "competitor",
15336
- "description": "How easy is it for new competitors to enter?"
15717
+ "label": "Solution",
15718
+ "entityTypeId": "solution",
15719
+ "description": "Top 3 features addressing each problem"
15337
15720
  },
15338
15721
  {
15339
- "label": "Threat of Substitutes",
15340
- "role": "substitutes",
15341
- "entityTypeId": "competitor",
15342
- "description": "Can customers switch to alternatives?"
15722
+ "label": "Key Metrics",
15723
+ "entityTypeId": "metric",
15724
+ "description": "Key numbers that tell you how your business is doing"
15343
15725
  },
15344
15726
  {
15345
- "label": "Buyer Power",
15346
- "role": "buyer_power",
15347
- "entityTypeId": "persona",
15348
- "description": "How much leverage do buyers have?"
15727
+ "label": "Unique Value Prop",
15728
+ "entityTypeId": "value_proposition",
15729
+ "description": "Single, clear, compelling message"
15349
15730
  },
15350
15731
  {
15351
- "label": "Supplier Power",
15352
- "role": "supplier_power",
15732
+ "label": "Unfair Advantage",
15733
+ "entityTypeId": "capability",
15734
+ "description": "Something that cannot be easily copied"
15735
+ },
15736
+ {
15737
+ "label": "Channels",
15738
+ "entityTypeId": "acquisition_channel",
15739
+ "description": "Path to customers"
15740
+ },
15741
+ {
15742
+ "label": "Customer Segments",
15353
15743
  "entityTypeId": "persona",
15354
- "description": "How much leverage do suppliers have?"
15744
+ "description": "Target customers"
15745
+ },
15746
+ {
15747
+ "label": "Existing Alternatives",
15748
+ "entityTypeId": "competitor",
15749
+ "description": "What do customers use today?"
15750
+ },
15751
+ {
15752
+ "label": "Early Adopters",
15753
+ "entityTypeId": "behavioral_segment",
15754
+ "description": "Your first target customers"
15755
+ },
15756
+ {
15757
+ "label": "Cost Structure",
15758
+ "entityTypeId": "cost_structure",
15759
+ "description": "Customer acquisition costs, hosting, etc."
15760
+ },
15761
+ {
15762
+ "label": "Revenue Streams",
15763
+ "entityTypeId": "revenue_stream",
15764
+ "description": "Revenue model, lifetime value, margins"
15355
15765
  }
15356
15766
  ],
15357
15767
  "data": {
15358
15768
  "entity_types": [
15769
+ {
15770
+ "type": "need",
15771
+ "role": "bucket"
15772
+ },
15773
+ {
15774
+ "type": "solution",
15775
+ "role": "bucket"
15776
+ },
15777
+ {
15778
+ "type": "value_proposition",
15779
+ "role": "bucket"
15780
+ },
15359
15781
  {
15360
15782
  "type": "competitor",
15361
- "role": "item"
15783
+ "role": "bucket"
15362
15784
  },
15363
15785
  {
15364
15786
  "type": "persona",
15787
+ "role": "bucket"
15788
+ },
15789
+ {
15790
+ "type": "behavioral_segment",
15791
+ "role": "bucket"
15792
+ },
15793
+ {
15794
+ "type": "metric",
15795
+ "role": "bucket"
15796
+ },
15797
+ {
15798
+ "type": "acquisition_channel",
15799
+ "role": "bucket"
15800
+ },
15801
+ {
15802
+ "type": "cost_structure",
15803
+ "role": "bucket"
15804
+ },
15805
+ {
15806
+ "type": "revenue_stream",
15807
+ "role": "bucket"
15808
+ },
15809
+ {
15810
+ "type": "capability",
15365
15811
  "role": "item"
15366
15812
  }
15367
15813
  ],
15368
15814
  "required_properties": {}
15369
15815
  },
15370
15816
  "structure": {
15371
- "pattern": "collection"
15817
+ "pattern": "matrix"
15372
15818
  },
15373
15819
  "presentation": {
15374
15820
  "layout": {
15375
- "type": "grid",
15376
- "groupBy": "type"
15821
+ "type": "matrix",
15822
+ "rows": 3,
15823
+ "cols": 4
15377
15824
  },
15378
15825
  "sort_by": {
15379
15826
  "property": "title",
15380
15827
  "direction": "asc"
15381
15828
  },
15382
- "colour_by": "type",
15829
+ "colour_by": "group",
15383
15830
  "card_fields": [
15384
15831
  "title",
15385
15832
  "description"
15386
15833
  ]
15387
15834
  },
15388
15835
  "education": {
15389
- "purpose": "Analyse the competitive dynamics of an industry through five structural forces that determine profitability, revealing where power lies and where threats emerge.",
15390
- "core_question": "What structural forces shape competition in our industry, and how do they affect our ability to capture value?",
15836
+ "purpose": "Capture an entire business model hypothesis on a single page so founders can test assumptions rapidly without writing a full business plan.",
15837
+ "core_question": "What are the riskiest assumptions in our business model, and how can we test them quickly?",
15391
15838
  "when_to_use": [
15392
- "You need to align the team on long-term direction",
15393
- "Market conditions are shifting and you need to reassess positioning",
15394
- "Leadership needs a structured view of strategic options"
15839
+ "You are designing or redesigning how the business creates and captures value",
15840
+ "You need to communicate the business model to stakeholders or investors",
15841
+ "You want to identify risks and assumptions in your business model"
15395
15842
  ],
15396
15843
  "when_not_to_use": [
15397
- "You are in pure execution mode with a clear strategy already set",
15398
- "The team is too early-stage to commit to strategic constraints"
15844
+ "The business model is mature and well-understood by all stakeholders",
15845
+ "You are focused on tactical execution rather than model design"
15399
15846
  ]
15400
15847
  }
15401
15848
  },
15402
15849
  {
15403
- "id": "swot-analysis",
15850
+ "id": "competitive-landscape",
15404
15851
  "approach_ids": [
15405
15852
  "inspect"
15406
15853
  ],
15407
- "name": "SWOT Analysis",
15854
+ "name": "Competitive Landscape",
15408
15855
  "version": "1.0.0",
15409
- "description": "Map Strengths, Weaknesses, Opportunities, and Threats in a 2x2 grid. Internal vs external, helpful vs harmful.",
15410
- "category": "strategy",
15856
+ "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.",
15857
+ "category": "competitive",
15411
15858
  "origin": {
15412
- "type": "practitioner",
15413
- "description": "Widely attributed to Albert Humphrey at Stanford Research Institute. One of the most commonly used strategy frameworks.",
15414
- "license": "public_domain"
15859
+ "type": "custom",
15860
+ "attribution": "The Product Creator",
15861
+ "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.",
15862
+ "license": "open_attribution"
15415
15863
  },
15416
15864
  "tags": [
15417
- "strategy",
15865
+ "competitive",
15418
15866
  "matrix"
15419
15867
  ],
15420
15868
  "slots": [
15421
15869
  {
15422
- "label": "Strengths",
15423
- "entityTypeId": "capability",
15424
- "description": "Internal advantages and core competencies"
15870
+ "label": "Competitors",
15871
+ "entityTypeId": "competitor",
15872
+ "description": "The rivals in the market you are surveying"
15425
15873
  },
15426
15874
  {
15427
- "label": "Weaknesses",
15428
- "entityTypeId": "need",
15429
- "description": "Internal limitations and gaps"
15875
+ "label": "Competitor Features",
15876
+ "entityTypeId": "competitor_feature",
15877
+ "description": "What those competitors actually ship"
15430
15878
  },
15431
15879
  {
15432
- "label": "Opportunities",
15433
- "entityTypeId": "opportunity",
15434
- "description": "External trends and openings"
15880
+ "label": "Market Trends",
15881
+ "entityTypeId": "market_trend",
15882
+ "description": "Forces acting on the whole market, not on one rival"
15435
15883
  },
15436
15884
  {
15437
- "label": "Threats",
15438
- "entityTypeId": "competitor",
15439
- "description": "External risks and competitive pressures"
15885
+ "label": "Positioning",
15886
+ "entityTypeId": "positioning",
15887
+ "description": "Where you stand relative to the field"
15440
15888
  }
15441
15889
  ],
15442
15890
  "data": {
15443
15891
  "entity_types": [
15444
15892
  {
15445
- "type": "capability",
15446
- "role": "bucket"
15893
+ "type": "competitor",
15894
+ "role": "item"
15447
15895
  },
15448
15896
  {
15449
- "type": "need",
15450
- "role": "bucket"
15897
+ "type": "competitor_feature",
15898
+ "role": "item"
15451
15899
  },
15452
15900
  {
15453
- "type": "opportunity",
15454
- "role": "bucket"
15901
+ "type": "market_trend",
15902
+ "role": "item"
15455
15903
  },
15456
15904
  {
15457
- "type": "competitor",
15458
- "role": "bucket"
15905
+ "type": "positioning",
15906
+ "role": "item"
15459
15907
  }
15460
15908
  ],
15461
15909
  "required_properties": {}
@@ -15469,15 +15917,204 @@ var UPG_FRAMEWORKS = [
15469
15917
  "rows": 2,
15470
15918
  "cols": 2
15471
15919
  },
15472
- "sort_by": {
15473
- "property": "title",
15474
- "direction": "asc"
15475
- },
15476
- "colour_by": "group",
15920
+ "colour_by": "type",
15477
15921
  "card_fields": [
15478
15922
  "title",
15479
- "description"
15480
- ]
15923
+ "description",
15924
+ "status"
15925
+ ]
15926
+ },
15927
+ "education": {
15928
+ "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.",
15929
+ "core_question": "What does the field look like from above, and where do we sit in it?",
15930
+ "when_to_use": [
15931
+ "You need the shape of a whole market before choosing which rival to study in depth",
15932
+ "Trends are moving the category and you need them beside the competitor set, not in a separate document",
15933
+ "A positioning statement needs the competitive evidence sitting next to it"
15934
+ ],
15935
+ "when_not_to_use": [
15936
+ "You already know which single rival matters and want depth on them: use competitor-profile",
15937
+ "You are comparing capabilities feature-by-feature against named rivals: use competitive-matrix",
15938
+ "You need to place rivals positionally on two scored axes: use positioning-map"
15939
+ ]
15940
+ }
15941
+ },
15942
+ {
15943
+ "id": "porter-five-forces",
15944
+ "approach_ids": [
15945
+ "inspect"
15946
+ ],
15947
+ "name": "Porter Five Forces",
15948
+ "version": "1.0.0",
15949
+ "description": "Analyse industry competitiveness through five forces: rivalry, new entrants, substitutes, buyer power, and supplier power.",
15950
+ "category": "strategy",
15951
+ "origin": {
15952
+ "type": "practitioner",
15953
+ "attribution": "Michael Porter",
15954
+ "description": "Published in Competitive Strategy (Free Press). The foundational framework for industry analysis.",
15955
+ "year": 1979,
15956
+ "license": "public_domain"
15957
+ },
15958
+ "tags": [
15959
+ "strategy",
15960
+ "collection"
15961
+ ],
15962
+ "slots": [
15963
+ {
15964
+ "label": "Competitive Rivalry",
15965
+ "role": "competitive_rivalry",
15966
+ "entityTypeId": "competitor",
15967
+ "description": "Intensity of competition among existing players"
15968
+ },
15969
+ {
15970
+ "label": "Threat of New Entrants",
15971
+ "role": "new_entrants",
15972
+ "entityTypeId": "competitor",
15973
+ "description": "How easy is it for new competitors to enter?"
15974
+ },
15975
+ {
15976
+ "label": "Threat of Substitutes",
15977
+ "role": "substitutes",
15978
+ "entityTypeId": "competitor",
15979
+ "description": "Can customers switch to alternatives?"
15980
+ },
15981
+ {
15982
+ "label": "Buyer Power",
15983
+ "role": "buyer_power",
15984
+ "entityTypeId": "persona",
15985
+ "description": "How much leverage do buyers have?"
15986
+ },
15987
+ {
15988
+ "label": "Supplier Power",
15989
+ "role": "supplier_power",
15990
+ "entityTypeId": "persona",
15991
+ "description": "How much leverage do suppliers have?"
15992
+ }
15993
+ ],
15994
+ "data": {
15995
+ "entity_types": [
15996
+ {
15997
+ "type": "competitor",
15998
+ "role": "item"
15999
+ },
16000
+ {
16001
+ "type": "persona",
16002
+ "role": "item"
16003
+ }
16004
+ ],
16005
+ "required_properties": {}
16006
+ },
16007
+ "structure": {
16008
+ "pattern": "collection"
16009
+ },
16010
+ "presentation": {
16011
+ "layout": {
16012
+ "type": "grid",
16013
+ "groupBy": "type"
16014
+ },
16015
+ "sort_by": {
16016
+ "property": "title",
16017
+ "direction": "asc"
16018
+ },
16019
+ "colour_by": "type",
16020
+ "card_fields": [
16021
+ "title",
16022
+ "description"
16023
+ ]
16024
+ },
16025
+ "education": {
16026
+ "purpose": "Analyse the competitive dynamics of an industry through five structural forces that determine profitability, revealing where power lies and where threats emerge.",
16027
+ "core_question": "What structural forces shape competition in our industry, and how do they affect our ability to capture value?",
16028
+ "when_to_use": [
16029
+ "You need to align the team on long-term direction",
16030
+ "Market conditions are shifting and you need to reassess positioning",
16031
+ "Leadership needs a structured view of strategic options"
16032
+ ],
16033
+ "when_not_to_use": [
16034
+ "You are in pure execution mode with a clear strategy already set",
16035
+ "The team is too early-stage to commit to strategic constraints"
16036
+ ]
16037
+ }
16038
+ },
16039
+ {
16040
+ "id": "swot-analysis",
16041
+ "approach_ids": [
16042
+ "inspect"
16043
+ ],
16044
+ "name": "SWOT Analysis",
16045
+ "version": "1.0.0",
16046
+ "description": "Map Strengths, Weaknesses, Opportunities, and Threats in a 2x2 grid. Internal vs external, helpful vs harmful.",
16047
+ "category": "strategy",
16048
+ "origin": {
16049
+ "type": "practitioner",
16050
+ "description": "Widely attributed to Albert Humphrey at Stanford Research Institute. One of the most commonly used strategy frameworks.",
16051
+ "license": "public_domain"
16052
+ },
16053
+ "tags": [
16054
+ "strategy",
16055
+ "matrix"
16056
+ ],
16057
+ "slots": [
16058
+ {
16059
+ "label": "Strengths",
16060
+ "entityTypeId": "capability",
16061
+ "description": "Internal advantages and core competencies"
16062
+ },
16063
+ {
16064
+ "label": "Weaknesses",
16065
+ "entityTypeId": "need",
16066
+ "description": "Internal limitations and gaps"
16067
+ },
16068
+ {
16069
+ "label": "Opportunities",
16070
+ "entityTypeId": "opportunity",
16071
+ "description": "External trends and openings"
16072
+ },
16073
+ {
16074
+ "label": "Threats",
16075
+ "entityTypeId": "competitor",
16076
+ "description": "External risks and competitive pressures"
16077
+ }
16078
+ ],
16079
+ "data": {
16080
+ "entity_types": [
16081
+ {
16082
+ "type": "capability",
16083
+ "role": "bucket"
16084
+ },
16085
+ {
16086
+ "type": "need",
16087
+ "role": "bucket"
16088
+ },
16089
+ {
16090
+ "type": "opportunity",
16091
+ "role": "bucket"
16092
+ },
16093
+ {
16094
+ "type": "competitor",
16095
+ "role": "bucket"
16096
+ }
16097
+ ],
16098
+ "required_properties": {}
16099
+ },
16100
+ "structure": {
16101
+ "pattern": "matrix"
16102
+ },
16103
+ "presentation": {
16104
+ "layout": {
16105
+ "type": "matrix",
16106
+ "rows": 2,
16107
+ "cols": 2
16108
+ },
16109
+ "sort_by": {
16110
+ "property": "title",
16111
+ "direction": "asc"
16112
+ },
16113
+ "colour_by": "group",
16114
+ "card_fields": [
16115
+ "title",
16116
+ "description"
16117
+ ]
15481
16118
  },
15482
16119
  "education": {
15483
16120
  "purpose": "Map internal strengths and weaknesses against external opportunities and threats so teams can align strategy with reality.",
@@ -15594,6 +16231,108 @@ var UPG_FRAMEWORKS = [
15594
16231
  ]
15595
16232
  }
15596
16233
  },
16234
+ {
16235
+ "id": "product-vision-board",
16236
+ "approach_ids": [
16237
+ "plan"
16238
+ ],
16239
+ "name": "Product Vision Board",
16240
+ "version": "1.0.0",
16241
+ "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.",
16242
+ "category": "strategy",
16243
+ "origin": {
16244
+ "type": "practitioner",
16245
+ "attribution": "Roman Pichler",
16246
+ "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.',
16247
+ "license": "published_methodology"
16248
+ },
16249
+ "tags": [
16250
+ "strategy",
16251
+ "matrix"
16252
+ ],
16253
+ "slots": [
16254
+ {
16255
+ "label": "Vision",
16256
+ "entityTypeId": "vision",
16257
+ "description": "The long-term change the product exists to bring about"
16258
+ },
16259
+ {
16260
+ "label": "Target Group",
16261
+ "entityTypeId": "persona",
16262
+ "description": "The customers and users the vision is for"
16263
+ },
16264
+ {
16265
+ "label": "Needs",
16266
+ "entityTypeId": "need",
16267
+ "description": "The problem the product solves or the benefit it provides for that group"
16268
+ },
16269
+ {
16270
+ "label": "Product",
16271
+ "entityTypeId": "solution",
16272
+ "description": "What the product is, and what makes it stand out"
16273
+ },
16274
+ {
16275
+ "label": "Business Goals",
16276
+ "entityTypeId": "outcome",
16277
+ "description": "How the product benefits the company that builds it"
16278
+ }
16279
+ ],
16280
+ "data": {
16281
+ "entity_types": [
16282
+ {
16283
+ "type": "vision",
16284
+ "role": "item"
16285
+ },
16286
+ {
16287
+ "type": "persona",
16288
+ "role": "item"
16289
+ },
16290
+ {
16291
+ "type": "need",
16292
+ "role": "item"
16293
+ },
16294
+ {
16295
+ "type": "solution",
16296
+ "role": "item"
16297
+ },
16298
+ {
16299
+ "type": "outcome",
16300
+ "role": "item"
16301
+ }
16302
+ ],
16303
+ "required_properties": {}
16304
+ },
16305
+ "structure": {
16306
+ "pattern": "matrix"
16307
+ },
16308
+ "presentation": {
16309
+ "layout": {
16310
+ "type": "matrix",
16311
+ "rows": 5,
16312
+ "cols": 1
16313
+ },
16314
+ "colour_by": "type",
16315
+ "card_fields": [
16316
+ "title",
16317
+ "description",
16318
+ "status"
16319
+ ]
16320
+ },
16321
+ "education": {
16322
+ "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.",
16323
+ "core_question": "What change are we trying to bring about, for whom, and how does building it pay off?",
16324
+ "when_to_use": [
16325
+ "A new product or a major new direction needs a shared vision before any roadmap exists",
16326
+ "The team can describe what it is building but not why anyone should want it",
16327
+ "Vision, target group and business goals are living in three different documents and drifting apart"
16328
+ ],
16329
+ "when_not_to_use": [
16330
+ "The vision is settled and the open question is sequencing: use goal-oriented-roadmap",
16331
+ "You need the causal chain from vision down to daily work: use strategic-cascade",
16332
+ "The question is which business model captures the value: use business-model-canvas"
16333
+ ]
16334
+ }
16335
+ },
15597
16336
  {
15598
16337
  "id": "three-horizons",
15599
16338
  "approach_ids": [
@@ -16179,25 +16918,95 @@ var UPG_FRAMEWORKS = [
16179
16918
  "label": "Must-haves",
16180
16919
  "role": "must_have",
16181
16920
  "entityTypeId": "feature",
16182
- "description": "Expected features: absence causes dissatisfaction"
16921
+ "description": "Expected features: absence causes dissatisfaction",
16922
+ "predicate": [
16923
+ {
16924
+ "scope": "framework",
16925
+ "property": "functional_response",
16926
+ "op": "in",
16927
+ "value": [
16928
+ "i_expect_it",
16929
+ "i_am_neutral",
16930
+ "i_can_tolerate_it"
16931
+ ]
16932
+ },
16933
+ {
16934
+ "scope": "framework",
16935
+ "property": "dysfunctional_response",
16936
+ "op": "eq",
16937
+ "value": "i_dislike_it"
16938
+ }
16939
+ ]
16183
16940
  },
16184
16941
  {
16185
16942
  "label": "Performance",
16186
16943
  "role": "performance",
16187
16944
  "entityTypeId": "feature",
16188
- "description": "More is better: linear satisfaction increase"
16945
+ "description": "More is better: linear satisfaction increase",
16946
+ "predicate": [
16947
+ {
16948
+ "scope": "framework",
16949
+ "property": "functional_response",
16950
+ "op": "eq",
16951
+ "value": "i_like_it"
16952
+ },
16953
+ {
16954
+ "scope": "framework",
16955
+ "property": "dysfunctional_response",
16956
+ "op": "eq",
16957
+ "value": "i_dislike_it"
16958
+ }
16959
+ ]
16189
16960
  },
16190
16961
  {
16191
16962
  "label": "Delighters",
16192
16963
  "role": "delighter",
16193
16964
  "entityTypeId": "feature",
16194
- "description": "Unexpected features: presence creates delight"
16965
+ "description": "Unexpected features: presence creates delight",
16966
+ "predicate": [
16967
+ {
16968
+ "scope": "framework",
16969
+ "property": "functional_response",
16970
+ "op": "eq",
16971
+ "value": "i_like_it"
16972
+ },
16973
+ {
16974
+ "scope": "framework",
16975
+ "property": "dysfunctional_response",
16976
+ "op": "in",
16977
+ "value": [
16978
+ "i_expect_it",
16979
+ "i_am_neutral",
16980
+ "i_can_tolerate_it"
16981
+ ]
16982
+ }
16983
+ ]
16195
16984
  },
16196
16985
  {
16197
16986
  "label": "Indifferent",
16198
16987
  "role": "indifferent",
16199
16988
  "entityTypeId": "feature",
16200
- "description": "Features users don't care about either way"
16989
+ "description": "Features users don't care about either way",
16990
+ "predicate": [
16991
+ {
16992
+ "scope": "framework",
16993
+ "property": "functional_response",
16994
+ "op": "in",
16995
+ "value": [
16996
+ "i_am_neutral",
16997
+ "i_can_tolerate_it"
16998
+ ]
16999
+ },
17000
+ {
17001
+ "scope": "framework",
17002
+ "property": "dysfunctional_response",
17003
+ "op": "in",
17004
+ "value": [
17005
+ "i_am_neutral",
17006
+ "i_can_tolerate_it"
17007
+ ]
17008
+ }
17009
+ ]
16201
17010
  }
16202
17011
  ],
16203
17012
  "data": {
@@ -16532,32 +17341,213 @@ var UPG_FRAMEWORKS = [
16532
17341
  "entityTypeId": "feature",
16533
17342
  "description": "Features or requirements sorted into the four MoSCoW buckets"
16534
17343
  }
16535
- ],
17344
+ ],
17345
+ "data": {
17346
+ "entity_types": [
17347
+ {
17348
+ "type": "feature",
17349
+ "role": "scored_item"
17350
+ }
17351
+ ],
17352
+ "required_properties": {
17353
+ "feature": [
17354
+ {
17355
+ "property": "moscow",
17356
+ "type": "enum",
17357
+ "required": true,
17358
+ "scope": "framework",
17359
+ "label": "MoSCoW priority",
17360
+ "description": "Which scope bucket this requirement falls into for the current release",
17361
+ "enum_values": [
17362
+ "must",
17363
+ "should",
17364
+ "could",
17365
+ "wont"
17366
+ ]
17367
+ }
17368
+ ]
17369
+ }
17370
+ },
17371
+ "structure": {
17372
+ "pattern": "table"
17373
+ },
17374
+ "presentation": {
17375
+ "layout": {
17376
+ "type": "table",
17377
+ "columns": [
17378
+ {
17379
+ "property": "title",
17380
+ "label": "Requirement",
17381
+ "sortable": true
17382
+ },
17383
+ {
17384
+ "property": "moscow",
17385
+ "label": "Priority",
17386
+ "sortable": true,
17387
+ "format": "badge"
17388
+ }
17389
+ ]
17390
+ },
17391
+ "sort_by": {
17392
+ "property": "moscow",
17393
+ "direction": "asc"
17394
+ },
17395
+ "colour_by": "type",
17396
+ "card_fields": [
17397
+ "title",
17398
+ "description",
17399
+ "status"
17400
+ ]
17401
+ },
17402
+ "education": {
17403
+ "purpose": "Categorise requirements into Must, Should, Could, and Won't buckets so stakeholders agree on scope boundaries before development begins.",
17404
+ "core_question": "Which requirements are truly non-negotiable for this release, and which can be deferred without blocking the goal?",
17405
+ "when_to_use": [
17406
+ "You have more ideas or features than capacity to build them",
17407
+ "Stakeholders disagree on what to build next",
17408
+ "You need a transparent, defensible prioritisation process"
17409
+ ],
17410
+ "when_not_to_use": [
17411
+ "You have a single obvious next step with no contention",
17412
+ "The backlog is small enough to sequence intuitively"
17413
+ ]
17414
+ }
17415
+ },
17416
+ {
17417
+ "id": "experiment-tracker",
17418
+ "name": "Experiment Tracker",
17419
+ "version": "1.0.0",
17420
+ "description": "Track experiments from design through execution to results. Velocity-focused.",
17421
+ "category": "validation",
17422
+ "origin": {
17423
+ "type": "practitioner",
17424
+ "description": "Common in growth and product teams running continuous experiments.",
17425
+ "license": "cc_by"
17426
+ },
17427
+ "tags": [
17428
+ "validation",
17429
+ "table"
17430
+ ],
17431
+ "relational": {
17432
+ "spine": "experiment_run",
17433
+ "columns": [
17434
+ {
17435
+ "kind": "field",
17436
+ "id": "experiment",
17437
+ "label": "Experiment",
17438
+ "property": "title",
17439
+ "sortable": true
17440
+ },
17441
+ {
17442
+ "kind": "projection",
17443
+ "id": "hypothesis",
17444
+ "label": "Hypothesis",
17445
+ "path": [
17446
+ {
17447
+ "edge": "experiment_plan_ran_as_experiment_run",
17448
+ "direction": "reverse"
17449
+ },
17450
+ {
17451
+ "edge": "hypothesis_requires_experiment_plan",
17452
+ "direction": "reverse"
17453
+ }
17454
+ ],
17455
+ "fields": [
17456
+ "title"
17457
+ ],
17458
+ "sortable": true
17459
+ },
17460
+ {
17461
+ "kind": "projection",
17462
+ "id": "success_metric",
17463
+ "label": "Success Metric",
17464
+ "path": [
17465
+ {
17466
+ "edge": "experiment_run_measures_metric",
17467
+ "direction": "forward"
17468
+ }
17469
+ ],
17470
+ "fields": [
17471
+ "title",
17472
+ "current_value",
17473
+ "target_value"
17474
+ ]
17475
+ },
17476
+ {
17477
+ "kind": "field",
17478
+ "id": "stage",
17479
+ "label": "Stage",
17480
+ "property": "status"
17481
+ },
17482
+ {
17483
+ "kind": "field",
17484
+ "id": "verdict",
17485
+ "label": "Verdict",
17486
+ "property": "disposition"
17487
+ },
17488
+ {
17489
+ "kind": "projection",
17490
+ "id": "result",
17491
+ "label": "Result",
17492
+ "path": [
17493
+ {
17494
+ "edge": "experiment_run_produces_learning",
17495
+ "direction": "forward"
17496
+ }
17497
+ ],
17498
+ "fields": [
17499
+ "title",
17500
+ "result_direction"
17501
+ ]
17502
+ },
17503
+ {
17504
+ "kind": "projection",
17505
+ "id": "evidence",
17506
+ "label": "Evidence",
17507
+ "path": [
17508
+ {
17509
+ "edge": "experiment_run_yields_evidence",
17510
+ "direction": "forward"
17511
+ }
17512
+ ],
17513
+ "fields": [
17514
+ "title"
17515
+ ]
17516
+ }
17517
+ ],
17518
+ "sort": {
17519
+ "column": "experiment",
17520
+ "direction": "asc"
17521
+ }
17522
+ },
16536
17523
  "data": {
16537
17524
  "entity_types": [
16538
17525
  {
16539
- "type": "feature",
16540
- "role": "scored_item"
17526
+ "type": "experiment_run",
17527
+ "role": "item"
17528
+ },
17529
+ {
17530
+ "type": "experiment_plan",
17531
+ "role": "item"
17532
+ },
17533
+ {
17534
+ "type": "hypothesis",
17535
+ "role": "item"
17536
+ },
17537
+ {
17538
+ "type": "metric",
17539
+ "role": "item"
17540
+ },
17541
+ {
17542
+ "type": "learning",
17543
+ "role": "item"
17544
+ },
17545
+ {
17546
+ "type": "evidence",
17547
+ "role": "item"
16541
17548
  }
16542
17549
  ],
16543
- "required_properties": {
16544
- "feature": [
16545
- {
16546
- "property": "moscow",
16547
- "type": "enum",
16548
- "required": true,
16549
- "scope": "framework",
16550
- "label": "MoSCoW priority",
16551
- "description": "Which scope bucket this requirement falls into for the current release",
16552
- "enum_values": [
16553
- "must",
16554
- "should",
16555
- "could",
16556
- "wont"
16557
- ]
16558
- }
16559
- ]
16560
- }
17550
+ "required_properties": {}
16561
17551
  },
16562
17552
  "structure": {
16563
17553
  "pattern": "table"
@@ -16565,22 +17555,10 @@ var UPG_FRAMEWORKS = [
16565
17555
  "presentation": {
16566
17556
  "layout": {
16567
17557
  "type": "table",
16568
- "columns": [
16569
- {
16570
- "property": "title",
16571
- "label": "Requirement",
16572
- "sortable": true
16573
- },
16574
- {
16575
- "property": "moscow",
16576
- "label": "Priority",
16577
- "sortable": true,
16578
- "format": "badge"
16579
- }
16580
- ]
17558
+ "columns": []
16581
17559
  },
16582
17560
  "sort_by": {
16583
- "property": "moscow",
17561
+ "property": "title",
16584
17562
  "direction": "asc"
16585
17563
  },
16586
17564
  "colour_by": "type",
@@ -16591,16 +17569,16 @@ var UPG_FRAMEWORKS = [
16591
17569
  ]
16592
17570
  },
16593
17571
  "education": {
16594
- "purpose": "Categorise requirements into Must, Should, Could, and Won't buckets so stakeholders agree on scope boundaries before development begins.",
16595
- "core_question": "Which requirements are truly non-negotiable for this release, and which can be deferred without blocking the goal?",
17572
+ "purpose": "Track all running and completed experiments in one place: hypothesis, method, status, result, learnings. The team builds a cumulative record of validated knowledge.",
17573
+ "core_question": "What experiments are running, what have we learned from completed experiments, and are we applying those learnings to new hypotheses?",
16596
17574
  "when_to_use": [
16597
- "You have more ideas or features than capacity to build them",
16598
- "Stakeholders disagree on what to build next",
16599
- "You need a transparent, defensible prioritisation process"
17575
+ "You have hypotheses about user needs or solutions that need testing",
17576
+ "You want to reduce risk before committing engineering resources",
17577
+ "The team is debating assumptions that can be tested empirically"
16600
17578
  ],
16601
17579
  "when_not_to_use": [
16602
- "You have a single obvious next step with no contention",
16603
- "The backlog is small enough to sequence intuitively"
17580
+ "The solution is already validated through real usage data",
17581
+ "Speed of shipping matters more than certainty about assumptions"
16604
17582
  ]
16605
17583
  }
16606
17584
  },
@@ -16930,6 +17908,117 @@ var UPG_FRAMEWORKS = [
16930
17908
  ]
16931
17909
  }
16932
17910
  },
17911
+ {
17912
+ "id": "threat-model-canvas",
17913
+ "approach_ids": [
17914
+ "inspect"
17915
+ ],
17916
+ "name": "Threat Model Canvas",
17917
+ "version": "1.0.0",
17918
+ "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.",
17919
+ "category": "security",
17920
+ "origin": {
17921
+ "type": "custom",
17922
+ "attribution": "The Product Creator",
17923
+ "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.",
17924
+ "license": "open_attribution"
17925
+ },
17926
+ "tags": [
17927
+ "security",
17928
+ "matrix"
17929
+ ],
17930
+ "slots": [
17931
+ {
17932
+ "label": "Threat Models",
17933
+ "entityTypeId": "threat_model",
17934
+ "description": "The modelling exercises themselves: scope, system, assumptions"
17935
+ },
17936
+ {
17937
+ "label": "Threats",
17938
+ "entityTypeId": "threat",
17939
+ "description": "What an adversary could attempt against the system"
17940
+ },
17941
+ {
17942
+ "label": "Vulnerabilities",
17943
+ "entityTypeId": "vulnerability",
17944
+ "description": "Weaknesses that make a threat realisable"
17945
+ },
17946
+ {
17947
+ "label": "Controls",
17948
+ "entityTypeId": "security_control",
17949
+ "description": "Technical and procedural measures that reduce risk"
17950
+ },
17951
+ {
17952
+ "label": "Policies",
17953
+ "entityTypeId": "security_policy",
17954
+ "description": "The standing rules the controls implement"
17955
+ },
17956
+ {
17957
+ "label": "Reviews",
17958
+ "entityTypeId": "security_review",
17959
+ "description": "Assessments that check the posture actually holds"
17960
+ }
17961
+ ],
17962
+ "data": {
17963
+ "entity_types": [
17964
+ {
17965
+ "type": "threat_model",
17966
+ "role": "item"
17967
+ },
17968
+ {
17969
+ "type": "threat",
17970
+ "role": "item"
17971
+ },
17972
+ {
17973
+ "type": "vulnerability",
17974
+ "role": "item"
17975
+ },
17976
+ {
17977
+ "type": "security_control",
17978
+ "role": "item"
17979
+ },
17980
+ {
17981
+ "type": "security_policy",
17982
+ "role": "item"
17983
+ },
17984
+ {
17985
+ "type": "security_review",
17986
+ "role": "item"
17987
+ }
17988
+ ],
17989
+ "required_properties": {}
17990
+ },
17991
+ "structure": {
17992
+ "pattern": "matrix"
17993
+ },
17994
+ "presentation": {
17995
+ "layout": {
17996
+ "type": "matrix",
17997
+ "rows": 2,
17998
+ "cols": 3
17999
+ },
18000
+ "colour_by": "type",
18001
+ "card_fields": [
18002
+ "title",
18003
+ "description",
18004
+ "status"
18005
+ ]
18006
+ },
18007
+ "education": {
18008
+ "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.",
18009
+ "core_question": "What could go wrong, what makes it possible, and what have we actually put in place against it?",
18010
+ "when_to_use": [
18011
+ "A threat modelling session needs somewhere to land that is not a document nobody reopens",
18012
+ "Controls and policies exist but nobody can point to the threats they answer",
18013
+ "An audit or review asks you to show posture, not just a list of findings"
18014
+ ],
18015
+ "when_not_to_use": [
18016
+ "You want STRIDE's six threat categories specifically. This canvas is a lifecycle, not that taxonomy",
18017
+ "You are triaging a live incident rather than modelling posture",
18018
+ "The work is scoring and ranking risks against each other rather than laying out what exists"
18019
+ ]
18020
+ }
18021
+ },
16933
18022
  {
16934
18023
  "id": "atomic-design",
16935
18024
  "approach_ids": [
@@ -17703,32 +18792,128 @@ var UPG_FRAMEWORKS = [
17703
18792
  "purpose": "Systematically test all 19 traction channels to find the one or two that drive scalable growth, avoiding premature commitment to a favourite channel.",
17704
18793
  "core_question": "Which of the 19 traction channels is our bullseye, the one that works at our current stage and scale?",
17705
18794
  "when_to_use": [
17706
- "You need to systematically identify and optimise growth levers",
17707
- "User acquisition, activation, or retention metrics need improvement",
17708
- "You want to build a structured growth experimentation practice"
18795
+ "You need to systematically identify and optimise growth levers",
18796
+ "User acquisition, activation, or retention metrics need improvement",
18797
+ "You want to build a structured growth experimentation practice"
18798
+ ],
18799
+ "when_not_to_use": [
18800
+ "The product has not yet achieved product-market fit",
18801
+ "Growth would scale problems rather than value"
18802
+ ]
18803
+ }
18804
+ },
18805
+ {
18806
+ "id": "product-led-growth-framework",
18807
+ "approach_ids": [
18808
+ "plan"
18809
+ ],
18810
+ "name": "PLG Framework",
18811
+ "version": "1.0.0",
18812
+ "description": "Product-led go-to-market motion. Free entry gives users access, the aha moment hooks them, they expand usage within their team, and monetisation captures value from power users.",
18813
+ "category": "go_to_market",
18814
+ "origin": {
18815
+ "type": "practitioner",
18816
+ "attribution": "Wes Bush",
18817
+ "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.',
18818
+ "url": "https://www.productled.com/",
18819
+ "year": 2019,
18820
+ "license": "published_methodology"
18821
+ },
18822
+ "tags": [
18823
+ "go_to_market",
18824
+ "flow"
18825
+ ],
18826
+ "slots": [
18827
+ {
18828
+ "label": "Free Entry",
18829
+ "role": "free_entry",
18830
+ "entityTypeId": "gtm_strategy",
18831
+ "description": "How users access the product for free"
18832
+ },
18833
+ {
18834
+ "label": "Aha Moment",
18835
+ "role": "aha_moment",
18836
+ "entityTypeId": "gtm_strategy",
18837
+ "description": "First experience of core value"
18838
+ },
18839
+ {
18840
+ "label": "Expansion Motion",
18841
+ "role": "expansion_motion",
18842
+ "entityTypeId": "sales_motion",
18843
+ "description": "How usage spreads within accounts"
18844
+ },
18845
+ {
18846
+ "label": "Monetisation Trigger",
18847
+ "role": "monetisation_trigger",
18848
+ "entityTypeId": "gtm_strategy",
18849
+ "description": "When and how to convert to paid"
18850
+ },
18851
+ {
18852
+ "label": "Metric",
18853
+ "role": "metric",
18854
+ "entityTypeId": "metric",
18855
+ "description": "Key metric driving the PLG motion"
18856
+ }
18857
+ ],
18858
+ "data": {
18859
+ "entity_types": [
18860
+ {
18861
+ "type": "gtm_strategy",
18862
+ "role": "item"
18863
+ },
18864
+ {
18865
+ "type": "sales_motion",
18866
+ "role": "item"
18867
+ },
18868
+ {
18869
+ "type": "metric",
18870
+ "role": "item"
18871
+ }
18872
+ ],
18873
+ "required_properties": {}
18874
+ },
18875
+ "structure": {
18876
+ "pattern": "flow"
18877
+ },
18878
+ "presentation": {
18879
+ "layout": {
18880
+ "type": "flow",
18881
+ "direction": "LR"
18882
+ },
18883
+ "colour_by": "status",
18884
+ "card_fields": [
18885
+ "title",
18886
+ "description",
18887
+ "status"
18888
+ ]
18889
+ },
18890
+ "education": {
18891
+ "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.",
18892
+ "core_question": "Can our product acquire, activate, and expand users without human intervention, and where in the loop do we still need sales?",
18893
+ "when_to_use": [
18894
+ "You are launching a new product, feature, or entering a new market",
18895
+ "You need to coordinate cross-functional launch activities",
18896
+ "You want to define target customers, channels, and messaging"
17709
18897
  ],
17710
18898
  "when_not_to_use": [
17711
- "The product has not yet achieved product-market fit",
17712
- "Growth would scale problems rather than value"
18899
+ "The product is mature with established distribution channels",
18900
+ "You are iterating on an existing product for existing customers"
17713
18901
  ]
17714
18902
  }
17715
18903
  },
17716
18904
  {
17717
- "id": "product-led-growth-framework",
18905
+ "id": "gtm-playbook",
17718
18906
  "approach_ids": [
17719
18907
  "plan"
17720
18908
  ],
17721
- "name": "PLG Framework",
18909
+ "name": "GTM Playbook",
17722
18910
  "version": "1.0.0",
17723
- "description": "Product-led go-to-market motion. Free entry gives users access, the aha moment hooks them, they expand usage within their team, and monetisation captures value from power users.",
18911
+ "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.",
17724
18912
  "category": "go_to_market",
17725
18913
  "origin": {
17726
18914
  "type": "practitioner",
17727
- "attribution": "Wes Bush",
17728
- "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.',
17729
- "url": "https://www.productled.com/",
17730
- "year": 2019,
17731
- "license": "published_methodology"
18915
+ "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.",
18916
+ "license": "cc_by"
17732
18917
  },
17733
18918
  "tags": [
17734
18919
  "go_to_market",
@@ -17736,34 +18921,34 @@ var UPG_FRAMEWORKS = [
17736
18921
  ],
17737
18922
  "slots": [
17738
18923
  {
17739
- "label": "Free Entry",
17740
- "role": "free_entry",
18924
+ "label": "Market Analysis",
17741
18925
  "entityTypeId": "gtm_strategy",
17742
- "description": "How users access the product for free"
18926
+ "description": "Market sizing and competitive landscape"
17743
18927
  },
17744
18928
  {
17745
- "label": "Aha Moment",
17746
- "role": "aha_moment",
17747
- "entityTypeId": "gtm_strategy",
17748
- "description": "First experience of core value"
18929
+ "label": "Ideal Customer Profile",
18930
+ "entityTypeId": "ideal_customer_profile",
18931
+ "description": "Ideal customer profile"
17749
18932
  },
17750
18933
  {
17751
- "label": "Expansion Motion",
17752
- "role": "expansion_motion",
17753
- "entityTypeId": "sales_motion",
17754
- "description": "How usage spreads within accounts"
18934
+ "label": "Positioning",
18935
+ "entityTypeId": "positioning",
18936
+ "description": "How you position against alternatives"
17755
18937
  },
17756
18938
  {
17757
- "label": "Monetisation Trigger",
17758
- "role": "monetisation_trigger",
17759
- "entityTypeId": "gtm_strategy",
17760
- "description": "When and how to convert to paid"
18939
+ "label": "Messaging",
18940
+ "entityTypeId": "messaging",
18941
+ "description": "Key messages by audience"
17761
18942
  },
17762
18943
  {
17763
- "label": "Metric",
17764
- "role": "metric",
17765
- "entityTypeId": "metric",
17766
- "description": "Key metric driving the PLG motion"
18944
+ "label": "Launch",
18945
+ "entityTypeId": "launch",
18946
+ "description": "Launch activities and timeline"
18947
+ },
18948
+ {
18949
+ "label": "Sales Motion",
18950
+ "entityTypeId": "sales_motion",
18951
+ "description": "How you will sell"
17767
18952
  }
17768
18953
  ],
17769
18954
  "data": {
@@ -17773,11 +18958,23 @@ var UPG_FRAMEWORKS = [
17773
18958
  "role": "item"
17774
18959
  },
17775
18960
  {
17776
- "type": "sales_motion",
18961
+ "type": "ideal_customer_profile",
17777
18962
  "role": "item"
17778
18963
  },
17779
18964
  {
17780
- "type": "metric",
18965
+ "type": "positioning",
18966
+ "role": "item"
18967
+ },
18968
+ {
18969
+ "type": "messaging",
18970
+ "role": "item"
18971
+ },
18972
+ {
18973
+ "type": "launch",
18974
+ "role": "item"
18975
+ },
18976
+ {
18977
+ "type": "sales_motion",
17781
18978
  "role": "item"
17782
18979
  }
17783
18980
  ],
@@ -17799,8 +18996,8 @@ var UPG_FRAMEWORKS = [
17799
18996
  ]
17800
18997
  },
17801
18998
  "education": {
17802
- "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.",
17803
- "core_question": "Can our product acquire, activate, and expand users without human intervention, and where in the loop do we still need sales?",
18999
+ "purpose": "Orchestrate every go-to-market activity (messaging, channels, pricing, enablement, launch timeline) into a single executable plan.",
19000
+ "core_question": "Do all go-to-market functions (product, marketing, sales, CS) have a shared plan for how we'll bring this to market?",
17804
19001
  "when_to_use": [
17805
19002
  "You are launching a new product, feature, or entering a new market",
17806
19003
  "You need to coordinate cross-functional launch activities",
@@ -20013,6 +21210,173 @@ var UPG_FRAMEWORKS = [
20013
21210
  "You are in a delivery phase with clear requirements"
20014
21211
  ]
20015
21212
  }
21213
+ },
21214
+ {
21215
+ "id": "assumption-map",
21216
+ "approach_ids": [
21217
+ "reflect"
21218
+ ],
21219
+ "name": "Assumption Map",
21220
+ "version": "1.0.0",
21221
+ "description": "Plot assumptions on axes of uncertainty vs risk. Most risky + most uncertain = test first.",
21222
+ "category": "validation",
21223
+ "origin": {
21224
+ "type": "practitioner",
21225
+ "attribution": "David Bland",
21226
+ "description": "Published in Testing Business Ideas (Wiley). Prioritises which assumptions to validate first.",
21227
+ "url": "https://www.strategyzer.com/books/testing-business-ideas",
21228
+ "year": 2019,
21229
+ "license": "published_methodology"
21230
+ },
21231
+ "tags": [
21232
+ "validation",
21233
+ "quadrant"
21234
+ ],
21235
+ "slots": [
21236
+ {
21237
+ "label": "Test First",
21238
+ "entityTypeId": "assumption",
21239
+ "role": "test_first",
21240
+ "description": "High risk, low confidence: validate immediately",
21241
+ "predicate": [
21242
+ {
21243
+ "scope": "entity",
21244
+ "property": "risk_level",
21245
+ "op": "band",
21246
+ "value": [
21247
+ 4,
21248
+ 6
21249
+ ]
21250
+ },
21251
+ {
21252
+ "scope": "entity",
21253
+ "property": "confidence",
21254
+ "op": "band",
21255
+ "value": [
21256
+ 1,
21257
+ 4
21258
+ ]
21259
+ }
21260
+ ]
21261
+ },
21262
+ {
21263
+ "label": "Research",
21264
+ "entityTypeId": "assumption",
21265
+ "role": "research",
21266
+ "description": "Low risk, low confidence: learn more",
21267
+ "predicate": [
21268
+ {
21269
+ "scope": "entity",
21270
+ "property": "risk_level",
21271
+ "op": "band",
21272
+ "value": [
21273
+ 1,
21274
+ 4
21275
+ ]
21276
+ },
21277
+ {
21278
+ "scope": "entity",
21279
+ "property": "confidence",
21280
+ "op": "band",
21281
+ "value": [
21282
+ 1,
21283
+ 4
21284
+ ]
21285
+ }
21286
+ ]
21287
+ },
21288
+ {
21289
+ "label": "Monitor",
21290
+ "entityTypeId": "assumption",
21291
+ "role": "monitor",
21292
+ "description": "High risk but well understood: watch closely",
21293
+ "predicate": [
21294
+ {
21295
+ "scope": "entity",
21296
+ "property": "risk_level",
21297
+ "op": "band",
21298
+ "value": [
21299
+ 4,
21300
+ 6
21301
+ ]
21302
+ },
21303
+ {
21304
+ "scope": "entity",
21305
+ "property": "confidence",
21306
+ "op": "band",
21307
+ "value": [
21308
+ 4,
21309
+ 6
21310
+ ]
21311
+ }
21312
+ ]
21313
+ },
21314
+ {
21315
+ "label": "Accept",
21316
+ "entityTypeId": "assumption",
21317
+ "role": "accept",
21318
+ "description": "Low risk, high confidence: safe to proceed",
21319
+ "predicate": [
21320
+ {
21321
+ "scope": "entity",
21322
+ "property": "risk_level",
21323
+ "op": "band",
21324
+ "value": [
21325
+ 1,
21326
+ 4
21327
+ ]
21328
+ },
21329
+ {
21330
+ "scope": "entity",
21331
+ "property": "confidence",
21332
+ "op": "band",
21333
+ "value": [
21334
+ 4,
21335
+ 6
21336
+ ]
21337
+ }
21338
+ ]
21339
+ }
21340
+ ],
21341
+ "data": {
21342
+ "entity_types": [
21343
+ {
21344
+ "type": "assumption",
21345
+ "role": "item"
21346
+ }
21347
+ ],
21348
+ "required_properties": {}
21349
+ },
21350
+ "structure": {
21351
+ "pattern": "quadrant"
21352
+ },
21353
+ "presentation": {
21354
+ "layout": {
21355
+ "type": "quadrant",
21356
+ "x_axis": "confidence",
21357
+ "y_axis": "risk_level",
21358
+ "x_label": "Confidence",
21359
+ "y_label": "Risk"
21360
+ },
21361
+ "colour_by": "group",
21362
+ "card_fields": [
21363
+ "title",
21364
+ "description"
21365
+ ]
21366
+ },
21367
+ "education": {
21368
+ "purpose": "Plot assumptions on a risk/evidence matrix so teams test the most dangerous unknowns first instead of building on unvalidated beliefs.",
21369
+ "core_question": "Which assumptions carry the most risk and the least evidence? Where do we run experiments first?",
21370
+ "when_to_use": [
21371
+ "You have hypotheses about user needs or solutions that need testing",
21372
+ "You want to reduce risk before committing engineering resources",
21373
+ "The team is debating assumptions that can be tested empirically"
21374
+ ],
21375
+ "when_not_to_use": [
21376
+ "The solution is already validated through real usage data",
21377
+ "Speed of shipping matters more than certainty about assumptions"
21378
+ ]
21379
+ }
20016
21380
  }
20017
21381
  ];
20018
21382
  var UPG_FRAMEWORKS_BY_ID = Object.fromEntries(
@@ -20683,6 +22047,8 @@ var ENTITY_EMOJI = {
20683
22047
  competitor_signal: "\u{1F4E1}",
20684
22048
  compliance_framework: "\u{1F3DF}\uFE0F",
20685
22049
  compliance_requirement: "\u26F2",
22050
+ capture: "\u{1F4F8}",
22051
+ composition: "\u{1F5BC}\uFE0F",
20686
22052
  constraint: "\u{1F512}",
20687
22053
  contact: "\u{1F4C7}",
20688
22054
  content_calendar: "\u{1F4C5}",
@@ -24061,9 +25427,9 @@ var PRODUCT_SPEC_GUIDE = {
24061
25427
  },
24062
25428
  {
24063
25429
  name: "Planning Cadence",
24064
- description: "The cadence axis. Create a planning_cycle (a sprint, iteration, quarter, or PI), nest finer cycles inside a coarse one, schedule the stories that flow through it, and scope the objectives the cycle serves. Scheduling and scoping are deliberate links, not containment: a story keeps its feature/epic parent.",
24065
- entity_types: ["planning_cycle", "user_story", "objective"],
24066
- edge_chain: ["planning_cycle_contains_planning_cycle", "planning_cycle_schedules_user_story", "objective_scoped_to_planning_cycle"]
25430
+ description: "The cadence axis. Create a planning_cycle (a sprint, iteration, quarter, or PI), nest finer cycles inside a coarse one, schedule the work that flows through it (a task, story, epic, feature or bug), and scope the objectives the cycle serves. Scheduling and scoping are deliberate links, not containment: the scheduled item keeps its feature/epic parent.",
25431
+ entity_types: ["planning_cycle", "task", "user_story", "objective"],
25432
+ edge_chain: ["planning_cycle_contains_planning_cycle", "planning_cycle_schedules_work_item", "objective_scoped_to_planning_cycle"]
24067
25433
  }
24068
25434
  ],
24069
25435
  required_bridges: [
@@ -24760,12 +26126,21 @@ var PORTFOLIO_GUIDE = {
24760
26126
  var WORKSPACE_GUIDE = {
24761
26127
  domain_id: "workspace",
24762
26128
  anchor_entity: "workspace",
24763
- creation_sequence: ["workspace", "framework_exercise"],
24764
- patterns: [],
26129
+ creation_sequence: ["workspace", "framework_exercise", "composition", "capture"],
26130
+ patterns: [
26131
+ {
26132
+ name: "Canvas to published view",
26133
+ 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",
26134
+ entity_types: ["workspace", "composition"],
26135
+ edge_chain: ["workspace_arranges_node", "workspace_produced_node", "composition_focuses_node"]
26136
+ }
26137
+ ],
24765
26138
  required_bridges: [],
24766
26139
  anti_patterns: [
24767
26140
  { description: "Workspaces are containers for exploration: do not over-structure them" },
24768
- { description: "Workspace content is transient by default: promote discoveries to the graph, do not leave them in the workspace" }
26141
+ { description: "Workspace content is transient by default: promote discoveries to the graph, do not leave them in the workspace" },
26142
+ { 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" },
26143
+ { description: "Writing every scratch canvas into a shared graph: only a workspace someone deliberately kept (retention: durable) belongs in a file other people pull" }
24769
26144
  ]
24770
26145
  };
24771
26146
  var FOUNDATIONS_GUIDE = {
@@ -27365,7 +28740,7 @@ var UPG_ANTI_PATTERNS = [
27365
28740
  {
27366
28741
  id: "planning-cycle-without-scheduled-work",
27367
28742
  name: "Planning cycle with no scheduled work",
27368
- description: "A planning_cycle exists but neither schedules any user_story nor contains a finer sub-cycle. An empty cadence box is a date range with nothing flowing through it: a sprint or iteration nobody planned work into, or a coarse period that was never broken down.",
28743
+ description: "A planning_cycle exists but neither schedules any work item nor contains a finer sub-cycle. An empty cadence box is a date range with nothing flowing through it: a sprint or iteration nobody planned work into, or a coarse period that was never broken down.",
27369
28744
  structured_condition: {
27370
28745
  operator: "and",
27371
28746
  checks: [
@@ -27374,8 +28749,8 @@ var UPG_ANTI_PATTERNS = [
27374
28749
  check: {
27375
28750
  type: "relationship",
27376
28751
  source_type: "planning_cycle",
27377
- edge_type: "planning_cycle_schedules_user_story",
27378
- target_type: "user_story",
28752
+ edge_type: "planning_cycle_schedules_work_item",
28753
+ target_type: "node",
27379
28754
  comparison: "not_exists"
27380
28755
  }
27381
28756
  },
@@ -27391,10 +28766,16 @@ var UPG_ANTI_PATTERNS = [
27391
28766
  ]
27392
28767
  },
27393
28768
  why_it_matters: "A cadence layer only earns its keep when work is planned through it. An interval with no scheduled stories and no nested cycles adds ceremony without telling anyone what the period is for.",
27394
- remediation: "Schedule the stories the cycle will carry via `planning_cycle_schedules_user_story`, or break a coarse period into finer cycles via `planning_cycle_contains_planning_cycle`. If neither applies, the interval is not yet a real cadence box.",
28769
+ remediation: "Schedule the work the cycle will carry via `planning_cycle_schedules_work_item`, or break a coarse period into finer cycles via `planning_cycle_contains_planning_cycle`. If neither applies, the interval is not yet a real cadence box.",
27395
28770
  stages: ["build", "beta", "launch", "growth", "mature"],
27396
28771
  severity: "low",
27397
28772
  since: "0.20.0"
28773
+ // RETARGETED at 0.32.0 from planning_cycle_schedules_user_story. Left
28774
+ // unchanged it would have become a false-positive generator the moment
28775
+ // cycles could hold tasks: a cycle full of scheduled tasks and no stories
28776
+ // would have reported as empty. Labeled fixtures cover the near-miss this
28777
+ // is most likely to get wrong — a coarse `period` cycle that legitimately
28778
+ // holds only sub-cycles and schedules nothing directly.
27398
28779
  },
27399
28780
  // ── Market intelligence layer ───────────────────────────────────────────
27400
28781
  {
@@ -27734,7 +29115,7 @@ var UPG_ANTI_PATTERNS = [
27734
29115
  id: "contended-surface-without-arbitration",
27735
29116
  since: "0.27.0",
27736
29117
  name: "Contended surface without arbitration",
27737
- 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.',
29118
+ 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.',
27738
29119
  // 0.28.0 (feedback 852a9721, from a 30-surface field audit). Three changes,
27739
29120
  // each earned by a reported false positive or a reported blind spot:
27740
29121
  //
@@ -27828,12 +29209,26 @@ var UPG_ANTI_PATTERNS = [
27828
29209
  // the natural move if the ordering detector is ever minted; it is not worth a
27829
29210
  // breaking property change before then.
27830
29211
  //
27831
- // STILL OPEN (banked at 0.28.0, deliberately NOT changed here): a `chained`
27832
- // slot that honestly declares `arbitration_state: 'none'` re-fires through
27833
- // branch (c), which carries no chained exemption. Current guidance stands:
27834
- // leave `arbitration_state` unset on chained surfaces. Changing (b)/(c) was
27835
- // out of scope for this release and still awaits the field confirmation the
27836
- // reporter will produce populating their 14 chained slots.
29212
+ // CLOSED (banked at 0.28.0, field-confirmed and resolved here): a `chained`
29213
+ // slot that honestly declared `arbitration_state: 'none'` used to re-fire
29214
+ // through branch (c), which carries no chained exemption. The field
29215
+ // confirmation arrived from a second reporter whose census left the state
29216
+ // unset on every one of its chained frames and named this branch as the
29217
+ // reason. THE FIX WAS THE VOCABULARY, NOT THE DETECTOR. Branch (c) matches
29218
+ // the literal string 'none', so the new fifth `SurfaceArbitrationState`
29219
+ // member `no_contention_by_design` never matches it and NOTHING in this
29220
+ // file, the evaluator, or the collector changed.
29221
+ //
29222
+ // Exempting `chained` from (c) was the alternative and it lost on both
29223
+ // correctness and cost. Correctness: `none` is defined as "nobody ever
29224
+ // decided", which is FALSE on a chained shell that has no decision to make,
29225
+ // and silencing a check on a false statement is worse than the empty field
29226
+ // it replaces. Cost: `evaluateEntityCount`'s except form covers the
29227
+ // PRESENCE filter only; the value-keyed path is a bare indexed lookup with
29228
+ // no exception arm, so giving it one means a parallel derived spec set, a
29229
+ // new collector aggregate keyed by (type, property, value, except_property,
29230
+ // except_value), the derivation walk, and its tests. That is the whole
29231
+ // 0.28.0 evaluator rider again, spent to make a false statement quiet.
27837
29232
  structured_condition: {
27838
29233
  operator: "and",
27839
29234
  checks: [
@@ -27886,7 +29281,23 @@ var UPG_ANTI_PATTERNS = [
27886
29281
  ]
27887
29282
  },
27888
29283
  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.",
27889
- 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.",
29284
+ // Remediation is deliberately the ACTIONABLE CORE only. It is read by
29285
+ // someone who has already been told there is a problem and now needs the
29286
+ // next move, so rationale in it is strictly in the way. The reasoning that
29287
+ // used to live here (why transcription is ten minutes rather than a
29288
+ // meeting, why safe_by_coincidence is the dangerous state, why declaring
29289
+ // capacity is the honest fix rather than inventing a rule, and why the
29290
+ // chained exemption is a factual claim rather than a mute button) is all
29291
+ // above in this comment block, which is where a reader who wants it looks.
29292
+ remediation: (
29293
+ // Remediation is read by someone already told there is a problem and now
29294
+ // wanting the action, so rationale is strictly in the way. The reasoning
29295
+ // trimmed out in 0.31.0: a surface at its designed capacity should state
29296
+ // that capacity rather than invent an arbitration rule it does not need,
29297
+ // and leaving the state empty on a chained surface reads as unassessed
29298
+ // rather than as "nothing to arbitrate".
29299
+ "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'`."
29300
+ ),
27890
29301
  stages: ["build", "beta", "launch", "growth", "mature", "maintenance"],
27891
29302
  severity: "medium",
27892
29303
  source: { kind: "fundamental" }
@@ -29490,7 +30901,7 @@ var UPG_REGIONS = [
29490
30901
  "roadmap_item_references_feature",
29491
30902
  "feature_request_voted_on_by_feedback_vote",
29492
30903
  "planning_cycle_contains_planning_cycle",
29493
- "planning_cycle_schedules_user_story"
30904
+ "planning_cycle_schedules_work_item"
29494
30905
  ],
29495
30906
  boundary_edges: [
29496
30907
  {
@@ -30858,6 +32269,63 @@ var UPG_STRUCTURE_PATTERNS = [
30858
32269
  "flow"
30859
32270
  ];
30860
32271
 
32272
+ // src/frameworks/relational-paths.ts
32273
+ function resolveRelationalPath(spine, path, catalog = UPG_EDGE_CATALOG) {
32274
+ const steps = [];
32275
+ if (path.length === 0) {
32276
+ return { steps, endpoint: null, reason: "path is empty; a projection must declare at least one step", failedAt: 0 };
32277
+ }
32278
+ let current = spine;
32279
+ for (let i = 0; i < path.length; i++) {
32280
+ const step = path[i];
32281
+ const entry = catalog[step.edge];
32282
+ if (!entry) {
32283
+ return {
32284
+ steps,
32285
+ endpoint: null,
32286
+ failedAt: i,
32287
+ reason: `unknown edge type "${step.edge}" \u2014 not in the UPG edge catalog`
32288
+ };
32289
+ }
32290
+ if (step.direction !== "forward" && step.direction !== "reverse") {
32291
+ return {
32292
+ steps,
32293
+ endpoint: null,
32294
+ failedAt: i,
32295
+ reason: `step direction must be "forward" or "reverse", got "${String(step.direction)}"`
32296
+ };
32297
+ }
32298
+ const entersAt = step.direction === "forward" ? entry.source_type : entry.target_type;
32299
+ const landsOn = step.direction === "forward" ? entry.target_type : entry.source_type;
32300
+ if (entersAt !== current) {
32301
+ return {
32302
+ steps,
32303
+ endpoint: null,
32304
+ failedAt: i,
32305
+ 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}".`
32306
+ };
32307
+ }
32308
+ steps.push({ from: current, to: landsOn });
32309
+ current = landsOn;
32310
+ }
32311
+ return { steps, endpoint: current, reason: null, failedAt: -1 };
32312
+ }
32313
+ function relationalCoveredEntityTypes(fw) {
32314
+ const covered = /* @__PURE__ */ new Set();
32315
+ const rel = fw.relational;
32316
+ if (!rel) return covered;
32317
+ covered.add(rel.spine);
32318
+ for (const column of rel.columns) {
32319
+ if (column.kind !== "projection") continue;
32320
+ const resolved = resolveRelationalPath(rel.spine, column.path);
32321
+ for (const step of resolved.steps) {
32322
+ covered.add(step.from);
32323
+ covered.add(step.to);
32324
+ }
32325
+ }
32326
+ return covered;
32327
+ }
32328
+
30861
32329
  // src/frameworks/validate.ts
30862
32330
  function validateUPGFramework(framework) {
30863
32331
  const errors = [];
@@ -30962,6 +32430,25 @@ function validateUPGFramework(framework) {
30962
32430
  }
30963
32431
  }
30964
32432
  }
32433
+ if (f.relational !== void 0) {
32434
+ if (typeof f.relational !== "object" || f.relational === null || Array.isArray(f.relational)) {
32435
+ errors.push('"relational" must be an object when present');
32436
+ } else {
32437
+ const rel = f.relational;
32438
+ errors.push(...validateRelationalSurface(rel));
32439
+ if (Array.isArray(f.slots) && f.slots.length > 0) {
32440
+ warnings.push(
32441
+ '"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.'
32442
+ );
32443
+ }
32444
+ const presLayout = f.presentation?.layout;
32445
+ if (presLayout?.type === "table" && Array.isArray(presLayout.columns) && presLayout.columns.length > 0) {
32446
+ errors.push(
32447
+ `"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.`
32448
+ );
32449
+ }
32450
+ }
32451
+ }
30965
32452
  if (!f.education || typeof f.education !== "object") {
30966
32453
  errors.push('"education" is required and must be an object');
30967
32454
  } else {
@@ -30979,8 +32466,144 @@ function validateUPGFramework(framework) {
30979
32466
  errors.push('"education.when_not_to_use" is required and must be an array');
30980
32467
  }
30981
32468
  }
32469
+ validateSlotPredicates(f.slots, errors);
30982
32470
  return { valid: errors.length === 0, errors, warnings };
30983
32471
  }
32472
+ var PREDICATE_SCOPES = ["entity", "framework"];
32473
+ var PREDICATE_OPS = ["eq", "in", "gte", "lt", "band"];
32474
+ function admits(atom) {
32475
+ switch (atom.op) {
32476
+ case "eq":
32477
+ return { kind: "discrete", values: [atom.value] };
32478
+ case "in":
32479
+ return { kind: "discrete", values: atom.value };
32480
+ case "gte":
32481
+ return { kind: "interval", lo: atom.value, hi: Number.POSITIVE_INFINITY };
32482
+ case "lt":
32483
+ return { kind: "interval", lo: Number.NEGATIVE_INFINITY, hi: atom.value };
32484
+ case "band":
32485
+ return { kind: "interval", lo: atom.value[0], hi: atom.value[1] };
32486
+ }
32487
+ }
32488
+ function atomsCannotBothHold(a, b) {
32489
+ const x = admits(a);
32490
+ const y = admits(b);
32491
+ if (x.kind === "discrete" && y.kind === "discrete") {
32492
+ return !x.values.some((v) => y.values.includes(v));
32493
+ }
32494
+ if (x.kind === "interval" && y.kind === "interval") {
32495
+ return !(x.lo < y.hi && y.lo < x.hi);
32496
+ }
32497
+ const set = x.kind === "discrete" ? x : y;
32498
+ const range = x.kind === "interval" ? x : y;
32499
+ return !set.values.some((v) => typeof v === "number" && v >= range.lo && v < range.hi);
32500
+ }
32501
+ function predicatesProvablyDisjoint(a, b) {
32502
+ for (const atomA of a) {
32503
+ for (const atomB of b) {
32504
+ if (atomA.scope === atomB.scope && atomA.property === atomB.property && atomsCannotBothHold(atomA, atomB)) {
32505
+ return true;
32506
+ }
32507
+ }
32508
+ }
32509
+ return false;
32510
+ }
32511
+ function parseAtom(atom, where, errors) {
32512
+ if (!atom || typeof atom !== "object" || Array.isArray(atom)) {
32513
+ errors.push(`${where} must be an object`);
32514
+ return null;
32515
+ }
32516
+ const a = atom;
32517
+ let ok = true;
32518
+ if (typeof a.scope !== "string" || !PREDICATE_SCOPES.includes(a.scope)) {
32519
+ errors.push(`${where}.scope must be one of: ${PREDICATE_SCOPES.join(", ")}`);
32520
+ ok = false;
32521
+ }
32522
+ if (typeof a.property !== "string" || a.property.length === 0) {
32523
+ errors.push(`${where}.property is required and must be a non-empty string`);
32524
+ ok = false;
32525
+ }
32526
+ if (typeof a.op !== "string" || !PREDICATE_OPS.includes(a.op)) {
32527
+ errors.push(`${where}.op must be one of: ${PREDICATE_OPS.join(", ")}`);
32528
+ return null;
32529
+ }
32530
+ switch (a.op) {
32531
+ case "eq":
32532
+ if (!["string", "number", "boolean"].includes(typeof a.value)) {
32533
+ errors.push(`${where}.value must be a string, number, or boolean for op "eq"`);
32534
+ ok = false;
32535
+ }
32536
+ break;
32537
+ case "in":
32538
+ if (!Array.isArray(a.value) || a.value.length === 0 || !a.value.every((v) => typeof v === "string" || typeof v === "number")) {
32539
+ errors.push(`${where}.value must be a non-empty array of strings or numbers for op "in"`);
32540
+ ok = false;
32541
+ }
32542
+ break;
32543
+ case "gte":
32544
+ case "lt":
32545
+ if (typeof a.value !== "number" || !Number.isFinite(a.value)) {
32546
+ errors.push(`${where}.value must be a finite number for op "${a.op}"`);
32547
+ ok = false;
32548
+ }
32549
+ break;
32550
+ case "band":
32551
+ if (!Array.isArray(a.value) || a.value.length !== 2 || !a.value.every((v) => typeof v === "number" && Number.isFinite(v))) {
32552
+ errors.push(`${where}.value must be a [min, max] pair of finite numbers for op "band"`);
32553
+ ok = false;
32554
+ } else if (a.value[0] >= a.value[1]) {
32555
+ errors.push(
32556
+ `${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)`
32557
+ );
32558
+ ok = false;
32559
+ }
32560
+ break;
32561
+ }
32562
+ return ok ? a : null;
32563
+ }
32564
+ function validateSlotPredicates(slots, errors) {
32565
+ if (!Array.isArray(slots)) return;
32566
+ const zones = [];
32567
+ slots.forEach((slot, i) => {
32568
+ if (!slot || typeof slot !== "object") return;
32569
+ const s = slot;
32570
+ if (s.predicate === void 0 || s.predicate === null) return;
32571
+ const label = typeof s.label === "string" && s.label.length > 0 ? ` ("${s.label}")` : "";
32572
+ const where = `"slots[${i}]${label}.predicate"`;
32573
+ if (!Array.isArray(s.predicate)) {
32574
+ errors.push(
32575
+ `${where} must be an array of atoms. A slot's membership rule is a conjunction, even when it has one atom`
32576
+ );
32577
+ return;
32578
+ }
32579
+ if (s.predicate.length === 0) {
32580
+ errors.push(
32581
+ `${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`
32582
+ );
32583
+ return;
32584
+ }
32585
+ const atoms = s.predicate.map((atom, j) => parseAtom(atom, `${where}[${j}]`, errors));
32586
+ if (atoms.some((a) => a === null)) return;
32587
+ zones.push({
32588
+ where: `slots[${i}]${label}`,
32589
+ entityTypeId: typeof s.entityTypeId === "string" ? s.entityTypeId : null,
32590
+ predicate: atoms
32591
+ });
32592
+ });
32593
+ for (let i = 0; i < zones.length; i++) {
32594
+ for (let j = i + 1; j < zones.length; j++) {
32595
+ const a = zones[i];
32596
+ const b = zones[j];
32597
+ if (a.entityTypeId && b.entityTypeId && a.entityTypeId !== b.entityTypeId) continue;
32598
+ if (predicatesProvablyDisjoint(a.predicate, b.predicate)) continue;
32599
+ const shared = a.predicate.filter((x) => b.predicate.some((y) => y.scope === x.scope && y.property === x.property)).map((x) => `${x.scope}.${x.property}`);
32600
+ 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`;
32601
+ errors.push(
32602
+ `"${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`
32603
+ );
32604
+ }
32605
+ }
32606
+ }
30984
32607
  function validateExpression(expr) {
30985
32608
  if (expr.trim().length === 0) {
30986
32609
  return "Expression must not be empty";
@@ -31000,6 +32623,122 @@ function validateExpression(expr) {
31000
32623
  }
31001
32624
  return null;
31002
32625
  }
32626
+ function validateRelationalSurface(rel) {
32627
+ const errors = [];
32628
+ const spine = rel.spine;
32629
+ if (!spine || typeof spine !== "string") {
32630
+ errors.push('"relational.spine" is required and must be a string (the entity type whose instances are the rows)');
32631
+ }
32632
+ if (!Array.isArray(rel.columns) || rel.columns.length === 0) {
32633
+ errors.push('"relational.columns" is required and must be a non-empty array');
32634
+ return errors;
32635
+ }
32636
+ const seenIds = /* @__PURE__ */ new Set();
32637
+ for (let i = 0; i < rel.columns.length; i++) {
32638
+ const col = rel.columns[i];
32639
+ const at = `"relational.columns[${i}]"`;
32640
+ if (!col || typeof col !== "object" || Array.isArray(col)) {
32641
+ errors.push(`${at} must be an object`);
32642
+ continue;
32643
+ }
32644
+ const c = col;
32645
+ if (!c.id || typeof c.id !== "string") {
32646
+ errors.push(`${at}.id is required and must be a string`);
32647
+ } else if (seenIds.has(c.id)) {
32648
+ errors.push(`${at}.id "${c.id}" is duplicated; column ids must be unique within a surface`);
32649
+ } else {
32650
+ seenIds.add(c.id);
32651
+ }
32652
+ if (!c.label || typeof c.label !== "string") {
32653
+ errors.push(`${at}.label is required and must be a string`);
32654
+ }
32655
+ switch (c.kind) {
32656
+ case "field": {
32657
+ if (!c.property || typeof c.property !== "string") {
32658
+ errors.push(`${at}.property is required on a field column and must be a string`);
32659
+ }
32660
+ if ("entityTypeId" in c) {
32661
+ errors.push(
32662
+ `${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.`
32663
+ );
32664
+ }
32665
+ if ("path" in c) {
32666
+ errors.push(`${at} is a field column and must NOT declare "path"; use kind:"projection" to traverse edges`);
32667
+ }
32668
+ break;
32669
+ }
32670
+ case "computed": {
32671
+ if (!c.expression || typeof c.expression !== "string") {
32672
+ errors.push(`${at}.expression is required on a computed column and must be a string`);
32673
+ } else {
32674
+ const exprError = validateExpression(c.expression);
32675
+ if (exprError) errors.push(`${at}.expression: ${exprError}`);
32676
+ }
32677
+ break;
32678
+ }
32679
+ case "projection": {
32680
+ if (!Array.isArray(c.path) || c.path.length === 0) {
32681
+ errors.push(
32682
+ `${at}.path is required on a projection column and must be a non-empty array of { edge, direction } steps`
32683
+ );
32684
+ } else {
32685
+ let stepShapeOk = true;
32686
+ for (let s = 0; s < c.path.length; s++) {
32687
+ const step = c.path[s];
32688
+ if (!step || typeof step !== "object" || Array.isArray(step)) {
32689
+ errors.push(`${at}.path[${s}] must be an object { edge, direction }`);
32690
+ stepShapeOk = false;
32691
+ continue;
32692
+ }
32693
+ const st = step;
32694
+ if (!st.edge || typeof st.edge !== "string") {
32695
+ errors.push(`${at}.path[${s}].edge is required and must be a string`);
32696
+ stepShapeOk = false;
32697
+ }
32698
+ if (st.direction !== "forward" && st.direction !== "reverse") {
32699
+ errors.push(
32700
+ `${at}.path[${s}].direction is required and must be "forward" or "reverse" (never inferred)`
32701
+ );
32702
+ stepShapeOk = false;
32703
+ }
32704
+ }
32705
+ if (stepShapeOk && typeof spine === "string") {
32706
+ const resolved = resolveRelationalPath(spine, c.path);
32707
+ if (resolved.reason) {
32708
+ errors.push(`${at}.path does not compose from spine "${spine}": ${resolved.reason}`);
32709
+ }
32710
+ }
32711
+ }
32712
+ if (!Array.isArray(c.fields) || c.fields.length === 0) {
32713
+ errors.push(`${at}.fields is required on a projection column and must be a non-empty array of property keys`);
32714
+ }
32715
+ if (c.limit !== void 0 && (typeof c.limit !== "number" || !Number.isInteger(c.limit) || c.limit < 1)) {
32716
+ errors.push(`${at}.limit must be a positive integer when present (it truncates rendering, never membership)`);
32717
+ }
32718
+ break;
32719
+ }
32720
+ default:
32721
+ errors.push(`${at}.kind must be one of: field, computed, projection. Got "${String(c.kind)}"`);
32722
+ }
32723
+ }
32724
+ const sort = rel.sort;
32725
+ if (!sort || typeof sort !== "object" || Array.isArray(sort)) {
32726
+ errors.push('"relational.sort" is required and must be an object { column, direction }');
32727
+ } else {
32728
+ const s = sort;
32729
+ if (!s.column || typeof s.column !== "string") {
32730
+ errors.push('"relational.sort.column" is required and must be a string (a column id)');
32731
+ } else if (seenIds.size > 0 && !seenIds.has(s.column)) {
32732
+ errors.push(
32733
+ `"relational.sort.column" is "${s.column}", which is not a declared column id. Declared ids: ${[...seenIds].join(", ")}`
32734
+ );
32735
+ }
32736
+ if (s.direction !== "asc" && s.direction !== "desc") {
32737
+ errors.push('"relational.sort.direction" is required and must be "asc" or "desc"');
32738
+ }
32739
+ }
32740
+ return errors;
32741
+ }
31003
32742
 
31004
32743
  // src/format/canonical.ts
31005
32744
  var nodeCrypto = __toESM(require("crypto"), 1);
@@ -31090,9 +32829,12 @@ var NODE_KEY_ORDER = [
31090
32829
  "title",
31091
32830
  "slug",
31092
32831
  "aliases",
32832
+ "key",
31093
32833
  "description",
31094
32834
  "tags",
31095
32835
  "status",
32836
+ "archived",
32837
+ "archived_at",
31096
32838
  "lifecycle_status",
31097
32839
  "source_id",
31098
32840
  "source_type",
@@ -31130,12 +32872,13 @@ function canonicalProduct(product) {
31130
32872
  function effectiveRootProduct(doc) {
31131
32873
  const node = doc.nodes?.find((n) => n.type === "product" && n.id === doc.product.id);
31132
32874
  if (!node) return doc.product;
32875
+ const propStage = node.properties?.stage;
32876
+ const nodeStage = (typeof propStage === "string" ? propStage : void 0) ?? node.status;
31133
32877
  return {
31134
32878
  ...doc.product,
31135
32879
  title: node.title ?? doc.product.title,
31136
32880
  description: node.description ?? doc.product.description,
31137
- // A product's lifecycle status IS its stage, the same axis (grammar/lifecycles).
31138
- stage: node.status ?? doc.product.stage
32881
+ stage: nodeStage ?? doc.product.stage
31139
32882
  };
31140
32883
  }
31141
32884
  function singleBody(doc) {
@@ -31381,7 +33124,7 @@ function checkHeaderSealText(text) {
31381
33124
  }
31382
33125
 
31383
33126
  // src/index.ts
31384
- var UPG_VERSION = "0.30.0";
33127
+ var UPG_VERSION = "0.32.0";
31385
33128
  var UPG_FORMAT_VERSION = "0.4.0";
31386
33129
  var MARKDOWN_FORMAT_VERSION = "0.1";
31387
33130
  var UPG_TYPES = getTypes();
@@ -31557,6 +33300,7 @@ var UPG_META_COUNT = UPG_ENTITY_META.length;
31557
33300
  USERS_NEEDS_PLAYBOOK,
31558
33301
  UnknownEntityTypeError,
31559
33302
  VARIES_BY_EDGE,
33303
+ WORKSPACE_ARRANGEMENT_EDGE_PROPERTY_SCHEMA,
31560
33304
  backfillSlug,
31561
33305
  buildTypeAliases,
31562
33306
  canBeChildOf,
@@ -31672,14 +33416,17 @@ var UPG_META_COUNT = UPG_ENTITY_META.length;
31672
33416
  normalizeDocument,
31673
33417
  parseUpg,
31674
33418
  pickCanonicalEdge,
33419
+ predicatesProvablyDisjoint,
31675
33420
  presenceExceptKey,
31676
33421
  projectGraph,
31677
33422
  readActiveWhen,
31678
33423
  readStringArray,
33424
+ relationalCoveredEntityTypes,
31679
33425
  resolveAllEdges,
31680
33426
  resolveContainmentEdge,
31681
33427
  resolveEntityType,
31682
33428
  resolveLabel,
33429
+ resolveRelationalPath,
31683
33430
  resolveSlugCollision,
31684
33431
  resolveTreePatternEdges,
31685
33432
  ringOrderedDomainIds,