@unified-product-graph/core 0.26.0 → 0.27.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/CHANGELOG.md CHANGED
@@ -7,6 +7,22 @@ This project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
8
  ---
9
9
 
10
+ ## [0.27.0] - 2026-08-12
11
+
12
+ **A `surface` entity gives the UI a place: what occupies it, and who wins when two things want it.** The catalog could say who OWNS a thing (`feature_area`) and what ARCHITECTURE it belongs to (`bounded_context`), but nothing answered *what else occupies the same place?*. `screen` is route-level (route / viewport / access_level) and too coarse for a shell, a pane, a slot, a field gutter, an action bar, or an overlay. Those places are contested, and the arbitration rule is decided constantly and then rediscovered forever, because prose is where it lands. `surface` records the place, its guest list, and the rule. Additive; no breaking change.
13
+
14
+ ### Added
15
+ - **`surface` entity** (`proposed`, `ent_359`) — a place in the UI, in the `ux_design` domain beside `screen`. Reached through the screen that renders it and self-nesting from there (shell holds panes, panes hold regions, regions hold slots). Lifecycle `draft → in_design → built → shipped → deprecated`, shared verbatim with `screen` so a screen and the surfaces inside it report progress on one vocabulary. Eight properties: `surface_kind` (shell / tool / pane / region / slot / gutter / action_bar / overlay / ambient), `persistence`, `visibility_condition`, `capacity`, `arbitration_rule`, `extensibility`, `mutates_content`, `dimensional_constraint`.
16
+ - **Ten surface edges.** Outbound: `surface_contains_surface` (hierarchy, the nesting spine), `surface_serves_job` (cross-domain), `surface_governed_by_design_guideline` (cross-domain, cross-product-eligible), `surface_renders_design_component` (hierarchy, cross-product-eligible), `surface_measured_by_metric` (semantic, cross-product-eligible), `surface_supersedes_surface` (semantic). Inbound: `feature_occupies_surface` (cross-domain, the guest list), `screen_renders_surface` (hierarchy), `decision_affects_surface` (cross-domain), `journey_step_occurs_on_surface` (semantic, the finer-grained sibling of `journey_step_shown_on_screen`).
17
+ - **Three anti-patterns.** `contended-surface-without-arbitration` (medium) fires when features occupy surfaces and at least one carries no `arbitration_rule`. `surface-without-job` and `surface-without-measurement` (low, both thin-graph advisory) are its coverage companions.
18
+ - **Property-presence filter on `EntityCheck`** — `filter: { property, present }` counts entities that do or do not carry a value for a named property, backed by a new optional `countsByTypeAndPropertyPresence` input. The pre-existing value-keyed filter cannot express absence, because a collector only indexes values that exist; without this, a detector keyed on an unfilled field could never fire.
19
+
20
+ ### Notes
21
+ - `surface_measured_by_metric` is `semantic`, not `hierarchy`. The `*_measured_by_metric` convention splits on ownership: `outcome` / `objective` / `strategic_pillar` are `hierarchy` because they own their metrics as children, and each carries a matching `UPG_VALID_CHILDREN` entry. A surface owns no metric, so it takes the `revenue_stream` / `cost_structure` half of the convention.
22
+ - `capacity` is a plain optional number whose ABSENCE means unbounded. `PropertyDefinition.type` is a single scalar kind with no union form, so a sentinel value was the alternative, and a sentinel every consumer must special-case is worse than a documented absence.
23
+
24
+ ---
25
+
10
26
  ## [0.20.1] - 2026-07-04
11
27
 
12
28
  **Three edges close the alignment-sheet seam: an initiative can now reach directly into the OKR ladder and the roadmap, and a strategic pillar gets its own north-star metric.** Composed flat region surfaces spanning org/team/product altitudes surfaced two gaps in the strategic cascade: an initiative's only path to a key result or a roadmap theme ran through its parent `strategic_theme`, and `strategic_pillar` — durable, multi-year, org-wide — had no measuring metric of its own, unlike every other rung of the cascade (`objective_measured_by_metric`, `key_result_quantified_by_metric`). Additive; no breaking change.
package/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  The TypeScript core for the **Unified Product Graph (UPG)**, an open specification that gives product tools a shared vocabulary for entities, edges, and frameworks.
9
9
 
10
- 320 entity types across 37 domains. 1058 typed edges. 46 canonical framework definitions, with more added as each is reviewed.
10
+ 321 entity types across 37 domains. 1068 typed edges. 46 canonical framework definitions, with more added as each is reviewed.
11
11
 
12
12
  ## Install
13
13
 
