From d0f94340a59504794f78ce13cd5a1573cc6026db Mon Sep 17 00:00:00 2001 From: Dan Wolfson Date: Tue, 18 Aug 2026 16:35:36 +0100 Subject: [PATCH 1/3] feat(multi-link): add missing SupportedGovernanceService OMVS wrapper Egeria-api-asset-maker.http documents dedicated attach/update/detach endpoints for SupportedGovernanceService (governance-engines/{guid}/ supported-governance-services/{guid}/attach, supported-governance- services/{guid}/update, supported-governance-services/{guid}/detach) that had no pyegeria wrapper at all -- confirmed via the live server's OpenAPI spec that the endpoints exist and are GUID-based for update/detach. The .http file's own comment already documents this as multi-link: "the same governance engine may call the same governance service many times, each with a different request type. The unique identifier of the new relationship is returned so it can be updated or removed later." New: _async_link_supported_governance_service / link_supported_governance_service (returns the new relationship's GUID via the fixed _async_new_relationship_request), _async_update_supported_governance_service / update_supported_governance_service, _async_detach_supported_governance_service / detach_supported_governance_service. Live-verified routing against a running server (fake GUIDs correctly reach createRelatedElementsInStore / return 404s, not URL/shape errors, for attach/update/detach). New unit tests (test_supported_governance_service.py) confirm URL construction and GUID return without a live server. No Dr.Egeria compact command exists for this relationship type yet -- out of scope here; this commit is the OMVS wrapper only (see ISSUE-68 in PYEGERIA_ISSUES.md). The other 3 OMVS-wrapper gaps found in the original MULTI_LINK audit (AssociatedSecurityList, DataLineageRelationship, NetworkGatewayLink) were NOT built: checked against a live server's OpenAPI spec as well as the repo's .http ground truth and found no dedicated REST endpoint for any of the three at all (AssociatedSecurityListProperties exists only as a schema, referenced by zero paths) -- only the generic, awkward MetadataExpert endpoint could create them (CLAUDE.md's documented ElementProperties/propertyValueMap gotcha), a materially different task than wrapping a real dedicated endpoint. Flagged for the user rather than guessing at undocumented URLs. Signed-off-by: Dan Wolfson --- pyegeria/omvs/asset_maker.py | 206 ++++++++++++++++++ .../test_supported_governance_service.py | 95 ++++++++ 2 files changed, 301 insertions(+) create mode 100644 tests/micro-tests/test_supported_governance_service.py diff --git a/pyegeria/omvs/asset_maker.py b/pyegeria/omvs/asset_maker.py index 5c77aab5..9142210b 100644 --- a/pyegeria/omvs/asset_maker.py +++ b/pyegeria/omvs/asset_maker.py @@ -1612,6 +1612,212 @@ def remove_catalog_target( self._async_remove_catalog_target(relationship_guid, body) ) + @dynamic_catch + async def _async_link_supported_governance_service( + self, + governance_engine_guid: str, + governance_service_guid: str, + body: dict | NewRelationshipRequestBody | None = None, + ) -> Optional[str]: + """Register a governance service with a governance engine. Async version. + + SupportedGovernanceService is MULTI_LINK (see + pyegeria.core.relationship_multiplicity, and confirmed by + Egeria-api-asset-maker.http's own comment: "the same governance + engine may call the same governance service many times, each with a + different request type") -- the returned GUID is needed to target + this specific registration later via + _async_update_supported_governance_service / + _async_detach_supported_governance_service. + + Parameters + ---------- + governance_engine_guid: str + Unique identifier of the governance engine. + governance_service_guid: str + Unique identifier of the governance service being registered. + body: dict | NewRelationshipRequestBody, optional + Properties for the SupportedGovernanceService relationship (requestType, + serviceRequestType, requestParameters, generateConnectorActivityReports, + deleteMethod). + + Returns + ------- + str | None + The GUID of the newly created SupportedGovernanceService relationship. + None if the server didn't return one. + + Raises + ------ + PyegeriaException + One of the pyegeria exceptions will be raised if there are issues in communications, message format, or + Egeria errors. + + Notes + ----- + See: https://egeria-project.org/concepts/governance-engine + + Sample body: + { + "class" : "NewRelationshipRequestBody", + "externalSourceGUID": "add guid here", + "externalSourceName": "add qualified name here", + "effectiveTime" : "{{$isoTimestamp}}", + "forLineage" : false, + "forDuplicateProcessing" : false, + "properties": { + "class": "SupportedGovernanceServiceProperties", + "requestType": "add request type here", + "serviceRequestType": "add service request type here", + "requestParameters": { + "propertyName1" : "propertyValue1", + "propertyName2" : "propertyValue2" + }, + "generateConnectorActivityReports": true, + "deleteMethod": "LOOK_FOR_LINEAGE", + "effectiveFrom": "{{$isoTimestamp}}", + "effectiveTo": "{{$isoTimestamp}}" + } + } + """ + url = f"{self.asset_command_root}/governance-engines/{governance_engine_guid}/supported-governance-services/{governance_service_guid}/attach" + return await self._async_new_relationship_request(url, ["SupportedGovernanceServiceProperties"], body) + + @dynamic_catch + def link_supported_governance_service( + self, + governance_engine_guid: str, + governance_service_guid: str, + body: dict | NewRelationshipRequestBody | None = None, + ) -> Optional[str]: + """Register a governance service with a governance engine. + + See _async_link_supported_governance_service for parameter and return details. + """ + loop = asyncio.get_event_loop() + return loop.run_until_complete( + self._async_link_supported_governance_service( + governance_engine_guid, governance_service_guid, body + ) + ) + + @dynamic_catch + async def _async_update_supported_governance_service( + self, + relationship_guid: str, + body: dict | UpdateRelationshipRequestBody | None = None, + ) -> None: + """Update the properties of a SupportedGovernanceService relationship. Async version. + + Parameters + ---------- + relationship_guid: str + Unique identifier of the SupportedGovernanceService relationship (as + returned by _async_link_supported_governance_service). + body: dict | UpdateRelationshipRequestBody, optional + Updated properties for the relationship. + + Returns + ------- + None + + Raises + ------ + PyegeriaException + One of the pyegeria exceptions will be raised if there are issues in communications, message format, or + Egeria errors. + + Notes + ----- + See: https://egeria-project.org/concepts/governance-engine + + Sample body: + { + "class" : "UpdateRelationshipRequestBody", + "properties" : { + "class": "SupportedGovernanceServiceProperties", + "requestType": "add request type here", + "serviceRequestType": "add service request type here", + "requestParameters": { + "propertyName1" : "propertyValue1", + "propertyName2" : "propertyValue2" + }, + "generateConnectorActivityReports": true, + "deleteMethod": "LOOK_FOR_LINEAGE" + }, + "mergeUpdate": true, + "externalSourceGUID": "add guid here", + "externalSourceName": "add qualified name here", + "effectiveTime" : "{{$isoTimestamp}}", + "forLineage" : false, + "forDuplicateProcessing" : false + } + """ + url = f"{self.asset_command_root}/supported-governance-services/{relationship_guid}/update" + await self._async_update_relationship_request(url, ["SupportedGovernanceServiceProperties"], body) + + @dynamic_catch + def update_supported_governance_service( + self, + relationship_guid: str, + body: dict | UpdateRelationshipRequestBody | None = None, + ) -> None: + """Update the properties of a SupportedGovernanceService relationship. + + See _async_update_supported_governance_service for parameter details. + """ + loop = asyncio.get_event_loop() + loop.run_until_complete( + self._async_update_supported_governance_service(relationship_guid, body) + ) + + @dynamic_catch + async def _async_detach_supported_governance_service( + self, + relationship_guid: str, + body: dict | DeleteRelationshipRequestBody | None = None, + ) -> None: + """Remove a SupportedGovernanceService relationship. Async version. + + Parameters + ---------- + relationship_guid: str + Unique identifier of the SupportedGovernanceService relationship to remove. + body: dict | DeleteRelationshipRequestBody, optional + Additional parameters for the delete operation. + + Returns + ------- + None + + Raises + ------ + PyegeriaException + One of the pyegeria exceptions will be raised if there are issues in communications, message format, or + Egeria errors. + + Notes + ----- + See: https://egeria-project.org/concepts/governance-engine + """ + url = f"{self.asset_command_root}/supported-governance-services/{relationship_guid}/detach" + await self._async_delete_relationship_request(url, body) + + @dynamic_catch + def detach_supported_governance_service( + self, + relationship_guid: str, + body: dict | DeleteRelationshipRequestBody | None = None, + ) -> None: + """Remove a SupportedGovernanceService relationship. + + See _async_detach_supported_governance_service for parameter details. + """ + loop = asyncio.get_event_loop() + loop.run_until_complete( + self._async_detach_supported_governance_service(relationship_guid, body) + ) + @dynamic_catch async def _async_detach_catalog_target( self, diff --git a/tests/micro-tests/test_supported_governance_service.py b/tests/micro-tests/test_supported_governance_service.py new file mode 100644 index 00000000..36561bc5 --- /dev/null +++ b/tests/micro-tests/test_supported_governance_service.py @@ -0,0 +1,95 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright Contributors to the ODPi Egeria project. +""" +Unit tests for AssetMaker's new SupportedGovernanceService relationship wrapper +(ISSUE-68 follow-up). SupportedGovernanceService is MULTI_LINK -- Egeria's own +Egeria-api-asset-maker.http documents it as such ("the same governance engine +may call the same governance service many times, each with a different +request type... The unique identifier of the new relationship is returned so +it can be updated or removed later"), and it previously had no OMVS wrapper +at all despite dedicated attach/update/detach REST endpoints existing +(confirmed against a live server's OpenAPI spec and the .http ground truth). + +These tests confirm the URL construction and that the create path returns +the GUID; they don't hit a live server (see PYEGERIA_ISSUES.md ISSUE-68 for +the separate live routing check performed against a running server, which +confirmed correct request routing via expected 404s on fake GUIDs). +""" +from typing import Any, cast + +import pytest + +from pyegeria.omvs.asset_maker import AssetMaker + + +class _FakeResponse: + def __init__(self, payload): + self._payload = payload + + def json(self): + return self._payload + + +class _CapturingClient(AssetMaker): + """Subclass that intercepts the outgoing HTTP call instead of a real client.""" + + def __init__(self): + super().__init__(view_server="fake-view", platform_url="https://fake:9443", user_id="fake-user") + self.calls = [] + + async def _async_make_request(self, method, url, payload=None): + self.calls.append((method, url, payload)) + return _FakeResponse({"class": "GUIDResponse", "guid": "sgs-rel-guid-0001"}) + + +@pytest.mark.asyncio +async def test_link_supported_governance_service_url_and_guid(): + client = _CapturingClient() + body = { + "class": "NewRelationshipRequestBody", + "properties": {"class": "SupportedGovernanceServiceProperties", "requestType": "survey-folder"}, + } + + guid = await client._async_link_supported_governance_service("engine-guid", "service-guid", body) + + assert guid == "sgs-rel-guid-0001" + assert len(client.calls) == 1 + method, url, _ = client.calls[0] + assert method == "POST" + assert url == ( + "https://fake:9443/servers/fake-view/api/open-metadata/asset-maker/" + "governance-engines/engine-guid/supported-governance-services/service-guid/attach" + ) + + +@pytest.mark.asyncio +async def test_update_supported_governance_service_url(): + client = _CapturingClient() + body = { + "class": "UpdateRelationshipRequestBody", + "properties": {"class": "SupportedGovernanceServiceProperties", "requestType": "survey-folder-v2"}, + "mergeUpdate": True, + } + + await client._async_update_supported_governance_service("sgs-rel-guid-0001", body) + + method, url, _ = client.calls[0] + assert method == "POST" + assert url == ( + "https://fake:9443/servers/fake-view/api/open-metadata/asset-maker/" + "supported-governance-services/sgs-rel-guid-0001/update" + ) + + +@pytest.mark.asyncio +async def test_detach_supported_governance_service_url(): + client = _CapturingClient() + + await client._async_detach_supported_governance_service("sgs-rel-guid-0001") + + method, url, _ = client.calls[0] + assert method == "POST" + assert url == ( + "https://fake:9443/servers/fake-view/api/open-metadata/asset-maker/" + "supported-governance-services/sgs-rel-guid-0001/detach" + ) From 77b23855451d00df928d675dfc58741136488d4c Mon Sep 17 00:00:00 2001 From: Dan Wolfson Date: Tue, 18 Aug 2026 16:59:06 +0100 Subject: [PATCH 2/3] feat(multi-link): add Dr.Egeria Update commands for Certification/License/Next Process Step (ISSUE-68) Update was never auto-generated for a Link-family compact command (build_command_variants' LINK_VERBS has no "Update"); the only prior example was Lineage Linker's hand-added "Update Lineage Relationship". Adds the same pattern for the 3 MULTI_LINK relationship types where the OMVS layer's Update is already GUID-based and a Dr.Egeria Link command already exists: - New compact commands (via the Dr.Egeria Spec Editor's REST API, per CLAUDE.md): "Update Certification", "Update License" (commands_governance_officer_compact.json), "Update Next Process Step" (commands_action_author.json). Each reuses its sibling Link command's existing attributes (minus the two endpoint-resolution attributes Update doesn't need) via a new bundle. - GovernanceLinkProcessor.apply_changes(): new Update branch for Certification/License, resolving the relationship GUID via the existing _resolve_relationship_guid() and calling _async_update_certification/_async_update_license. Registered explicitly in dr_egeria.py (register_governance_processors()'s family loop routes any non-Link verb to GovernanceProcessor, the *element* processor, by default -- same override pattern as "Create Embedded Process"). - ActionProcessStepLinkProcessor.apply_changes(): new Update branch for NextGovernanceActionProcessStep, calling _async_update_next_action_process_step (auto-routed via the family loop's existing om_type special-case, no explicit reg() needed). Also fixed the Link branch to capture and display the GUID _async_setup_next_action_process_step now returns (previously discarded, same gap as the other Link-branch fixes in the prior commit). - "Update Agreement Actor" was considered and dropped: checked Egeria-api-collection-manager.http directly and AgreementActor has no update endpoint at all, only attach/detach. Second bug found and fixed while verifying these live (blocked the feature, not part of the original request): AsyncBaseCommandProcessor .execute()'s step 5 (the shared Create<->Update upsert-transition logic) was not gated by supports_target_element_lookup() like steps 1a/3/7 are. A relationship-only processor (as_is_element always None, no qualified_name) silently had its verb rewritten Update -> Create -- confirmed live on both the 3 new commands here AND, independently, the pre-existing "Update Lineage Relationship" (apparently never exercised through --validate/--process with a full valid attribute set before). Fixed by gating the existence/rewrite side effects on supports_target_element_lookup() (current_qn itself stays unconditional, since it's read later in the method regardless) and overriding that method to return False in GovernanceLinkProcessor, ActionProcessStepLinkProcessor, LineageLinkProcessor, and UpdateLineageRelationshipProcessor. Not comprehensively audited: every other relationship-only processor has the same latent exposure but none currently register an Update-verb command, so it's dormant for them -- flagged in PYEGERIA_ISSUES.md as a structural risk for the next one added, not fixed preemptively. New tests: test_multilink_update_commands.py (6 tests). All 3 new commands (plus "Update Lineage Relationship", to confirm the shared-code fix) live-verified via --validate against a running server -- each now correctly keeps verb=Update through execute() instead of silently becoming Create. Full pytest tests/micro-tests/ green throughout. Signed-off-by: Dan Wolfson --- .../commands_action_author.json | 23 + .../commands_governance_officer_compact.json | 72 +++ md_processing/dr_egeria.py | 12 + md_processing/v2/action_author.py | 37 +- md_processing/v2/governance.py | 76 ++- md_processing/v2/lineage_linker.py | 20 + md_processing/v2/processors.py | 80 ++-- .../Action Author/Update_Next_Process_Step.md | 322 +++++++++++++ .../Update_Certification.md | 444 ++++++++++++++++++ .../Governance Officer/Update_License.md | 444 ++++++++++++++++++ .../test_multilink_update_commands.py | 168 +++++++ 11 files changed, 1665 insertions(+), 33 deletions(-) create mode 100644 sample-data/templates/advanced/Action Author/Update_Next_Process_Step.md create mode 100644 sample-data/templates/advanced/Governance Officer/Update_Certification.md create mode 100644 sample-data/templates/advanced/Governance Officer/Update_License.md create mode 100644 tests/micro-tests/test_multilink_update_commands.py diff --git a/md_processing/data/compact_commands/commands_action_author.json b/md_processing/data/compact_commands/commands_action_author.json index d36e74c8..43fad6c8 100644 --- a/md_processing/data/compact_commands/commands_action_author.json +++ b/md_processing/data/compact_commands/commands_action_author.json @@ -3188,6 +3188,29 @@ "bundle": "Cancel Engine Action Base", "custom_attributes": [], "Journal Entry": "" + }, + "Update Next Process Step": { + "display_name": "next_process_step", + "qn_prefix": "", + "alternate_names": [], + "family": "Action Author", + "description": "Update the properties of an existing NextGovernanceActionProcessStep relationship, identified by its own relationship GUID (as returned by Link Next Process Step).", + "verb": "Update", + "upsert": false, + "attach": false, + "level": "Advanced", + "find_method": "", + "find_constraints": "", + "extra_find": "", + "extra_constraints": "", + "OM_TYPE": "NextGovernanceActionProcessStep", + "bundle": "Link Command Base", + "custom_attributes": [ + "GUID", + "Mandatory Guard", + "Guard" + ], + "Journal Entry": "" } } } diff --git a/md_processing/data/compact_commands/commands_governance_officer_compact.json b/md_processing/data/compact_commands/commands_governance_officer_compact.json index ebf8449e..83fe5568 100644 --- a/md_processing/data/compact_commands/commands_governance_officer_compact.json +++ b/md_processing/data/compact_commands/commands_governance_officer_compact.json @@ -4790,6 +4790,78 @@ "Data Asset" ], "Journal Entry": "" + }, + "Update Certification": { + "display_name": "certification", + "qn_prefix": "", + "alternate_names": [], + "family": "Governance Officer", + "description": "Update the properties of an existing Certification relationship, identified by its own relationship GUID (Certificate GUID, as returned by Link Certification).", + "verb": "Update", + "upsert": false, + "attach": false, + "level": "Advanced", + "find_method": "", + "find_constraints": "", + "extra_find": "", + "extra_constraints": "", + "OM_TYPE": "Certification", + "bundle": "Link Command Base", + "custom_attributes": [ + "Certificate GUID", + "Certified By", + "Certified By Property Name", + "Certified By Type Name", + "Conditions", + "Custodian", + "Custodian Property Name", + "Custodian Type Name", + "End Date", + "Entitlements", + "Obligations", + "Recipient", + "Recipient Property Name", + "Recipient Type Name", + "Restrictions", + "Start Date" + ], + "Journal Entry": "" + }, + "Update License": { + "display_name": "license", + "qn_prefix": "", + "alternate_names": [], + "family": "Governance Officer", + "description": "Update the properties of an existing License relationship, identified by its own relationship GUID (License GUID, as returned by Link License).", + "verb": "Update", + "upsert": false, + "attach": false, + "level": "Advanced", + "find_method": "", + "find_constraints": "", + "extra_find": "", + "extra_constraints": "", + "OM_TYPE": "License", + "bundle": "Link Command Base", + "custom_attributes": [ + "License GUID", + "Licensed By", + "Licensed By Property Name", + "Licensed By Type Name", + "Conditions", + "Custodian", + "Custodian Property Name", + "Custodian Type Name", + "End Date", + "Entitlements", + "Licensee", + "Licensee Property Name", + "Licensee Type Name", + "Obligations", + "Restrictions", + "Start Date" + ], + "Journal Entry": "" } } } diff --git a/md_processing/dr_egeria.py b/md_processing/dr_egeria.py index b0754483..df126250 100644 --- a/md_processing/dr_egeria.py +++ b/md_processing/dr_egeria.py @@ -363,6 +363,18 @@ def reg(base, cls): # Governance (spec-driven to keep coverage aligned with compact commands) register_governance_processors(reg) + # "Update Certification"/"Update License" -- register_governance_processors()'s + # family loop routes by the base command's own verb (Link/Attach/Add -> + # GovernanceLinkProcessor, everything else -> GovernanceProcessor, the + # governance-*definition*-element processor), so these two Update-a- + # relationship commands would otherwise be silently misrouted there. + # Override explicitly, same pattern as "Create Embedded Process"/ + # "Initiate Engine Action" above overriding their own family's generic + # walker (ISSUE-68: Certification/License are MULTI_LINK, need their own + # relationship GUID to target one instance for update). + reg("Update Certification", GovernanceLinkProcessor) + reg("Update License", GovernanceLinkProcessor) + # Lineage Linker -- one generic Link/Update/Unlink command triple covering # all seven Lineage Linker OMVS relationship types (DataFlow, ControlFlow, # ProcessCall, LineageMapping, DataMapping, UltimateSource, diff --git a/md_processing/v2/action_author.py b/md_processing/v2/action_author.py index fa33c3ad..a3ed2872 100644 --- a/md_processing/v2/action_author.py +++ b/md_processing/v2/action_author.py @@ -25,6 +25,12 @@ class ActionProcessStepLinkProcessor(AsyncBaseCommandProcessor): Link Next Process Step (NextGovernanceActionProcessStep). """ + def supports_target_element_lookup(self) -> bool: + # Relationship-only processor -- see GovernanceLinkProcessor's + # identical override for why this is required, not optional, once a + # processor gains an "Update" command (ISSUE-68 follow-up). + return False + async def fetch_as_is(self) -> Optional[Dict[str, Any]]: return None @@ -89,6 +95,32 @@ async def apply_changes(self) -> str: logger.success(f"Removed first-process-step link from {process_guid}") return f"\n\n## {verb} {object_type}\n\nRemoved first-process-step link from {process_guid}" + elif verb == "Update": + # NextGovernanceActionProcessStep is MULTI_LINK (see + # pyegeria.core.relationship_multiplicity) -- more than one may + # exist between the same pair of steps, so updating one requires + # its own relationship GUID, not the pair of step GUIDs the Link/ + # Detach branches below use. Handled first, before the pair + # resolution below, since "Update Next Process Step" doesn't + # carry 'Governance Action Process Step'/'Next Governance Action + # Process Step' attributes at all -- just GUID/Guard/Mandatory Guard. + relationship_guid = self._resolve_relationship_guid(object_type, attributes) + if not relationship_guid: + raise ValueError("Cannot update Next Process Step link: no relationship GUID resolved. Provide 'GUID' (as returned by the original Link Next Process Step).") + body = { + "class": "UpdateRelationshipRequestBody", + "mergeUpdate": True, + "properties": { + "class": "NextGovernanceActionProcessStepProperties", + "guard": attributes.get('Guard', {}).get('value'), + "mandatoryGuard": attributes.get('Mandatory Guard', {}).get('value'), + }, + } + self.last_body = body = body_slimmer(body) + await self.client._async_update_next_action_process_step(relationship_guid, body) + logger.success(f"Updated next-process-step link {relationship_guid}") + return f"\n\n## {verb} {object_type}\n\nUpdated next-process-step link {relationship_guid}" + else: step_guid = attributes.get('Governance Action Process Step', {}).get('guid') next_step_guid = attributes.get('Next Governance Action Process Step', {}).get('guid') @@ -106,8 +138,11 @@ async def apply_changes(self) -> str: "mandatoryGuard": attributes.get('Mandatory Guard', {}).get('value', False), } self.last_body = body = body_slimmer(body) - await self.client._async_setup_next_action_process_step(step_guid, next_step_guid, body) + new_rel_guid = await self.client._async_setup_next_action_process_step(step_guid, next_step_guid, body) logger.success(f"Linked {next_step_guid} as next process step after {step_guid}") + if new_rel_guid: + self.parsed_output["guid"] = new_rel_guid + return f"\n\n## {verb} {object_type}\n\nLinked {next_step_guid} as next process step after {step_guid}. Relationship GUID: {new_rel_guid}" return f"\n\n## {verb} {object_type}\n\nLinked {next_step_guid} as next process step after {step_guid}" elif verb in ["Detach", "Unlink", "Remove"]: diff --git a/md_processing/v2/governance.py b/md_processing/v2/governance.py index 6d559c27..4d29886c 100644 --- a/md_processing/v2/governance.py +++ b/md_processing/v2/governance.py @@ -265,7 +265,17 @@ class GovernanceLinkProcessor(AsyncBaseCommandProcessor): Processor for Governance Peer/Supporting/Governed-By links. """ - + def supports_target_element_lookup(self) -> bool: + # A relationship-only processor -- there is no target Referenceable + # element for AsyncBaseCommandProcessor.execute() to look up by + # qualified name. Without this override, step 5's Create<->Update + # upsert-transition logic (as_is_element always None here + no + # qualified_name to plan against) silently rewrites every "Update + # Certification"/"Update License" command to "Create" instead -- + # confirmed live (ISSUE-68 follow-up) and reproduced independently + # against the pre-existing "Update Lineage Relationship" command + # too, so this isn't specific to the new commands added here. + return False async def fetch_as_is(self) -> Optional[Dict[str, Any]]: return None @@ -326,6 +336,70 @@ async def apply_changes(self) -> str: spec = self.get_command_spec() om_type = spec.get("OM_TYPE") + if verb == "Update" and object_type in {"Certification", "License"}: + # Certification/License are MULTI_LINK (see + # pyegeria.core.relationship_multiplicity) -- more than one may + # exist between the same pair, so updating one requires its own + # relationship GUID, not the pair of element GUIDs the rest of + # this method resolves via endpoint_map below. Handled here, + # before that resolution, since "Update Certification"/"Update + # License" don't carry the "Certification Type"/"Referenceable" + # attributes endpoint_map would otherwise require. Unlike every + # other object_type here, Update isn't auto-generated by + # build_command_variants (LINK_VERBS has no "Update") -- these + # are hand-added compact-spec commands, same pattern as Lineage + # Linker's "Update Lineage Relationship". + rel_guid = self._resolve_relationship_guid(object_type, attributes) + if not rel_guid: + raise ValueError( + f"Update {object_type} requires the relationship GUID. Provide it in `Certificate GUID`/`License GUID` (as returned by the original Link {object_type})." + ) + if object_type == "Certification": + properties = { + "class": "CertificationProperties", + "startDate": attributes.get("Start Date", {}).get("value"), + "endDate": attributes.get("End Date", {}).get("value"), + "conditions": attributes.get("Conditions", {}).get("value"), + "certifiedBy": attributes.get("Certified By", {}).get("value"), + "certifiedByTypeName": attributes.get("Certified By Type Name", {}).get("value"), + "certifiedByPropertyName": attributes.get("Certified By Property Name", {}).get("value"), + "custodian": attributes.get("Custodian", {}).get("value"), + "custodianTypeName": attributes.get("Custodian Type Name", {}).get("value"), + "custodianPropertyName": attributes.get("Custodian Property Name", {}).get("value"), + "recipient": attributes.get("Recipient", {}).get("value"), + "recipientTypeName": attributes.get("Recipient Type Name", {}).get("value"), + "recipientPropertyName": attributes.get("Recipient Property Name", {}).get("value"), + "entitlements": attributes.get("Entitlements", {}).get("value"), + "obligations": attributes.get("Obligations", {}).get("value"), + "restrictions": attributes.get("Restrictions", {}).get("value"), + } + body = body_slimmer({"class": "UpdateRelationshipRequestBody", "properties": properties, "mergeUpdate": True}) + await self.client._async_update_certification(rel_guid, body) + else: + properties = { + "class": "LicenseProperties", + "startDate": attributes.get("Start Date", {}).get("value"), + "endDate": attributes.get("End Date", {}).get("value"), + "conditions": attributes.get("Conditions", {}).get("value"), + "licensedBy": attributes.get("Licensed By", {}).get("value"), + "licensedByTypeName": attributes.get("Licensed By Type Name", {}).get("value"), + "licensedByPropertyName": attributes.get("Licensed By Property Name", {}).get("value"), + "custodian": attributes.get("Custodian", {}).get("value"), + "custodianTypeName": attributes.get("Custodian Type Name", {}).get("value"), + "custodianPropertyName": attributes.get("Custodian Property Name", {}).get("value"), + "licensee": attributes.get("Licensee", {}).get("value"), + "licenseeTypeName": attributes.get("Licensee Type Name", {}).get("value"), + "licenseePropertyName": attributes.get("Licensee Property Name", {}).get("value"), + "entitlements": attributes.get("Entitlements", {}).get("value"), + "obligations": attributes.get("Obligations", {}).get("value"), + "restrictions": attributes.get("Restrictions", {}).get("value"), + } + body = body_slimmer({"class": "UpdateRelationshipRequestBody", "properties": properties, "mergeUpdate": True}) + await self.client._async_update_license(rel_guid, body) + + logger.success(f"Updated {object_type} relationship {rel_guid}") + return f"\n\n# {verb} {object_type}\n\nUpdated relationship {rel_guid}." + endpoint_map = { "Governance Response": ("Driver", "Policy"), "Governance Mechanism": ("Policy", "Mechanism"), diff --git a/md_processing/v2/lineage_linker.py b/md_processing/v2/lineage_linker.py index afc24db0..75cb4a86 100644 --- a/md_processing/v2/lineage_linker.py +++ b/md_processing/v2/lineage_linker.py @@ -108,6 +108,12 @@ class LineageLinkProcessor(AsyncBaseCommandProcessor): established pattern rather than inventing a new one. """ + def supports_target_element_lookup(self) -> bool: + # Relationship-only processor -- see + # GovernanceLinkProcessor.supports_target_element_lookup (ISSUE-68 + # follow-up) for why this override matters. + return False + async def fetch_as_is(self) -> Optional[Dict[str, Any]]: return None @@ -161,6 +167,20 @@ async def apply_changes(self) -> str: class UpdateLineageRelationshipProcessor(AsyncBaseCommandProcessor): """Processor for Update Lineage Relationship.""" + def supports_target_element_lookup(self) -> bool: + # Relationship-only processor. Without this override, + # AsyncBaseCommandProcessor.execute()'s step-5 Create<->Update + # upsert-transition logic (as_is_element always None here + no + # qualified_name to plan against) silently rewrites every "Update + # Lineage Relationship" command to "Create Lineage Relationship" + # instead of calling apply_changes() with verb="Update" -- confirmed + # live (ISSUE-68 follow-up): with all required attributes present, + # command.verb ends execute() as "Create", not "Update". Pre-existing + # bug, not introduced by this change; just never exercised before + # since no prior test ran this command with a full valid attribute + # set through --validate/--process. + return False + async def fetch_as_is(self) -> Optional[Dict[str, Any]]: return None diff --git a/md_processing/v2/processors.py b/md_processing/v2/processors.py index d752cf24..bd48de00 100644 --- a/md_processing/v2/processors.py +++ b/md_processing/v2/processors.py @@ -543,38 +543,56 @@ async def execute(self) -> Dict[str, Any]: self._add_warning(msg) # 5. Determine element existence and handle Upsert (Create <-> Update) transitions + # + # Gated on supports_target_element_lookup(): a relationship-only + # processor (GovernanceLinkProcessor, ActionProcessStepLinkProcessor, + # LineageLinkProcessor/UpdateLineageRelationshipProcessor, ...) has no + # target Referenceable element to track existence/qualified-name for, + # so as_is_element is always None and current_qn is always empty -- + # which, left ungated, made every branch below fall into "doesn't + # exist anywhere" and silently rewrite verb="Update" to verb="Create" + # for ANY relationship processor with an Update command (confirmed + # live, ISSUE-68 follow-up: broke the new "Update Certification"/ + # "Update License"/"Update Next Process Step" commands as well as the + # pre-existing "Update Lineage Relationship", which had apparently + # never been exercised through --validate/--process with a full + # valid attribute set before). + # current_qn is read unconditionally (referenced further down in this + # method regardless of supports_target_element_lookup()); only the + # existence/rewrite side effects below are gated. current_qn = self.parsed_output.get("qualified_name") - planned = self.context.get("planned_elements") - - # Check if it was already planned by a previous command in the same file - is_already_planned = False - if isinstance(planned, set) and current_qn: - is_already_planned = current_qn in planned - - if self.as_is_element: - # Transition Create -> Update if it already exists in Egeria - if self.command.verb in ["Create", "Define", "Register", "Add", "Upsert"]: - logger.info(f"Rewriting '{self.command.verb} {self.command.object_type}' to 'Update' as it already exists.") - self.command.verb = "Update" - - self.parsed_output["exists"] = True - header = self.as_is_element.get('elementHeader', {}) - self.parsed_output["guid"] = header.get('guid') - elif not is_already_planned: - # Transition Update -> Create if it doesn't exist anywhere - if self.command.verb in ["Update", "Modify", "Upsert"]: - logger.info(f"Rewriting '{self.command.verb} {self.command.object_type}' to 'Create' as it does not exist.") - self.command.verb = "Create" - self.parsed_output["exists"] = False - else: - # Found in planned_elements (planned by previous command) - self.parsed_output["exists"] = True - self.parsed_output["is_planned"] = True - # Note: Step 7 will resolve the (Planned: ...) GUID - - # Record this element in the shared 'planned_elements' set for subsequent commands - if isinstance(planned, set) and current_qn and self.command.verb in ["Create", "Define", "Register", "Add", "Update", "Modify", "Upsert"]: - planned.add(current_qn) + if self.supports_target_element_lookup(): + planned = self.context.get("planned_elements") + + # Check if it was already planned by a previous command in the same file + is_already_planned = False + if isinstance(planned, set) and current_qn: + is_already_planned = current_qn in planned + + if self.as_is_element: + # Transition Create -> Update if it already exists in Egeria + if self.command.verb in ["Create", "Define", "Register", "Add", "Upsert"]: + logger.info(f"Rewriting '{self.command.verb} {self.command.object_type}' to 'Update' as it already exists.") + self.command.verb = "Update" + + self.parsed_output["exists"] = True + header = self.as_is_element.get('elementHeader', {}) + self.parsed_output["guid"] = header.get('guid') + elif not is_already_planned: + # Transition Update -> Create if it doesn't exist anywhere + if self.command.verb in ["Update", "Modify", "Upsert"]: + logger.info(f"Rewriting '{self.command.verb} {self.command.object_type}' to 'Create' as it does not exist.") + self.command.verb = "Create" + self.parsed_output["exists"] = False + else: + # Found in planned_elements (planned by previous command) + self.parsed_output["exists"] = True + self.parsed_output["is_planned"] = True + # Note: Step 7 will resolve the (Planned: ...) GUID + + # Record this element in the shared 'planned_elements' set for subsequent commands + if isinstance(planned, set) and current_qn and self.command.verb in ["Create", "Define", "Register", "Add", "Update", "Modify", "Upsert"]: + planned.add(current_qn) # 6. Dry-run validation (optional/future) diff --git a/sample-data/templates/advanced/Action Author/Update_Next_Process_Step.md b/sample-data/templates/advanced/Action Author/Update_Next_Process_Step.md new file mode 100644 index 00000000..4ce63abb --- /dev/null +++ b/sample-data/templates/advanced/Action Author/Update_Next_Process_Step.md @@ -0,0 +1,322 @@ +___ + +## Update Next Process Step +> Update the properties of an existing NextGovernanceActionProcessStep relationship, identified by its own relationship GUID (as returned by Link Next Process Step). + +### Label +> **Input Required**: False + +> **Attribute Type**: Simple + +> **Description**: A label used to identify or categorise a relationship link. + +> **Alternative Labels**: Wire Label + + +### Journal Entry +> **Input Required**: False + +> **Attribute Type**: Simple + +> **Description**: A text entry into a journal. + + +### Description +> **Input Required**: False + +> **Attribute Type**: Simple + +> **Description**: A description. + + +### Category +> **Input Required**: False + +> **Attribute Type**: Simple + +> **Description**: A user specified category name that can be used for example, to define product types or agreement types. + +> **Alternative Labels**: Category Name + + +### Qualified Name +> **Input Required**: False + +> **Attribute Type**: QN + +> **Description**: A unique qualified name for the element. Generated using the qualified name pattern if not user specified. + + +### GUID +> **Input Required**: False + +> **Attribute Type**: GUID + +> **Description**: A unique identifier - typically of an element in this context. + +> **Alternative Labels**: guid; Guid + + +### Mandatory Guard +> **Input Required**: False + +> **Attribute Type**: Bool + +> **Description**: Whether this guard must be present for the step to be actioned. + +> **Default Value**: false + + +### Guard +> **Input Required**: False + +> **Attribute Type**: Simple + +> **Description**: Informational value passed to the process step; the step's behaviour may vary depending on the guard it receives. + + +### Version Identifier +> **Input Required**: False + +> **Attribute Type**: Simple + +> **Description**: Published product version identifier. + +> **Alternative Labels**: Version + +> **Default Value**: 1.0 + + +### Identifier +> **Input Required**: False + +> **Attribute Type**: Simple + +> **Description**: role identifier + +> **Alternative Labels**: ID + + +### URL +> **Input Required**: False + +> **Attribute Type**: Simple + +> **Description**: Link to supporting information + + +### Search Keywords +> **Input Required**: False + +> **Attribute Type**: Simple List + +> **Description**: Keywords to facilitate finding the element + + +### Effective From +> **Input Required**: False + +> **Attribute Type**: Simple + +> **Description**: The beginning of when an element is viewable. + + +### Effective Time +> **Input Required**: False + +> **Attribute Type**: Simple + +> **Description**: The time at which an element must be effective in order to be returned by the request. + + +### Effective To +> **Input Required**: False + +> **Attribute Type**: Simple + +> **Description**: The ending time at which an element is visible. + + +### External Source GUID +> **Input Required**: False + +> **Attribute Type**: GUID + +> **Description**: The unique identifier of an external source. + + +### External Source Name +> **Input Required**: False + +> **Attribute Type**: Simple + +> **Description**: The name of an external source + + +### For Duplicate Processing +> **Input Required**: False + +> **Attribute Type**: Bool + +> **Description**: Flag indicating if the request is to support duplicate processing. + + +### For Lineage +> **Input Required**: False + +> **Attribute Type**: Bool + +> **Description**: Flag indicating if the request is to support lineage. + + +### Request ID +> **Input Required**: False + +> **Attribute Type**: Simple + +> **Description**: A user provided or system generated request id for a conversation. + + +### Anchor Scope IDs +> **Input Required**: False + +> **Attribute Type**: Reference Name List + +> **Description**: A list of IDs that are anchor scopes for this element. + + +### Make Anchor +> **Input Required**: False + +> **Attribute Type**: Bool + +> **Description**: Is the element at end2 an anchor to end1? + +> **Default Value**: false + + +### Status +> **Input Required**: False + +> **Attribute Type**: Valid Value + +> **Description**: The status of the digital product. There is a list of valid values that this conforms to. + +> **Default Value**: ACTIVE + + +### User Defined Status +> **Input Required**: False + +> **Attribute Type**: Simple + +> **Description**: Only valid if Status is set to OTHER. User defined & managed status values. + + +### Classifications +> **Input Required**: false + +> **Attribute Type**: Named DICT + +> **Description**: Optionally specify the initial classifications for a collection. Multiple classifications can be specified. + +> **Alternative Labels**: classification + +> | Parameter Name | Parameter Value | +> |---|---| +> | example_key | example_value | + + +### Is Own Anchor +> **Input Required**: False + +> **Attribute Type**: Bool + +> **Description**: Generally True. + +> **Alternative Labels**: Own Anchor + +> **Default Value**: True + + +### Anchor ID +> **Input Required**: False + +> **Attribute Type**: Reference Name + +> **Description**: Anchor identity for the collection. Typically a qualified name but if display name is unique then it could be used (not recommended) + + +### Parent ID +> **Input Required**: False + +> **Attribute Type**: Reference Name + +> **Description**: Unique name of the parent element. + +> **Alternative Labels**: Parent; + + +### Parent Relationship Type Name +> **Input Required**: False + +> **Attribute Type**: Simple + +> **Description**: The kind of the relationship to the parent element. + + +### Anchor Scope Name +> **Input Required**: False + +> **Attribute Type**: Reference Name + +> **Description**: Optional qualified name of an anchor scope. + + +### Parent at End1 +> **Input Required**: False + +> **Attribute Type**: Bool + +> **Description**: Is the parent at end1 of the relationship? + +> **Default Value**: True + + +### Merge Update +> **Input Required**: False + +> **Attribute Type**: Bool + +> **Description**: If True, only those attributes specified in the update will be updated; If False, any attributes not provided during the update will be set to None. + +> **Alternative Labels**: Merge + +> **Default Value**: True + + +### Additional Properties +> **Input Required**: False + +> **Attribute Type**: Dictionary + +> **Description**: Additional user defined values organized as name value pairs in a dictionary. + +> | Parameter Name | Parameter Value | +> |---|---| +> | example_key | example_value | + + +### Supplementary Properties +> **Input Required**: False + +> **Attribute Type**: Named DICT + +> **Description**: Provide supplementary information to the element using the structure of a glossary term + +> | Parameter Name | Parameter Value | +> |---|---| +> | example_key | example_value | + + +___ diff --git a/sample-data/templates/advanced/Governance Officer/Update_Certification.md b/sample-data/templates/advanced/Governance Officer/Update_Certification.md new file mode 100644 index 00000000..3ba3571e --- /dev/null +++ b/sample-data/templates/advanced/Governance Officer/Update_Certification.md @@ -0,0 +1,444 @@ +___ + +## Update Certification +> Update the properties of an existing Certification relationship, identified by its own relationship GUID (Certificate GUID, as returned by Link Certification). + +### Label +> **Input Required**: False + +> **Attribute Type**: Simple + +> **Description**: A label used to identify or categorise a relationship link. + +> **Alternative Labels**: Wire Label + + +### Certificate GUID +> **Input Required**: False + +> **Attribute Type**: Simple + +> **Description**: Unique identifier of the certificate. + + +### Certified By +> **Input Required**: False + +> **Attribute Type**: Simple + +> **Description**: The name of the person or organization that issued the certification. + + +### Certified By Property Name +> **Input Required**: False + +> **Attribute Type**: Simple + +> **Description**: The property name used to identify the certifier element (used with Certified By Type Name). + + +### Certified By Type Name +> **Input Required**: False + +> **Attribute Type**: Simple + +> **Description**: The open metadata type name of the element that issued the certification (used with Certified By Property Name to identify the certifier). + + +### Conditions +> **Input Required**: False + +> **Attribute Type**: Simple + +> **Description**: Conditions for certifications or licenses. + + +### Custodian +> **Input Required**: False + +> **Attribute Type**: Simple + +> **Description**: Custodian of the license or certification. + + +### Custodian Property Name +> **Input Required**: False + +> **Attribute Type**: Simple + +> **Description**: The property name used to identify the custodian element (used with Custodian Type Name). + + +### Custodian Type Name +> **Input Required**: False + +> **Attribute Type**: Simple + +> **Description**: The open metadata type name of the element acting as custodian (used with Custodian Property Name to identify the custodian). + + +### End Date +> **Input Required**: False + +> **Attribute Type**: Simple + +> **Description**: Date at which the license or certification expires. + + +### Obligations +> **Input Required**: False + +> **Attribute Type**: Dictionary + +> **Description**: A dictionary of property:value pairs describing obligations. + +> | Parameter Name | Parameter Value | +> |---|---| +> | example_key | example_value | + + +### Recipient +> **Input Required**: False + +> **Attribute Type**: Simple + +> **Description**: The receiver of the certification. + + +### Recipient Property Name +> **Input Required**: False + +> **Attribute Type**: Simple + +> **Description**: The property name used to identify the certification recipient element (used with Recipient Type Name). + + +### Recipient Type Name +> **Input Required**: False + +> **Attribute Type**: Simple + +> **Description**: The open metadata type name of the element receiving the certification (used with Recipient Property Name). + + +### Start Date +> **Input Required**: False + +> **Attribute Type**: Simple + +> **Description**: Date at which the license or certification takes effect. + + +### Journal Entry +> **Input Required**: False + +> **Attribute Type**: Simple + +> **Description**: A text entry into a journal. + + +### Description +> **Input Required**: False + +> **Attribute Type**: Simple + +> **Description**: A description. + + +### Entitlements +> **Input Required**: False + +> **Attribute Type**: Dictionary + +> **Description**: A dictionary of property:value pairs describing entitlements. + +> | Parameter Name | Parameter Value | +> |---|---| +> | example_key | example_value | + + +### Restrictions +> **Input Required**: False + +> **Attribute Type**: Dictionary + +> **Description**: A dictionary of property:value pairs describing restrictions. + +> | Parameter Name | Parameter Value | +> |---|---| +> | example_key | example_value | + + +### Category +> **Input Required**: False + +> **Attribute Type**: Simple + +> **Description**: A user specified category name that can be used for example, to define product types or agreement types. + +> **Alternative Labels**: Category Name + + +### Qualified Name +> **Input Required**: False + +> **Attribute Type**: QN + +> **Description**: A unique qualified name for the element. Generated using the qualified name pattern if not user specified. + + +### Version Identifier +> **Input Required**: False + +> **Attribute Type**: Simple + +> **Description**: Published product version identifier. + +> **Alternative Labels**: Version + +> **Default Value**: 1.0 + + +### Identifier +> **Input Required**: False + +> **Attribute Type**: Simple + +> **Description**: role identifier + +> **Alternative Labels**: ID + + +### GUID +> **Input Required**: False + +> **Attribute Type**: GUID + +> **Description**: A system generated unique identifier. + +> **Alternative Labels**: Guid; guid + + +### URL +> **Input Required**: False + +> **Attribute Type**: Simple + +> **Description**: Link to supporting information + + +### Search Keywords +> **Input Required**: False + +> **Attribute Type**: Simple List + +> **Description**: Keywords to facilitate finding the element + + +### Effective From +> **Input Required**: False + +> **Attribute Type**: Simple + +> **Description**: The beginning of when an element is viewable. + + +### Effective Time +> **Input Required**: False + +> **Attribute Type**: Simple + +> **Description**: The time at which an element must be effective in order to be returned by the request. + + +### Effective To +> **Input Required**: False + +> **Attribute Type**: Simple + +> **Description**: The ending time at which an element is visible. + + +### External Source GUID +> **Input Required**: False + +> **Attribute Type**: GUID + +> **Description**: The unique identifier of an external source. + + +### External Source Name +> **Input Required**: False + +> **Attribute Type**: Simple + +> **Description**: The name of an external source + + +### For Duplicate Processing +> **Input Required**: False + +> **Attribute Type**: Bool + +> **Description**: Flag indicating if the request is to support duplicate processing. + + +### For Lineage +> **Input Required**: False + +> **Attribute Type**: Bool + +> **Description**: Flag indicating if the request is to support lineage. + + +### Request ID +> **Input Required**: False + +> **Attribute Type**: Simple + +> **Description**: A user provided or system generated request id for a conversation. + + +### Anchor Scope IDs +> **Input Required**: False + +> **Attribute Type**: Reference Name List + +> **Description**: A list of IDs that are anchor scopes for this element. + + +### Make Anchor +> **Input Required**: False + +> **Attribute Type**: Bool + +> **Description**: Is the element at end2 an anchor to end1? + +> **Default Value**: false + + +### Status +> **Input Required**: False + +> **Attribute Type**: Valid Value + +> **Description**: The status of the digital product. There is a list of valid values that this conforms to. + +> **Default Value**: ACTIVE + + +### User Defined Status +> **Input Required**: False + +> **Attribute Type**: Simple + +> **Description**: Only valid if Status is set to OTHER. User defined & managed status values. + + +### Classifications +> **Input Required**: false + +> **Attribute Type**: Named DICT + +> **Description**: Optionally specify the initial classifications for a collection. Multiple classifications can be specified. + +> **Alternative Labels**: classification + +> | Parameter Name | Parameter Value | +> |---|---| +> | example_key | example_value | + + +### Is Own Anchor +> **Input Required**: False + +> **Attribute Type**: Bool + +> **Description**: Generally True. + +> **Alternative Labels**: Own Anchor + +> **Default Value**: True + + +### Anchor ID +> **Input Required**: False + +> **Attribute Type**: Reference Name + +> **Description**: Anchor identity for the collection. Typically a qualified name but if display name is unique then it could be used (not recommended) + + +### Parent ID +> **Input Required**: False + +> **Attribute Type**: Reference Name + +> **Description**: Unique name of the parent element. + +> **Alternative Labels**: Parent; + + +### Parent Relationship Type Name +> **Input Required**: False + +> **Attribute Type**: Simple + +> **Description**: The kind of the relationship to the parent element. + + +### Anchor Scope Name +> **Input Required**: False + +> **Attribute Type**: Reference Name + +> **Description**: Optional qualified name of an anchor scope. + + +### Parent at End1 +> **Input Required**: False + +> **Attribute Type**: Bool + +> **Description**: Is the parent at end1 of the relationship? + +> **Default Value**: True + + +### Merge Update +> **Input Required**: False + +> **Attribute Type**: Bool + +> **Description**: If True, only those attributes specified in the update will be updated; If False, any attributes not provided during the update will be set to None. + +> **Alternative Labels**: Merge + +> **Default Value**: True + + +### Additional Properties +> **Input Required**: False + +> **Attribute Type**: Dictionary + +> **Description**: Additional user defined values organized as name value pairs in a dictionary. + +> | Parameter Name | Parameter Value | +> |---|---| +> | example_key | example_value | + + +### Supplementary Properties +> **Input Required**: False + +> **Attribute Type**: Named DICT + +> **Description**: Provide supplementary information to the element using the structure of a glossary term + +> | Parameter Name | Parameter Value | +> |---|---| +> | example_key | example_value | + + +___ diff --git a/sample-data/templates/advanced/Governance Officer/Update_License.md b/sample-data/templates/advanced/Governance Officer/Update_License.md new file mode 100644 index 00000000..a9502772 --- /dev/null +++ b/sample-data/templates/advanced/Governance Officer/Update_License.md @@ -0,0 +1,444 @@ +___ + +## Update License +> Update the properties of an existing License relationship, identified by its own relationship GUID (License GUID, as returned by Link License). + +### Label +> **Input Required**: False + +> **Attribute Type**: Simple + +> **Description**: A label used to identify or categorise a relationship link. + +> **Alternative Labels**: Wire Label + + +### License GUID +> **Input Required**: False + +> **Attribute Type**: Simple + +> **Description**: Unique identifier of the license. + + +### Licensed By +> **Input Required**: False + +> **Attribute Type**: Simple + +> **Description**: The name of the person or organization that granted the license. + + +### Licensed By Property Name +> **Input Required**: False + +> **Attribute Type**: Simple + +> **Description**: The property name used to identify the licensor element (used with Licensed By Type Name). + + +### Licensed By Type Name +> **Input Required**: False + +> **Attribute Type**: Simple + +> **Description**: The open metadata type name of the element that granted the license (used with Licensed By Property Name). + + +### Conditions +> **Input Required**: False + +> **Attribute Type**: Simple + +> **Description**: Conditions for certifications or licenses. + + +### Custodian +> **Input Required**: False + +> **Attribute Type**: Simple + +> **Description**: Custodian of the license or certification. + + +### Custodian Property Name +> **Input Required**: False + +> **Attribute Type**: Simple + +> **Description**: The property name used to identify the custodian element (used with Custodian Type Name). + + +### Custodian Type Name +> **Input Required**: False + +> **Attribute Type**: Simple + +> **Description**: The open metadata type name of the element acting as custodian (used with Custodian Property Name to identify the custodian). + + +### End Date +> **Input Required**: False + +> **Attribute Type**: Simple + +> **Description**: Date at which the license or certification expires. + + +### Licensee +> **Input Required**: False + +> **Attribute Type**: Simple + +> **Description**: The licensee. + + +### Licensee Property Name +> **Input Required**: False + +> **Attribute Type**: Simple + +> **Description**: The property name used to identify the licensee element (used with Licensee Type Name). + + +### Licensee Type Name +> **Input Required**: False + +> **Attribute Type**: Simple + +> **Description**: The open metadata type name of the element receiving the license (used with Licensee Property Name). + + +### Obligations +> **Input Required**: False + +> **Attribute Type**: Dictionary + +> **Description**: A dictionary of property:value pairs describing obligations. + +> | Parameter Name | Parameter Value | +> |---|---| +> | example_key | example_value | + + +### Start Date +> **Input Required**: False + +> **Attribute Type**: Simple + +> **Description**: Date at which the license or certification takes effect. + + +### Journal Entry +> **Input Required**: False + +> **Attribute Type**: Simple + +> **Description**: A text entry into a journal. + + +### Description +> **Input Required**: False + +> **Attribute Type**: Simple + +> **Description**: A description. + + +### Entitlements +> **Input Required**: False + +> **Attribute Type**: Dictionary + +> **Description**: A dictionary of property:value pairs describing entitlements. + +> | Parameter Name | Parameter Value | +> |---|---| +> | example_key | example_value | + + +### Restrictions +> **Input Required**: False + +> **Attribute Type**: Dictionary + +> **Description**: A dictionary of property:value pairs describing restrictions. + +> | Parameter Name | Parameter Value | +> |---|---| +> | example_key | example_value | + + +### Category +> **Input Required**: False + +> **Attribute Type**: Simple + +> **Description**: A user specified category name that can be used for example, to define product types or agreement types. + +> **Alternative Labels**: Category Name + + +### Qualified Name +> **Input Required**: False + +> **Attribute Type**: QN + +> **Description**: A unique qualified name for the element. Generated using the qualified name pattern if not user specified. + + +### Version Identifier +> **Input Required**: False + +> **Attribute Type**: Simple + +> **Description**: Published product version identifier. + +> **Alternative Labels**: Version + +> **Default Value**: 1.0 + + +### Identifier +> **Input Required**: False + +> **Attribute Type**: Simple + +> **Description**: role identifier + +> **Alternative Labels**: ID + + +### GUID +> **Input Required**: False + +> **Attribute Type**: GUID + +> **Description**: A system generated unique identifier. + +> **Alternative Labels**: Guid; guid + + +### URL +> **Input Required**: False + +> **Attribute Type**: Simple + +> **Description**: Link to supporting information + + +### Search Keywords +> **Input Required**: False + +> **Attribute Type**: Simple List + +> **Description**: Keywords to facilitate finding the element + + +### Effective From +> **Input Required**: False + +> **Attribute Type**: Simple + +> **Description**: The beginning of when an element is viewable. + + +### Effective Time +> **Input Required**: False + +> **Attribute Type**: Simple + +> **Description**: The time at which an element must be effective in order to be returned by the request. + + +### Effective To +> **Input Required**: False + +> **Attribute Type**: Simple + +> **Description**: The ending time at which an element is visible. + + +### External Source GUID +> **Input Required**: False + +> **Attribute Type**: GUID + +> **Description**: The unique identifier of an external source. + + +### External Source Name +> **Input Required**: False + +> **Attribute Type**: Simple + +> **Description**: The name of an external source + + +### For Duplicate Processing +> **Input Required**: False + +> **Attribute Type**: Bool + +> **Description**: Flag indicating if the request is to support duplicate processing. + + +### For Lineage +> **Input Required**: False + +> **Attribute Type**: Bool + +> **Description**: Flag indicating if the request is to support lineage. + + +### Request ID +> **Input Required**: False + +> **Attribute Type**: Simple + +> **Description**: A user provided or system generated request id for a conversation. + + +### Anchor Scope IDs +> **Input Required**: False + +> **Attribute Type**: Reference Name List + +> **Description**: A list of IDs that are anchor scopes for this element. + + +### Make Anchor +> **Input Required**: False + +> **Attribute Type**: Bool + +> **Description**: Is the element at end2 an anchor to end1? + +> **Default Value**: false + + +### Status +> **Input Required**: False + +> **Attribute Type**: Valid Value + +> **Description**: The status of the digital product. There is a list of valid values that this conforms to. + +> **Default Value**: ACTIVE + + +### User Defined Status +> **Input Required**: False + +> **Attribute Type**: Simple + +> **Description**: Only valid if Status is set to OTHER. User defined & managed status values. + + +### Classifications +> **Input Required**: false + +> **Attribute Type**: Named DICT + +> **Description**: Optionally specify the initial classifications for a collection. Multiple classifications can be specified. + +> **Alternative Labels**: classification + +> | Parameter Name | Parameter Value | +> |---|---| +> | example_key | example_value | + + +### Is Own Anchor +> **Input Required**: False + +> **Attribute Type**: Bool + +> **Description**: Generally True. + +> **Alternative Labels**: Own Anchor + +> **Default Value**: True + + +### Anchor ID +> **Input Required**: False + +> **Attribute Type**: Reference Name + +> **Description**: Anchor identity for the collection. Typically a qualified name but if display name is unique then it could be used (not recommended) + + +### Parent ID +> **Input Required**: False + +> **Attribute Type**: Reference Name + +> **Description**: Unique name of the parent element. + +> **Alternative Labels**: Parent; + + +### Parent Relationship Type Name +> **Input Required**: False + +> **Attribute Type**: Simple + +> **Description**: The kind of the relationship to the parent element. + + +### Anchor Scope Name +> **Input Required**: False + +> **Attribute Type**: Reference Name + +> **Description**: Optional qualified name of an anchor scope. + + +### Parent at End1 +> **Input Required**: False + +> **Attribute Type**: Bool + +> **Description**: Is the parent at end1 of the relationship? + +> **Default Value**: True + + +### Merge Update +> **Input Required**: False + +> **Attribute Type**: Bool + +> **Description**: If True, only those attributes specified in the update will be updated; If False, any attributes not provided during the update will be set to None. + +> **Alternative Labels**: Merge + +> **Default Value**: True + + +### Additional Properties +> **Input Required**: False + +> **Attribute Type**: Dictionary + +> **Description**: Additional user defined values organized as name value pairs in a dictionary. + +> | Parameter Name | Parameter Value | +> |---|---| +> | example_key | example_value | + + +### Supplementary Properties +> **Input Required**: False + +> **Attribute Type**: Named DICT + +> **Description**: Provide supplementary information to the element using the structure of a glossary term + +> | Parameter Name | Parameter Value | +> |---|---| +> | example_key | example_value | + + +___ diff --git a/tests/micro-tests/test_multilink_update_commands.py b/tests/micro-tests/test_multilink_update_commands.py new file mode 100644 index 00000000..083b7ffb --- /dev/null +++ b/tests/micro-tests/test_multilink_update_commands.py @@ -0,0 +1,168 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright Contributors to the ODPi Egeria project. +""" +New Dr.Egeria "Update " commands for MULTI_LINK types where the +OMVS layer's Update/Detach was already GUID-based but Dr.Egeria had no way to +target one specific instance for an update (ISSUE-68 follow-up). + +Before this, `Update` was never auto-generated for a Link-family compact +command (build_command_variants' LINK_VERBS has no "Update"); the only +existing example was Lineage Linker's hand-added "Update Lineage +Relationship". This adds the same pattern for Certification, License, and +NextGovernanceActionProcessStep, since each already has a GUID-based OMVS +update endpoint and an existing Dr.Egeria Link/Detach command family. + +No live server needed: fake clients capture the outgoing call. +""" +from typing import Any, cast + +import pytest + +from md_processing.v2.extraction import DrECommand +from md_processing.v2.governance import GovernanceLinkProcessor +from md_processing.v2.action_author import ActionProcessStepLinkProcessor + + +def _command(verb: str, object_type: str) -> DrECommand: + return DrECommand(verb=verb, object_type=object_type, attributes={}, + raw_block=f"# {verb} {object_type}") + + +class _FakeGovClient: + def __init__(self): + self.certify_update_calls = [] + self.license_update_calls = [] + + async def _async_update_certification(self, rel_guid, body): + self.certify_update_calls.append((rel_guid, body)) + + async def _async_update_license(self, rel_guid, body): + self.license_update_calls.append((rel_guid, body)) + + +@pytest.mark.asyncio +async def test_update_certification_targets_relationship_guid(): + client = _FakeGovClient() + p = GovernanceLinkProcessor(client=cast(Any, client), command=_command("Update", "Certification"), context={}) + p.canonical_object_type = "Certification" + p.get_command_spec = lambda: {"OM_TYPE": "Certification"} + p.parsed_output = { + "qualified_name": "Certification::test::1", + "attributes": { + "Certificate GUID": {"value": "aaaaaaaa-0000-0000-0000-000000000001"}, + "Conditions": {"value": "renewed"}, + }, + } + + result = await p.apply_changes() + + assert len(client.certify_update_calls) == 1 + rel_guid, body = client.certify_update_calls[0] + assert rel_guid == "aaaaaaaa-0000-0000-0000-000000000001" + assert body["properties"]["conditions"] == "renewed" + assert body["mergeUpdate"] is True + assert "aaaaaaaa-0000-0000-0000-000000000001" in result + + +@pytest.mark.asyncio +async def test_update_certification_without_guid_raises(): + client = _FakeGovClient() + p = GovernanceLinkProcessor(client=cast(Any, client), command=_command("Update", "Certification"), context={}) + p.canonical_object_type = "Certification" + p.get_command_spec = lambda: {"OM_TYPE": "Certification"} + p.parsed_output = {"qualified_name": "Certification::test::1", "attributes": {}} + + with pytest.raises(ValueError, match="requires the relationship GUID"): + await p.apply_changes() + + +@pytest.mark.asyncio +async def test_update_license_targets_relationship_guid(): + client = _FakeGovClient() + p = GovernanceLinkProcessor(client=cast(Any, client), command=_command("Update", "License"), context={}) + p.canonical_object_type = "License" + p.get_command_spec = lambda: {"OM_TYPE": "License"} + p.parsed_output = { + "qualified_name": "License::test::1", + "attributes": { + "License GUID": {"value": "bbbbbbbb-0000-0000-0000-000000000002"}, + "Entitlements": {"value": "read-only"}, + }, + } + + result = await p.apply_changes() + + assert len(client.license_update_calls) == 1 + rel_guid, body = client.license_update_calls[0] + assert rel_guid == "bbbbbbbb-0000-0000-0000-000000000002" + assert body["properties"]["entitlements"] == "read-only" + assert "bbbbbbbb-0000-0000-0000-000000000002" in result + + +class _FakeActionAuthorClient: + def __init__(self): + self.update_calls = [] + self.setup_calls = [] + + async def _async_update_next_action_process_step(self, relationship_guid, body): + self.update_calls.append((relationship_guid, body)) + + async def _async_setup_next_action_process_step(self, step_guid, next_step_guid, body): + self.setup_calls.append((step_guid, next_step_guid, body)) + return "cccccccc-0000-0000-0000-000000000003" + + +@pytest.mark.asyncio +async def test_update_next_process_step_targets_relationship_guid(): + client = _FakeActionAuthorClient() + p = ActionProcessStepLinkProcessor(client=cast(Any, client), command=_command("Update", "Next Process Step"), context={}) + p.get_command_spec = lambda: {"OM_TYPE": "NextGovernanceActionProcessStep"} + p.parsed_output = { + "qualified_name": "NextProcessStep::test::1", + "attributes": { + "GUID": {"value": "cccccccc-0000-0000-0000-000000000003"}, + "Guard": {"value": "SUCCESS"}, + }, + } + + result = await p.apply_changes() + + assert len(client.update_calls) == 1 + rel_guid, body = client.update_calls[0] + assert rel_guid == "cccccccc-0000-0000-0000-000000000003" + assert body["properties"]["guard"] == "SUCCESS" + assert "cccccccc-0000-0000-0000-000000000003" in result + + +@pytest.mark.asyncio +async def test_update_next_process_step_without_guid_raises(): + client = _FakeActionAuthorClient() + p = ActionProcessStepLinkProcessor(client=cast(Any, client), command=_command("Update", "Next Process Step"), context={}) + p.get_command_spec = lambda: {"OM_TYPE": "NextGovernanceActionProcessStep"} + p.parsed_output = {"qualified_name": "NextProcessStep::test::1", "attributes": {}} + + with pytest.raises(ValueError, match="no relationship GUID resolved"): + await p.apply_changes() + + +@pytest.mark.asyncio +async def test_link_next_process_step_still_displays_new_relationship_guid(): + # Regression check for the Link-branch GUID-display fix made alongside + # the new Update command (previously discarded the GUID + # _async_setup_next_action_process_step now returns). + client = _FakeActionAuthorClient() + p = ActionProcessStepLinkProcessor(client=cast(Any, client), command=_command("Link", "Next Process Step"), context={}) + p.get_command_spec = lambda: {"OM_TYPE": "NextGovernanceActionProcessStep"} + p.parsed_output = { + "qualified_name": "NextProcessStep::test::1", + "attributes": { + "Governance Action Process Step": {"guid": "step-1-guid"}, + "Next Governance Action Process Step": {"guid": "step-2-guid"}, + }, + } + + result = await p.apply_changes() + + assert len(client.setup_calls) == 1 + assert p.parsed_output["guid"] == "cccccccc-0000-0000-0000-000000000003" + assert "cccccccc-0000-0000-0000-000000000003" in result From fba7b26cfe91c0c4bce97a17ee15beba1415000a Mon Sep 17 00:00:00 2001 From: Dan Wolfson Date: Tue, 18 Aug 2026 16:59:13 +0100 Subject: [PATCH 3/3] docs(issues): ISSUE-68 - document Update commands + upsert-rewrite bug fix Extends the ISSUE-68 writeup with the SupportedGovernanceService wrapper follow-up, the 5 total MULTI_LINK types with no Egeria-side GUID-targeted detach endpoint (ValidValuesImplementation + 4 more found on re-check: MediaReference/ExternalReferenceLink/CitedDocumentLink/AgreementItem), the 3 missing-OMVS-wrapper types checked against a live server's OpenAPI spec and left unbuilt (no dedicated endpoint exists for any of them), and the new Update Certification/License/Next Process Step commands plus the AsyncBaseCommandProcessor upsert-rewrite bug they surfaced and fixed. Signed-off-by: Dan Wolfson --- PYEGERIA_ISSUES.md | 132 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 127 insertions(+), 5 deletions(-) diff --git a/PYEGERIA_ISSUES.md b/PYEGERIA_ISSUES.md index fd3b346a..5aa1f401 100644 --- a/PYEGERIA_ISSUES.md +++ b/PYEGERIA_ISSUES.md @@ -1232,7 +1232,8 @@ already had fully correct GUID-based multi-link semantics end-to-end, confirmed via `git log` (`b17f71e`) — no change needed. `AssociatedSecurityList`/`DataLineageRelationship`/`NetworkGatewayLink`/ `SupportedGovernanceService` have no OMVS wrapper implemented at all — -nothing to fix. +nothing to fix in this commit (`SupportedGovernanceService` was added in +a follow-up commit, see below). **Known discrepancy documented, not changed:** `SolutionLinkingWire` is `UNI_LINK` in the live type registry despite being treated as multi-link @@ -1242,12 +1243,133 @@ forward for new/updated detection logic; reconciling `SolutionLinkingWire`'s existing multi-link-shaped code against that is left as a follow-up, not addressed here. +**Follow-up, commit 3** (`asset_maker.py` — dwolfson asked whether the 4 +missing-wrapper types needed building): checked each of the 4 against a +*live server's OpenAPI spec* (`/v3/api-docs`), not just this repo's +cached `.http` files, since the `.http` files could themselves be stale. +Result: 3 of the 4 (`AssociatedSecurityList`, `DataLineageRelationship`, +`NetworkGatewayLink`) have **no dedicated REST endpoint anywhere** — +`AssociatedSecurityListProperties` exists only as an orphaned OpenAPI +schema referenced by zero paths, and the other two don't appear in the +spec at all. Only the generic `MetadataExpert` endpoint could create +them, which needs the verbose typed `ElementProperties`/`propertyValueMap` +body shape (see the `pyegeria/models/models.py` gotcha at the top of this +file) — a materially different, larger task than wrapping a real +dedicated endpoint, so these 3 were left unbuilt rather than guessing at +undocumented URLs or building against the awkward generic path +speculatively. Separately, `AssociatedSecurityList` **does** have an +orphaned Dr.Egeria compact command (`Link Associated List`, in +`commands_governance_officer_compact.json`) that was never registered in +`dr_egeria.py` — confirmed dead (`--process` would report "no processor +registered"), left as-is since there's still no backing wrapper to +register it against. + +The 4th, `SupportedGovernanceService`, **does** have real, dedicated, +GUID-based-for-update/detach endpoints (`Egeria-api-asset-maker.http`'s +own comment already documents it as multi-link: "the same governance +engine may call the same governance service many times... The unique +identifier of the new relationship is returned so it can be updated or +removed later") — built the full wrapper: +`_async_link_supported_governance_service`/`link_supported_governance_service` +(returns the GUID), `_async_update_supported_governance_service`/ +`update_supported_governance_service`, +`_async_detach_supported_governance_service`/ +`detach_supported_governance_service`. Live-verified routing against a +running server (fake GUIDs correctly reach `createRelatedElementsInStore` +and return 404s, not URL/shape errors). No Dr.Egeria compact command +exists for this relationship type — out of scope for this commit, which +is the OMVS wrapper only. + +**Follow-up on `ValidValuesImplementation`'s pair-only-API note above:** +re-checked all remaining implemented `MULTI_LINK` types' Detach methods +for GUID- vs pair-based targeting, and found 4 more with the same +Egeria-side limitation as `ValidValuesImplementation` — no +relationship-guid-targeted detach endpoint exists in Egeria's own REST +API at all, confirmed against the `.http` ground truth for each: +`MediaReference`, `ExternalReferenceLink`, `CitedDocumentLink` (all +`Egeria-api-external-links.http`, pair-based `elements/{elementGUID}/ +media-references|external-references|cited-document-references/ +{refGUID}/detach` only) and `AgreementItem` +(`Egeria-api-collection-manager.http` — its sibling `AgreementActor` +*does* have a GUID-based +`agreements/agreement-actors/{agreementActorRelationshipGUID}/detach`, +but `AgreementItem` only has pair-based +`agreements/{agreementGUID}/agreement-items/{agreementItemGUID}/detach`). +For these 5 types total, the create-time GUID this issue's earlier fixes +now surface is real and correctly returned, but there is currently no +API to *use* it for a later Update/Detach — that gap can only be closed +by Egeria adding the missing endpoints, not by anything in pyegeria. + +**Follow-up, Dr.Egeria `Update` commands added** (dwolfson: "do all the +Dr.Egeria upgrades"): confirmed via `build_command_variants` that `Update` +is never auto-generated for a Link-family command — `LINK_VERBS` is +`(Link, Attach, Add, Detach, Unlink, Remove)`, no `Update`; the only +family with a working `Update ` command before this was +Lineage Linker, which hand-adds a separate compact-spec entry. Added the +same pattern (new compact commands via the Spec Editor's REST API, one +new bundle each reusing existing attributes, `refresh_specs` regeneration) +for the 3 relationship types where the OMVS layer's Update is already +GUID-based *and* a Dr.Egeria Link command already exists: `Update +Certification`/`Update License` (`GovernanceLinkProcessor`, registered +explicitly — the family loop otherwise routes any non-Link verb to +`GovernanceProcessor`, the *element* processor, same override pattern as +`Create Embedded Process` above), `Update Next Process Step` +(`ActionProcessStepLinkProcessor`, auto-routed via the family loop's +existing `om_type` special-case). **`Update Agreement Actor` was +considered and dropped**: `AgreementActor`'s OMVS layer only has a +GUID-based *detach*, not update — checked `Egeria-api-collection- +manager.http` directly and Egeria has no `/agreement-actors/{guid}/ +update` endpoint at all, only `attach`/`{guid}/detach`. `CatalogTarget` +is also GUID-based but has no Dr.Egeria command at all yet — net-new +command family, treated as future work, not an "upgrade" to an existing +one. Also fixed the pre-existing gap where `Link Next Process Step` +discarded the GUID `_async_setup_next_action_process_step` now returns +(same pattern as the other Link-branch fixes above) — it's displayed the +same way the other newly-fixed create paths are. + +**Second bug found and fixed while verifying these live** (not part of +the original request, but directly blocked it): +`AsyncBaseCommandProcessor.execute()`'s step 5 — the shared Create↔Update +upsert-transition logic every processor goes through — was **not gated +by `supports_target_element_lookup()`**, unlike steps 1a/3/7 which are. +A relationship-only processor (no target Referenceable element, +`fetch_as_is()` always `None`, no `qualified_name`) fell through step 5's +"doesn't exist anywhere" branch unconditionally and had its verb silently +rewritten `Update` → `Create` — confirmed live via `--validate` on both +the 3 new commands above *and*, independently, the **pre-existing** +`Update Lineage Relationship` (shipped earlier, apparently never +exercised through `--validate`/`--process` with a complete valid +attribute set before now — with only a relationship GUID + label +supplied it fails pre-flight validation first and never reaches this +code path, which is exactly what happened the first time it was tried +here too). Fixed in two parts: `current_qn` is now read unconditionally +(it's referenced later in `execute()` regardless of the flag) but the +existence/rewrite side effects are now wrapped in `if +self.supports_target_element_lookup():`; and +`GovernanceLinkProcessor`/`ActionProcessStepLinkProcessor`/ +`LineageLinkProcessor`/`UpdateLineageRelationshipProcessor` now override +`supports_target_element_lookup()` to return `False`. **Not +comprehensively audited**: every other relationship-only processor in the +codebase (`SolutionLinkProcessor`, `CollectionLinkProcessor`, +`CurationLinkProcessor`, `ActorManagerLinkProcessor`, +`FeedbackLinkProcessor`, `TermRelationshipProcessor`, +`ProjectLinkProcessor`, ...) has this same latent exposure, but none of +them currently register an `Update`-verb command, so the bug is dormant +for them today — flagged here as a structural risk for whoever adds the +next relationship-`Update` command, not fixed preemptively. + **Tests:** `test_relationship_multiplicity.py` (7 tests, the detection utility), `test_solution_linking_wire_multilink.py` (2 tests), -`test_governance_link_multilink_guid.py` (2 tests) — all with fake -clients, no live server required. Full `pytest tests/micro-tests/` -green throughout. `relationship_multiplicity` also live-verified against -a running server. +`test_governance_link_multilink_guid.py` (2 tests), +`test_supported_governance_service.py` (3 tests), +`test_multilink_update_commands.py` (6 tests, the 3 new Update commands) +— all with fake clients, no live server required. Full `pytest +tests/micro-tests/` green throughout. `relationship_multiplicity`, +`_async_link_supported_governance_service`, and all 3 new `Update` +commands (plus the pre-existing `Update Lineage Relationship`, to +confirm the shared-code fix) also live-verified via `--validate` against +a running server — each now correctly keeps `verb=Update` through +`execute()` instead of silently becoming `Create`. ---