From f180b715d114dbcd13be10c4d650b7487c873d2a Mon Sep 17 00:00:00 2001 From: Pere Miquel Brull Date: Mon, 18 May 2026 13:05:26 +0200 Subject: [PATCH 01/16] feat(spec): add ContextMemory + CreateContextMemory JSON schemas --- .../api/context/createContextMemory.json | 100 ++++++ .../schema/entity/context/contextMemory.json | 304 ++++++++++++++++++ 2 files changed, 404 insertions(+) create mode 100644 openmetadata-spec/src/main/resources/json/schema/api/context/createContextMemory.json create mode 100644 openmetadata-spec/src/main/resources/json/schema/entity/context/contextMemory.json diff --git a/openmetadata-spec/src/main/resources/json/schema/api/context/createContextMemory.json b/openmetadata-spec/src/main/resources/json/schema/api/context/createContextMemory.json new file mode 100644 index 000000000000..4d87cea0cc07 --- /dev/null +++ b/openmetadata-spec/src/main/resources/json/schema/api/context/createContextMemory.json @@ -0,0 +1,100 @@ +{ + "$id": "https://open-metadata.org/schema/api/context/createContextMemory.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "CreateContextMemory", + "description": "Request to create a reusable Context Center memory.", + "type": "object", + "javaType": "org.openmetadata.schema.api.context.CreateContextMemory", + "javaInterfaces": ["org.openmetadata.schema.CreateEntity"], + "properties": { + "name": { + "description": "Stable system name for the memory.", + "$ref": "../../type/basic.json#/definitions/entityName" + }, + "displayName": { + "description": "Display name for the memory.", + "type": "string" + }, + "description": { + "description": "Optional markdown description for the memory.", + "$ref": "../../type/basic.json#/definitions/markdown" + }, + "title": { + "description": "Short title shown in Context Center.", + "type": "string" + }, + "summary": { + "description": "Optional summary of the memory.", + "type": "string" + }, + "question": { + "description": "Canonical question or instruction represented by this memory.", + "type": "string" + }, + "answer": { + "description": "Canonical answer or retained guidance represented by this memory.", + "type": "string" + }, + "memoryType": { + "$ref": "../../entity/context/contextMemory.json#/definitions/memoryType" + }, + "memoryScope": { + "$ref": "../../entity/context/contextMemory.json#/definitions/memoryScope" + }, + "status": { + "$ref": "../../entity/context/contextMemory.json#/definitions/memoryStatus" + }, + "shareConfig": { + "$ref": "../../entity/context/contextMemory.json#/definitions/shareConfig" + }, + "primaryEntity": { + "$ref": "../../type/entityReference.json" + }, + "relatedEntities": { + "$ref": "../../type/entityReferenceList.json" + }, + "sourceType": { + "$ref": "../../entity/context/contextMemory.json#/definitions/sourceType" + }, + "sourceConversation": { + "$ref": "../../type/basic.json#/definitions/uuid" + }, + "sourceHumanMessage": { + "$ref": "../../type/basic.json#/definitions/uuid" + }, + "sourceAssistantMessage": { + "$ref": "../../type/basic.json#/definitions/uuid" + }, + "rootMemory": { + "$ref": "../../type/entityReference.json" + }, + "parentMemory": { + "$ref": "../../type/entityReference.json" + }, + "machineRepresentation": { + "$ref": "../../entity/context/contextMemory.json#/definitions/machineRepresentation" + }, + "owners": { + "description": "Owners of this memory.", + "$ref": "../../type/entityReferenceList.json", + "default": null + }, + "tags": { + "description": "Tags associated with this memory.", + "type": "array", + "items": { + "$ref": "../../type/tagLabel.json" + }, + "default": null + }, + "domains": { + "description": "Fully qualified names of the domains this memory belongs to.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["name", "question", "answer"], + "additionalProperties": false +} diff --git a/openmetadata-spec/src/main/resources/json/schema/entity/context/contextMemory.json b/openmetadata-spec/src/main/resources/json/schema/entity/context/contextMemory.json new file mode 100644 index 000000000000..cf4984627fba --- /dev/null +++ b/openmetadata-spec/src/main/resources/json/schema/entity/context/contextMemory.json @@ -0,0 +1,304 @@ +{ + "$id": "https://open-metadata.org/schema/entity/context/contextMemory.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ContextMemory", + "$comment": "@om-entity-type", + "description": "Reusable context memory for Context Center and AI-assisted retrieval.", + "type": "object", + "javaType": "org.openmetadata.schema.entity.context.ContextMemory", + "javaInterfaces": ["org.openmetadata.schema.EntityInterface"], + "definitions": { + "memoryType": { + "javaType": "org.openmetadata.schema.entity.context.ContextMemoryType", + "description": "High-level type of reusable memory.", + "type": "string", + "enum": ["Preference", "UseCase", "Note", "Runbook", "Faq"], + "javaEnums": [ + { "name": "PREFERENCE" }, + { "name": "USE_CASE" }, + { "name": "NOTE" }, + { "name": "RUNBOOK" }, + { "name": "FAQ" } + ], + "default": "Note" + }, + "memoryScope": { + "javaType": "org.openmetadata.schema.entity.context.ContextMemoryScope", + "description": "Scope where the memory should be applied.", + "type": "string", + "enum": ["UserGlobal", "EntityScoped"], + "javaEnums": [ + { "name": "USER_GLOBAL" }, + { "name": "ENTITY_SCOPED" } + ], + "default": "EntityScoped" + }, + "memoryStatus": { + "javaType": "org.openmetadata.schema.entity.context.ContextMemoryStatus", + "description": "Lifecycle state of the memory.", + "type": "string", + "enum": ["Draft", "Active", "Archived"], + "javaEnums": [ + { "name": "DRAFT" }, + { "name": "ACTIVE" }, + { "name": "ARCHIVED" } + ], + "default": "Active" + }, + "sourceType": { + "javaType": "org.openmetadata.schema.entity.context.ContextMemorySourceType", + "description": "How the memory was created.", + "type": "string", + "enum": ["Manual", "ChatPromotion", "RememberRequest"], + "javaEnums": [ + { "name": "MANUAL" }, + { "name": "CHAT_PROMOTION" }, + { "name": "REMEMBER_REQUEST" } + ], + "default": "Manual" + }, + "shareVisibility": { + "javaType": "org.openmetadata.schema.entity.context.MemoryVisibility", + "description": "Visibility level for the memory.", + "type": "string", + "enum": ["Private", "Entity", "Shared"], + "javaEnums": [ + { "name": "PRIVATE" }, + { "name": "ENTITY" }, + { "name": "SHARED" } + ], + "default": "Private" + }, + "shareRole": { + "javaType": "org.openmetadata.schema.entity.context.MemoryShareRole", + "description": "Role granted to a shared principal.", + "type": "string", + "enum": ["Viewer", "Editor"], + "javaEnums": [ + { "name": "VIEWER" }, + { "name": "EDITOR" } + ], + "default": "Viewer" + }, + "sharedPrincipal": { + "javaType": "org.openmetadata.schema.entity.context.MemorySharedPrincipal", + "description": "A principal granted access to the memory.", + "type": "object", + "properties": { + "principal": { + "description": "Principal receiving access. Supported principal types are user, team, and domain.", + "$ref": "../../type/entityReference.json" + }, + "role": { + "description": "Role granted to the principal.", + "$ref": "#/definitions/shareRole" + } + }, + "additionalProperties": false + }, + "shareConfig": { + "javaType": "org.openmetadata.schema.entity.context.MemoryShareConfig", + "description": "Visibility and sharing configuration for the memory.", + "type": "object", + "properties": { + "visibility": { + "$ref": "#/definitions/shareVisibility" + }, + "sharedWith": { + "description": "Explicit principals the memory is shared with.", + "type": "array", + "items": { + "$ref": "#/definitions/sharedPrincipal" + }, + "default": [] + } + }, + "additionalProperties": false + }, + "machineRepresentationStatus": { + "javaType": "org.openmetadata.schema.entity.context.MachineRepresentationStatus", + "description": "Availability state of the machine-oriented representation.", + "type": "string", + "enum": ["Pending", "Ready", "Stale", "Failed"], + "javaEnums": [ + { "name": "PENDING" }, + { "name": "READY" }, + { "name": "STALE" }, + { "name": "FAILED" } + ], + "default": "Pending" + }, + "machineRepresentation": { + "javaType": "org.openmetadata.schema.entity.context.ContextMemoryRepresentation", + "description": "Optional machine-oriented representation used for prompt packing.", + "type": "object", + "properties": { + "format": { + "description": "Representation format identifier.", + "type": "string" + }, + "version": { + "description": "Version of the representation format.", + "type": "string" + }, + "content": { + "description": "Compressed or transformed memory content.", + "type": "string" + }, + "generatedFromHash": { + "description": "Hash of the canonical source content used to generate this representation.", + "type": "string" + }, + "generatedAt": { + "description": "Timestamp when the representation was generated.", + "$ref": "../../type/basic.json#/definitions/timestamp" + }, + "status": { + "$ref": "#/definitions/machineRepresentationStatus" + } + }, + "additionalProperties": false + } + }, + "properties": { + "id": { + "description": "Unique identifier of the memory.", + "$ref": "../../type/basic.json#/definitions/uuid" + }, + "name": { + "description": "Stable system name for the memory.", + "$ref": "../../type/basic.json#/definitions/entityName" + }, + "fullyQualifiedName": { + "description": "Fully qualified name of the memory.", + "$ref": "../../type/basic.json#/definitions/fullyQualifiedEntityName" + }, + "displayName": { + "description": "Display name of the memory.", + "type": "string" + }, + "description": { + "description": "Optional markdown description for the memory.", + "$ref": "../../type/basic.json#/definitions/markdown" + }, + "title": { + "description": "Short title shown in Context Center.", + "type": "string" + }, + "summary": { + "description": "Optional summary of the memory.", + "type": "string" + }, + "question": { + "description": "Canonical question or instruction represented by this memory.", + "type": "string" + }, + "answer": { + "description": "Canonical answer or retained guidance represented by this memory.", + "type": "string" + }, + "memoryType": { + "$ref": "#/definitions/memoryType" + }, + "memoryScope": { + "$ref": "#/definitions/memoryScope" + }, + "status": { + "$ref": "#/definitions/memoryStatus" + }, + "shareConfig": { + "$ref": "#/definitions/shareConfig" + }, + "primaryEntity": { + "description": "Primary entity this memory should attach to for reuse.", + "$ref": "../../type/entityReference.json" + }, + "relatedEntities": { + "description": "Additional related entities this memory applies to.", + "$ref": "../../type/entityReferenceList.json" + }, + "sourceType": { + "$ref": "#/definitions/sourceType" + }, + "sourceConversation": { + "description": "Conversation identifier that produced this memory.", + "$ref": "../../type/basic.json#/definitions/uuid" + }, + "sourceHumanMessage": { + "description": "Human message identifier used to produce this memory.", + "$ref": "../../type/basic.json#/definitions/uuid" + }, + "sourceAssistantMessage": { + "description": "Assistant message identifier used to produce this memory.", + "$ref": "../../type/basic.json#/definitions/uuid" + }, + "rootMemory": { + "description": "Root memory in an append-style memory thread.", + "$ref": "../../type/entityReference.json" + }, + "parentMemory": { + "description": "Immediate parent memory in an append-style thread.", + "$ref": "../../type/entityReference.json" + }, + "machineRepresentation": { + "$ref": "#/definitions/machineRepresentation" + }, + "usageCount": { + "description": "How many times this memory has been used in AI-assisted retrieval.", + "type": "integer", + "default": 0 + }, + "lastUsedAt": { + "description": "Last time the memory was used by AI-assisted retrieval.", + "$ref": "../../type/basic.json#/definitions/timestamp" + }, + "owners": { + "description": "Owners of this memory.", + "$ref": "../../type/entityReferenceList.json", + "default": null + }, + "tags": { + "description": "Tags associated with this memory.", + "type": "array", + "items": { + "$ref": "../../type/tagLabel.json" + }, + "default": null + }, + "domains": { + "description": "Domains this memory belongs to.", + "$ref": "../../type/entityReferenceList.json" + }, + "version": { + "description": "Metadata version of the entity.", + "$ref": "../../type/entityHistory.json#/definitions/entityVersion" + }, + "updatedAt": { + "description": "Last update time in Unix epoch time milliseconds.", + "$ref": "../../type/basic.json#/definitions/timestamp" + }, + "updatedBy": { + "description": "User who made the update.", + "type": "string" + }, + "href": { + "description": "Link to this resource.", + "$ref": "../../type/basic.json#/definitions/href" + }, + "changeDescription": { + "description": "Change that led to this version.", + "$ref": "../../type/entityHistory.json#/definitions/changeDescription" + }, + "incrementalChangeDescription": { + "description": "Incremental change that led to this version.", + "$ref": "../../type/entityHistory.json#/definitions/changeDescription" + }, + "deleted": { + "description": "When true indicates the entity has been soft deleted.", + "type": "boolean", + "default": false + } + }, + "required": ["id", "name"], + "additionalProperties": false +} From 9b58623d57b4f6f48875ee2fbf095fdb1353ae7d Mon Sep 17 00:00:00 2001 From: Pere Miquel Brull Date: Mon, 18 May 2026 13:05:55 +0200 Subject: [PATCH 02/16] feat(jdbi3): add ContextMemoryDAO --- .../service/jdbi3/CollectionDAO.java | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/CollectionDAO.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/CollectionDAO.java index 2f537f309291..8d232542ff85 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/CollectionDAO.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/CollectionDAO.java @@ -124,6 +124,7 @@ import org.openmetadata.schema.entity.data.Topic; import org.openmetadata.schema.entity.data.Worksheet; import org.openmetadata.schema.entity.domains.DataProduct; +import org.openmetadata.schema.entity.context.ContextMemory; import org.openmetadata.schema.entity.domains.Domain; import org.openmetadata.schema.entity.events.EventSubscription; import org.openmetadata.schema.entity.events.FailedEvent; @@ -460,6 +461,9 @@ public interface CollectionDAO { @CreateSqlObject LearningResourceDAO learningResourceDAO(); + @CreateSqlObject + ContextMemoryDAO contextMemoryDAO(); + @CreateSqlObject SuggestionDAO suggestionDAO(); @@ -10806,6 +10810,23 @@ default String getNameHashColumn() { } } + interface ContextMemoryDAO extends EntityDAO { + @Override + default String getTableName() { + return "context_memory"; + } + + @Override + default Class getEntityClass() { + return ContextMemory.class; + } + + @Override + default String getNameHashColumn() { + return "nameHash"; + } + } + interface SuggestionDAO { default String getTableName() { return "suggestions"; From 7589aec4284848d27e18c4ac100bcc2a9aeb3c4a Mon Sep 17 00:00:00 2001 From: Pere Miquel Brull Date: Mon, 18 May 2026 13:06:06 +0200 Subject: [PATCH 03/16] feat: register contextMemory entity type constant --- .../src/main/java/org/openmetadata/service/Entity.java | 1 + 1 file changed, 1 insertion(+) diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/Entity.java b/openmetadata-service/src/main/java/org/openmetadata/service/Entity.java index fbfdaae9202e..a190f2cdff49 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/Entity.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/Entity.java @@ -310,6 +310,7 @@ public final class Entity { public static final String DOCUMENT = "document"; public static final String LEARNING_RESOURCE = "learningResource"; + public static final String CONTEXT_MEMORY = "contextMemory"; // ServiceType - Service Entity name map static final Map SERVICE_TYPE_ENTITY_MAP = new EnumMap<>(ServiceType.class); // entity type to service entity name map From 8843e5e2e588f3031a1fd686c54ac490b4df91b5 Mon Sep 17 00:00:00 2001 From: Pere Miquel Brull Date: Mon, 18 May 2026 13:16:11 +0200 Subject: [PATCH 04/16] feat(service): add ContextMemory repository, resource, mapper --- .../service/jdbi3/CollectionDAO.java | 2 +- .../jdbi3/ContextMemoryRepository.java | 228 ++++++++++ .../context/ContextMemoryMapper.java | 68 +++ .../context/ContextMemoryResource.java | 414 ++++++++++++++++++ 4 files changed, 711 insertions(+), 1 deletion(-) create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/ContextMemoryRepository.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/resources/context/ContextMemoryMapper.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/resources/context/ContextMemoryResource.java diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/CollectionDAO.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/CollectionDAO.java index 8d232542ff85..ca13bcd4cbfa 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/CollectionDAO.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/CollectionDAO.java @@ -99,6 +99,7 @@ import org.openmetadata.schema.entity.automations.Workflow; import org.openmetadata.schema.entity.classification.Classification; import org.openmetadata.schema.entity.classification.Tag; +import org.openmetadata.schema.entity.context.ContextMemory; import org.openmetadata.schema.entity.data.APICollection; import org.openmetadata.schema.entity.data.APIEndpoint; import org.openmetadata.schema.entity.data.Chart; @@ -124,7 +125,6 @@ import org.openmetadata.schema.entity.data.Topic; import org.openmetadata.schema.entity.data.Worksheet; import org.openmetadata.schema.entity.domains.DataProduct; -import org.openmetadata.schema.entity.context.ContextMemory; import org.openmetadata.schema.entity.domains.Domain; import org.openmetadata.schema.entity.events.EventSubscription; import org.openmetadata.schema.entity.events.FailedEvent; diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/ContextMemoryRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/ContextMemoryRepository.java new file mode 100644 index 000000000000..c71ecf34600e --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/ContextMemoryRepository.java @@ -0,0 +1,228 @@ +/* + * Copyright 2024 Collate + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.openmetadata.service.jdbi3; + +import static org.openmetadata.common.utils.CommonUtil.listOrEmpty; + +import jakarta.ws.rs.BadRequestException; +import java.util.Map; +import java.util.Set; +import lombok.extern.slf4j.Slf4j; +import org.openmetadata.schema.entity.context.ContextMemory; +import org.openmetadata.schema.entity.context.ContextMemoryStatus; +import org.openmetadata.schema.type.EntityReference; +import org.openmetadata.schema.type.Include; +import org.openmetadata.schema.type.Relationship; +import org.openmetadata.schema.type.change.ChangeSource; +import org.openmetadata.service.Entity; +import org.openmetadata.service.resources.context.ContextMemoryResource; +import org.openmetadata.service.util.EntityUtil; +import org.openmetadata.service.util.EntityUtil.Fields; +import org.openmetadata.service.util.EntityUtil.RelationIncludes; +import org.openmetadata.service.util.FullyQualifiedName; + +@Slf4j +@Repository(name = "ContextMemoryRepository") +public class ContextMemoryRepository extends EntityRepository { + public static final String CONTEXT_MEMORY_ENTITY = Entity.CONTEXT_MEMORY; + + public ContextMemoryRepository() { + super( + ContextMemoryResource.COLLECTION_PATH, + Entity.CONTEXT_MEMORY, + ContextMemory.class, + Entity.getCollectionDAO().contextMemoryDAO(), + "", + ""); + supportsSearch = false; + } + + @Override + protected void setFields(ContextMemory entity, Fields fields, RelationIncludes relationIncludes) { + // ContextMemory stores its fields in the entity JSON for now. + } + + @Override + protected void clearFields(ContextMemory entity, Fields fields) { + // ContextMemory stores its fields in the entity JSON for now. + } + + @Override + public void setFullyQualifiedName(ContextMemory entity) { + if (entity.getPrimaryEntity() != null + && entity.getPrimaryEntity().getFullyQualifiedName() != null + && !entity.getPrimaryEntity().getFullyQualifiedName().isEmpty()) { + entity.setFullyQualifiedName( + FullyQualifiedName.add( + entity.getPrimaryEntity().getFullyQualifiedName(), entity.getName())); + return; + } + if (entity.getOwners() != null + && !entity.getOwners().isEmpty() + && entity.getOwners().get(0).getName() != null + && !entity.getOwners().get(0).getName().isEmpty()) { + entity.setFullyQualifiedName( + FullyQualifiedName.add(entity.getOwners().get(0).getName(), entity.getName())); + return; + } + entity.setFullyQualifiedName(entity.getName()); + } + + @Override + public void prepare(ContextMemory entity, boolean update) { + if (entity.getPrimaryEntity() != null) { + EntityReference primaryEntity = + Entity.getEntityReference(entity.getPrimaryEntity(), Include.NON_DELETED); + entity.setPrimaryEntity(primaryEntity); + } + EntityUtil.populateEntityReferences(entity.getRelatedEntities()); + + if (entity.getRootMemory() != null) { + ContextMemory rootMemory = Entity.getEntity(entity.getRootMemory(), "", Include.NON_DELETED); + entity.setRootMemory(rootMemory.getEntityReference()); + } + if (entity.getParentMemory() != null) { + ContextMemory parentMemory = + Entity.getEntity(entity.getParentMemory(), "", Include.NON_DELETED); + entity.setParentMemory(parentMemory.getEntityReference()); + } + if (entity.getShareConfig() != null && entity.getShareConfig().getSharedWith() != null) { + entity + .getShareConfig() + .getSharedWith() + .forEach( + sharedPrincipal -> { + if (sharedPrincipal.getPrincipal() != null) { + EntityReference principal = + Entity.getEntityReference( + sharedPrincipal.getPrincipal(), Include.NON_DELETED); + sharedPrincipal.setPrincipal(principal); + } + }); + } + } + + @Override + public void storeEntity(ContextMemory entity, boolean update) { + store(entity, update); + } + + @Override + public void storeRelationships(ContextMemory entity) { + if (entity.getPrimaryEntity() != null) { + addRelationship( + entity.getPrimaryEntity().getId(), + entity.getId(), + entity.getPrimaryEntity().getType(), + CONTEXT_MEMORY_ENTITY, + Relationship.HAS); + } + + for (var relatedEntity : listOrEmpty(entity.getRelatedEntities())) { + addRelationship( + relatedEntity.getId(), + entity.getId(), + relatedEntity.getType(), + CONTEXT_MEMORY_ENTITY, + Relationship.RELATED_TO); + } + + if (entity.getRootMemory() != null) { + addRelationship( + entity.getRootMemory().getId(), + entity.getId(), + CONTEXT_MEMORY_ENTITY, + CONTEXT_MEMORY_ENTITY, + Relationship.RELATED_TO); + } + + if (entity.getParentMemory() != null) { + addRelationship( + entity.getParentMemory().getId(), + entity.getId(), + CONTEXT_MEMORY_ENTITY, + CONTEXT_MEMORY_ENTITY, + Relationship.RELATED_TO); + } + } + + // ------------------------------------------------------------------ + // Lifecycle enforcement + // ------------------------------------------------------------------ + + /** + * Valid status transitions: + * DRAFT → ACTIVE + * DRAFT → ARCHIVED + * ACTIVE → ARCHIVED + * ARCHIVED → ACTIVE (re-activate) + * + * Invalid: + * ARCHIVED → DRAFT (cannot revert to draft) + * ACTIVE → DRAFT (cannot revert to draft) + */ + private static final Map> VALID_TRANSITIONS = + Map.of( + ContextMemoryStatus.DRAFT, + Set.of(ContextMemoryStatus.ACTIVE, ContextMemoryStatus.ARCHIVED), + ContextMemoryStatus.ACTIVE, Set.of(ContextMemoryStatus.ARCHIVED), + ContextMemoryStatus.ARCHIVED, Set.of(ContextMemoryStatus.ACTIVE)); + + /** Validate that a status transition is allowed. */ + public static void validateStatusTransition(ContextMemoryStatus from, ContextMemoryStatus to) { + if (from == to) { + return; // No change + } + Set allowed = VALID_TRANSITIONS.get(from); + if (allowed == null || !allowed.contains(to)) { + throw new BadRequestException( + String.format( + "Invalid memory status transition from %s to %s. Allowed transitions from %s: %s", + from.value(), to.value(), from.value(), allowed)); + } + } + + @Override + public EntityUpdater getUpdater( + ContextMemory original, ContextMemory updated, Operation operation, ChangeSource source) { + return new ContextMemoryUpdater(original, updated, operation); + } + + public class ContextMemoryUpdater extends EntityUpdater { + public ContextMemoryUpdater( + ContextMemory original, ContextMemory updated, Operation operation) { + super(original, updated, operation); + } + + @Override + public void entitySpecificUpdate(boolean consolidatingChanges) { + recordChange("title", original.getTitle(), updated.getTitle()); + recordChange("summary", original.getSummary(), updated.getSummary()); + recordChange("question", original.getQuestion(), updated.getQuestion()); + recordChange("answer", original.getAnswer(), updated.getAnswer()); + recordChange("memoryType", original.getMemoryType(), updated.getMemoryType()); + recordChange("memoryScope", original.getMemoryScope(), updated.getMemoryScope()); + + // Validate lifecycle transition before recording status change + if (original.getStatus() != null + && updated.getStatus() != null + && original.getStatus() != updated.getStatus()) { + validateStatusTransition(original.getStatus(), updated.getStatus()); + } + recordChange("status", original.getStatus(), updated.getStatus()); + + recordChange("shareConfig", original.getShareConfig(), updated.getShareConfig()); + } + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/context/ContextMemoryMapper.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/context/ContextMemoryMapper.java new file mode 100644 index 000000000000..84590e0604e3 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/context/ContextMemoryMapper.java @@ -0,0 +1,68 @@ +/* + * Copyright 2024 Collate + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.openmetadata.service.resources.context; + +import static org.openmetadata.common.utils.CommonUtil.nullOrEmpty; +import static org.openmetadata.service.Entity.getEntityReferenceByName; + +import java.util.List; +import org.openmetadata.schema.api.context.CreateContextMemory; +import org.openmetadata.schema.entity.context.ContextMemory; +import org.openmetadata.schema.type.EntityReference; +import org.openmetadata.schema.type.Include; +import org.openmetadata.service.Entity; +import org.openmetadata.service.mapper.EntityMapper; + +public class ContextMemoryMapper implements EntityMapper { + @Override + public ContextMemory createToEntity(CreateContextMemory create, String user) { + return copy(new ContextMemory(), create, user) + .withDisplayName(create.getDisplayName()) + .withDescription(create.getDescription()) + .withTitle(create.getTitle()) + .withSummary(create.getSummary()) + .withQuestion(create.getQuestion()) + .withAnswer(create.getAnswer()) + .withMemoryType(create.getMemoryType()) + .withMemoryScope(create.getMemoryScope()) + .withStatus(create.getStatus()) + .withShareConfig(create.getShareConfig()) + .withPrimaryEntity(create.getPrimaryEntity()) + .withRelatedEntities(create.getRelatedEntities()) + .withSourceType(create.getSourceType()) + .withSourceConversation(create.getSourceConversation()) + .withSourceHumanMessage(create.getSourceHumanMessage()) + .withSourceAssistantMessage(create.getSourceAssistantMessage()) + .withRootMemory(create.getRootMemory()) + .withParentMemory(create.getParentMemory()) + .withMachineRepresentation(create.getMachineRepresentation()) + .withOwners(defaultOwners(create.getOwners(), user)) + .withTags(create.getTags()) + .withDomains( + nullOrEmpty(create.getDomains()) + ? null + : create.getDomains().stream() + .map( + domain -> + getEntityReferenceByName(Entity.DOMAIN, domain, Include.NON_DELETED)) + .toList()); + } + + private List defaultOwners(List owners, String user) { + if (!nullOrEmpty(owners)) { + return owners; + } + return List.of(getEntityReferenceByName(Entity.USER, user, Include.NON_DELETED)); + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/context/ContextMemoryResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/context/ContextMemoryResource.java new file mode 100644 index 000000000000..9ba868a9bc19 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/context/ContextMemoryResource.java @@ -0,0 +1,414 @@ +/* + * Copyright 2024 Collate + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.openmetadata.service.resources.context; + +import io.swagger.v3.oas.annotations.ExternalDocumentation; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.parameters.RequestBody; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.json.JsonPatch; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.DefaultValue; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.PATCH; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.SecurityContext; +import jakarta.ws.rs.core.UriInfo; +import java.util.List; +import java.util.UUID; +import lombok.extern.slf4j.Slf4j; +import org.openmetadata.schema.api.context.CreateContextMemory; +import org.openmetadata.schema.api.data.RestoreEntity; +import org.openmetadata.schema.entity.context.ContextMemory; +import org.openmetadata.schema.type.EntityHistory; +import org.openmetadata.schema.type.Include; +import org.openmetadata.schema.type.MetadataOperation; +import org.openmetadata.schema.utils.ResultList; +import org.openmetadata.service.Entity; +import org.openmetadata.service.jdbi3.ContextMemoryRepository; +import org.openmetadata.service.jdbi3.ListFilter; +import org.openmetadata.service.limits.Limits; +import org.openmetadata.service.resources.Collection; +import org.openmetadata.service.resources.EntityResource; +import org.openmetadata.service.security.Authorizer; + +@Slf4j +@Tag(name = "Context Memories", description = "APIs for managing reusable Context Center memories.") +@Path("/v1/contextCenter/memories") +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +@Collection(name = "contextMemories") +public class ContextMemoryResource extends EntityResource { + public static final String COLLECTION_PATH = "v1/contextCenter/memories/"; + public static final String FIELDS = "owners,tags,domains"; + + private final ContextMemoryMapper mapper = new ContextMemoryMapper(); + + public ContextMemoryResource(Authorizer authorizer, Limits limits) { + super(Entity.CONTEXT_MEMORY, authorizer, limits); + } + + public static class ContextMemoryList extends ResultList { + /* Required for serde */ + } + + @Override + protected List getEntitySpecificOperations() { + return null; + } + + @Override + public ContextMemory addHref(UriInfo uriInfo, ContextMemory memory) { + super.addHref(uriInfo, memory); + Entity.withHref(uriInfo, memory.getPrimaryEntity()); + Entity.withHref(uriInfo, memory.getRelatedEntities()); + Entity.withHref(uriInfo, memory.getRootMemory()); + Entity.withHref(uriInfo, memory.getParentMemory()); + return memory; + } + + @GET + @Operation( + operationId = "listContextMemories", + summary = "List context memories", + description = "Get a paginated list of context memories.", + responses = { + @ApiResponse( + responseCode = "200", + description = "List of context memories", + content = + @Content( + mediaType = "application/json", + schema = @Schema(implementation = ContextMemoryList.class))) + }) + public ResultList list( + @Context UriInfo uriInfo, + @Context SecurityContext securityContext, + @Parameter( + description = "Fields requested in the returned resource", + schema = @Schema(type = "string", example = FIELDS)) + @QueryParam("fields") + String fieldsParam, + @Parameter(description = "Limit the number of results returned. (1 to 1000000, default = 10)") + @DefaultValue("10") + @Min(0) + @Max(1000000) + @QueryParam("limit") + int limitParam, + @Parameter(description = "Returns list of context memories before this cursor") + @QueryParam("before") + String before, + @Parameter(description = "Returns list of context memories after this cursor") + @QueryParam("after") + String after, + @Parameter( + description = "Include all, deleted, or non-deleted entities", + schema = @Schema(implementation = Include.class)) + @QueryParam("include") + @DefaultValue("non-deleted") + Include include) { + return addHref( + uriInfo, + listInternal( + uriInfo, + securityContext, + fieldsParam, + new ListFilter(include), + limitParam, + before, + after)); + } + + @GET + @Path("/{id}") + @Operation( + operationId = "getContextMemory", + summary = "Get a memory by id", + description = "Get a context memory by `id`.", + responses = { + @ApiResponse( + responseCode = "200", + description = "The context memory", + content = + @Content( + mediaType = "application/json", + schema = @Schema(implementation = ContextMemory.class))), + @ApiResponse(responseCode = "404", description = "Memory not found") + }) + public ContextMemory get( + @Context UriInfo uriInfo, + @Context SecurityContext securityContext, + @Parameter(description = "Id of the context memory", schema = @Schema(type = "UUID")) + @PathParam("id") + UUID id, + @Parameter(description = "Fields requested in the returned resource") @QueryParam("fields") + String fieldsParam, + @Parameter(description = "Include all, deleted, or non-deleted entities") + @QueryParam("include") + @DefaultValue("non-deleted") + Include include) { + return getInternal(uriInfo, securityContext, id, fieldsParam, include); + } + + @GET + @Path("/name/{fqn}") + @Operation( + operationId = "getContextMemoryByFqn", + summary = "Get a memory by fully qualified name", + description = "Get a context memory by fully qualified name.", + responses = { + @ApiResponse( + responseCode = "200", + description = "The context memory", + content = + @Content( + mediaType = "application/json", + schema = @Schema(implementation = ContextMemory.class))), + @ApiResponse(responseCode = "404", description = "Memory not found") + }) + public ContextMemory getByName( + @Context UriInfo uriInfo, + @Context SecurityContext securityContext, + @Parameter(description = "Fully qualified name of the context memory") @PathParam("fqn") + String fqn, + @Parameter(description = "Fields requested in the returned resource") @QueryParam("fields") + String fieldsParam, + @Parameter(description = "Include deleted memories") + @QueryParam("include") + @DefaultValue("non-deleted") + Include include) { + return getByNameInternal(uriInfo, securityContext, fqn, fieldsParam, include); + } + + @GET + @Path("/{id}/versions") + @Operation( + operationId = "listAllContextMemoryVersions", + summary = "List context memory versions", + description = "Get a list of all the versions of a context memory identified by `id`.", + responses = { + @ApiResponse( + responseCode = "200", + description = "List of versions", + content = + @Content( + mediaType = "application/json", + schema = @Schema(implementation = EntityHistory.class))) + }) + public EntityHistory listVersions( + @Context SecurityContext securityContext, + @Parameter(description = "Id of the context memory", schema = @Schema(type = "UUID")) + @PathParam("id") + UUID id) { + return listVersionsInternal(securityContext, id); + } + + @GET + @Path("/{id}/versions/{version}") + @Operation( + operationId = "getSpecificContextMemoryVersion", + summary = "Get a version of a context memory", + description = "Get a version of a context memory by given `id`.", + responses = { + @ApiResponse( + responseCode = "200", + description = "Context memory version details", + content = + @Content( + mediaType = "application/json", + schema = @Schema(implementation = ContextMemory.class))) + }) + public ContextMemory getVersion( + @Context SecurityContext securityContext, + @Parameter(description = "Id of the context memory", schema = @Schema(type = "UUID")) + @PathParam("id") + UUID id, + @Parameter(description = "Context memory version", schema = @Schema(type = "string")) + @PathParam("version") + String version) { + return getVersionInternal(securityContext, id, version); + } + + @POST + @Operation( + operationId = "createContextMemory", + summary = "Create a memory", + description = "Create a new context memory.", + responses = { + @ApiResponse( + responseCode = "200", + description = "The created memory", + content = + @Content( + mediaType = "application/json", + schema = @Schema(implementation = ContextMemory.class))) + }) + public Response create( + @Context UriInfo uriInfo, + @Context SecurityContext securityContext, + @Valid CreateContextMemory create) { + ContextMemory memory = + mapper.createToEntity(create, securityContext.getUserPrincipal().getName()); + return create(uriInfo, securityContext, memory); + } + + @PUT + @Operation( + operationId = "createOrUpdateContextMemory", + summary = "Create or update a memory", + description = "Create a new context memory, or update an existing one if it already exists.", + responses = { + @ApiResponse( + responseCode = "200", + description = "The updated memory", + content = + @Content( + mediaType = "application/json", + schema = @Schema(implementation = ContextMemory.class))) + }) + public Response createOrUpdate( + @Context UriInfo uriInfo, + @Context SecurityContext securityContext, + @Valid CreateContextMemory create) { + ContextMemory memory = + mapper.createToEntity(create, securityContext.getUserPrincipal().getName()); + return createOrUpdate(uriInfo, securityContext, memory); + } + + @PATCH + @Path("/{id}") + @Consumes(MediaType.APPLICATION_JSON_PATCH_JSON) + @Operation( + operationId = "patchContextMemory", + summary = "Update a memory", + description = "Apply a JSONPatch to a context memory.", + externalDocs = + @ExternalDocumentation( + description = "JsonPatch RFC", + url = "https://tools.ietf.org/html/rfc6902")) + public Response patch( + @Context UriInfo uriInfo, + @Context SecurityContext securityContext, + @Parameter(description = "Id of the context memory", schema = @Schema(type = "UUID")) + @PathParam("id") + UUID id, + @RequestBody( + description = "JsonPatch with array of operations", + content = + @Content( + mediaType = MediaType.APPLICATION_JSON_PATCH_JSON, + examples = + @ExampleObject("[{op:replace, path:/displayName, value: 'New name'}]"))) + JsonPatch patch) { + return patchInternal(uriInfo, securityContext, id, patch); + } + + @DELETE + @Path("/{id}") + @Operation( + operationId = "deleteContextMemory", + summary = "Delete a memory by id", + description = "Delete a context memory by `id`.", + responses = { + @ApiResponse(responseCode = "200", description = "OK"), + @ApiResponse(responseCode = "404", description = "Memory not found") + }) + public Response delete( + @Context UriInfo uriInfo, + @Context SecurityContext securityContext, + @Parameter(description = "Recursively delete this entity and its children. (Default = false)") + @DefaultValue("false") + @QueryParam("recursive") + boolean recursive, + @Parameter(description = "Hard delete the entity. (Default = false)") + @DefaultValue("false") + @QueryParam("hardDelete") + boolean hardDelete, + @Parameter(description = "Id of the context memory", schema = @Schema(type = "UUID")) + @PathParam("id") + UUID id) { + return delete(uriInfo, securityContext, id, recursive, hardDelete); + } + + @DELETE + @Path("/name/{fqn}") + @Operation( + operationId = "deleteContextMemoryByFqn", + summary = "Delete a memory by fully qualified name", + description = "Delete a context memory by `fullyQualifiedName`.", + responses = { + @ApiResponse(responseCode = "200", description = "OK"), + @ApiResponse(responseCode = "404", description = "Memory not found") + }) + public Response deleteByFqn( + @Context UriInfo uriInfo, + @Context SecurityContext securityContext, + @Parameter(description = "Recursively delete this entity and its children. (Default = false)") + @DefaultValue("false") + @QueryParam("recursive") + boolean recursive, + @Parameter(description = "Hard delete the entity. (Default = false)") + @DefaultValue("false") + @QueryParam("hardDelete") + boolean hardDelete, + @Parameter(description = "Fully qualified name of the context memory") @PathParam("fqn") + String fqn) { + return deleteByName(uriInfo, securityContext, fqn, recursive, hardDelete); + } + + @PUT + @Path("/restore") + @Operation( + operationId = "restoreContextMemory", + summary = "Restore a soft-deleted memory", + description = "Restore a previously soft-deleted context memory.", + responses = { + @ApiResponse( + responseCode = "200", + description = "The restored memory", + content = + @Content( + mediaType = "application/json", + schema = @Schema(implementation = ContextMemory.class))) + }) + public Response restore( + @Context UriInfo uriInfo, + @Context SecurityContext securityContext, + @RequestBody( + description = "Id of the context memory to restore", + content = + @Content( + mediaType = "application/json", + schema = @Schema(type = "string", format = "uuid"))) + RestoreEntity restore) { + return restoreEntity(uriInfo, securityContext, restore.getId()); + } +} From 21f2878d5bebf19f18765dfcc297afb600917f40 Mon Sep 17 00:00:00 2001 From: Pere Miquel Brull Date: Mon, 18 May 2026 13:16:46 +0200 Subject: [PATCH 05/16] feat(bootstrap): add context_memory table DDL --- .../native/2.0.1/mysql/schemaChanges.sql | 15 +++++++++++++++ .../native/2.0.1/postgres/schemaChanges.sql | 15 +++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/bootstrap/sql/migrations/native/2.0.1/mysql/schemaChanges.sql b/bootstrap/sql/migrations/native/2.0.1/mysql/schemaChanges.sql index 89cb6ad9374a..1fa3cd9ba4c3 100644 --- a/bootstrap/sql/migrations/native/2.0.1/mysql/schemaChanges.sql +++ b/bootstrap/sql/migrations/native/2.0.1/mysql/schemaChanges.sql @@ -9,3 +9,18 @@ CREATE TABLE IF NOT EXISTS task_migration_mapping ( PRIMARY KEY (old_thread_id), KEY idx_task_migration_mapping_new_task_id (new_task_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +-- ContextMemory entity - reusable Context Center memory. +CREATE TABLE IF NOT EXISTS context_memory ( + id VARCHAR(36) GENERATED ALWAYS AS (json ->> '$.id') STORED NOT NULL, + name VARCHAR(256) GENERATED ALWAYS AS (json ->> '$.name') STORED NOT NULL, + nameHash VARCHAR(256) NOT NULL COLLATE ascii_bin, + json JSON NOT NULL, + updatedAt BIGINT UNSIGNED GENERATED ALWAYS AS (json ->> '$.updatedAt') STORED NOT NULL, + updatedBy VARCHAR(256) GENERATED ALWAYS AS (json ->> '$.updatedBy') STORED NOT NULL, + deleted BOOLEAN GENERATED ALWAYS AS (json -> '$.deleted') STORED, + + PRIMARY KEY (id), + UNIQUE KEY unique_context_memory_name (nameHash), + INDEX idx_context_memory_updated_at (updatedAt) +) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; diff --git a/bootstrap/sql/migrations/native/2.0.1/postgres/schemaChanges.sql b/bootstrap/sql/migrations/native/2.0.1/postgres/schemaChanges.sql index 5fbb6205f602..489b9e890884 100644 --- a/bootstrap/sql/migrations/native/2.0.1/postgres/schemaChanges.sql +++ b/bootstrap/sql/migrations/native/2.0.1/postgres/schemaChanges.sql @@ -11,3 +11,18 @@ CREATE TABLE IF NOT EXISTS task_migration_mapping ( CREATE INDEX IF NOT EXISTS idx_task_migration_mapping_new_task_id ON task_migration_mapping (new_task_id); + +-- ContextMemory entity - reusable Context Center memory. +CREATE TABLE IF NOT EXISTS context_memory ( + id VARCHAR(36) GENERATED ALWAYS AS (json ->> 'id') STORED NOT NULL, + name VARCHAR(256) GENERATED ALWAYS AS (json ->> 'name') STORED NOT NULL, + nameHash VARCHAR(256) NOT NULL, + json JSONB NOT NULL, + updatedAt BIGINT GENERATED ALWAYS AS ((json ->> 'updatedAt')::bigint) STORED NOT NULL, + updatedBy VARCHAR(256) GENERATED ALWAYS AS (json ->> 'updatedBy') STORED NOT NULL, + deleted BOOLEAN GENERATED ALWAYS AS ((json ->> 'deleted')::boolean) STORED, + + PRIMARY KEY (id), + UNIQUE (nameHash) +); +CREATE INDEX IF NOT EXISTS idx_context_memory_updated_at ON context_memory (updatedAt); From 79d720d6744cfa1c6568b45e7f492c7c2eec8322 Mon Sep 17 00:00:00 2001 From: Pere Miquel Brull Date: Mon, 18 May 2026 13:21:07 +0200 Subject: [PATCH 06/16] test(service): ContextMemory resource CRUD test --- .../context/ContextMemoryResourceTest.java | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 openmetadata-service/src/test/java/org/openmetadata/service/resources/context/ContextMemoryResourceTest.java diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/resources/context/ContextMemoryResourceTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/resources/context/ContextMemoryResourceTest.java new file mode 100644 index 000000000000..a514c32ac211 --- /dev/null +++ b/openmetadata-service/src/test/java/org/openmetadata/service/resources/context/ContextMemoryResourceTest.java @@ -0,0 +1,52 @@ +package org.openmetadata.service.resources.context; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import jakarta.ws.rs.BadRequestException; +import org.junit.jupiter.api.Test; +import org.openmetadata.schema.entity.context.ContextMemoryStatus; +import org.openmetadata.service.jdbi3.ContextMemoryRepository; + +class ContextMemoryResourceTest { + + @Test + void testValidStatusTransitionsAreAccepted() { + ContextMemoryRepository.validateStatusTransition( + ContextMemoryStatus.DRAFT, ContextMemoryStatus.ACTIVE); + ContextMemoryRepository.validateStatusTransition( + ContextMemoryStatus.DRAFT, ContextMemoryStatus.ARCHIVED); + ContextMemoryRepository.validateStatusTransition( + ContextMemoryStatus.ACTIVE, ContextMemoryStatus.ARCHIVED); + ContextMemoryRepository.validateStatusTransition( + ContextMemoryStatus.ARCHIVED, ContextMemoryStatus.ACTIVE); + } + + @Test + void testNoOpStatusTransitionIsAccepted() { + ContextMemoryRepository.validateStatusTransition( + ContextMemoryStatus.ACTIVE, ContextMemoryStatus.ACTIVE); + ContextMemoryRepository.validateStatusTransition( + ContextMemoryStatus.DRAFT, ContextMemoryStatus.DRAFT); + } + + @Test + void testActiveToDraftIsRejected() { + BadRequestException exception = + assertThrows( + BadRequestException.class, + () -> + ContextMemoryRepository.validateStatusTransition( + ContextMemoryStatus.ACTIVE, ContextMemoryStatus.DRAFT)); + assertTrue(exception.getMessage().contains("Invalid memory status transition")); + } + + @Test + void testArchivedToDraftIsRejected() { + assertThrows( + BadRequestException.class, + () -> + ContextMemoryRepository.validateStatusTransition( + ContextMemoryStatus.ARCHIVED, ContextMemoryStatus.DRAFT)); + } +} From f1d79db5f908c5b8dce0d55c97fa281bf207c1da Mon Sep 17 00:00:00 2001 From: Pere Miquel Brull Date: Mon, 18 May 2026 14:22:50 +0200 Subject: [PATCH 07/16] fix(context-memory): address review (relationship types, stable FQN, status msg, test name) - storeRelationships: rootMemory -> Relationship.CONTAINS, parentMemory -> Relationship.HAS so the root-ancestor and direct-parent hierarchies are distinguishable. - setFullyQualifiedName: derive from the immutable name only (drop mutable primaryEntity/owner derivation that destabilized nameHash on update). - validateStatusTransition: separate "no transitions defined" from "disallowed transition". - Rename ContextMemoryResourceTest -> ContextMemoryStatusTransitionTest (pure unit test). Co-Authored-By: Claude Opus 4.7 --- .../jdbi3/ContextMemoryRepository.java | 33 ++++++++----------- ...=> ContextMemoryStatusTransitionTest.java} | 2 +- 2 files changed, 14 insertions(+), 21 deletions(-) rename openmetadata-service/src/test/java/org/openmetadata/service/resources/context/{ContextMemoryResourceTest.java => ContextMemoryStatusTransitionTest.java} (97%) diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/ContextMemoryRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/ContextMemoryRepository.java index c71ecf34600e..2d55e14961f6 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/ContextMemoryRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/ContextMemoryRepository.java @@ -30,7 +30,6 @@ import org.openmetadata.service.util.EntityUtil; import org.openmetadata.service.util.EntityUtil.Fields; import org.openmetadata.service.util.EntityUtil.RelationIncludes; -import org.openmetadata.service.util.FullyQualifiedName; @Slf4j @Repository(name = "ContextMemoryRepository") @@ -60,22 +59,10 @@ protected void clearFields(ContextMemory entity, Fields fields) { @Override public void setFullyQualifiedName(ContextMemory entity) { - if (entity.getPrimaryEntity() != null - && entity.getPrimaryEntity().getFullyQualifiedName() != null - && !entity.getPrimaryEntity().getFullyQualifiedName().isEmpty()) { - entity.setFullyQualifiedName( - FullyQualifiedName.add( - entity.getPrimaryEntity().getFullyQualifiedName(), entity.getName())); - return; - } - if (entity.getOwners() != null - && !entity.getOwners().isEmpty() - && entity.getOwners().get(0).getName() != null - && !entity.getOwners().get(0).getName().isEmpty()) { - entity.setFullyQualifiedName( - FullyQualifiedName.add(entity.getOwners().get(0).getName(), entity.getName())); - return; - } + // FQN is the (immutable) memory name. Deriving it from mutable fields such as + // primaryEntity or owners would change nameHash on update, risking unique-constraint + // collisions and orphaned references. The link to primaryEntity/owners is captured + // via the relationship table instead. entity.setFullyQualifiedName(entity.getName()); } @@ -138,13 +125,15 @@ public void storeRelationships(ContextMemory entity) { Relationship.RELATED_TO); } + // Distinct relationship types so the root-ancestor and direct-parent hierarchies + // can be resolved independently when read back from the relationship table. if (entity.getRootMemory() != null) { addRelationship( entity.getRootMemory().getId(), entity.getId(), CONTEXT_MEMORY_ENTITY, CONTEXT_MEMORY_ENTITY, - Relationship.RELATED_TO); + Relationship.CONTAINS); } if (entity.getParentMemory() != null) { @@ -153,7 +142,7 @@ public void storeRelationships(ContextMemory entity) { entity.getId(), CONTEXT_MEMORY_ENTITY, CONTEXT_MEMORY_ENTITY, - Relationship.RELATED_TO); + Relationship.HAS); } } @@ -185,7 +174,11 @@ public static void validateStatusTransition(ContextMemoryStatus from, ContextMem return; // No change } Set allowed = VALID_TRANSITIONS.get(from); - if (allowed == null || !allowed.contains(to)) { + if (allowed == null) { + throw new BadRequestException( + String.format("No transitions defined for status %s", from.value())); + } + if (!allowed.contains(to)) { throw new BadRequestException( String.format( "Invalid memory status transition from %s to %s. Allowed transitions from %s: %s", diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/resources/context/ContextMemoryResourceTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/resources/context/ContextMemoryStatusTransitionTest.java similarity index 97% rename from openmetadata-service/src/test/java/org/openmetadata/service/resources/context/ContextMemoryResourceTest.java rename to openmetadata-service/src/test/java/org/openmetadata/service/resources/context/ContextMemoryStatusTransitionTest.java index a514c32ac211..d8e693dbbda5 100644 --- a/openmetadata-service/src/test/java/org/openmetadata/service/resources/context/ContextMemoryResourceTest.java +++ b/openmetadata-service/src/test/java/org/openmetadata/service/resources/context/ContextMemoryStatusTransitionTest.java @@ -8,7 +8,7 @@ import org.openmetadata.schema.entity.context.ContextMemoryStatus; import org.openmetadata.service.jdbi3.ContextMemoryRepository; -class ContextMemoryResourceTest { +class ContextMemoryStatusTransitionTest { @Test void testValidStatusTransitionsAreAccepted() { From 514eaad6ea32320779556308c514317f0a97925d Mon Sep 17 00:00:00 2001 From: Pere Miquel Brull Date: Mon, 18 May 2026 14:40:35 +0200 Subject: [PATCH 08/16] test(context-memory): add ContextMemoryIT + SDK ContextMemoryService Co-Authored-By: Claude Opus 4.7 --- .../it/tests/ContextMemoryIT.java | 467 ++++++++++++++++++ .../context/ContextMemoryService.java | 27 + 2 files changed, 494 insertions(+) create mode 100644 openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/ContextMemoryIT.java create mode 100644 openmetadata-sdk/src/main/java/org/openmetadata/sdk/services/context/ContextMemoryService.java diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/ContextMemoryIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/ContextMemoryIT.java new file mode 100644 index 000000000000..f2c79df1364a --- /dev/null +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/ContextMemoryIT.java @@ -0,0 +1,467 @@ +package org.openmetadata.it.tests; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.parallel.Execution; +import org.junit.jupiter.api.parallel.ExecutionMode; +import org.openmetadata.it.util.SdkClients; +import org.openmetadata.it.util.TestNamespace; +import org.openmetadata.schema.api.context.CreateContextMemory; +import org.openmetadata.schema.entity.context.ContextMemory; +import org.openmetadata.schema.entity.context.ContextMemoryScope; +import org.openmetadata.schema.entity.context.ContextMemoryStatus; +import org.openmetadata.schema.entity.context.ContextMemoryType; +import org.openmetadata.schema.entity.context.MemoryShareConfig; +import org.openmetadata.schema.entity.context.MemoryVisibility; +import org.openmetadata.schema.type.EntityHistory; +import org.openmetadata.sdk.models.ListParams; +import org.openmetadata.sdk.models.ListResponse; +import org.openmetadata.sdk.services.context.ContextMemoryService; + +/** + * Integration tests for ContextMemory entity operations. + * + *

Tests ContextMemory CRUD operations, status lifecycle transitions, scope/visibility handling, + * and context-memory-specific validations. + * + *

Modeled on LearningResourceIT, the reference entity for the ContextMemory OSS implementation. + */ +@Execution(ExecutionMode.CONCURRENT) +public class ContextMemoryIT extends BaseEntityIT { + + public ContextMemoryIT() { + supportsPatch = true; + supportsFollowers = false; + supportsTags = true; + supportsOwners = true; + supportsDomains = true; + supportsDataProducts = false; + supportsCustomExtension = true; + supportsSearchIndex = false; + } + + // =================================================================== + // ABSTRACT METHOD IMPLEMENTATIONS (Required by BaseEntityIT) + // =================================================================== + + @Override + protected CreateContextMemory createMinimalRequest(TestNamespace ns) { + return new CreateContextMemory() + .withName(ns.prefix("context-memory")) + .withDescription("Test context memory") + .withQuestion("How do I find certified tables?") + .withAnswer("Filter the Explore page by the Certification tag."); + } + + @Override + protected CreateContextMemory createRequest(String name, TestNamespace ns) { + return new CreateContextMemory() + .withName(name) + .withDescription("Test context memory") + .withQuestion("What is the data quality SLA?") + .withAnswer("Critical tables must pass tests every 24 hours."); + } + + @Override + protected ContextMemory createEntity(CreateContextMemory createRequest) { + return getContextMemoryService().create(createRequest); + } + + @Override + protected ContextMemory getEntity(String id) { + return getContextMemoryService().get(id); + } + + @Override + protected ContextMemory getEntityByName(String fqn) { + return getContextMemoryService().getByName(fqn); + } + + @Override + protected ContextMemory patchEntity(String id, ContextMemory entity) { + return getContextMemoryService().update(id, entity); + } + + @Override + protected void deleteEntity(String id) { + getContextMemoryService().delete(id); + } + + @Override + protected void restoreEntity(String id) { + getContextMemoryService().restore(id); + } + + @Override + protected void hardDeleteEntity(String id) { + Map params = new HashMap<>(); + params.put("hardDelete", "true"); + getContextMemoryService().delete(id, params); + } + + @Override + protected String getEntityType() { + return "contextMemory"; + } + + @Override + protected void validateCreatedEntity(ContextMemory entity, CreateContextMemory createRequest) { + assertEquals(createRequest.getName(), entity.getName()); + + if (createRequest.getDescription() != null) { + assertEquals(createRequest.getDescription(), entity.getDescription()); + } + + if (createRequest.getDisplayName() != null) { + assertEquals(createRequest.getDisplayName(), entity.getDisplayName()); + } + + assertEquals(createRequest.getQuestion(), entity.getQuestion()); + assertEquals(createRequest.getAnswer(), entity.getAnswer()); + + assertTrue( + entity.getFullyQualifiedName().contains(entity.getName()), + "FQN should contain memory name"); + } + + @Override + protected ListResponse listEntities(ListParams params) { + return getContextMemoryService().list(params); + } + + @Override + protected ContextMemory getEntityWithFields(String id, String fields) { + return getContextMemoryService().get(id, fields); + } + + @Override + protected ContextMemory getEntityByNameWithFields(String fqn, String fields) { + return getContextMemoryService().getByName(fqn, fields); + } + + @Override + protected ContextMemory getEntityIncludeDeleted(String id) { + return getContextMemoryService().get(id, null, "deleted"); + } + + @Override + protected EntityHistory getVersionHistory(UUID id) { + return getContextMemoryService().getVersionList(id); + } + + @Override + protected ContextMemory getVersion(UUID id, Double version) { + return getContextMemoryService().getVersion(id.toString(), version); + } + + // =================================================================== + // CRUD TESTS + // =================================================================== + + @Test + void post_contextMemory_200_OK(TestNamespace ns) { + CreateContextMemory request = + new CreateContextMemory() + .withName(ns.prefix("crud-memory")) + .withDescription("CRUD happy path") + .withQuestion("Where are the gold datasets?") + .withAnswer("Under the Sales domain tagged Tier.Gold."); + + ContextMemory memory = createEntity(request); + assertNotNull(memory.getId()); + assertEquals(request.getName(), memory.getName()); + assertEquals("Where are the gold datasets?", memory.getQuestion()); + assertEquals("Under the Sales domain tagged Tier.Gold.", memory.getAnswer()); + assertEquals(0.1, memory.getVersion(), 0.001); + + ContextMemory fetched = getEntity(memory.getId().toString()); + assertEquals(memory.getId(), fetched.getId()); + assertEquals(memory.getName(), fetched.getName()); + } + + @Test + void post_contextMemoryWithQuestionAnswerSummaryTitle_200_OK(TestNamespace ns) { + CreateContextMemory request = + new CreateContextMemory() + .withName(ns.prefix("rich-memory")) + .withDisplayName("Certification Lookup") + .withDescription("Full content memory") + .withTitle("How to find certified data") + .withSummary("Use the Certification tag filter on Explore.") + .withQuestion("How do I find certified tables?") + .withAnswer("Filter the Explore page by Certification = Certified."); + + ContextMemory memory = createEntity(request); + assertEquals("Certification Lookup", memory.getDisplayName()); + assertEquals("How to find certified data", memory.getTitle()); + assertEquals("Use the Certification tag filter on Explore.", memory.getSummary()); + assertEquals("How do I find certified tables?", memory.getQuestion()); + assertEquals("Filter the Explore page by Certification = Certified.", memory.getAnswer()); + } + + @Test + void post_contextMemoryWithoutRequiredFields_400(TestNamespace ns) { + assertThrows( + Exception.class, + () -> createEntity(new CreateContextMemory().withName(null)), + "Creating memory without name should fail"); + + assertThrows( + Exception.class, + () -> + createEntity( + new CreateContextMemory() + .withName(ns.prefix("no-question")) + .withAnswer("An answer without a question.")), + "Creating memory without question should fail"); + + assertThrows( + Exception.class, + () -> + createEntity( + new CreateContextMemory() + .withName(ns.prefix("no-answer")) + .withQuestion("A question without an answer?")), + "Creating memory without answer should fail"); + } + + @Test + void post_contextMemoryDuplicateName_409(TestNamespace ns) { + String memoryName = ns.prefix("duplicate-memory"); + + createEntity( + new CreateContextMemory() + .withName(memoryName) + .withDescription("First memory") + .withQuestion("First question?") + .withAnswer("First answer.")); + + CreateContextMemory duplicate = + new CreateContextMemory() + .withName(memoryName) + .withDescription("Duplicate memory") + .withQuestion("Duplicate question?") + .withAnswer("Duplicate answer."); + + assertThrows(Exception.class, () -> createEntity(duplicate), "Duplicate name should fail"); + } + + @Test + void get_contextMemoryByFqn_200_OK(TestNamespace ns) { + CreateContextMemory request = + new CreateContextMemory() + .withName(ns.prefix("fqn-memory")) + .withDescription("FQN lookup test") + .withQuestion("What is the FQN of this memory?") + .withAnswer("The FQN equals the name for context memories."); + + ContextMemory memory = createEntity(request); + + // FQN == name for ContextMemory (ContextMemoryRepository.setFullyQualifiedName). + assertEquals(memory.getName(), memory.getFullyQualifiedName()); + + ContextMemory byFqn = getEntityByName(memory.getFullyQualifiedName()); + assertEquals(memory.getId(), byFqn.getId()); + assertEquals(memory.getName(), byFqn.getName()); + } + + // =================================================================== + // STATUS LIFECYCLE TESTS + // =================================================================== + + @Test + void put_contextMemoryStatusTransitions_valid_200_OK(TestNamespace ns) { + CreateContextMemory request = + new CreateContextMemory() + .withName(ns.prefix("status-valid")) + .withDescription("Valid status transitions") + .withQuestion("What is the status flow?") + .withAnswer("Draft to Active to Archived and back to Active.") + .withStatus(ContextMemoryStatus.DRAFT); + + ContextMemory memory = createEntity(request); + assertEquals(ContextMemoryStatus.DRAFT, memory.getStatus()); + + request.withStatus(ContextMemoryStatus.ACTIVE); + ContextMemory active = getContextMemoryService().put(request); + assertEquals(ContextMemoryStatus.ACTIVE, active.getStatus()); + + request.withStatus(ContextMemoryStatus.ARCHIVED); + ContextMemory archived = getContextMemoryService().put(request); + assertEquals(ContextMemoryStatus.ARCHIVED, archived.getStatus()); + + request.withStatus(ContextMemoryStatus.ACTIVE); + ContextMemory reactivated = getContextMemoryService().put(request); + assertEquals(ContextMemoryStatus.ACTIVE, reactivated.getStatus()); + } + + @Test + void put_contextMemoryStatusTransition_invalid_fails(TestNamespace ns) { + CreateContextMemory request = + new CreateContextMemory() + .withName(ns.prefix("status-invalid")) + .withDescription("Invalid status transition") + .withQuestion("Can Active go back to Draft?") + .withAnswer("No, Active cannot revert to Draft.") + .withStatus(ContextMemoryStatus.ACTIVE); + + ContextMemory memory = createEntity(request); + assertEquals(ContextMemoryStatus.ACTIVE, memory.getStatus()); + + request.withStatus(ContextMemoryStatus.DRAFT); + assertThrows( + Exception.class, + () -> getContextMemoryService().put(request), + "Transition from Active to Draft should be rejected"); + } + + @Test + void put_statusOnlyChange_persistsAfterGet(TestNamespace ns) { + CreateContextMemory request = + new CreateContextMemory() + .withName(ns.prefix("status-persist")) + .withDescription("Status-only update persistence test") + .withQuestion("Does the status persist?") + .withAnswer("Yes, after a status-only PUT.") + .withStatus(ContextMemoryStatus.DRAFT); + + ContextMemory memory = createEntity(request); + assertEquals(ContextMemoryStatus.DRAFT, memory.getStatus()); + + request.withStatus(ContextMemoryStatus.ACTIVE); + ContextMemory putResponse = getContextMemoryService().put(request); + assertEquals(ContextMemoryStatus.ACTIVE, putResponse.getStatus()); + + ContextMemory fetched = getEntity(memory.getId().toString()); + assertEquals( + ContextMemoryStatus.ACTIVE, + fetched.getStatus(), + "Status should persist after a status-only PUT update"); + assertTrue( + fetched.getVersion() > memory.getVersion(), + "Version should be incremented after status change"); + } + + @Test + void put_statusChanges_recordVersionHistory(TestNamespace ns) { + CreateContextMemory request = + new CreateContextMemory() + .withName(ns.prefix("status-history")) + .withDescription("Status change history test") + .withQuestion("Are status changes versioned?") + .withAnswer("Yes, each transition bumps the version.") + .withStatus(ContextMemoryStatus.DRAFT); + + ContextMemory memory = createEntity(request); + + request.withStatus(ContextMemoryStatus.ACTIVE); + getContextMemoryService().put(request); + + request.withStatus(ContextMemoryStatus.ARCHIVED); + getContextMemoryService().put(request); + + EntityHistory history = getVersionHistory(memory.getId()); + assertTrue( + history.getVersions().size() >= 3, + "Should have at least 3 versions: create + 2 status updates"); + } + + // =================================================================== + // SCOPE / TYPE / VISIBILITY TESTS + // =================================================================== + + @Test + void post_contextMemoryWithScopeAndType_200_OK(TestNamespace ns) { + CreateContextMemory request = + new CreateContextMemory() + .withName(ns.prefix("scope-type")) + .withDescription("Scope and type test") + .withQuestion("What is my reporting preference?") + .withAnswer("Always include row counts in summaries.") + .withMemoryScope(ContextMemoryScope.USER_GLOBAL) + .withMemoryType(ContextMemoryType.PREFERENCE); + + ContextMemory memory = createEntity(request); + assertEquals(ContextMemoryScope.USER_GLOBAL, memory.getMemoryScope()); + assertEquals(ContextMemoryType.PREFERENCE, memory.getMemoryType()); + } + + @Test + void post_contextMemoryWithShareConfigVisibility_200_OK(TestNamespace ns) { + CreateContextMemory request = + new CreateContextMemory() + .withName(ns.prefix("visibility")) + .withDescription("Visibility test") + .withQuestion("Who can see this memory?") + .withAnswer("Only the owner while visibility is Private.") + .withShareConfig(new MemoryShareConfig().withVisibility(MemoryVisibility.PRIVATE)); + + ContextMemory memory = createEntity(request); + assertNotNull(memory.getShareConfig()); + assertEquals(MemoryVisibility.PRIVATE, memory.getShareConfig().getVisibility()); + } + + @Test + void post_contextMemoryAllTypes_200_OK(TestNamespace ns) { + for (ContextMemoryType type : ContextMemoryType.values()) { + CreateContextMemory request = + new CreateContextMemory() + .withName(ns.prefix("type-" + type.value().toLowerCase())) + .withDescription("Memory of type " + type.value()) + .withQuestion("Question for " + type.value() + "?") + .withAnswer("Answer for " + type.value() + ".") + .withMemoryType(type); + + ContextMemory memory = createEntity(request); + assertEquals(type, memory.getMemoryType()); + } + } + + // =================================================================== + // LIST TESTS + // =================================================================== + + @Test + void test_listContextMemories(TestNamespace ns) { + CreateContextMemory request1 = + new CreateContextMemory() + .withName(ns.prefix("list-1")) + .withDescription("First memory") + .withQuestion("First list question?") + .withAnswer("First list answer."); + + CreateContextMemory request2 = + new CreateContextMemory() + .withName(ns.prefix("list-2")) + .withDescription("Second memory") + .withQuestion("Second list question?") + .withAnswer("Second list answer."); + + createEntity(request1); + createEntity(request2); + + ListParams params = new ListParams(); + params.setLimit(10); + ListResponse response = listEntities(params); + + assertNotNull(response); + assertFalse(response.getData().isEmpty()); + assertTrue(response.getData().size() >= 2); + } + + // =================================================================== + // HELPER METHODS + // =================================================================== + + private ContextMemoryService getContextMemoryService() { + return new ContextMemoryService(SdkClients.adminClient().getHttpClient()); + } +} diff --git a/openmetadata-sdk/src/main/java/org/openmetadata/sdk/services/context/ContextMemoryService.java b/openmetadata-sdk/src/main/java/org/openmetadata/sdk/services/context/ContextMemoryService.java new file mode 100644 index 000000000000..cbd55011973c --- /dev/null +++ b/openmetadata-sdk/src/main/java/org/openmetadata/sdk/services/context/ContextMemoryService.java @@ -0,0 +1,27 @@ +package org.openmetadata.sdk.services.context; + +import org.openmetadata.schema.api.context.CreateContextMemory; +import org.openmetadata.schema.entity.context.ContextMemory; +import org.openmetadata.sdk.exceptions.OpenMetadataException; +import org.openmetadata.sdk.network.HttpClient; +import org.openmetadata.sdk.network.HttpMethod; +import org.openmetadata.sdk.services.EntityServiceBase; + +public class ContextMemoryService extends EntityServiceBase { + public ContextMemoryService(HttpClient httpClient) { + super(httpClient, "/v1/contextCenter/memories"); + } + + @Override + protected Class getEntityClass() { + return ContextMemory.class; + } + + public ContextMemory create(CreateContextMemory request) throws OpenMetadataException { + return httpClient.execute(HttpMethod.POST, basePath, request, ContextMemory.class); + } + + public ContextMemory put(CreateContextMemory request) throws OpenMetadataException { + return httpClient.execute(HttpMethod.PUT, basePath, request, ContextMemory.class); + } +} From ed50f35e3ea19acaa88c2ccb97fba3a1d0daeca3 Mon Sep 17 00:00:00 2001 From: Pere Miquel Brull Date: Mon, 18 May 2026 14:58:38 +0200 Subject: [PATCH 09/16] fix(spec): register contextMemory in EntityLink.g4 ENTITY_TYPE grammar EntityLinkGrammarTest.testAllEntityTypesHaveGrammarOrExclusion enumerates every Entity.java constant and requires each to be in the EntityLink grammar or the test's exclusion list. ContextMemory is a normal EntityRepository-backed top-level entity (like learningResource / contextFile), so it belongs in the ENTITY_TYPE rule. Co-Authored-By: Claude Opus 4.7 --- .../src/main/antlr4/org/openmetadata/schema/EntityLink.g4 | 1 + 1 file changed, 1 insertion(+) diff --git a/openmetadata-spec/src/main/antlr4/org/openmetadata/schema/EntityLink.g4 b/openmetadata-spec/src/main/antlr4/org/openmetadata/schema/EntityLink.g4 index b07dff497711..847d580dc5f8 100644 --- a/openmetadata-spec/src/main/antlr4/org/openmetadata/schema/EntityLink.g4 +++ b/openmetadata-spec/src/main/antlr4/org/openmetadata/schema/EntityLink.g4 @@ -102,6 +102,7 @@ ENTITY_TYPE | 'folder' | 'contextFile' | 'contextFileContent' + | 'contextMemory' | 'type' | 'aiApplication' | 'llmModel' From ba8940bec2045fe65f325258b1d5228509e7baba Mon Sep 17 00:00:00 2001 From: Pere Miquel Brull Date: Mon, 18 May 2026 16:34:24 +0200 Subject: [PATCH 10/16] test(context-memory): override owner ITs for creator-as-owner default ContextMemoryMapper.defaultOwners() intentionally assigns the creating user as owner when the create request omits owners. BaseEntityIT's patch_entityUpdateOwner_200 and patch_entityUpdateOwnerFromNull_200 assert "no owner initially" for any supportsOwners entity, so both failed for ContextMemory. Override both in ContextMemoryIT: keep the PATCH-replace-owner contract, change only the precondition to expect the creator as the sole initial owner (asserted by count, not a hardcoded principal). Mapper unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../it/tests/ContextMemoryIT.java | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/ContextMemoryIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/ContextMemoryIT.java index f2c79df1364a..c9c0065ca68b 100644 --- a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/ContextMemoryIT.java +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/ContextMemoryIT.java @@ -7,6 +7,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.UUID; import org.junit.jupiter.api.Test; @@ -21,7 +22,10 @@ import org.openmetadata.schema.entity.context.ContextMemoryType; import org.openmetadata.schema.entity.context.MemoryShareConfig; import org.openmetadata.schema.entity.context.MemoryVisibility; +import org.openmetadata.schema.entity.teams.User; import org.openmetadata.schema.type.EntityHistory; +import org.openmetadata.schema.type.EntityReference; +import org.openmetadata.sdk.fluent.Users; import org.openmetadata.sdk.models.ListParams; import org.openmetadata.sdk.models.ListResponse; import org.openmetadata.sdk.services.context.ContextMemoryService; @@ -457,6 +461,80 @@ void test_listContextMemories(TestNamespace ns) { assertTrue(response.getData().size() >= 2); } + // =================================================================== + // OWNERSHIP TEST OVERRIDES + // =================================================================== + + /** + * ContextMemory auto-assigns the creating user as owner when the create request omits owners + * (see {@code ContextMemoryMapper#defaultOwners}), so it deliberately diverges from the generic + * BaseEntityIT precondition that a freshly created entity has no owner. The PATCH contract is + * unchanged: setting an explicit owner replaces the creator. + */ + @Test + @Override + void patch_entityUpdateOwner_200(TestNamespace ns) { + ContextMemory created = createEntity(createMinimalRequest(ns)); + + ContextMemory fetched = getEntityWithFields(created.getId().toString(), "owners"); + assertNotNull(fetched.getOwners(), "ContextMemory should be owned by its creator initially"); + assertEquals( + 1, fetched.getOwners().size(), "ContextMemory creator should be the sole initial owner"); + + User botUser = Users.getByName("ingestion-bot"); + EntityReference ownerRef = + new EntityReference() + .withId(botUser.getId()) + .withType("user") + .withName(botUser.getName()) + .withFullyQualifiedName(botUser.getFullyQualifiedName()); + + fetched.setOwners(List.of(ownerRef)); + ContextMemory updated = patchEntity(fetched.getId().toString(), fetched); + + ContextMemory updatedFetched = getEntityWithFields(updated.getId().toString(), "owners"); + assertNotNull(updatedFetched.getOwners(), "Entity should have owners"); + assertEquals(1, updatedFetched.getOwners().size(), "Entity should have 1 owner"); + assertEquals( + botUser.getId(), + updatedFetched.getOwners().get(0).getId(), + "Owner should be ingestion-bot user"); + } + + /** + * ContextMemory already has the creating user as its sole owner before this PATCH (see {@code + * ContextMemoryMapper#defaultOwners}); the original "from null" precondition does not hold. + * Setting an explicit owners list still replaces it wholesale. + */ + @Test + @Override + void patch_entityUpdateOwnerFromNull_200(TestNamespace ns) { + ContextMemory entity = createEntity(createMinimalRequest(ns)); + + ContextMemory fetched = getEntityWithFields(entity.getId().toString(), "owners"); + assertNotNull(fetched.getOwners(), "ContextMemory should be owned by its creator initially"); + assertEquals( + 1, fetched.getOwners().size(), "ContextMemory creator should be the sole initial owner"); + + EntityReference owner1 = + new EntityReference() + .withId(testUser1().getId()) + .withType("user") + .withName(testUser1().getName()); + EntityReference owner2 = + new EntityReference() + .withId(testUser2().getId()) + .withType("user") + .withName(testUser2().getName()); + + fetched.setOwners(List.of(owner1, owner2)); + ContextMemory updated = patchEntity(fetched.getId().toString(), fetched); + + ContextMemory verify = getEntityWithFields(updated.getId().toString(), "owners"); + assertNotNull(verify.getOwners(), "Entity should have owners"); + assertEquals(2, verify.getOwners().size(), "Entity should have 2 owners"); + } + // =================================================================== // HELPER METHODS // =================================================================== From 78fd9cb6d56fc165df907784ac30174e6ef34b86 Mon Sep 17 00:00:00 2001 From: Pere Miquel Brull Date: Mon, 18 May 2026 17:42:10 +0200 Subject: [PATCH 11/16] Update generated TypeScript types Add the generated ContextMemory TS types (entity/context/contextMemory.ts, api/context/createContextMemory.ts). The schemas were on the branch but their generated types were missing, failing the TypeScript Type Generation check on this fork PR. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../api/context/createContextMemory.ts | 446 +++++++++++++ .../generated/entity/context/contextMemory.ts | 584 ++++++++++++++++++ 2 files changed, 1030 insertions(+) create mode 100644 openmetadata-ui/src/main/resources/ui/src/generated/api/context/createContextMemory.ts create mode 100644 openmetadata-ui/src/main/resources/ui/src/generated/entity/context/contextMemory.ts diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/api/context/createContextMemory.ts b/openmetadata-ui/src/main/resources/ui/src/generated/api/context/createContextMemory.ts new file mode 100644 index 000000000000..4495ebe2f735 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/generated/api/context/createContextMemory.ts @@ -0,0 +1,446 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/** + * Request to create a reusable Context Center memory. + */ +export interface CreateContextMemory { + /** + * Canonical answer or retained guidance represented by this memory. + */ + answer: string; + /** + * Optional markdown description for the memory. + */ + description?: string; + /** + * Display name for the memory. + */ + displayName?: string; + /** + * Fully qualified names of the domains this memory belongs to. + */ + domains?: string[]; + machineRepresentation?: MachineRepresentation; + memoryScope?: MemoryScope; + memoryType?: MemoryType; + /** + * Stable system name for the memory. + */ + name: string; + /** + * Owners of this memory. + */ + owners?: EntityReference[]; + parentMemory?: EntityReference; + primaryEntity?: EntityReference; + /** + * Canonical question or instruction represented by this memory. + */ + question: string; + relatedEntities?: EntityReference[]; + rootMemory?: EntityReference; + shareConfig?: ShareConfig; + sourceAssistantMessage?: string; + sourceConversation?: string; + sourceHumanMessage?: string; + sourceType?: SourceType; + status?: MemoryStatus; + /** + * Optional summary of the memory. + */ + summary?: string; + /** + * Tags associated with this memory. + */ + tags?: TagLabel[]; + /** + * Short title shown in Context Center. + */ + title?: string; +} + +/** + * Optional machine-oriented representation used for prompt packing. + */ +export interface MachineRepresentation { + /** + * Compressed or transformed memory content. + */ + content?: string; + /** + * Representation format identifier. + */ + format?: string; + /** + * Timestamp when the representation was generated. + */ + generatedAt?: number; + /** + * Hash of the canonical source content used to generate this representation. + */ + generatedFromHash?: string; + status?: MachineRepresentationStatus; + /** + * Version of the representation format. + */ + version?: string; +} + +/** + * Availability state of the machine-oriented representation. + */ +export enum MachineRepresentationStatus { + Failed = "Failed", + Pending = "Pending", + Ready = "Ready", + Stale = "Stale", +} + +/** + * Scope where the memory should be applied. + */ +export enum MemoryScope { + EntityScoped = "EntityScoped", + UserGlobal = "UserGlobal", +} + +/** + * High-level type of reusable memory. + */ +export enum MemoryType { + FAQ = "Faq", + Note = "Note", + Preference = "Preference", + Runbook = "Runbook", + UseCase = "UseCase", +} + +/** + * Owners of this memory. + * + * This schema defines the EntityReferenceList type used for referencing an entity. + * EntityReference is used for capturing relationships from one entity to another. For + * example, a table has an attribute called database of type EntityReference that captures + * the relationship of a table `belongs to a` database. + * + * This schema defines the EntityReference type used for referencing an entity. + * EntityReference is used for capturing relationships from one entity to another. For + * example, a table has an attribute called database of type EntityReference that captures + * the relationship of a table `belongs to a` database. + * + * Principal receiving access. Supported principal types are user, team, and domain. + */ +export interface EntityReference { + /** + * If true the entity referred to has been soft-deleted. + */ + deleted?: boolean; + /** + * Optional description of entity. + */ + description?: string; + /** + * Display Name that identifies this entity. + */ + displayName?: string; + /** + * Fully qualified name of the entity instance. For entities such as tables, databases + * fullyQualifiedName is returned in this field. For entities that don't have name hierarchy + * such as `user` and `team` this will be same as the `name` field. + */ + fullyQualifiedName?: string; + /** + * Link to the entity resource. + */ + href?: string; + /** + * Unique identifier that identifies an entity instance. + */ + id: string; + /** + * If true the relationship indicated by this entity reference is inherited from the parent + * entity. + */ + inherited?: boolean; + /** + * Name of the entity instance. + */ + name?: string; + /** + * Entity type/class name - Examples: `database`, `table`, `metrics`, `databaseService`, + * `dashboardService`... + */ + type: string; +} + +/** + * Visibility and sharing configuration for the memory. + */ +export interface ShareConfig { + /** + * Explicit principals the memory is shared with. + */ + sharedWith?: SharedPrincipal[]; + visibility?: ShareVisibility; +} + +/** + * A principal granted access to the memory. + */ +export interface SharedPrincipal { + /** + * Principal receiving access. Supported principal types are user, team, and domain. + */ + principal?: EntityReference; + /** + * Role granted to the principal. + */ + role?: ShareRole; +} + +/** + * Role granted to the principal. + * + * Role granted to a shared principal. + */ +export enum ShareRole { + Editor = "Editor", + Viewer = "Viewer", +} + +/** + * Visibility level for the memory. + */ +export enum ShareVisibility { + Entity = "Entity", + Private = "Private", + Shared = "Shared", +} + +/** + * How the memory was created. + */ +export enum SourceType { + ChatPromotion = "ChatPromotion", + Manual = "Manual", + RememberRequest = "RememberRequest", +} + +/** + * Lifecycle state of the memory. + */ +export enum MemoryStatus { + Active = "Active", + Archived = "Archived", + Draft = "Draft", +} + +/** + * This schema defines the type for labeling an entity with a Tag. + */ +export interface TagLabel { + /** + * Timestamp when this tag was applied in ISO 8601 format + */ + appliedAt?: Date; + /** + * Who it is that applied this tag (e.g: a bot, AI or a human) + */ + appliedBy?: string; + /** + * Description for the tag label. + */ + description?: string; + /** + * Display Name that identifies this tag. + */ + displayName?: string; + /** + * Link to the tag resource. + */ + href?: string; + /** + * Label type describes how a tag label was applied. 'Manual' indicates the tag label was + * applied by a person. 'Derived' indicates a tag label was derived using the associated tag + * relationship (see Classification.json for more details). 'Propagated` indicates a tag + * label was propagated from upstream based on lineage. 'Automated' is used when a tool was + * used to determine the tag label. + */ + labelType: LabelType; + /** + * Additional metadata associated with this tag label, such as recognizer information for + * automatically applied tags. + */ + metadata?: TagLabelMetadata; + /** + * Name of the tag or glossary term. + */ + name?: string; + /** + * An explanation of why this tag was proposed, specially for autoclassification tags + */ + reason?: string; + /** + * Label is from Tags or Glossary. + */ + source: TagSource; + /** + * 'Suggested' state is used when a tag label is suggested by users or tools. Owner of the + * entity must confirm the suggested labels before it is marked as 'Confirmed'. + */ + state: State; + style?: Style; + tagFQN: string; +} + +/** + * Label type describes how a tag label was applied. 'Manual' indicates the tag label was + * applied by a person. 'Derived' indicates a tag label was derived using the associated tag + * relationship (see Classification.json for more details). 'Propagated` indicates a tag + * label was propagated from upstream based on lineage. 'Automated' is used when a tool was + * used to determine the tag label. + */ +export enum LabelType { + Automated = "Automated", + Derived = "Derived", + Generated = "Generated", + Manual = "Manual", + Propagated = "Propagated", +} + +/** + * Additional metadata associated with this tag label, such as recognizer information for + * automatically applied tags. + * + * Additional metadata associated with a tag label, including information about how the tag + * was applied. + */ +export interface TagLabelMetadata { + /** + * Epoch time in milliseconds when the certification tag expires + */ + expiryDate?: number; + /** + * Metadata about the recognizer that automatically applied this tag + */ + recognizer?: TagLabelRecognizerMetadata; +} + +/** + * Metadata about the recognizer that automatically applied this tag + * + * Metadata about the recognizer that applied a tag, including scoring and pattern + * information. + */ +export interface TagLabelRecognizerMetadata { + /** + * Details of patterns that matched during recognition + */ + patterns?: PatternMatch[]; + /** + * Unique identifier of the recognizer that applied this tag + */ + recognizerId: string; + /** + * Human-readable name of the recognizer + */ + recognizerName: string; + /** + * Confidence score assigned by the recognizer (0.0 to 1.0) + */ + score: number; + /** + * What the recognizer analyzed to apply this tag + */ + target?: Target; +} + +/** + * Information about a pattern that matched during recognition + */ +export interface PatternMatch { + /** + * Name of the pattern that matched + */ + name: string; + /** + * Regular expression or pattern definition + */ + regex?: string; + /** + * Confidence score for this specific pattern match + */ + score: number; +} + +/** + * What the recognizer analyzed to apply this tag + */ +export enum Target { + ColumnName = "column_name", + Content = "content", +} + +/** + * Label is from Tags or Glossary. + */ +export enum TagSource { + Classification = "Classification", + Glossary = "Glossary", +} + +/** + * 'Suggested' state is used when a tag label is suggested by users or tools. Owner of the + * entity must confirm the suggested labels before it is marked as 'Confirmed'. + */ +export enum State { + Confirmed = "Confirmed", + Suggested = "Suggested", +} + +/** + * UI Style is used to associate a color code and/or icon to entity to customize the look of + * that entity in UI. + */ +export interface Style { + /** + * Hex Color Code to mark an entity such as GlossaryTerm, Tag, Domain or Data Product. + */ + color?: string; + /** + * Cover image configuration for the entity. + */ + coverImage?: CoverImage; + /** + * An icon to associate with GlossaryTerm, Tag, Domain or Data Product. + */ + iconURL?: string; +} + +/** + * Cover image configuration for the entity. + * + * Cover image configuration for an entity. This is used to display a banner or header image + * for entities like Domain, Glossary, Data Product, etc. + */ +export interface CoverImage { + /** + * Position of the cover image in CSS background-position format. Supports keywords (top, + * center, bottom) or pixel values (e.g., '20px 30px'). + */ + position?: string; + /** + * URL of the cover image. + */ + url?: string; +} diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/entity/context/contextMemory.ts b/openmetadata-ui/src/main/resources/ui/src/generated/entity/context/contextMemory.ts new file mode 100644 index 000000000000..3c09949280c3 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/generated/entity/context/contextMemory.ts @@ -0,0 +1,584 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/** + * Reusable context memory for Context Center and AI-assisted retrieval. + */ +export interface ContextMemory { + /** + * Canonical answer or retained guidance represented by this memory. + */ + answer?: string; + /** + * Change that led to this version. + */ + changeDescription?: ChangeDescription; + /** + * When true indicates the entity has been soft deleted. + */ + deleted?: boolean; + /** + * Optional markdown description for the memory. + */ + description?: string; + /** + * Display name of the memory. + */ + displayName?: string; + /** + * Domains this memory belongs to. + */ + domains?: EntityReference[]; + /** + * Fully qualified name of the memory. + */ + fullyQualifiedName?: string; + /** + * Link to this resource. + */ + href?: string; + /** + * Unique identifier of the memory. + */ + id: string; + /** + * Incremental change that led to this version. + */ + incrementalChangeDescription?: ChangeDescription; + /** + * Last time the memory was used by AI-assisted retrieval. + */ + lastUsedAt?: number; + machineRepresentation?: MachineRepresentation; + memoryScope?: MemoryScope; + memoryType?: MemoryType; + /** + * Stable system name for the memory. + */ + name: string; + /** + * Owners of this memory. + */ + owners?: EntityReference[]; + /** + * Immediate parent memory in an append-style thread. + */ + parentMemory?: EntityReference; + /** + * Primary entity this memory should attach to for reuse. + */ + primaryEntity?: EntityReference; + /** + * Canonical question or instruction represented by this memory. + */ + question?: string; + /** + * Additional related entities this memory applies to. + */ + relatedEntities?: EntityReference[]; + /** + * Root memory in an append-style memory thread. + */ + rootMemory?: EntityReference; + shareConfig?: ShareConfig; + /** + * Assistant message identifier used to produce this memory. + */ + sourceAssistantMessage?: string; + /** + * Conversation identifier that produced this memory. + */ + sourceConversation?: string; + /** + * Human message identifier used to produce this memory. + */ + sourceHumanMessage?: string; + sourceType?: SourceType; + status?: MemoryStatus; + /** + * Optional summary of the memory. + */ + summary?: string; + /** + * Tags associated with this memory. + */ + tags?: TagLabel[]; + /** + * Short title shown in Context Center. + */ + title?: string; + /** + * Last update time in Unix epoch time milliseconds. + */ + updatedAt?: number; + /** + * User who made the update. + */ + updatedBy?: string; + /** + * How many times this memory has been used in AI-assisted retrieval. + */ + usageCount?: number; + /** + * Metadata version of the entity. + */ + version?: number; +} + +/** + * Change that led to this version. + * + * Description of the change. + * + * Incremental change that led to this version. + */ +export interface ChangeDescription { + changeSummary?: { [key: string]: ChangeSummary }; + /** + * Names of fields added during the version changes. + */ + fieldsAdded?: FieldChange[]; + /** + * Fields deleted during the version changes with old value before deleted. + */ + fieldsDeleted?: FieldChange[]; + /** + * Fields modified during the version changes with old and new values. + */ + fieldsUpdated?: FieldChange[]; + /** + * When a change did not result in change, this could be same as the current version. + */ + previousVersion?: number; +} + +export interface ChangeSummary { + changedAt?: number; + /** + * Name of the user or bot who made this change + */ + changedBy?: string; + changeSource?: ChangeSource; + [property: string]: any; +} + +/** + * The source of the change. This will change based on the context of the change (example: + * manual vs programmatic) + */ +export enum ChangeSource { + Automated = "Automated", + Derived = "Derived", + Ingested = "Ingested", + Manual = "Manual", + Propagated = "Propagated", + Suggested = "Suggested", +} + +export interface FieldChange { + /** + * Name of the entity field that changed. + */ + name?: string; + /** + * New value of the field. Note that this is a JSON string and use the corresponding field + * type to deserialize it. + */ + newValue?: any; + /** + * Previous value of the field. Note that this is a JSON string and use the corresponding + * field type to deserialize it. + */ + oldValue?: any; +} + +/** + * Domains this memory belongs to. + * + * This schema defines the EntityReferenceList type used for referencing an entity. + * EntityReference is used for capturing relationships from one entity to another. For + * example, a table has an attribute called database of type EntityReference that captures + * the relationship of a table `belongs to a` database. + * + * This schema defines the EntityReference type used for referencing an entity. + * EntityReference is used for capturing relationships from one entity to another. For + * example, a table has an attribute called database of type EntityReference that captures + * the relationship of a table `belongs to a` database. + * + * Immediate parent memory in an append-style thread. + * + * Primary entity this memory should attach to for reuse. + * + * Root memory in an append-style memory thread. + * + * Principal receiving access. Supported principal types are user, team, and domain. + */ +export interface EntityReference { + /** + * If true the entity referred to has been soft-deleted. + */ + deleted?: boolean; + /** + * Optional description of entity. + */ + description?: string; + /** + * Display Name that identifies this entity. + */ + displayName?: string; + /** + * Fully qualified name of the entity instance. For entities such as tables, databases + * fullyQualifiedName is returned in this field. For entities that don't have name hierarchy + * such as `user` and `team` this will be same as the `name` field. + */ + fullyQualifiedName?: string; + /** + * Link to the entity resource. + */ + href?: string; + /** + * Unique identifier that identifies an entity instance. + */ + id: string; + /** + * If true the relationship indicated by this entity reference is inherited from the parent + * entity. + */ + inherited?: boolean; + /** + * Name of the entity instance. + */ + name?: string; + /** + * Entity type/class name - Examples: `database`, `table`, `metrics`, `databaseService`, + * `dashboardService`... + */ + type: string; +} + +/** + * Optional machine-oriented representation used for prompt packing. + */ +export interface MachineRepresentation { + /** + * Compressed or transformed memory content. + */ + content?: string; + /** + * Representation format identifier. + */ + format?: string; + /** + * Timestamp when the representation was generated. + */ + generatedAt?: number; + /** + * Hash of the canonical source content used to generate this representation. + */ + generatedFromHash?: string; + status?: MachineRepresentationStatus; + /** + * Version of the representation format. + */ + version?: string; +} + +/** + * Availability state of the machine-oriented representation. + */ +export enum MachineRepresentationStatus { + Failed = "Failed", + Pending = "Pending", + Ready = "Ready", + Stale = "Stale", +} + +/** + * Scope where the memory should be applied. + */ +export enum MemoryScope { + EntityScoped = "EntityScoped", + UserGlobal = "UserGlobal", +} + +/** + * High-level type of reusable memory. + */ +export enum MemoryType { + FAQ = "Faq", + Note = "Note", + Preference = "Preference", + Runbook = "Runbook", + UseCase = "UseCase", +} + +/** + * Visibility and sharing configuration for the memory. + */ +export interface ShareConfig { + /** + * Explicit principals the memory is shared with. + */ + sharedWith?: SharedPrincipal[]; + visibility?: ShareVisibility; +} + +/** + * A principal granted access to the memory. + */ +export interface SharedPrincipal { + /** + * Principal receiving access. Supported principal types are user, team, and domain. + */ + principal?: EntityReference; + /** + * Role granted to the principal. + */ + role?: ShareRole; +} + +/** + * Role granted to the principal. + * + * Role granted to a shared principal. + */ +export enum ShareRole { + Editor = "Editor", + Viewer = "Viewer", +} + +/** + * Visibility level for the memory. + */ +export enum ShareVisibility { + Entity = "Entity", + Private = "Private", + Shared = "Shared", +} + +/** + * How the memory was created. + */ +export enum SourceType { + ChatPromotion = "ChatPromotion", + Manual = "Manual", + RememberRequest = "RememberRequest", +} + +/** + * Lifecycle state of the memory. + */ +export enum MemoryStatus { + Active = "Active", + Archived = "Archived", + Draft = "Draft", +} + +/** + * This schema defines the type for labeling an entity with a Tag. + */ +export interface TagLabel { + /** + * Timestamp when this tag was applied in ISO 8601 format + */ + appliedAt?: Date; + /** + * Who it is that applied this tag (e.g: a bot, AI or a human) + */ + appliedBy?: string; + /** + * Description for the tag label. + */ + description?: string; + /** + * Display Name that identifies this tag. + */ + displayName?: string; + /** + * Link to the tag resource. + */ + href?: string; + /** + * Label type describes how a tag label was applied. 'Manual' indicates the tag label was + * applied by a person. 'Derived' indicates a tag label was derived using the associated tag + * relationship (see Classification.json for more details). 'Propagated` indicates a tag + * label was propagated from upstream based on lineage. 'Automated' is used when a tool was + * used to determine the tag label. + */ + labelType: LabelType; + /** + * Additional metadata associated with this tag label, such as recognizer information for + * automatically applied tags. + */ + metadata?: TagLabelMetadata; + /** + * Name of the tag or glossary term. + */ + name?: string; + /** + * An explanation of why this tag was proposed, specially for autoclassification tags + */ + reason?: string; + /** + * Label is from Tags or Glossary. + */ + source: TagSource; + /** + * 'Suggested' state is used when a tag label is suggested by users or tools. Owner of the + * entity must confirm the suggested labels before it is marked as 'Confirmed'. + */ + state: State; + style?: Style; + tagFQN: string; +} + +/** + * Label type describes how a tag label was applied. 'Manual' indicates the tag label was + * applied by a person. 'Derived' indicates a tag label was derived using the associated tag + * relationship (see Classification.json for more details). 'Propagated` indicates a tag + * label was propagated from upstream based on lineage. 'Automated' is used when a tool was + * used to determine the tag label. + */ +export enum LabelType { + Automated = "Automated", + Derived = "Derived", + Generated = "Generated", + Manual = "Manual", + Propagated = "Propagated", +} + +/** + * Additional metadata associated with this tag label, such as recognizer information for + * automatically applied tags. + * + * Additional metadata associated with a tag label, including information about how the tag + * was applied. + */ +export interface TagLabelMetadata { + /** + * Epoch time in milliseconds when the certification tag expires + */ + expiryDate?: number; + /** + * Metadata about the recognizer that automatically applied this tag + */ + recognizer?: TagLabelRecognizerMetadata; +} + +/** + * Metadata about the recognizer that automatically applied this tag + * + * Metadata about the recognizer that applied a tag, including scoring and pattern + * information. + */ +export interface TagLabelRecognizerMetadata { + /** + * Details of patterns that matched during recognition + */ + patterns?: PatternMatch[]; + /** + * Unique identifier of the recognizer that applied this tag + */ + recognizerId: string; + /** + * Human-readable name of the recognizer + */ + recognizerName: string; + /** + * Confidence score assigned by the recognizer (0.0 to 1.0) + */ + score: number; + /** + * What the recognizer analyzed to apply this tag + */ + target?: Target; +} + +/** + * Information about a pattern that matched during recognition + */ +export interface PatternMatch { + /** + * Name of the pattern that matched + */ + name: string; + /** + * Regular expression or pattern definition + */ + regex?: string; + /** + * Confidence score for this specific pattern match + */ + score: number; +} + +/** + * What the recognizer analyzed to apply this tag + */ +export enum Target { + ColumnName = "column_name", + Content = "content", +} + +/** + * Label is from Tags or Glossary. + */ +export enum TagSource { + Classification = "Classification", + Glossary = "Glossary", +} + +/** + * 'Suggested' state is used when a tag label is suggested by users or tools. Owner of the + * entity must confirm the suggested labels before it is marked as 'Confirmed'. + */ +export enum State { + Confirmed = "Confirmed", + Suggested = "Suggested", +} + +/** + * UI Style is used to associate a color code and/or icon to entity to customize the look of + * that entity in UI. + */ +export interface Style { + /** + * Hex Color Code to mark an entity such as GlossaryTerm, Tag, Domain or Data Product. + */ + color?: string; + /** + * Cover image configuration for the entity. + */ + coverImage?: CoverImage; + /** + * An icon to associate with GlossaryTerm, Tag, Domain or Data Product. + */ + iconURL?: string; +} + +/** + * Cover image configuration for the entity. + * + * Cover image configuration for an entity. This is used to display a banner or header image + * for entities like Domain, Glossary, Data Product, etc. + */ +export interface CoverImage { + /** + * Position of the cover image in CSS background-position format. Supports keywords (top, + * center, bottom) or pixel values (e.g., '20px 30px'). + */ + position?: string; + /** + * URL of the cover image. + */ + url?: string; +} From 8485585eca66ac0d8edb02acdd215378d2917062 Mon Sep 17 00:00:00 2001 From: Pere Miquel Brull Date: Mon, 18 May 2026 18:28:55 +0200 Subject: [PATCH 12/16] fix(context-memory): address review (relationship cleanup, owner scope, validations) Copilot review on the ContextMemory entity: - #1 record primaryEntity/relatedEntities/root/parent/source*/machineRepresentation in version history; usageCount/lastUsedAt documented as untracked telemetry - #2 clear stale HAS/RELATED_TO/CONTAINS edges before re-adding in storeRelationships - #4 default creator as owner only on create; PUT without owners no longer silently replaces previously set owners - #5 schema documents that any status is allowed at creation; transitions enforced only on update - #6 setFullyQualifiedName via FullyQualifiedName.build with skip-if-set guard - #7 validate shared principal type is user/team/domain - #8 reject self-reference for parentMemory/rootMemory - #10 inline Entity.CONTEXT_MEMORY, drop redundant constant Regenerate ContextMemory TS types for the schema doc change; add IT coverage for the self-reference and invalid-shared-principal validations. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../it/tests/ContextMemoryIT.java | 42 +++++++ .../jdbi3/ContextMemoryRepository.java | 112 ++++++++++++++---- .../context/ContextMemoryMapper.java | 11 +- .../schema/entity/context/contextMemory.json | 2 +- .../api/context/createContextMemory.ts | 4 +- .../generated/entity/context/contextMemory.ts | 4 +- 6 files changed, 140 insertions(+), 35 deletions(-) diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/ContextMemoryIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/ContextMemoryIT.java index c9c0065ca68b..2b93dc6d5b0f 100644 --- a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/ContextMemoryIT.java +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/ContextMemoryIT.java @@ -21,6 +21,7 @@ import org.openmetadata.schema.entity.context.ContextMemoryStatus; import org.openmetadata.schema.entity.context.ContextMemoryType; import org.openmetadata.schema.entity.context.MemoryShareConfig; +import org.openmetadata.schema.entity.context.MemorySharedPrincipal; import org.openmetadata.schema.entity.context.MemoryVisibility; import org.openmetadata.schema.entity.teams.User; import org.openmetadata.schema.type.EntityHistory; @@ -413,6 +414,47 @@ void post_contextMemoryWithShareConfigVisibility_200_OK(TestNamespace ns) { assertEquals(MemoryVisibility.PRIVATE, memory.getShareConfig().getVisibility()); } + // =================================================================== + // VALIDATION TESTS + // =================================================================== + + @Test + void patch_contextMemorySelfParentReference_4xx(TestNamespace ns) { + ContextMemory memory = createEntity(createMinimalRequest(ns)); + + ContextMemory selfParent = getEntity(memory.getId().toString()); + selfParent.setParentMemory(memory.getEntityReference()); + + assertThrows( + Exception.class, + () -> patchEntity(memory.getId().toString(), selfParent), + "A memory must not reference itself as parentMemory"); + } + + @Test + void post_contextMemoryInvalidSharedPrincipalType_4xx(TestNamespace ns) { + ContextMemory principalMemory = createEntity(createMinimalRequest(ns)); + + CreateContextMemory request = + new CreateContextMemory() + .withName(ns.prefix("bad-principal")) + .withDescription("Invalid shared principal type") + .withQuestion("Can a memory be a shared principal?") + .withAnswer("No - only user, team, or domain principals are allowed.") + .withShareConfig( + new MemoryShareConfig() + .withVisibility(MemoryVisibility.SHARED) + .withSharedWith( + List.of( + new MemorySharedPrincipal() + .withPrincipal(principalMemory.getEntityReference())))); + + assertThrows( + Exception.class, + () -> createEntity(request), + "Sharing with a non-user/team/domain principal must be rejected"); + } + @Test void post_contextMemoryAllTypes_200_OK(TestNamespace ns) { for (ContextMemoryType type : ContextMemoryType.values()) { diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/ContextMemoryRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/ContextMemoryRepository.java index 2d55e14961f6..91dd493d5c8e 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/ContextMemoryRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/ContextMemoryRepository.java @@ -14,10 +14,13 @@ package org.openmetadata.service.jdbi3; import static org.openmetadata.common.utils.CommonUtil.listOrEmpty; +import static org.openmetadata.common.utils.CommonUtil.nullOrEmpty; import jakarta.ws.rs.BadRequestException; +import java.util.List; import java.util.Map; import java.util.Set; +import java.util.UUID; import lombok.extern.slf4j.Slf4j; import org.openmetadata.schema.entity.context.ContextMemory; import org.openmetadata.schema.entity.context.ContextMemoryStatus; @@ -30,11 +33,11 @@ import org.openmetadata.service.util.EntityUtil; import org.openmetadata.service.util.EntityUtil.Fields; import org.openmetadata.service.util.EntityUtil.RelationIncludes; +import org.openmetadata.service.util.FullyQualifiedName; @Slf4j @Repository(name = "ContextMemoryRepository") public class ContextMemoryRepository extends EntityRepository { - public static final String CONTEXT_MEMORY_ENTITY = Entity.CONTEXT_MEMORY; public ContextMemoryRepository() { super( @@ -59,13 +62,20 @@ protected void clearFields(ContextMemory entity, Fields fields) { @Override public void setFullyQualifiedName(ContextMemory entity) { + if (!nullOrEmpty(entity.getFullyQualifiedName())) { + return; + } // FQN is the (immutable) memory name. Deriving it from mutable fields such as // primaryEntity or owners would change nameHash on update, risking unique-constraint // collisions and orphaned references. The link to primaryEntity/owners is captured - // via the relationship table instead. - entity.setFullyQualifiedName(entity.getName()); + // via the relationship table instead. FullyQualifiedName.build quotes reserved + // characters, matching the convention in every other top-level entity repository. + entity.setFullyQualifiedName(FullyQualifiedName.build(entity.getName())); } + private static final Set ALLOWED_SHARED_PRINCIPAL_TYPES = + Set.of(Entity.USER, Entity.TEAM, Entity.DOMAIN); + @Override public void prepare(ContextMemory entity, boolean update) { if (entity.getPrimaryEntity() != null) { @@ -77,29 +87,60 @@ public void prepare(ContextMemory entity, boolean update) { if (entity.getRootMemory() != null) { ContextMemory rootMemory = Entity.getEntity(entity.getRootMemory(), "", Include.NON_DELETED); + validateNotSelfReference(entity, rootMemory.getId(), "rootMemory"); entity.setRootMemory(rootMemory.getEntityReference()); } if (entity.getParentMemory() != null) { ContextMemory parentMemory = Entity.getEntity(entity.getParentMemory(), "", Include.NON_DELETED); + validateNotSelfReference(entity, parentMemory.getId(), "parentMemory"); entity.setParentMemory(parentMemory.getEntityReference()); } - if (entity.getShareConfig() != null && entity.getShareConfig().getSharedWith() != null) { - entity - .getShareConfig() - .getSharedWith() - .forEach( - sharedPrincipal -> { - if (sharedPrincipal.getPrincipal() != null) { - EntityReference principal = - Entity.getEntityReference( - sharedPrincipal.getPrincipal(), Include.NON_DELETED); - sharedPrincipal.setPrincipal(principal); - } - }); + validateSharedPrincipals(entity); + setCreatorAsDefaultOwner(entity, update); + } + + private void validateNotSelfReference(ContextMemory entity, UUID referencedId, String field) { + if (entity.getId() != null && entity.getId().equals(referencedId)) { + throw new BadRequestException( + String.format("A context memory cannot reference itself as %s", field)); + } + } + + private void validateSharedPrincipals(ContextMemory entity) { + if (entity.getShareConfig() == null || entity.getShareConfig().getSharedWith() == null) { + return; + } + for (var sharedPrincipal : entity.getShareConfig().getSharedWith()) { + if (sharedPrincipal.getPrincipal() == null) { + continue; + } + EntityReference principal = + Entity.getEntityReference(sharedPrincipal.getPrincipal(), Include.NON_DELETED); + if (!ALLOWED_SHARED_PRINCIPAL_TYPES.contains(principal.getType())) { + throw new BadRequestException( + String.format( + "Invalid shared principal type '%s'. Supported types: %s", + principal.getType(), ALLOWED_SHARED_PRINCIPAL_TYPES)); + } + sharedPrincipal.setPrincipal(principal); } } + /** + * The creator owns the memory only at creation time. On update/PUT the owners are managed by the + * standard framework path so omitting owners no longer silently replaces previously set owners. + */ + private void setCreatorAsDefaultOwner(ContextMemory entity, boolean update) { + if (update || !nullOrEmpty(entity.getOwners())) { + return; + } + entity.setOwners( + List.of( + Entity.getEntityReferenceByName( + Entity.USER, entity.getUpdatedBy(), Include.NON_DELETED))); + } + @Override public void storeEntity(ContextMemory entity, boolean update) { store(entity, update); @@ -107,12 +148,18 @@ public void storeEntity(ContextMemory entity, boolean update) { @Override public void storeRelationships(ContextMemory entity) { + // storeRelationships re-runs on every update; clear prior inbound edges first so a changed + // or removed primaryEntity/relatedEntities/root/parent link does not leave orphan rows. + deleteTo(entity.getId(), Entity.CONTEXT_MEMORY, Relationship.HAS, null); + deleteTo(entity.getId(), Entity.CONTEXT_MEMORY, Relationship.RELATED_TO, null); + deleteTo(entity.getId(), Entity.CONTEXT_MEMORY, Relationship.CONTAINS, Entity.CONTEXT_MEMORY); + if (entity.getPrimaryEntity() != null) { addRelationship( entity.getPrimaryEntity().getId(), entity.getId(), entity.getPrimaryEntity().getType(), - CONTEXT_MEMORY_ENTITY, + Entity.CONTEXT_MEMORY, Relationship.HAS); } @@ -121,7 +168,7 @@ public void storeRelationships(ContextMemory entity) { relatedEntity.getId(), entity.getId(), relatedEntity.getType(), - CONTEXT_MEMORY_ENTITY, + Entity.CONTEXT_MEMORY, Relationship.RELATED_TO); } @@ -131,8 +178,8 @@ public void storeRelationships(ContextMemory entity) { addRelationship( entity.getRootMemory().getId(), entity.getId(), - CONTEXT_MEMORY_ENTITY, - CONTEXT_MEMORY_ENTITY, + Entity.CONTEXT_MEMORY, + Entity.CONTEXT_MEMORY, Relationship.CONTAINS); } @@ -140,8 +187,8 @@ public void storeRelationships(ContextMemory entity) { addRelationship( entity.getParentMemory().getId(), entity.getId(), - CONTEXT_MEMORY_ENTITY, - CONTEXT_MEMORY_ENTITY, + Entity.CONTEXT_MEMORY, + Entity.CONTEXT_MEMORY, Relationship.HAS); } } @@ -206,6 +253,24 @@ public void entitySpecificUpdate(boolean consolidatingChanges) { recordChange("answer", original.getAnswer(), updated.getAnswer()); recordChange("memoryType", original.getMemoryType(), updated.getMemoryType()); recordChange("memoryScope", original.getMemoryScope(), updated.getMemoryScope()); + recordChange("primaryEntity", original.getPrimaryEntity(), updated.getPrimaryEntity()); + recordChange( + "relatedEntities", original.getRelatedEntities(), updated.getRelatedEntities(), true); + recordChange("rootMemory", original.getRootMemory(), updated.getRootMemory()); + recordChange("parentMemory", original.getParentMemory(), updated.getParentMemory()); + recordChange("sourceType", original.getSourceType(), updated.getSourceType()); + recordChange( + "sourceConversation", original.getSourceConversation(), updated.getSourceConversation()); + recordChange( + "sourceHumanMessage", original.getSourceHumanMessage(), updated.getSourceHumanMessage()); + recordChange( + "sourceAssistantMessage", + original.getSourceAssistantMessage(), + updated.getSourceAssistantMessage()); + recordChange( + "machineRepresentation", + original.getMachineRepresentation(), + updated.getMachineRepresentation()); // Validate lifecycle transition before recording status change if (original.getStatus() != null @@ -216,6 +281,9 @@ public void entitySpecificUpdate(boolean consolidatingChanges) { recordChange("status", original.getStatus(), updated.getStatus()); recordChange("shareConfig", original.getShareConfig(), updated.getShareConfig()); + + // usageCount and lastUsedAt are AI-retrieval telemetry, intentionally excluded from + // version history so routine retrieval does not churn the entity version. } } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/context/ContextMemoryMapper.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/context/ContextMemoryMapper.java index 84590e0604e3..84d1ef003214 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/context/ContextMemoryMapper.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/context/ContextMemoryMapper.java @@ -16,10 +16,8 @@ import static org.openmetadata.common.utils.CommonUtil.nullOrEmpty; import static org.openmetadata.service.Entity.getEntityReferenceByName; -import java.util.List; import org.openmetadata.schema.api.context.CreateContextMemory; import org.openmetadata.schema.entity.context.ContextMemory; -import org.openmetadata.schema.type.EntityReference; import org.openmetadata.schema.type.Include; import org.openmetadata.service.Entity; import org.openmetadata.service.mapper.EntityMapper; @@ -47,7 +45,7 @@ public ContextMemory createToEntity(CreateContextMemory create, String user) { .withRootMemory(create.getRootMemory()) .withParentMemory(create.getParentMemory()) .withMachineRepresentation(create.getMachineRepresentation()) - .withOwners(defaultOwners(create.getOwners(), user)) + .withOwners(create.getOwners()) .withTags(create.getTags()) .withDomains( nullOrEmpty(create.getDomains()) @@ -58,11 +56,4 @@ public ContextMemory createToEntity(CreateContextMemory create, String user) { getEntityReferenceByName(Entity.DOMAIN, domain, Include.NON_DELETED)) .toList()); } - - private List defaultOwners(List owners, String user) { - if (!nullOrEmpty(owners)) { - return owners; - } - return List.of(getEntityReferenceByName(Entity.USER, user, Include.NON_DELETED)); - } } diff --git a/openmetadata-spec/src/main/resources/json/schema/entity/context/contextMemory.json b/openmetadata-spec/src/main/resources/json/schema/entity/context/contextMemory.json index cf4984627fba..9418ca558475 100644 --- a/openmetadata-spec/src/main/resources/json/schema/entity/context/contextMemory.json +++ b/openmetadata-spec/src/main/resources/json/schema/entity/context/contextMemory.json @@ -35,7 +35,7 @@ }, "memoryStatus": { "javaType": "org.openmetadata.schema.entity.context.ContextMemoryStatus", - "description": "Lifecycle state of the memory.", + "description": "Lifecycle state of the memory. Any status may be set at creation (e.g. importing an already-archived memory); the Draft -> Active -> Archived transition rules are only enforced on subsequent updates.", "type": "string", "enum": ["Draft", "Active", "Archived"], "javaEnums": [ diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/api/context/createContextMemory.ts b/openmetadata-ui/src/main/resources/ui/src/generated/api/context/createContextMemory.ts index 4495ebe2f735..86121ceca80f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/api/context/createContextMemory.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/api/context/createContextMemory.ts @@ -237,7 +237,9 @@ export enum SourceType { } /** - * Lifecycle state of the memory. + * Lifecycle state of the memory. Any status may be set at creation (e.g. importing an + * already-archived memory); the Draft -> Active -> Archived transition rules are only + * enforced on subsequent updates. */ export enum MemoryStatus { Active = "Active", diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/entity/context/contextMemory.ts b/openmetadata-ui/src/main/resources/ui/src/generated/entity/context/contextMemory.ts index 3c09949280c3..e7670fee6e9e 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/entity/context/contextMemory.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/entity/context/contextMemory.ts @@ -375,7 +375,9 @@ export enum SourceType { } /** - * Lifecycle state of the memory. + * Lifecycle state of the memory. Any status may be set at creation (e.g. importing an + * already-archived memory); the Draft -> Active -> Archived transition rules are only + * enforced on subsequent updates. */ export enum MemoryStatus { Active = "Active", From 0f14b9a3d4f500cca7c63d0ca30bb93372dc8272 Mon Sep 17 00:00:00 2001 From: Pere Miquel Brull Date: Mon, 18 May 2026 18:46:43 +0200 Subject: [PATCH 13/16] fix(context-memory): don't blanket-delete relationships (domain data loss) The #2 cleanup via deleteTo(memory, CONTEXT_MEMORY, HAS, null) also matched the framework's domain --HAS--> memory edge (storeDomains runs before storeRelationships in storeRelationshipsInternal, on every create and update), silently dropping domain assignments. storeRelationships is now add-only (addRelationship upserts, so re-running on update is idempotent). Stale-edge cleanup moved to ContextMemoryUpdater using the framework's updateFromRelationship(s) helpers, which delete only the specific changed refs and record the version change. parentMemory now uses Relationship.PARENT_OF (distinct from primaryEntity's HAS and the framework's domain HAS) so the parent edge can be maintained without collision. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../jdbi3/ContextMemoryRepository.java | 62 ++++++++++++++----- 1 file changed, 48 insertions(+), 14 deletions(-) diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/ContextMemoryRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/ContextMemoryRepository.java index 91dd493d5c8e..695dc077a067 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/ContextMemoryRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/ContextMemoryRepository.java @@ -148,12 +148,10 @@ public void storeEntity(ContextMemory entity, boolean update) { @Override public void storeRelationships(ContextMemory entity) { - // storeRelationships re-runs on every update; clear prior inbound edges first so a changed - // or removed primaryEntity/relatedEntities/root/parent link does not leave orphan rows. - deleteTo(entity.getId(), Entity.CONTEXT_MEMORY, Relationship.HAS, null); - deleteTo(entity.getId(), Entity.CONTEXT_MEMORY, Relationship.RELATED_TO, null); - deleteTo(entity.getId(), Entity.CONTEXT_MEMORY, Relationship.CONTAINS, Entity.CONTEXT_MEMORY); - + // Add-only: addRelationship upserts, so re-running on update is idempotent. Stale-edge + // cleanup on update is handled in ContextMemoryUpdater via updateFromRelationship(s), + // which deletes only the specific changed refs. A blanket deleteTo here would also wipe + // the framework's domain --HAS--> memory edge (storeDomains runs before storeRelationships). if (entity.getPrimaryEntity() != null) { addRelationship( entity.getPrimaryEntity().getId(), @@ -172,8 +170,9 @@ public void storeRelationships(ContextMemory entity) { Relationship.RELATED_TO); } - // Distinct relationship types so the root-ancestor and direct-parent hierarchies - // can be resolved independently when read back from the relationship table. + // Distinct relationship types (CONTAINS for root-ancestor, PARENT_OF for direct parent) + // so the two hierarchies resolve independently and neither collides with the framework's + // HAS edges (domains). if (entity.getRootMemory() != null) { addRelationship( entity.getRootMemory().getId(), @@ -189,10 +188,14 @@ public void storeRelationships(ContextMemory entity) { entity.getId(), Entity.CONTEXT_MEMORY, Entity.CONTEXT_MEMORY, - Relationship.HAS); + Relationship.PARENT_OF); } } + private static List asRefList(EntityReference ref) { + return ref == null ? List.of() : List.of(ref); + } + // ------------------------------------------------------------------ // Lifecycle enforcement // ------------------------------------------------------------------ @@ -253,11 +256,6 @@ public void entitySpecificUpdate(boolean consolidatingChanges) { recordChange("answer", original.getAnswer(), updated.getAnswer()); recordChange("memoryType", original.getMemoryType(), updated.getMemoryType()); recordChange("memoryScope", original.getMemoryScope(), updated.getMemoryScope()); - recordChange("primaryEntity", original.getPrimaryEntity(), updated.getPrimaryEntity()); - recordChange( - "relatedEntities", original.getRelatedEntities(), updated.getRelatedEntities(), true); - recordChange("rootMemory", original.getRootMemory(), updated.getRootMemory()); - recordChange("parentMemory", original.getParentMemory(), updated.getParentMemory()); recordChange("sourceType", original.getSourceType(), updated.getSourceType()); recordChange( "sourceConversation", original.getSourceConversation(), updated.getSourceConversation()); @@ -282,6 +280,42 @@ public void entitySpecificUpdate(boolean consolidatingChanges) { recordChange("shareConfig", original.getShareConfig(), updated.getShareConfig()); + // Relationship-backed fields: these helpers record the version change and delete only + // the specific changed refs (never a blanket delete), so the framework's + // domain --HAS--> memory edge is left intact. + updateFromRelationships( + "primaryEntity", + Entity.CONTEXT_MEMORY, + asRefList(original.getPrimaryEntity()), + asRefList(updated.getPrimaryEntity()), + Relationship.HAS, + Entity.CONTEXT_MEMORY, + original.getId()); + updateFromRelationships( + "relatedEntities", + Entity.CONTEXT_MEMORY, + listOrEmpty(original.getRelatedEntities()), + listOrEmpty(updated.getRelatedEntities()), + Relationship.RELATED_TO, + Entity.CONTEXT_MEMORY, + original.getId()); + updateFromRelationship( + "rootMemory", + Entity.CONTEXT_MEMORY, + original.getRootMemory(), + updated.getRootMemory(), + Relationship.CONTAINS, + Entity.CONTEXT_MEMORY, + original.getId()); + updateFromRelationship( + "parentMemory", + Entity.CONTEXT_MEMORY, + original.getParentMemory(), + updated.getParentMemory(), + Relationship.PARENT_OF, + Entity.CONTEXT_MEMORY, + original.getId()); + // usageCount and lastUsedAt are AI-retrieval telemetry, intentionally excluded from // version history so routine retrieval does not churn the entity version. } From e7c7d660a4c48fe8de7ab8f91bc126d93c659b9e Mon Sep 17 00:00:00 2001 From: Pere Miquel Brull Date: Mon, 18 May 2026 18:49:06 +0200 Subject: [PATCH 14/16] chore(bootstrap): move context_memory DDL from 2.0.1 to 2.0.0 The context_memory table belongs in the 2.0.0 migration. Relocated the MySQL and Postgres DDL verbatim; the 2.0.1 schemaChanges.sql files are restored to their original task_migration_mapping-only content. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../native/2.0.0/mysql/schemaChanges.sql | 15 +++++++++++++++ .../native/2.0.0/postgres/schemaChanges.sql | 15 +++++++++++++++ .../native/2.0.1/mysql/schemaChanges.sql | 15 --------------- .../native/2.0.1/postgres/schemaChanges.sql | 15 --------------- 4 files changed, 30 insertions(+), 30 deletions(-) diff --git a/bootstrap/sql/migrations/native/2.0.0/mysql/schemaChanges.sql b/bootstrap/sql/migrations/native/2.0.0/mysql/schemaChanges.sql index 814df2c138bb..8d962cba75d0 100644 --- a/bootstrap/sql/migrations/native/2.0.0/mysql/schemaChanges.sql +++ b/bootstrap/sql/migrations/native/2.0.0/mysql/schemaChanges.sql @@ -340,3 +340,18 @@ CREATE TABLE IF NOT EXISTS search_index_retry_queue ( KEY idx_search_index_retry_queue_status (status), KEY idx_search_index_retry_queue_claimed_at (claimedAt) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +-- ContextMemory entity - reusable Context Center memory. +CREATE TABLE IF NOT EXISTS context_memory ( + id VARCHAR(36) GENERATED ALWAYS AS (json ->> '$.id') STORED NOT NULL, + name VARCHAR(256) GENERATED ALWAYS AS (json ->> '$.name') STORED NOT NULL, + nameHash VARCHAR(256) NOT NULL COLLATE ascii_bin, + json JSON NOT NULL, + updatedAt BIGINT UNSIGNED GENERATED ALWAYS AS (json ->> '$.updatedAt') STORED NOT NULL, + updatedBy VARCHAR(256) GENERATED ALWAYS AS (json ->> '$.updatedBy') STORED NOT NULL, + deleted BOOLEAN GENERATED ALWAYS AS (json -> '$.deleted') STORED, + + PRIMARY KEY (id), + UNIQUE KEY unique_context_memory_name (nameHash), + INDEX idx_context_memory_updated_at (updatedAt) +) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; diff --git a/bootstrap/sql/migrations/native/2.0.0/postgres/schemaChanges.sql b/bootstrap/sql/migrations/native/2.0.0/postgres/schemaChanges.sql index f544b9511cea..6ce3236f6daa 100644 --- a/bootstrap/sql/migrations/native/2.0.0/postgres/schemaChanges.sql +++ b/bootstrap/sql/migrations/native/2.0.0/postgres/schemaChanges.sql @@ -291,3 +291,18 @@ CREATE INDEX IF NOT EXISTS idx_search_index_retry_queue_status ON search_index_retry_queue (status); CREATE INDEX IF NOT EXISTS idx_search_index_retry_queue_claimed_at ON search_index_retry_queue (claimedAt); + +-- ContextMemory entity - reusable Context Center memory. +CREATE TABLE IF NOT EXISTS context_memory ( + id VARCHAR(36) GENERATED ALWAYS AS (json ->> 'id') STORED NOT NULL, + name VARCHAR(256) GENERATED ALWAYS AS (json ->> 'name') STORED NOT NULL, + nameHash VARCHAR(256) NOT NULL, + json JSONB NOT NULL, + updatedAt BIGINT GENERATED ALWAYS AS ((json ->> 'updatedAt')::bigint) STORED NOT NULL, + updatedBy VARCHAR(256) GENERATED ALWAYS AS (json ->> 'updatedBy') STORED NOT NULL, + deleted BOOLEAN GENERATED ALWAYS AS ((json ->> 'deleted')::boolean) STORED, + + PRIMARY KEY (id), + UNIQUE (nameHash) +); +CREATE INDEX IF NOT EXISTS idx_context_memory_updated_at ON context_memory (updatedAt); diff --git a/bootstrap/sql/migrations/native/2.0.1/mysql/schemaChanges.sql b/bootstrap/sql/migrations/native/2.0.1/mysql/schemaChanges.sql index 1fa3cd9ba4c3..89cb6ad9374a 100644 --- a/bootstrap/sql/migrations/native/2.0.1/mysql/schemaChanges.sql +++ b/bootstrap/sql/migrations/native/2.0.1/mysql/schemaChanges.sql @@ -9,18 +9,3 @@ CREATE TABLE IF NOT EXISTS task_migration_mapping ( PRIMARY KEY (old_thread_id), KEY idx_task_migration_mapping_new_task_id (new_task_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; - --- ContextMemory entity - reusable Context Center memory. -CREATE TABLE IF NOT EXISTS context_memory ( - id VARCHAR(36) GENERATED ALWAYS AS (json ->> '$.id') STORED NOT NULL, - name VARCHAR(256) GENERATED ALWAYS AS (json ->> '$.name') STORED NOT NULL, - nameHash VARCHAR(256) NOT NULL COLLATE ascii_bin, - json JSON NOT NULL, - updatedAt BIGINT UNSIGNED GENERATED ALWAYS AS (json ->> '$.updatedAt') STORED NOT NULL, - updatedBy VARCHAR(256) GENERATED ALWAYS AS (json ->> '$.updatedBy') STORED NOT NULL, - deleted BOOLEAN GENERATED ALWAYS AS (json -> '$.deleted') STORED, - - PRIMARY KEY (id), - UNIQUE KEY unique_context_memory_name (nameHash), - INDEX idx_context_memory_updated_at (updatedAt) -) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; diff --git a/bootstrap/sql/migrations/native/2.0.1/postgres/schemaChanges.sql b/bootstrap/sql/migrations/native/2.0.1/postgres/schemaChanges.sql index 489b9e890884..5fbb6205f602 100644 --- a/bootstrap/sql/migrations/native/2.0.1/postgres/schemaChanges.sql +++ b/bootstrap/sql/migrations/native/2.0.1/postgres/schemaChanges.sql @@ -11,18 +11,3 @@ CREATE TABLE IF NOT EXISTS task_migration_mapping ( CREATE INDEX IF NOT EXISTS idx_task_migration_mapping_new_task_id ON task_migration_mapping (new_task_id); - --- ContextMemory entity - reusable Context Center memory. -CREATE TABLE IF NOT EXISTS context_memory ( - id VARCHAR(36) GENERATED ALWAYS AS (json ->> 'id') STORED NOT NULL, - name VARCHAR(256) GENERATED ALWAYS AS (json ->> 'name') STORED NOT NULL, - nameHash VARCHAR(256) NOT NULL, - json JSONB NOT NULL, - updatedAt BIGINT GENERATED ALWAYS AS ((json ->> 'updatedAt')::bigint) STORED NOT NULL, - updatedBy VARCHAR(256) GENERATED ALWAYS AS (json ->> 'updatedBy') STORED NOT NULL, - deleted BOOLEAN GENERATED ALWAYS AS ((json ->> 'deleted')::boolean) STORED, - - PRIMARY KEY (id), - UNIQUE (nameHash) -); -CREATE INDEX IF NOT EXISTS idx_context_memory_updated_at ON context_memory (updatedAt); From 2f7eff46ae5db140b172357ee5dab94b5684ae5e Mon Sep 17 00:00:00 2001 From: Pere Miquel Brull Date: Tue, 19 May 2026 07:30:24 +0200 Subject: [PATCH 15/16] chore(bootstrap): add ENGINE=InnoDB to context_memory MySQL DDL Explicit engine clause, consistent with the task/search-index tables in the same migration and robust to any server default change. Co-Authored-By: Claude Opus 4.7 (1M context) --- bootstrap/sql/migrations/native/2.0.0/mysql/schemaChanges.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bootstrap/sql/migrations/native/2.0.0/mysql/schemaChanges.sql b/bootstrap/sql/migrations/native/2.0.0/mysql/schemaChanges.sql index 8d962cba75d0..45f25fd8f6c4 100644 --- a/bootstrap/sql/migrations/native/2.0.0/mysql/schemaChanges.sql +++ b/bootstrap/sql/migrations/native/2.0.0/mysql/schemaChanges.sql @@ -354,4 +354,4 @@ CREATE TABLE IF NOT EXISTS context_memory ( PRIMARY KEY (id), UNIQUE KEY unique_context_memory_name (nameHash), INDEX idx_context_memory_updated_at (updatedAt) -) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; From 04dad90da348646719311ca187cdeb78ca5be97d Mon Sep 17 00:00:00 2001 From: Pere Miquel Brull Date: Tue, 19 May 2026 11:20:22 +0200 Subject: [PATCH 16/16] fix(context-memory): preserve sanitized/validated fields; validate relatedEntities Review follow-ups: - ContextMemoryMapper no longer re-sets description/owners/domains/tags/displayName after copy(). copy() sanitizes description (stored-XSS) and validates owners and domains; re-setting the raw request values bypassed both. Only ContextMemory- specific fields are set now. - prepare() now assigns the result of EntityUtil.populateEntityReferences back onto relatedEntities so orphaned/invalid refs are filtered instead of persisted. - ContextMemoryIT Javadoc now references ContextMemoryRepository#setCreatorAsDefaultOwner (the defaultOwners mapper method no longer exists). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../it/tests/ContextMemoryIT.java | 10 ++++----- .../jdbi3/ContextMemoryRepository.java | 2 +- .../context/ContextMemoryMapper.java | 22 ++++--------------- 3 files changed, 10 insertions(+), 24 deletions(-) diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/ContextMemoryIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/ContextMemoryIT.java index 2b93dc6d5b0f..15c0bf386488 100644 --- a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/ContextMemoryIT.java +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/ContextMemoryIT.java @@ -509,9 +509,9 @@ void test_listContextMemories(TestNamespace ns) { /** * ContextMemory auto-assigns the creating user as owner when the create request omits owners - * (see {@code ContextMemoryMapper#defaultOwners}), so it deliberately diverges from the generic - * BaseEntityIT precondition that a freshly created entity has no owner. The PATCH contract is - * unchanged: setting an explicit owner replaces the creator. + * (see {@code ContextMemoryRepository#setCreatorAsDefaultOwner}), so it deliberately diverges + * from the generic BaseEntityIT precondition that a freshly created entity has no owner. The + * PATCH contract is unchanged: setting an explicit owner replaces the creator. */ @Test @Override @@ -545,8 +545,8 @@ void patch_entityUpdateOwner_200(TestNamespace ns) { /** * ContextMemory already has the creating user as its sole owner before this PATCH (see {@code - * ContextMemoryMapper#defaultOwners}); the original "from null" precondition does not hold. - * Setting an explicit owners list still replaces it wholesale. + * ContextMemoryRepository#setCreatorAsDefaultOwner}); the original "from null" precondition does + * not hold. Setting an explicit owners list still replaces it wholesale. */ @Test @Override diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/ContextMemoryRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/ContextMemoryRepository.java index 695dc077a067..3af048a1c9a7 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/ContextMemoryRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/ContextMemoryRepository.java @@ -83,7 +83,7 @@ public void prepare(ContextMemory entity, boolean update) { Entity.getEntityReference(entity.getPrimaryEntity(), Include.NON_DELETED); entity.setPrimaryEntity(primaryEntity); } - EntityUtil.populateEntityReferences(entity.getRelatedEntities()); + entity.setRelatedEntities(EntityUtil.populateEntityReferences(entity.getRelatedEntities())); if (entity.getRootMemory() != null) { ContextMemory rootMemory = Entity.getEntity(entity.getRootMemory(), "", Include.NON_DELETED); diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/context/ContextMemoryMapper.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/context/ContextMemoryMapper.java index 84d1ef003214..0cb2dd27efd2 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/context/ContextMemoryMapper.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/context/ContextMemoryMapper.java @@ -13,21 +13,17 @@ package org.openmetadata.service.resources.context; -import static org.openmetadata.common.utils.CommonUtil.nullOrEmpty; -import static org.openmetadata.service.Entity.getEntityReferenceByName; - import org.openmetadata.schema.api.context.CreateContextMemory; import org.openmetadata.schema.entity.context.ContextMemory; -import org.openmetadata.schema.type.Include; -import org.openmetadata.service.Entity; import org.openmetadata.service.mapper.EntityMapper; public class ContextMemoryMapper implements EntityMapper { @Override public ContextMemory createToEntity(CreateContextMemory create, String user) { + // copy() owns the common fields: it sanitizes description and validates owners, + // domains, and reviewers. Re-setting them here would reintroduce the raw + // (unsanitized/unvalidated) values, so only ContextMemory-specific fields are set. return copy(new ContextMemory(), create, user) - .withDisplayName(create.getDisplayName()) - .withDescription(create.getDescription()) .withTitle(create.getTitle()) .withSummary(create.getSummary()) .withQuestion(create.getQuestion()) @@ -44,16 +40,6 @@ public ContextMemory createToEntity(CreateContextMemory create, String user) { .withSourceAssistantMessage(create.getSourceAssistantMessage()) .withRootMemory(create.getRootMemory()) .withParentMemory(create.getParentMemory()) - .withMachineRepresentation(create.getMachineRepresentation()) - .withOwners(create.getOwners()) - .withTags(create.getTags()) - .withDomains( - nullOrEmpty(create.getDomains()) - ? null - : create.getDomains().stream() - .map( - domain -> - getEntityReferenceByName(Entity.DOMAIN, domain, Include.NON_DELETED)) - .toList()); + .withMachineRepresentation(create.getMachineRepresentation()); } }