package/dist/index.cjs CHANGED
@@ -135,6 +135,8 @@ __export(index_exports, {
135
135
  backfillSlug: () => backfillSlug,
136
136
  buildTypeAliases: () => buildTypeAliases,
137
137
  canBeChildOf: () => canBeChildOf,
138
+ checkHeaderSeal: () => checkHeaderSeal,
139
+ checkHeaderSealText: () => checkHeaderSealText,
138
140
  coerceProductStage: () => coerceProductStage,
139
141
  collectSlugsForType: () => collectSlugsForType,
140
142
  compareVersions: () => compareVersions,
@@ -143,6 +145,9 @@ __export(index_exports, {
143
145
  concernFor: () => concernFor,
144
146
  concernGatesFor: () => concernGatesFor,
145
147
  crossProductScope: () => crossProductScope,
148
+ deriveCounts: () => deriveCounts,
149
+ derivePortfolioCounts: () => derivePortfolioCounts,
150
+ deriveSingleCounts: () => deriveSingleCounts,
146
151
  describeTreePattern: () => describeTreePattern,
147
152
  edgeCarriesProperties: () => edgeCarriesProperties,
148
153
  evaluateAntiPatterns: () => evaluateAntiPatterns,
@@ -329,8 +334,8 @@ var UPG_DOMAINS = [
329
334
  {
330
335
  id: "ux_design",
331
336
  label: "Experience Design",
332
- description: "How users experience and interact with your product. User journeys map end-to-end experiences, broken into journey steps. User flows chart navigation paths. Screens and screen states define what users see. Design questions frame open problems. Design concepts explore possible solutions. Prototypes and wireframes make ideas tangible. Connects User (who) to Product Specification (what) through Design System (how).",
333
- types: ["user_journey", "journey_step", "journey_phase", "journey_action", "user_flow", "screen", "screen_state", "design_question", "design_concept", "prototype", "wireframe"]
337
+ description: "How users experience and interact with your product. User journeys map end-to-end experiences, broken into journey steps. User flows chart navigation paths. Screens and screen states define what users see. Surfaces name the places inside a screen, who may occupy them, and the rule that settles contention. Design questions frame open problems. Design concepts explore possible solutions. Prototypes and wireframes make ideas tangible. Connects User (who) to Product Specification (what) through Design System (how).",
338
+ types: ["user_journey", "journey_step", "journey_phase", "journey_action", "user_flow", "screen", "screen_state", "surface", "design_question", "design_concept", "prototype", "wireframe"]
334
339
  },
335
340
  {
336
341
  id: "design_system",
@@ -896,6 +901,11 @@ var UPG_ENTITY_META = [
896
901
  { name: "user_flow", type_id: "ent_068", maturity: "stable", since: "0.1.0" },
897
902
  { name: "screen", type_id: "ent_069", maturity: "stable", since: "0.1.0" },
898
903
  { name: "screen_state", type_id: "ent_070", maturity: "stable", since: "0.1.0" },
904
+ // surface (0.27.0): the place inside a screen. `screen` is route-level
905
+ // (route / viewport / access_level); `surface` is the contested slot within
906
+ // it, carrying its occupants and the rule that arbitrates between them.
907
+ // Enters `proposed` per the maturity rubric (precedent: planning_cycle 0.20.0).
908
+ { name: "surface", type_id: "ent_359", maturity: "proposed", since: "0.27.0" },
899
909
  { name: "journey_phase", type_id: "ent_330", maturity: "proposed", since: "0.2.0" },
900
910
  { name: "journey_action", type_id: "ent_331", maturity: "proposed", since: "0.2.0" },
901
911
  { name: "design_decision", type_id: "ent_319", maturity: "deprecated", since: "0.2.0", deprecated_in: "0.2.0", replacement: "decision" },
@@ -1788,6 +1798,57 @@ var UPG_EDGE_CATALOG = {
1788
1798
  // edge mirrors `journey_step_realised_by_feature` one level deeper.
1789
1799
  journey_action_surfaces_need: { forward_verb: "surfaces", reverse_verb: "surfaced_in", classification: "cross-domain", source_type: "journey_action", target_type: "need" },
1790
1800
  journey_action_realised_by_feature: { forward_verb: "realised_by", reverse_verb: "realises", classification: "cross-domain", source_type: "journey_action", target_type: "feature" },
1801
+ // ── surface (0.27.0): the place inside a screen ─────────────────────────
1802
+ // `screen` is route-level; `surface` is the contested place within it. The
1803
+ // ten edges below give a surface its spine (nesting), its guest list, its
1804
+ // purpose, its governing rule, its rendering vocabulary, its measurement,
1805
+ // and its replacement path.
1806
+ //
1807
+ // Classification adjudications, each anchored on the nearest precedent:
1808
+ // contains → hierarchy (feature_area_contains_feature_area, planning_cycle_contains_planning_cycle)
1809
+ // serves job → cross-domain (ux_design → users_needs; cf. user_journey_addresses_job).
1810
+ // Verb is `serves`, not `addresses`: a feature ADDRESSES a job
1811
+ // (it satisfies the struggle); a surface SERVES it (it is the
1812
+ // place where the job gets done). `serves`/`served_by` is an
1813
+ // established house pair (service_serves_api_endpoint,
1814
+ // api_endpoint_serves_feature, product_serves_account).
1815
+ // governed_by → cross-domain (ux_design → design_system). The verb precedent
1816
+ // design_component_governed_by_design_guideline is `hierarchy`,
1817
+ // but both of its endpoints sit inside design_system; once the
1818
+ // seam is crossed the catalog uses cross-domain
1819
+ // (insight_informs_design_guideline). cross_product_eligible:
1820
+ // the guideline usually lives in the shared design-system graph.
1821
+ // renders comp. → hierarchy + cross_product_eligible, matching its direct
1822
+ // sibling screen_renders_design_component verbatim.
1823
+ // measured_by → semantic + cross_product_eligible. The enumerated
1824
+ // *_measured_by_metric convention splits on OWNERSHIP, not on the
1825
+ // verb: outcome / objective / strategic_pillar are `hierarchy`
1826
+ // because those entities own their metrics as children (each has a
1827
+ // UPG_VALID_CHILDREN entry to match, which the hierarchy-integrity
1828
+ // guardrail enforces). A surface does not own a metric any more
1829
+ // than a revenue_stream does, so it takes the `semantic` half of
1830
+ // the convention (revenue_stream / cost_structure). It stays
1831
+ // cross_product_eligible with the rest: a metric DEFINITION is
1832
+ // portfolio_shared, so the reading it names commonly lives in the
1833
+ // registry rather than in the product graph.
1834
+ // supersedes → semantic (prompt_version_supersedes_prompt_version).
1835
+ surface_contains_surface: { forward_verb: "contains", reverse_verb: "belongs_to", classification: "hierarchy", source_type: "surface", target_type: "surface" },
1836
+ surface_serves_job: { forward_verb: "serves", reverse_verb: "served_by", classification: "cross-domain", source_type: "surface", target_type: "job" },
1837
+ surface_governed_by_design_guideline: { forward_verb: "governed_by", reverse_verb: "governs", classification: "cross-domain", source_type: "surface", target_type: "design_guideline", cross_product_eligible: true },
1838
+ surface_renders_design_component: { forward_verb: "renders", reverse_verb: "rendered_on", classification: "hierarchy", source_type: "surface", target_type: "design_component", cross_product_eligible: true },
1839
+ surface_measured_by_metric: { forward_verb: "measured_by", reverse_verb: "measures", classification: "semantic", source_type: "surface", target_type: "metric", cross_product_eligible: true },
1840
+ surface_supersedes_surface: { forward_verb: "supersedes", reverse_verb: "superseded_by", classification: "semantic", source_type: "surface", target_type: "surface" },
1841
+ // Inbound. `feature_occupies_surface` is the guest list: the edge that makes
1842
+ // "what else lives here?" answerable, and the one the contention anti-pattern
1843
+ // counts. cross-domain (product_spec → ux_design), like screen_surfaces_feature
1844
+ // running the other way.
1845
+ feature_occupies_surface: { forward_verb: "occupies", reverse_verb: "occupied_by", classification: "cross-domain", source_type: "feature", target_type: "surface" },
1846
+ screen_renders_surface: { forward_verb: "renders", reverse_verb: "rendered_by", classification: "hierarchy", source_type: "screen", target_type: "surface" },
1847
+ decision_affects_surface: { forward_verb: "affects", reverse_verb: "affected_by", classification: "cross-domain", source_type: "decision", target_type: "surface" },
1848
+ // The finer-grained sibling of journey_step_shown_on_screen: a step happens on
1849
+ // a PLACE, not just a route. Same semantic classification as that edge (a step
1850
+ // is not contained by the surface; it references where it happens).
1851
+ journey_step_occurs_on_surface: { forward_verb: "occurs_on", reverse_verb: "hosts", classification: "semantic", source_type: "journey_step", target_type: "surface" },
1791
1852
  // 2.5 UI System Domain
1792
1853
  product_systematised_in_design_system: { forward_verb: "systematised_in", reverse_verb: "systematises", classification: "hierarchy", source_type: "product", target_type: "design_system" },
1793
1854
  product_built_with_design_component: { forward_verb: "built_with", reverse_verb: "built_for", classification: "hierarchy", source_type: "product", target_type: "design_component" },
@@ -4309,8 +4370,24 @@ var UPG_VALID_CHILDREN = {
4309
4370
  "user_flow",
4310
4371
  "insight"
4311
4372
  ],
4312
- screen: ["screen_state", "screen", "design_component", "wireframe"],
4313
- // self-nesting + components + wireframes
4373
+ screen: ["screen_state", "screen", "surface", "design_component", "wireframe"],
4374
+ // self-nesting + surfaces + components + wireframes
4375
+ // surface (0.27.0): the place inside a screen. Self-nests, so a shell holds
4376
+ // panes, a pane holds regions, a region holds slots. A surface is reached
4377
+ // through the screen that renders it (`screen: [... 'surface']`), never as a
4378
+ // top-level product child: every UPG_VALID_CHILDREN pair must be backed by a
4379
+ // hierarchy-classified edge (guardrail G2b), and the ratified ten do not
4380
+ // include a product-to-surface verb. An app-shell surface therefore hangs off
4381
+ // the screen that mounts it.
4382
+ //
4383
+ // NOTE: `surface_kind` narrows this further than UPG_VALID_CHILDREN can
4384
+ // express. UPG_VALID_CHILDREN is keyed on entity TYPE, so it can only say
4385
+ // "a surface may contain a surface"; the kind ordering (shell > tool > pane >
4386
+ // region > slot / gutter / action_bar, with overlay and ambient parented by
4387
+ // whatever raises them) is a property-level rule documented on `SurfaceKind`
4388
+ // and enforced by validators, not by this table. Precedent: the same split
4389
+ // applies to `planning_cycle`, whose `cadence_kind` narrows its self-nesting.
4390
+ surface: ["surface", "design_component"],
4314
4391
  // ── Marketing & Communications hierarchy ────────────────────────────────────
4315
4392
  marketing_strategy: [
4316
4393
  "marketing_channel",
@@ -5382,6 +5459,48 @@ var SCREEN_LIFECYCLE = {
5382
5459
  }
5383
5460
  ]
5384
5461
  };
5462
+ var SURFACE_LIFECYCLE = {
5463
+ entity_type: "surface",
5464
+ initial_phase: "draft",
5465
+ terminal_phases: ["deprecated"],
5466
+ phases: [
5467
+ {
5468
+ id: "draft",
5469
+ status_category: "started",
5470
+ label: "Draft",
5471
+ description: "The surface is a rough idea: named and scoped, but not yet worked in a design tool. Its occupants and arbitration rule are usually still undecided.",
5472
+ transitions_to: ["in_design"]
5473
+ },
5474
+ {
5475
+ id: "in_design",
5476
+ status_category: "started",
5477
+ label: "In Design",
5478
+ description: "The surface is being designed: its dimensions, occupants, and arbitration rule are being settled. May fall back to `draft` if the approach is rethought from scratch.",
5479
+ transitions_to: ["built", "draft"]
5480
+ },
5481
+ {
5482
+ id: "built",
5483
+ status_category: "started",
5484
+ label: "Built",
5485
+ description: "The surface has been implemented in code. It exists in a build but is not yet live for users. May return to `in_design` if implementation surfaces a layout or contention gap.",
5486
+ transitions_to: ["shipped", "in_design"]
5487
+ },
5488
+ {
5489
+ id: "shipped",
5490
+ status_category: "completed",
5491
+ label: "Shipped",
5492
+ description: "The surface is live in production and reachable by users. Its occupants are the real guest list.",
5493
+ transitions_to: ["deprecated"]
5494
+ },
5495
+ {
5496
+ id: "deprecated",
5497
+ status_category: "completed",
5498
+ label: "Deprecated",
5499
+ description: "The surface has been retired. Its occupants have moved elsewhere, or a replacement has taken the place; record the replacement with `surface_supersedes_surface`.",
5500
+ transitions_to: []
5501
+ }
5502
+ ]
5503
+ };
5385
5504
  var SERVICE_LIFECYCLE = {
5386
5505
  entity_type: "service",
5387
5506
  initial_phase: "development",
@@ -6583,6 +6702,7 @@ var UPG_LIFECYCLES = [
6583
6702
  DESIGN_CONCEPT_LIFECYCLE,
6584
6703
  BRAND_IDENTITY_LIFECYCLE,
6585
6704
  SCREEN_LIFECYCLE,
6705
+ SURFACE_LIFECYCLE,
6586
6706
  // Engineering & Operations
6587
6707
  SERVICE_LIFECYCLE,
6588
6708
  DEPLOYMENT_LIFECYCLE,
@@ -13312,6 +13432,17 @@ var UPG_PROPERTY_SCHEMA = {
13312
13432
  signal_channel: { type: "string", description: "Channel through which the signal was received" },
13313
13433
  signal_urgency: { type: "string", enum: ["low", "medium", "high", "critical"], description: "Perceived urgency of the customer's request" }
13314
13434
  },
13435
+ // SurfaceProperties: A place in the UI, its occupants, and the rule that arbitrates between them.
13436
+ surface: {
13437
+ 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." },
13438
+ persistence: { type: "string", enum: ["always", "conditional", "on_demand", "transient"], description: "How reliably the surface is present." },
13439
+ 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"' },
13440
+ capacity: { type: "number", description: 'How many occupants the surface holds at once, as a non-negative count. 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".' },
13441
+ 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."' },
13442
+ extensibility: { type: "string", enum: ["closed", "plugin_registerable", "user_configurable"], description: "Who may add occupants to the surface." },
13443
+ 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." },
13444
+ 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"' }
13445
+ },
13315
13446
  // SurveyResponseProperties: Aggregated survey response data.
13316
13447
  survey_response: {
13317
13448
  response_count: { type: "number", description: "Total responses", modifier: "snapshot" },
@@ -20288,6 +20419,7 @@ var ENTITY_EMOJI = {
20288
20419
  sales_motion: "\u{1F501}",
20289
20420
  screen: "\u{1F5A5}\uFE0F",
20290
20421
  screen_state: "\u{1F533}",
20422
+ surface: "\u{1FA9F}",
20291
20423
  security_audit: "\u{1F6C3}",
20292
20424
  security_control: "\u{1F39A}\uFE0F",
20293
20425
  security_policy: "\u{1F510}",
@@ -20911,6 +21043,7 @@ var STANDARD_LABELS = {
20911
21043
  design_system: { alt_labels: ["component library", "style system", "ui kit"] },
20912
21044
  screen: { alt_labels: ["page", "view", "route", "ui state"] },
20913
21045
  screen_state: { alt_labels: ["view state", "empty state", "loading state", "error state"] },
21046
+ surface: { alt_labels: ["slot", "pane", "region", "zone", "shell", "panel", "placement", "ui surface"] },
20914
21047
  // Brand layer
20915
21048
  brand_identity: { alt_labels: ["brand", "brand guidelines", "brand book"] },
20916
21049
  brand_imagery: { alt_labels: ["brand image", "visual asset", "brand photo"] },
@@ -23492,22 +23625,30 @@ var UX_DESIGN_GUIDE = {
23492
23625
  // of a journey_step, so the step must precede the action. The journey is the
23493
23626
  // anchor; phases are the band overlay; steps are the timeline; actions
23494
23627
  // decompose a step into service-blueprint rows.
23495
- creation_sequence: ["user_journey", "journey_phase", "journey_step", "journey_action", "screen", "screen_state", "user_flow", "wireframe", "prototype", "design_question", "design_concept"],
23628
+ creation_sequence: ["user_journey", "journey_phase", "journey_step", "journey_action", "screen", "screen_state", "surface", "user_flow", "wireframe", "prototype", "design_question", "design_concept"],
23496
23629
  patterns: [
23497
23630
  {
23498
23631
  name: "Journey to Screen Flow",
23499
23632
  description: "Journeys map the experience, journey steps are shown on screens, prototypes simulate screens so the design can be tested",
23500
23633
  entity_types: ["user_journey", "journey_step", "screen", "prototype"],
23501
23634
  edge_chain: ["persona_experiences_user_journey", "user_journey_contains_journey_step", "journey_step_shown_on_screen", "prototype_simulates_screen"]
23635
+ },
23636
+ {
23637
+ name: "Surface Contention",
23638
+ description: "A screen renders surfaces, features occupy them, and the surface records the rule that settles who wins. Nest the place (shell, pane, region, slot), list the occupants, then write the arbitration rule down once so it is not re-argued at the next feature.",
23639
+ entity_types: ["screen", "surface", "feature"],
23640
+ edge_chain: ["screen_renders_surface", "surface_contains_surface", "feature_occupies_surface", "surface_serves_job"]
23502
23641
  }
23503
23642
  ],
23504
23643
  required_bridges: [
23505
23644
  { edge_type: "persona_experiences_user_journey", target_domain: "user", when: "Every journey must be anchored to a persona" },
23645
+ { edge_type: "feature_occupies_surface", target_domain: "product_spec", when: "A surface should list the features that occupy it; the guest list is what makes contention visible" },
23506
23646
  { edge_type: "prototype_tests_hypothesis", target_domain: "validation", when: "Prototypes should test design hypotheses before building" },
23507
23647
  { edge_type: "screen_surfaces_feature", target_domain: "product_spec", when: "Screens should connect to the features they surface" }
23508
23648
  ],
23509
23649
  anti_patterns: [
23510
23650
  { description: "Screens without journeys: isolated screens miss the experience context" },
23651
+ { description: "Surfaces without an arbitration rule: a place two features both want, with no recorded answer for who wins, is a decision that gets made again every time it comes up. Fill `arbitration_rule`, or link the design guideline that already answers it via surface_governed_by_design_guideline." },
23511
23652
  { description: "Prototypes without testing: if nobody tests the prototype, it is just art" },
23512
23653
  { description: "Design questions left open: exploration status should progress to resolved or parked" },
23513
23654
  { description: "Touchpoints stuffed in the deprecated journey_step.touchpoint string (UPG-675). Touchpoints belong in one of two layers: journey_action is the in-product blueprint layer (the finest band of a journey_step), and the touchpoint entity is the cross-channel customer-success layer (touchpoint_occurs_in_journey_step). Pick the layer; do not duplicate the touch as a free-text string." }
@@ -27122,6 +27263,99 @@ var UPG_ANTI_PATTERNS = [
27122
27263
  stages: ["concept", "validation", "build", "beta", "launch", "growth", "mature", "maintenance", "sunset"],
27123
27264
  severity: "medium",
27124
27265
  source: { kind: "fundamental" }
27266
+ },
27267
+ // ── surface (0.27.0): the place, its guest list, and its arbitration rule ──
27268
+ // Three patterns, calibrated so the contention one leads and the two coverage
27269
+ // companions sit a tier below it. Like every entry in this file, each detector
27270
+ // is a WHOLE-GRAPH approximation of a per-node rule (cf. personas-without-jobs,
27271
+ // which fires on "no persona anywhere links to a job", not per persona): the
27272
+ // `IntelligenceCondition` language composes aggregate counts, not per-node
27273
+ // predicates.
27274
+ {
27275
+ id: "contended-surface-without-arbitration",
27276
+ since: "0.27.0",
27277
+ name: "Contended surface without arbitration",
27278
+ description: 'Features occupy surfaces in this graph, but at least one surface carries no arbitration_rule. A surface is a place features compete for; the arbitration rule is the recorded answer to "who wins here, and why". Left blank, that answer lives in whoever remembers the last argument.',
27279
+ structured_condition: {
27280
+ operator: "and",
27281
+ checks: [
27282
+ { check: { type: "entity_count", entity_type: "surface", comparison: "nonzero" } },
27283
+ {
27284
+ check: {
27285
+ type: "relationship",
27286
+ source_type: "feature",
27287
+ edge_type: "feature_occupies_surface",
27288
+ target_type: "surface",
27289
+ comparison: "exists"
27290
+ }
27291
+ },
27292
+ {
27293
+ check: {
27294
+ type: "entity_count",
27295
+ entity_type: "surface",
27296
+ filter: { property: "arbitration_rule", present: false },
27297
+ comparison: "nonzero"
27298
+ }
27299
+ }
27300
+ ]
27301
+ },
27302
+ 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.",
27303
+ remediation: "For each surface with more than one `feature_occupies_surface` edge, write the rule that settles contention into `arbitration_rule`, and record the constraint it works within (`capacity`, `dimensional_constraint`). Where a design system already answers it, link the source with `surface_governed_by_design_guideline`.",
27304
+ stages: ["build", "beta", "launch", "growth", "mature", "maintenance"],
27305
+ severity: "medium",
27306
+ source: { kind: "fundamental" }
27307
+ },
27308
+ {
27309
+ id: "surface-without-job",
27310
+ since: "0.27.0",
27311
+ name: "Surface without a job",
27312
+ description: "The graph has surfaces, but none links to the job it exists to serve. A surface with no job is a place that survives on precedent: it is there because it has always been there.",
27313
+ structured_condition: {
27314
+ operator: "and",
27315
+ checks: [
27316
+ { check: { type: "entity_count", entity_type: "surface", comparison: "nonzero" } },
27317
+ {
27318
+ check: {
27319
+ type: "relationship",
27320
+ source_type: "surface",
27321
+ edge_type: "surface_serves_job",
27322
+ target_type: "job",
27323
+ comparison: "not_exists"
27324
+ }
27325
+ }
27326
+ ]
27327
+ },
27328
+ why_it_matters: "Without a job, there is no test for whether a surface still earns its space. Places accumulate, and the layout argument becomes a matter of taste rather than of purpose.",
27329
+ remediation: "Link each surface to the job it serves with `surface_serves_job`. A surface that cannot name one is a candidate for removal or for merging into its parent.",
27330
+ stages: ["build", "beta", "launch", "growth", "mature"],
27331
+ severity: "low",
27332
+ source: { kind: "practitioner", attribution: "Clayton Christensen, Jobs to Be Done" }
27333
+ },
27334
+ {
27335
+ id: "surface-without-measurement",
27336
+ since: "0.27.0",
27337
+ name: "Surface without measurement",
27338
+ description: "The graph has surfaces, but none links to a metric. Nothing reports whether the place is used, ignored, or in the way.",
27339
+ structured_condition: {
27340
+ operator: "and",
27341
+ checks: [
27342
+ { check: { type: "entity_count", entity_type: "surface", comparison: "nonzero" } },
27343
+ {
27344
+ check: {
27345
+ type: "relationship",
27346
+ source_type: "surface",
27347
+ edge_type: "surface_measured_by_metric",
27348
+ target_type: "metric",
27349
+ comparison: "not_exists"
27350
+ }
27351
+ }
27352
+ ]
27353
+ },
27354
+ why_it_matters: "An unmeasured surface cannot be retired on evidence. It is defended by whoever built it and challenged by whoever wants its space, with no reading to settle the question.",
27355
+ remediation: "Attach the reading that would justify keeping the place with `surface_measured_by_metric`. Engagement, or the rate at which its occupants are actually invoked, is usually the honest one.",
27356
+ stages: ["build", "beta", "launch", "growth", "mature"],
27357
+ severity: "low",
27358
+ source: { kind: "fundamental" }
27125
27359
  }
27126
27360
  ];
27127
27361
  function getAntiPatternById(id) {
@@ -27166,7 +27400,14 @@ function concernGatesFor(kind, concern) {
27166
27400
  var COVERAGE_ANTI_PATTERNS = /* @__PURE__ */ new Set([
27167
27401
  "single-domain-graph",
27168
27402
  "persona-count-below-stage-benchmark",
27169
- "competitors-missing-past-validation"
27403
+ "competitors-missing-past-validation",
27404
+ // surface companions (0.27.0): both presuppose the graph has grown past the
27405
+ // point where a place is worth justifying and measuring. A stub that has
27406
+ // sketched one surface has not yet drifted. `contended-surface-without-
27407
+ // arbitration` is deliberately NOT here: it only fires once features actually
27408
+ // occupy surfaces, which is itself the evidence that the graph has grown.
27409
+ "surface-without-job",
27410
+ "surface-without-measurement"
27170
27411
  ]);
27171
27412
  var THIN_GRAPH_THRESHOLD = 8;
27172
27413
  function isThinCoverageAdvisory(antiPatternId, totalEntityCount) {
@@ -27264,7 +27505,10 @@ function compareNumber(value, comparison, threshold) {
27264
27505
  function evaluateEntityCount(check, inputs) {
27265
27506
  const filter = check.filter;
27266
27507
  let count = 0;
27267
- if (filter && typeof filter.property === "string" && typeof filter.value === "string") {
27508
+ if (filter && typeof filter.property === "string" && typeof filter.present === "boolean") {
27509
+ const withValue = inputs.countsByTypeAndPropertyPresence?.[check.entity_type]?.[filter.property] ?? 0;
27510
+ count = filter.present ? withValue : Math.max(0, (inputs.countsByType[check.entity_type] ?? 0) - withValue);
27511
+ } else if (filter && typeof filter.property === "string" && typeof filter.value === "string") {
27268
27512
  count = inputs.countsByTypeAndProperty?.[check.entity_type]?.[filter.property]?.[filter.value] ?? 0;
27269
27513
  } else if (filter && typeof filter.status === "string") {
27270
27514
  count = inputs.countsByTypeAndStatus?.[check.entity_type]?.[filter.status] ?? 0;
@@ -28169,6 +28413,10 @@ var UPG_REGIONS = [
28169
28413
  type: "screen_state",
28170
28414
  role: "leaf"
28171
28415
  },
28416
+ {
28417
+ type: "surface",
28418
+ role: "container"
28419
+ },
28172
28420
  {
28173
28421
  type: "wireframe",
28174
28422
  role: "leaf"
@@ -28277,7 +28525,9 @@ var UPG_REGIONS = [
28277
28525
  "user_journey_contains_journey_step",
28278
28526
  "design_system_contains_design_component",
28279
28527
  "brand_identity_coloured_with_brand_colour",
28280
- "design_component_composes_design_component"
28528
+ "design_component_composes_design_component",
28529
+ "screen_renders_surface",
28530
+ "surface_contains_surface"
28281
28531
  ],
28282
28532
  boundary_edges: [
28283
28533
  {
@@ -28285,6 +28535,16 @@ var UPG_REGIONS = [
28285
28535
  edge_id: "need_reframed_as_design_question",
28286
28536
  crosses_into: "users_needs"
28287
28537
  },
28538
+ {
28539
+ direction: "import",
28540
+ edge_id: "feature_occupies_surface",
28541
+ crosses_into: "product_delivery"
28542
+ },
28543
+ {
28544
+ direction: "export",
28545
+ edge_id: "surface_serves_job",
28546
+ crosses_into: "users_needs"
28547
+ },
28288
28548
  {
28289
28549
  direction: "import",
28290
28550
  edge_id: "opportunity_explores_via_design_concept",
@@ -30160,6 +30420,28 @@ function portfolioBody(doc) {
30160
30420
  ...doc.signals && doc.signals.length > 0 ? { signals: sortNodes(doc.signals).map(canonicalNode) } : {}
30161
30421
  };
30162
30422
  }
30423
+ function deriveSingleCounts(doc) {
30424
+ return { nodes: doc.nodes?.length ?? 0, edges: doc.edges?.length ?? 0 };
30425
+ }
30426
+ function derivePortfolioCounts(doc) {
30427
+ const members = doc.products ?? [];
30428
+ const memberKindOf = (p) => p.member_kind ?? "product";
30429
+ const watchedCount = members.filter((p) => memberKindOf(p) === "watched").length;
30430
+ const rollupCount = members.filter((p) => memberKindOf(p) === "org_rollup").length;
30431
+ const operatingFunctionCount = members.filter((p) => memberKindOf(p) === "operating_function").length;
30432
+ return {
30433
+ products: members.filter((p) => memberKindOf(p) === "product").length,
30434
+ ...watchedCount > 0 ? { watched_products: watchedCount } : {},
30435
+ ...rollupCount > 0 ? { org_rollups: rollupCount } : {},
30436
+ ...operatingFunctionCount > 0 ? { operating_functions: operatingFunctionCount } : {},
30437
+ product_areas: doc.product_areas?.length ?? 0,
30438
+ portfolios: doc.portfolios?.length ?? 0,
30439
+ cross_edges: doc.cross_edges?.length ?? 0
30440
+ };
30441
+ }
30442
+ function deriveCounts(doc) {
30443
+ return isPortfolio(doc) ? derivePortfolioCounts(doc) : deriveSingleCounts(doc);
30444
+ }
30163
30445
  function computeBodyChecksum(doc) {
30164
30446
  const body = isPortfolio(doc) ? portfolioBody(doc) : singleBody(doc);
30165
30447
  const content = JSON.stringify(body);
@@ -30199,7 +30481,7 @@ function serializeSingleWithHeader(doc, opts) {
30199
30481
  };
30200
30482
  const summary = deriveSummary(product.description);
30201
30483
  if (summary) header.summary = summary;
30202
- header.counts = { nodes: doc.nodes?.length ?? 0, edges: doc.edges?.length ?? 0 };
30484
+ header.counts = deriveSingleCounts(doc);
30203
30485
  if (doc.member_kind === "org_rollup" || doc.member_kind === "watched" || doc.member_kind === "operating_function") {
30204
30486
  header.member_kind = doc.member_kind;
30205
30487
  }
@@ -30222,20 +30504,7 @@ function serializePortfolioWithHeader(doc, opts) {
30222
30504
  };
30223
30505
  const summary = deriveSummary(org.description);
30224
30506
  if (summary) header.summary = summary;
30225
- const members = doc.products ?? [];
30226
- const memberKindOf = (p) => p.member_kind ?? "product";
30227
- const watchedCount = members.filter((p) => memberKindOf(p) === "watched").length;
30228
- const rollupCount = members.filter((p) => memberKindOf(p) === "org_rollup").length;
30229
- const operatingFunctionCount = members.filter((p) => memberKindOf(p) === "operating_function").length;
30230
- header.counts = {
30231
- products: members.filter((p) => memberKindOf(p) === "product").length,
30232
- ...watchedCount > 0 ? { watched_products: watchedCount } : {},
30233
- ...rollupCount > 0 ? { org_rollups: rollupCount } : {},
30234
- ...operatingFunctionCount > 0 ? { operating_functions: operatingFunctionCount } : {},
30235
- product_areas: doc.product_areas?.length ?? 0,
30236
- portfolios: doc.portfolios?.length ?? 0,
30237
- cross_edges: doc.cross_edges?.length ?? 0
30238
- };
30507
+ header.counts = derivePortfolioCounts(doc);
30239
30508
  header.provenance = buildProvenance(doc, opts);
30240
30509
  header.integrity = { algorithm: INTEGRITY_ALGORITHM, body: computeBodyChecksum(doc) };
30241
30510
  return JSON.stringify({ $upg: header, ...body }, null, 2) + "\n";
@@ -30303,9 +30572,61 @@ function isCanonical(text) {
30303
30572
  const parsed = parseUpg(text);
30304
30573
  return text === serializeCanonical(parsed);
30305
30574
  }
30575
+ function checkHeaderSeal(obj) {
30576
+ const raw = obj;
30577
+ const header = raw?.$upg;
30578
+ const report = {
30579
+ header_present: !!header,
30580
+ counts_checked: false,
30581
+ integrity_checked: false,
30582
+ counts_drift: [],
30583
+ integrity_drift: []
30584
+ };
30585
+ if (!header) return report;
30586
+ if (header.format_version !== UPG_CANONICAL_FORMAT_VERSION) {
30587
+ report.skipped_reason = `format_version "${header.format_version}" is not this serialiser's "${UPG_CANONICAL_FORMAT_VERSION}"; counts keys and body layout are format-version contracts, so neither seal can be judged here.`;
30588
+ return report;
30589
+ }
30590
+ const doc = normalizeDocument(raw);
30591
+ const declaredCounts = header.counts ?? {};
30592
+ const actualCounts = deriveCounts(doc);
30593
+ if (header.counts === void 0) {
30594
+ report.skipped_reason = "header has no `counts` block to compare.";
30595
+ } else {
30596
+ report.counts_checked = true;
30597
+ const fields = /* @__PURE__ */ new Set([...Object.keys(declaredCounts), ...Object.keys(actualCounts)]);
30598
+ for (const field of [...fields].sort()) {
30599
+ const declaredRaw = declaredCounts[field];
30600
+ const declared = typeof declaredRaw === "number" ? declaredRaw : 0;
30601
+ const actual = actualCounts[field] ?? 0;
30602
+ if (declared !== actual) report.counts_drift.push({ field, declared, actual });
30603
+ }
30604
+ }
30605
+ const integrity = header.integrity;
30606
+ if (!integrity || typeof integrity.body !== "string") {
30607
+ report.skipped_reason = report.skipped_reason ?? "header has no `integrity.body` seal to compare.";
30608
+ } else if (integrity.algorithm !== INTEGRITY_ALGORITHM) {
30609
+ report.skipped_reason = report.skipped_reason ?? `integrity.algorithm "${integrity.algorithm}" is not "${INTEGRITY_ALGORITHM}"; the declared digest cannot be recomputed here.`;
30610
+ } else {
30611
+ report.integrity_checked = true;
30612
+ const computed = computeBodyChecksum(doc);
30613
+ if (computed !== integrity.body) {
30614
+ report.integrity_drift.push({
30615
+ algorithm: integrity.algorithm,
30616
+ declared: integrity.body,
30617
+ computed
30618
+ });
30619
+ }
30620
+ }
30621
+ return report;
30622
+ }
30623
+ function checkHeaderSealText(text) {
30624
+ const noBom = text.charCodeAt(0) === 65279 ? text.slice(1) : text;
30625
+ return checkHeaderSeal(JSON.parse(noBom));
30626
+ }
30306
30627
 
30307
30628
  // src/index.ts
30308
- var UPG_VERSION = "0.26.0";
30629
+ var UPG_VERSION = "0.27.0";
30309
30630
  var UPG_FORMAT_VERSION = "0.4.0";
30310
30631
  var MARKDOWN_FORMAT_VERSION = "0.1";
30311
30632
  var UPG_TYPES = getTypes();
@@ -30475,6 +30796,8 @@ var UPG_META_COUNT = UPG_ENTITY_META.length;
30475
30796
  backfillSlug,
30476
30797
  buildTypeAliases,
30477
30798
  canBeChildOf,
30799
+ checkHeaderSeal,
30800
+ checkHeaderSealText,
30478
30801
  coerceProductStage,
30479
30802
  collectSlugsForType,
30480
30803
  compareVersions,
@@ -30483,6 +30806,9 @@ var UPG_META_COUNT = UPG_ENTITY_META.length;
30483
30806
  concernFor,
30484
30807
  concernGatesFor,
30485
30808
  crossProductScope,
30809
+ deriveCounts,
30810
+ derivePortfolioCounts,
30811
+ deriveSingleCounts,
30486
30812
  describeTreePattern,
30487
30813
  edgeCarriesProperties,
30488
30814
  evaluateAntiPatterns,