@unified-product-graph/core 0.33.1 → 0.35.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,110 @@ This project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
8
  ---
9
9
 
10
+ ## [0.35.0] - 2026-08-23
11
+
12
+ **Six card designs asked the graph a question it could not answer, and one property name was quietly two different measurements.** The UCS edge-canon packet (Captain-ratified 2026-08-22) filed eight edge asks against the catalogue; three were already answerable, so five landed. The same audit found `risk` rating likelihood on an epistemic ladder and severity on a benefit-framed one, which rendered a catastrophic risk green. Additive throughout, with one staged deprecation: no data migration, no breaking change.
13
+
14
+ **This release also carries 0.34.1, which was built, verified and then never published.** That patch answered an independent MCP regression audit across 0.31.0 to 0.34.0 — nine findings, four HIGH — and its own verdict was that **nothing regressed**: every HIGH is a contract a release note ALREADY ASSERTED and the primary consumer did not honour, and the six-stage gate could not see one of them, because it checks that the server is the right binary exposing the right catalog and completing a round trip and never that a documented refusal actually refuses. The sharpest: 0.32.2 shipped as the patch that stops silent write loss, and through MCP that silent write loss was still there at 0.34.0. It is folded here rather than cut ahead of this release because the two branches were one queue, and publishing the same nine fixes under two version numbers a day apart buys a reader nothing and costs them a version to reason about. **Everything 0.34.1 fixed ships in 0.35.0**, under the sections marked below; the fold changes no fix, no export and no count.
15
+
16
+ ### Added
17
+
18
+ - **`research_study_captures_quote`** (hierarchy). Provenance for a verbatim quote: which study produced it. Study-first, matching the whole `research_study_*` family, so the quote card renders it inbound. The `captures` verb is reused from the observation twin on purpose: the duplicate gate is on the source→target pair, and a study captures raw material by one verb regardless of which raw shape it is. `UPG_VALID_CHILDREN.research_study` gains `quote`, giving a quote three declared parents (observation for raw capture, insight for synthesis, study for provenance). Multi-parent GRAMMAR; per-instance parentage stays single via `parent_id`.
19
+ - **`feature_specified_by_user_story`** (hierarchy). The feature-level rung of the ladder `epic_specified_by_user_story` already had. The epic level is optional in UPG, so a feature that skips it could not reach the stories that specify it, and `user_story` had exactly one declared parent. NOT named `groups`: the verb pair is cloned verbatim from the epic rung so the two rungs read identically. Same widening shape as the 0.23.0 epic twins.
20
+ - **`stakeholder_invested_in_outcome`** (cross-domain). Who cares about which outcome. Deliberately verb-only and not a scale-on-edge: the stake MAGNITUDE is already `influence` / `interest` on the stakeholder, and the reverse reading is what makes the pair right ("outcome matters_to stakeholder"). Retires the denormalised `stake_in` string a card was carrying.
21
+ - **`risk_threatens_node`** and **`risk_mitigated_by_node`** (cross-domain, polymorphic), registered as one new **16th family, "risk exposure"** — the sanctioned decision-to-anything construction with `risk` in the source slot. What a risk puts at stake is genuinely unbounded (outcome, key_result, release, service, contract, launch) and so is what mitigates it (decision, feature, experiment, security_control); enumerating either side balloons the catalogue across two open sets while the endpoint carries no structural role. Both cross-domain, so neither enters `UPG_VALID_CHILDREN` and neither can be walked as containment. `security_control_mitigates_threat` is NOT absorbed: it stays the security-domain typed edge, and this is the product-risk generalisation.
22
+ - **`likelihood_5`** (Rare · Unlikely · Possible · Likely · Almost certain). The nine spec ladders had no probability ladder, so `likelihood` was resolving to `confidence_5`, which is EPISTEMIC: it says how sure the assessor is, not how likely the event is. A risk you are certain about and a risk that is certain to happen are different facts. Vocabulary is ISO 31000's, which is also what the risk lifecycle prose and the fixtures already said.
23
+ - **`PROPERTY_SCALE_MAP_BY_ENTITY`**, wired into `getPropertyDefaultScale`. This activates the per-entity disambiguation that function has reserved since v0.4.0; resolution order becomes entity → name → `scale_5`, so every pair without an override resolves exactly as before. Two entries, both on `risk`: `impact → severity_5` and `probability → likelihood_5` (see Changed). `impact` legitimately means magnitude of BENEFIT on discovery and market entities, where high is good; on a risk it means severity of harm, where high is bad, and one name-keyed map cannot hold both. With the ladder corrected the traffic-light story arrives free from the existing polarity direction, since `severity_5` reads low-is-good.
24
+ - **`RiskProperties.likelihood`** (`UPGAssessment`, on `likelihood_5`).
25
+ - **`StakeholderProperties.engagement_posture`** (`champion | supporter | neutral | skeptic | blocker`, with `UPG_ENUM_SCALES.EngagementPosture`) and **`engagement_cadence`** (the existing `Cadence` primitive, zero new vocabulary). Posture is the third axis of the stakeholder model: `influence` and `interest` are magnitudes and carry no direction, so a high-influence, high-interest stakeholder renders identically whether they are the strongest champion or the one who kills it. The closed enum is what makes "who blocks this?" a query.
26
+
27
+ - *(MCP contracts — regression audit R1–R9, folded from the unpublished 0.34.1.)* **`UPG_ENTITY_DESCRIPTIONS`** (and `getEntityDescription`), 328 entries covering all 324 entity types. Stated rather than slipped in: **this export was BANKED and is pulled forward by necessity.** 0.34.0's notes recorded it as the one line that would unlock the editorial gate's description term, and deliberately left it out of the ratified bundle. There is no other way for an agent surface to return an authored description when the only authored copy lives in an application, and the alternative was a second hand-maintained copy of a 328-entry string table — the exact defect the derived base-node field list exists to prevent. The documentation generator now READS this table instead of holding one, and the generated-artifact gate proves the emitted site data is byte-identical across the move. **Consequence, stated because it arrives on its own:** the editorial gate's entity-description term has been WIRED BUT INERT since 0.32.1 for want of a published export to compare against. From the next release it compares, and a reworded description becomes a change the gate can see.
28
+ - *(MCP contracts — regression audit R1–R9, folded from the unpublished 0.34.1.)* **`undeclared_property_drift`**, a local `validate_graph` scope. **Deliberately NOT gating `structurally_valid`**, and the estate says how firmly: it measures 5,956 undeclared keys across 15 shapes on the 1,118-node tracker and 219 across 35 on the dogfood graph. Gating would flip both to structurally invalid on a release that changed nothing about them — precisely the failure this same audit reported against 0.33.0, where a status migration flipped that flag and broke every consumer reading it. Making it gate needs a migration path and a deprecation window and belongs in a minor.
29
+ - *(MCP contracts — regression audit R1–R9, folded from the unpublished 0.34.1.)* **`gate:mcp` stage 7 — "documented refusals actually refuse"** (six stages to seven). 13 wire cases and 3 startup cases, each a reproduction from the audit carrying the release note it grades, because the case IS the note in executable form. It ships with its own labeled fixture set. **Negative control, run before it was trusted: the same corpus against the published 0.34.0 binary reports 32 failures across all nine findings.** A stage that has never been seen to fail is not a gate. One detail kept because it is the control on the control: the base-field round-trip case correctly does NOT fail on 0.34.0, because that contract was met at 0.33.0.
30
+ - *(MCP contracts — regression audit R1–R9, folded from the unpublished 0.34.1.)* Additive SDK exports supporting the above: `buildNodePatch`, `DuplicateNodeKeyError`, `UPGFileStore.getNodeByKey`, `checkUndeclaredProperties`, `isNamespacedPropertyKey`, `normalizeCompositionLifecycle`, and `description` on the entity-schema shape.
31
+
32
+ ### Changed
33
+
34
+ - **Legacy `risk.probability` renders on the likelihood ladder for the length of its deprecation window** (`PROPERTY_SCALE_MAP_BY_ENTITY.risk.probability → likelihood_5`, Captain-ratified 2026-08-22). The staged deprecation above first shipped with the old spelling deliberately left on `confidence_5`, on the reasoning that stored values must keep resolving as they did. That reasoning was right about the bytes and wrong about the reader: with `likelihood` on the new ladder and `probability` on the old one, **one stored `4` read "Confident" through the deprecated name and "Likely" through its replacement — on one card, from one graph, during the exact window in which both are supposed to be live.** A staged deprecation exists so a reader can migrate WITHOUT their data changing meaning, and a ladder that differs across the rename is that meaning changing, just deferred to whenever they get round to it. The legacy spelling is pinned to the ladder it always should have had, so the two names agree until 1.0.0 drops the old one. **`forecast.probability` is untouched** and still resolves to `confidence_5` through the name-level map — it is a bare sales percentage and belongs on the epistemic ladder. Correcting one entity without disturbing the other is the whole reason the per-entity layer exists; this is the first use of it that is not a polarity fix.
35
+ - *(MCP contracts — regression audit R1–R9, folded from the unpublished 0.34.1.)* **Two published claims are corrected as overclaims rather than quietly rewritten.** 0.33.0 wrote "uniqueness is enforced per product (the store index is `(product_id, key)`)" as settled fact and 0.34.0 restated the same invariant as stated-but-unchecked without reconciling the two; neither described anything that ran, on any release through 0.34.0. This release makes the sentence TRUE rather than deleting it. And `UPGBaseNode.properties` cited a tracker import as its example of the `<tool>:<key>` convention — that import writes `linear_state_history`, with an underscore, the exact shape the rule forbids. Nothing could see it until `undeclared_property_drift` existed, which then measured 5,779 such keys. The claim is corrected; the data is left for its own migration, and where a migration author will be standing is where it is written down.
36
+ - *(MCP contracts — regression audit R1–R9, folded from the unpublished 0.34.1.)* **`eval:tool-use` is labelled as a one-case wire probe, not coverage.** Its output read like a score. The reporter now prints the denominator beside the pass count, read live off `tools/list` rather than hardcoded: **1 case over 99 tools, checking existence and argument shape only.** It ran green throughout the entire window in which the nine findings above were live, and could not have caught any of them, because every one is semantic.
37
+
38
+ ### Deprecated
39
+ - **`RiskProperties.probability`** → `likelihood`. Staged, not removed: the `epic.estimate` → `effort` pattern. Writers emit `likelihood`; readers prefer `likelihood` and fall back to `probability`. Its ladder moves WITH the name, to `likelihood_5`, via a per-entity override — see Changed. Stored values keep resolving; what changes is that they stop resolving to a ladder the replacement field does not share. Three reasons the name loses: `probability` was one name for two incompatible types (`UPGAssessment` on `risk`, a bare `number` on `forecast`) resolving through a name-keyed map; `likelihood` is already the spec's own word in the risk lifecycle prose and on `threat`; and the fixtures had drifted to it on their own — the saturated corpus carries `likelihood` on seven risks beside `probability` on nine.
40
+
41
+ ### Fixed — MCP contracts (regression audit R1–R9)
42
+
43
+ **Folded from the unpublished 0.34.1.** No schema change: no entity, edge, property, enum, lifecycle or tool added, removed or altered by any item in this section, and no public export removed or renamed. What changes is that nine contracts these notes already asserted are now honoured on the MCP surface, where an independent regression audit across 0.31.0 to 0.34.0 found them unhonoured.
44
+ - **`update_node` and `batch_update_nodes` honour the refusals 0.32.2 documented.** That release shipped `UnknownNodeFieldError` and `ImmutableNodeFieldError` on the store and described them as firing "on runtime-shaped input such as a parsed argument bag". An MCP tool-call argument bag is exactly that, and they never fired on it: the tool layer hand-built its patch by naming the fields it knew, so an undeclared key was discarded one frame ABOVE the guard and the caller received a success envelope holding an unchanged node. **The guard was real and unreachable** — the same defect the derived field list was introduced to end, one layer up. The patch is now DERIVED from `UPG_BASE_NODE_FIELDS` at the boundary where runtime input arrives (`buildNodePatch`), throwing the store's own error objects so the messages are byte-identical on both surfaces rather than paraphrased on one. Refused BEFORE the work, not at it: the single door checks before the type migration, the batch checks in its pre-pass, so a rejected call leaves nothing half-applied. `batch_update_nodes` also stops dropping a per-entry `type` in silence — it re-infers no edges, so it now says so and names the two doors that do.
45
+ - **`key`, `archived` and `archived_at` round-trip through the MCP write path.** Same root cause read from the other side. 0.32.0 declared all three and 0.32.2's note said the write path that was supposed to carry them now did; that was true of the store and false of MCP, which returned success and stored nothing for three releases. They land at 0.33.0 for the store and, verifiably, at 0.34.1 for the wire.
46
+ - **`upsert_composition` writes a view that lifecycle-aware reads can see.** 0.34.0's only new tool wrote the phase into `properties.lifecycle`, an undeclared and un-namespaced bag key, leaving the node with **no `status` at all** — so `list_nodes({ type: 'composition', status: 'published' })` returned nothing for a view that had just been published, and `get_node` reported `lifecycle` and `updated_at` as unknown properties on every composition the tool had ever written. The phase now goes to the base `status` field, whose vocabulary is the bespoke `composition` lifecycle that was already in the spec; there was never a need for a private one. `updated_at` goes to the declared base field of that name. Both stale bag keys are cleared on the next publish, so a graph written by 0.34.0 heals itself, and the reader falls back to `properties.lifecycle` until it does. **`retired` IS `archived`**: the lifecycle's terminal phase is described as withdrawn-but-retained, which is exactly what the tool meant, so the spec's name wins and `retired` becomes a deprecated input alias that stores `archived`. The union is widened, never narrowed. `rev` semantics were correct throughout and are untouched.
47
+ - **Within-product `key` uniqueness is ENFORCED on the create path.** `UPGBaseNode.key` has declared it since 0.32.0 and nothing checked it: two creates naming one key both succeeded, with no warning, on every release through 0.34.0. Measured on the only keyed graph in the estate — a second node claiming a key that already identified another was accepted. **This mattered beyond the collision**, because 0.34.0 shipped `duplicate-key-across-products` as an ENFORCED portfolio anti-pattern whose stated premise is that the per-product invariant holds; a portfolio check that assumes each product is internally clean was resting on nothing. The check lives on the single store-level chokepoint every node-create flows through, so five calling surfaces are covered by one check rather than by five. A typed `DuplicateNodeKeyError` and **not** a silent no-key: the minting rules drop a key in silence only where nobody asked for one and it was inferred, and here the caller named it. Enforced on CREATE and not on open, so a graph that already holds a collision still loads and can be repaired.
48
+ - **An unloadable `.upg` is diagnosable instead of silent.** Reported as a hang; it was not one. The server wrote the reason to stderr and exited non-zero, which almost no MCP client surfaces, so what a user saw was a server that connected and went quiet with the diagnosis somewhere they could not reach — a dead server, indistinguishable from a wedged one. The handshake is now answered: the failure path starts a degraded server that replies to `initialize`, lists the tool surface so the client renders normally, and returns the diagnosis from every `tools/call`, still exiting non-zero when the client disconnects. **The genuinely silent path was found while tracing this**: the `preflight` entry point ended with a bare dynamic import and relied on the main module's auto-start guard, which compares `argv[1]` against its own module URL and is false through preflight — so nothing started and the process exited **0 with empty stdout and empty stderr**, which is the exact failure that file exists to prevent.
49
+ - **The envelope error named fields that do not exist.** The envelope is flattened before validation, and the validator reported that flat shape: `$.upg_version` and `$.exported_at`. No `.upg` file carries either name, so a reader searching their own file found nothing and concluded the format was undocumented. The paths are now `$upg.spec_version` and `$upg.provenance.exported_at`. **The accompanying advice was chosen by a regex over the error text**, so any envelope failure mentioning `exported_at` — which both do, by construction — was told "don't hand-author a bare `{ product, nodes, edges }` file", including a file with a complete envelope, 2,158 nodes and an integrity hash that was missing exactly two fields. It now branches on whether the file actually HAS an envelope, and tells that reader to add two lines rather than rebuild.
50
+ - **`get_catalog_entry` names the replacement for a renamed edge type.** `create_edge` did; the catalog reader answered "Unknown edge type" flat, though the rename is registered in `UPG_EDGE_MIGRATIONS` and listed by `list_catalog`. **Backwards:** the server's instructions tell an agent to introspect before writing, so the agent that obeyed hit the dead end and only the one that wrote blind got the hint. Both surfaces now call one function, and a registered rename is its FIRST tier — ahead of the edit-distance match that had been getting both catalogued renames right by luck, and would not have for a rename that widened a verb.
51
+ - **`validate_graph` and `get_node` stop contradicting each other about one node.** `get_node` reported `unknown_properties` while `validate_graph` reported `property_drift: 0` for the same node. Neither was lying: they asked different questions — is this key DECLARED, versus is it covered by a migration RULE — and for a key nobody wrote a rule for, zero is honest. The real gap was that **no drift class of any kind could see an undeclared bag key**, so a whole class of spec violation was invisible to the graph-wide surface while being reported node by node. `undeclared_property_drift` is that class; `property_drift` keeps its rule-driven contract and its numbers. The shared helper also fixes a latent false positive: a correctly-namespaced `<tool>:<key>` extension was reported as unknown, which punished the one convention that makes a deliberate extension distinguishable from a typo.
52
+ - **Three surfaces answered "is this bag key declared?" three different ways.** The read surface and the graph-wide validator were collapsed onto one helper above; a THIRD copy backed the write path, so `create_node` went on warning about a correctly-namespaced `<tool>:<key>` extension that `get_node` and `validate_graph` had just stopped warning about, and one response could carry two verdicts about one key. All three now delegate. Three implementations of one question disagree eventually; the only question is which release notices.
53
+ - **`summary.undeclared_property_drift` reported the PAGE, not the total.** Every other class reports the graph's true count beside a clipped list — `summary.top_level_drift` says 1,032 next to a 100-entry array — because the summary answers "how much is there" and the array answers "here is a page of it". This class shipped counting `array.length`, so at the default limit it reported 100 on a graph holding 5,956 and the figure MOVED when a caller changed the page size. It was the first local drift class whose real population exceeds the cap, which is why the same shape in its siblings has never been visible. Fixed with an independent counter, and the payload pre-flight deliberately keeps using the page, because a summary figure and a wire-size estimate answer different questions.
54
+ - **A degraded server now says so in the handshake.** The failure path answers `initialize`; the first cut answered it with `serverInfo` and `instructions` byte-identical to a healthy server, which trades one silent failure for a quieter one — anything that reads capabilities without calling a tool reports green. The server name and the head of the instructions now carry the degradation, the file path and the missing fields.
55
+ - **`get_entity_schema` and `get_catalog_entry(entity_meta)` return the authored type description.** Neither carried one, byte-identical from 0.32.0 through 0.34.0, while the server's own instructions say to call `get_entity_schema` before creating an entity — so the sentence answering the question the caller actually has was the one thing that call did not return. The descriptions existed and were gated by `check:editorial` since 0.32.1; they lived in a documentation site's build script, so **the gate was protecting a surface no agent could see.**
56
+
57
+ ### Rejected (recorded, because absence is a decision)
58
+ - **`quote_evidences_insight`** and **`stakeholder_owns_decision`** — already canon as `insight_evidenced_by_quote` and the polymorphic `node_owned_by_stakeholder`. The latter also retires the non-canon `risk_owned_by` key open since 2026-05-18.
59
+ - **`feature_contains_acceptance_criterion`** — derivable. With the story rung above, the path is feature → user_story → acceptance_criterion; a direct containment edge is the "`X_contains_Y` verb that duplicates the parent link" `ARCHITECTURE.md` forbids.
60
+ - **`risk.risk_status`** — `risk` already carries the canonical RISK_ITEM lifecycle on `status` (identified → assessed → mitigated / accepted / closed). A second status axis would shadow the first.
61
+
62
+ ### Notes
63
+ - **The per-entity layer's size guard was RAISED, not bypassed.** `PROPERTY_SCALE_MAP_BY_ENTITY` ships with a test asserting it holds at most N overrides, on the reasoning that a name needing an override on three entities is a name that should be SPLIT rather than overridden. The `risk.probability` ruling makes the count 2, so the bound moved 1 → 2 with the call written into the test itself rather than the number quietly edited. A second assertion was ADDED in the same place and does not move: every override so far is on `risk`, and widening to a second ENTITY is a different decision from adding a second property to the same one. Both entries close at 1.0.0 when `probability` is dropped.
64
+
65
+ - `threat.likelihood` moves to `likelihood_5` in the same line as `risk`, which is the point of a name-keyed map when the name is right.
66
+ - No stored data changes meaning. Neither the dogfood graph nor the saturated fixture stores an explicit `impact_5` scale id anywhere, so the `risk.impact` ladder correction is a rendering fix with nothing to re-stamp; assessments that pin a `scale_id` keep it, by the existing contract.
67
+ - Truth: **324 entities · 1091 edges · 69 cross-edges · 26 anti-patterns · 99 tools · 28 polymorphic keys in 16 families · 342 lifecycle phases**. Every figure re-derived from the built dist, not carried forward from a plan. No entity minted, no tool, no anti-pattern, no lifecycle phase. Cross-edges unchanged: `risk`, `stakeholder`, `research_study`, `quote` and `user_story` are not `portfolio_shared`, so nothing here can be eligible. `UPG_SCALES` 9 → 10.
68
+ - `getPropertiesForScale` stays NAME-level by design and does not fold in the per-entity layer: a bare property name cannot express "impact, but only on risk", and listing `impact` under both ladders would tell a reader less than the current answer does.
69
+ - **0.34.1 was FOLDED into this release and never published.** No `0.34.1` heading exists above and no `0.34.1` dist exists on npm; the version was never cut. Its heading is removed rather than left as an empty marker, and this note is what a reader looking for it finds. The one line of that entry NOT re-emitted verbatim is its truth line, which said `324 · 1086 · 69 · 26 · 99 · 26 keys in 15 families · 342` and re-emitted those figures precisely to show that a patch had moved none of them. That is still true of the patch and no longer true of the release carrying it: the figures above are 0.35.0's, and the delta between the two lines is the risk-exposure minor's, not the audit's.
70
+ - *(MCP contracts — regression audit R1–R9, folded from the unpublished 0.34.1.)* **Zero migrations.** Every fix is a refusal that did not fire, a value written to a field that was already declared, or a message that named the wrong thing.
71
+ - *(MCP contracts — regression audit R1–R9, folded from the unpublished 0.34.1.)* **One migration NOTE, for the single release that wrote the wrong shape.** A composition written by 0.34.0 carries its phase in `properties.lifecycle` and no `status`, so it stays invisible to `list_nodes({ type: 'composition', status: 'published' })` until it is next published — reads are unaffected, because `readComposition` falls back to the bag. Republishing the view heals it. No migration ships because the estate exposure is zero: the only composition anywhere is the CI fixture's, which 0.34.0's own notes flagged when the tool shipped ahead of its first real caller.
72
+ - *(MCP contracts — regression audit R1–R9, folded from the unpublished 0.34.1.)* **One field-graph migration ran, as its own change**: the dogfood graph carried an `agent_skill` still on the pre-0.33.0 lifecycle, so every server from 0.33.0 including `@latest` reported it structurally invalid over a single node. 0.34.0 made that pass a report rather than a gate, so nothing chased it. The server had been handing over the fix the whole time — `validate_graph` named the node, the target phase and the registry — and it has now been run.
73
+
74
+ ---
75
+
76
+ ## [0.34.0] - 2026-08-22
77
+
78
+ Minor, additive, with **one stated compile-time break** (see Changed). Zero new entity types. Two edges, one tool, one check, three optional fields, and seven normative rulings on key machinery. The through-line: 0.33.0 gave the graph reach and left the reaching unmeasured, so most of this release is places where a shape existed, a consumer existed, and nothing joined them.
79
+
80
+ ### Added
81
+ - **`document_transcludes_node`**, polymorphic, `semantic`, `deliberate_only`, with **no position property**. `describes` says a document is ABOUT a thing; transclusion says it RENDERS that node where the prose sits, so the value a reader sees is current by construction. Collapsing them would lose the only property transclusion is bought for. The item halved on discovery: the inline anchor form already existed, was parsed with source line numbers, and was published in the paper. What was missing was the BINDING, now normative: an anchor in a `document` body IS a transclusion anchor and a conformant parser writes the edge beside it. Measured before minting: 43 document nodes, 28 of them (65%) with zero outbound edge, and the canonical case was unexpressible because `metric` is not among the nine `document_describes_*` targets. No `anchor_line`, deliberately: a line number is wrong after the next paragraph above it and nothing reports the drift. The anchor IS the position.
82
+ - **`person_holds_role`**, `semantic`, `person` to `role`, verbs `holds` / `held_by`, unqualified. Minted on a grammar-versus-catalog inconsistency readable entirely inside the spec rather than on the field condition it was banked against, and that distinction is stated rather than blurred: `role` terminates at `filled` and `vacant`, `team_staffed_with_role` runs team to slot, `person_member_of_team` runs person to team, and nothing connected a person to a role. **A graph could legally mark a role `filled` and no edge could say by whom.** A terminal phase no edge can substantiate is dead schema, and worse than a phantom edge name because a phantom is inert while `filled` is a value graphs carry. `semantic` and not `cross-domain`: person, role and team are one domain, and the guardrail forbids new same-domain offenders. Direction is person to role, so the containment reading stays unavailable rather than merely unchosen.
83
+ - **`orphan_disposition?: 'root' | 'hide'` on `UPGViewPresentation`, absent means `'root'`.** 0.33.0's `nest_by` made nesting portable and left the FAILURE of nesting unportable. Measured on a real imported tracker graph of 1,118 nodes: one tree selects 218 members and renders ONE card under `hide`; three more go 185 to 1, one goes 11 to 1. The orphans ARE the dataset, because tracker-imported work wires through reference-axis relations these trees never traverse. Absent-means-root makes "everything the scope admits is visible somewhere" a checkable invariant, and the majority that prefers hiding pays for it EXPLICITLY, which is the right way round for a default deciding whether data disappears. Reclassified from application chassis to spec BY MEASUREMENT: running the two dispositions changes which nodes APPEAR, not where pixels go.
84
+ - **`provenance?: UPGEdgeProvenance` on `UPGEdge`** (`tool?`, `from?`, `at?`), **ungated**. The gate question is settled by precedent rather than by argument: `mapping_confidence` is already an ungated provenance field on every edge. `carries_properties` exists to keep semantic edges free of domain PAYLOAD; provenance is a fact about the record, and requiring an edge to opt in to having a history is the wrong shape. Nested because `UPGEdge.source` already means the source NODE, so a flat `source_id` would sit one character away meaning something else. The node side has carried provenance since the beginning; a bulk emission of 651 derived edges had nowhere on the edge to record what it derived them from.
85
+ - **`upsert_composition`** (98 to 99 tools). ONE tool, not the three asked for: `get_node` already returns a node AND its edges, so the focus join is served, and the id IS the slug, so address-by-slug works. The only thing the generic path gets actively WRONG is `rev`, which `update_node` writes from the caller's argument, silently. Write semantics are MIRRORED from the shipped `graph-service` contract rather than designed: `rev` re-derived inside the write and incremented only on a transition into `published`; `rev` as an optional precondition returning a stale-revision outcome with the stored value; omitting `members` preserving the arrangement; node and focus edges written together. The tool declares the view shapes INLINE in its own input schema, which is half its justification: they are `object` in the runtime registry, so schema introspection otherwise hands an agent three opaque blobs.
86
+ - **`duplicate-key-across-products`**, a fifth `scope: 'portfolio'` anti-pattern (25 to 26), **ENFORCED**. The same `(prefix, number)` pair identifying nodes in two products of one portfolio. Mechanically decidable, which is unusual in this catalog and is why it can ship enforced; every other portfolio entry approximates a per-node rule. What was staged is the CORPUS, not the enforcement: there is no defined-then-enforced mechanic and a registered detector that declines to fire is worse than either real option. **This release also discovered that a registered anti-pattern had no evaluator behind it** and wired one, so the claim and the code now agree.
87
+ - **`UPG_PHASE_COUNT` = 342**, distinct (template, phase) pairs, wired into `check:count-drift`. The last figure in the truth line that no check asserted, and therefore the only one that drifted. 869 counts phase ROWS and double-counts every shared template; 334 was transcribed forward since 0.32.0 and reproduces under neither computation. The definition lives on the export so the gate and the docket row cannot be derived twice.
88
+ - **`UPG_POLYMORPHIC_EDGE_FAMILIES`** is now an export, and `EDGE_KEY_ORDER` / `CROSS_EDGE_KEY_ORDER` join `NODE_KEY_ORDER` in being exported and asserted.
89
+
90
+ ### Changed
91
+ - **`UPGViewClause` is a discriminated union on `dimension`**, so the `type` axis carries `UPGEntityType[]` and every other axis keeps `string[]`. **The wire format does not change at all: the JSON is identical, no migration, no fixture.** What changes is compile-time, and **this is a break; calling it additive would be dishonest.** Two measured facts make it acceptable: the field shipped ONE release ago, and the only known consumer already casts at this exact boundary with a comment explaining that the tiers disagree, so the break lands where someone has already written down why. A stated break with one known site beats a silent narrowing with none. The round-trip rule ships regardless of the union, because a union constrains authors and not JSON arriving from a file: **the clause list is authoritative, the shorthand is a positive-only projection, and a consumer that cannot represent a clause MUST REFUSE rather than narrow.**
92
+ - **A declared team prefix supersedes `product.key_prefix` and NOTHING ELSE.** The candidate set is the UNION of declared and observed prefixes, and the picker keeps asking while more than one stands. The shipped 0.33.0 sentence made ANY declaration replace the candidate set outright, while its own next sentence explains the rule entirely in terms of a single string that cannot represent two teams. An OBSERVED prefix is not that: it is evidence of a namespace already in use. Measured live: the only keyed graph in the estate carries two observed prefixes, 370 keys under one and 662 under the other, and declares neither. Under the wider reading one team declaring the smaller prefix collapses the candidate set, the picker disappears, and every later create mints under it, including the other 662 keys' worth of work. Shipped as a CHANGE rather than a patch correction because implementers had already built to the wider text.
93
+ - **Four further key-machinery rulings become normative.** The HOME PRODUCT is decided by EVIDENCE rather than by a stored marker (derivable, needs no migration, cannot go stale, cannot disagree with the keys). The UNDECIDABLE case refuses BOTH mints with no invented tiebreak, because creation order is not recorded and `max(existing)` measures import volume rather than precedence. "IN SCOPE" is ENGINE-DEFINED with a floor (every product the engine can enumerate for this caller) and a security ceiling (never a product the caller could not otherwise read); portfolio altitude is measurably the wrong altitude, since deleting the portfolio document changes nothing about minting. The honest consequence is stated rather than left to be discovered: **the invariant is scope-relative, so two engines can legitimately disagree about whether one mint is safe.** And `team.key_prefix` is IMMUTABLE once anything has minted under it IN THAT PRODUCT, refusal-shaped, scoped per product because a portfolio-shared team must stay free to declare in a product it has never minted in.
94
+ - **`check:editorial`'s fingerprint is hardened in five places.** It now attributes polymorphic edges through the family partition (a wildcard edge was attributed to NOBODY, so the 0.33.0 work-item widening moved zero fingerprints on `task`, `bug`, `user_story` or `feature`); hashes the deprecation clause rather than its truthiness; hashes the description of `object`-typed properties, where the description IS the entire declared shape; reads BOTH edge registries, where it had been reading one while its sibling function was fixed a release ago; and reads four more prose fields from the published docs. The sensitivity change was discharged with a per-arm before/after run against real published releases rather than asserted.
95
+ - **GitLab emits project membership for every work-item type**, not only epics. It was the only one of five adapters gating the emission on a concrete type, so an ordinary issue resolving to `task` or `bug` carried the project id and never reached the seam. Keyed on the work-item allowlist, never on the wildcard.
96
+ - **The field-graph migration pass is a named release step** with the releasing lane as its owner. It walks every graph, reports which migrations are due, and NEVER rewrites: when a graph is migrated the version bump is that pass's output rather than an incidental edit, and "not migrated, here is why" is a legitimate outcome. A report and not a gate, because blocking a release on a field graph being behind makes the honest answer the expensive one.
97
+
98
+ ### Fixed
99
+ - **`UPGCrossEdge.properties` was never in `CROSS_EDGE_KEY_ORDER`**, and the cross-edge canonicaliser never treated it as an open bag. Declared since 0.10.0. Nothing was corrupt, because unlisted keys fall to a deterministic sorted tail, but two writers emitting the same parity assessment with their keys in a different order produced different bytes, which breaks this format's writer-agnosticism contract. Found by the new subset assertion on its first run, eleven minors after the node twin got the same guard. Measured before fixing, because a canonical-order edit rewrites bytes: **zero of the 61 cross-edges in the estate carry properties**, so the fix is free today and would not have been after the first parity assessment landed.
100
+ - **The cross-only tier's declared target contract** said these edges point at a registry entry. All three live instances target a product-qualified id and the registry is EMPTY, so a reader following the comment would look for something that has never existed. Both forms are legitimate; the field has only produced the second.
101
+ - **`UPGEdge.properties`' JSDoc named one edge as carrying the gate; eleven set it.** One word, "currently", doing the work of a gate nobody wired, wrong for ten mints.
102
+ - **A citation check called correct prose a fabricated edge name.** Widening the editorial read set surfaced it immediately: a data-product name that begins with a real entity type matched a head-only test. A real edge name ends in the type it points AT, so the check now requires both ends. Ruled rather than allowlisted, and rather than asked of the doc author: the prose was correct and the detector was not.
103
+
104
+ ### Notes
105
+ - Truth: **324 entities · 1086 edges · 69 cross-edges · 26 anti-patterns · 99 tools · 26 polymorphic keys in 15 families · 342 lifecycle phases**, the last of these gated for the first time. No entity minted. Cross-edges unchanged because `document`, `person` and `role` are not `portfolio_shared`, so nothing here can be eligible.
106
+ - **Zero spec-side migrations.** Both new edges are new types with nothing to convert, all three new fields are optional, and the view-clause union changes no bytes.
107
+ - **`UPG_CROSS_ONLY_EDGE_TYPES` has NO runtime consumer**, recorded exhaustively. No writer reads it. The tier is a declared vocabulary that nothing validates at write time, which changes what a non-colliding key would buy: discipline, not a guarantee.
108
+ - **The within-graph conformance edge is NOT minted**, and its banked condition is restated to the case that is actually live. It is not "a specification in a graph with no portfolio", which no graph instantiates. It is the HOLDER PRODUCT: the graph that holds a specification has three siblings reaching it through the cross tier and no way to state its own relation to it. The verb the live case wants is `defines` rather than `conforms_to`.
109
+ - **Migration note corrections carried on `external_links`.** The overflow residue is **179, not 162** (a second bag key was never counted), and **524 of 528 nodes duplicate their own canonical reference inside the parked bag**, so a straight copy would put the canonical artifact into the non-canonical list on 99% of that field's first real population. Subtract before copying.
110
+ - **The labeled-fixture doctrine is discharged for the first time.** The new check ships with a corpus whose provenance is machine-readable and required, whose near-misses are all labelled CONSTRUCTED, and which carries a dated obligation to re-grade precision and recall on the first sampled collision. Until then the recall figure is a claim about the author's imagination, and the corpus says so in its own output rather than in a plan nobody opens.
111
+ - **A limitation this release demonstrates rather than describes.** The seven key-machinery rulings are the most consequential text in it and not one of them moves an editorial fingerprint, because prose is excluded by design. The gate that exists to catch documentation going stale cannot see the change most likely to be built against wrongly.
112
+ - Banked with conditions, unchanged: `rename_bag_key`; SDK patch derivation from `UPG_BASE_NODE_FIELDS` (this release adds no base-node field, so the trigger did not fire); re-classifying the a11y conformance edge from `hierarchy` to `semantic`; widening `document_describes_*` to the node wildcard, whose own evidence is real and separate; the within-graph conformance edge. Newly banked: an entity-description export so the editorial gate can hash descriptions on BOTH sides of a release comparison, which is what stops that arm being inert. **The `NODE_KEY_ORDER` subset assertion is CLOSED: it shipped in 0.33.0.** The "0.33.0 one-release tolerances due at 0.34.0" do not exist; 0.33.0 created none.
113
+
10
114
  ## [0.33.1] - 2026-08-22
11
115
 
12
116
  Patch, docs only. No schema change: no entity, edge, property, enum, lifecycle or tool added, removed or altered. One normative contradiction, published in 0.33.0, is resolved.
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
- 324 entity types across 37 domains. 1084 typed edges. 53 canonical framework definitions, with more added as each is reviewed.
10
+ 324 entity types across 37 domains. 1091 typed edges. 53 canonical framework definitions, with more added as each is reviewed.
11
11
 
12
12
  ## Install
13
13