diff --git a/openframe-api-lib/src/main/java/com/openframe/api/service/device/DeviceTagService.java b/openframe-api-lib/src/main/java/com/openframe/api/service/device/DeviceTagService.java
new file mode 100644
index 000000000..5433c34ca
--- /dev/null
+++ b/openframe-api-lib/src/main/java/com/openframe/api/service/device/DeviceTagService.java
@@ -0,0 +1,182 @@
+package com.openframe.api.service.device;
+
+import com.openframe.api.exception.DeviceNotFoundException;
+import com.openframe.data.document.tag.Tag;
+import com.openframe.data.document.tag.TagAssignment;
+import com.openframe.data.document.tag.TagValidation;
+import com.openframe.data.repository.device.MachineRepository;
+import com.openframe.data.repository.tag.TagAssignmentRepository;
+import com.openframe.data.repository.tag.TagRepository;
+import jakarta.validation.constraints.NotBlank;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.validation.annotation.Validated;
+
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Optional;
+
+import static com.openframe.data.document.tag.TagEntityType.DEVICE;
+
+/**
+ * Attaches and detaches DEVICE tags at runtime — the counterpart to
+ * {@code RegistrationTagAssignmentService}, which does the same thing from the agent-registration
+ * payload. Both write only Mongo ({@code tags} + {@code tag_assignments}); the Pinot facet columns
+ * are refreshed by {@code MachineTagEventAspect}, which intercepts the repository calls made here
+ * and republishes the machine's full tag list to Kafka.
+ *
+ *
Every write therefore has to go through an intercepted repository method — {@code save} or
+ * {@code deleteByEntityIdAndTagIdAndEntityType}. Bypassing them (e.g. {@code deleteAll}) would
+ * leave the device's Pinot row carrying tags it no longer has.
+ *
+ *
Not reused from the client-core service because {@code openframe-client-core} does not depend
+ * on {@code openframe-api-lib}, and pulling the logic down into a shared module would drag the
+ * machine/tag repositories along with it.
+ */
+@Service
+@Slf4j
+@Validated
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+public class DeviceTagService {
+
+ private final TagRepository tagRepository;
+ private final TagAssignmentRepository tagAssignmentRepository;
+ private final MachineRepository machineRepository;
+
+ /**
+ * Tags a device with {@code key}, creating the tag key on first use.
+ *
+ *
Additive in both directions: new {@code values} are merged into the device's existing
+ * assignment rather than replacing it, and into the tag's list of predefined options. Calling
+ * it twice with the same key and values is a no-op beyond the Pinot republish.
+ *
+ * @return the tag, carrying this device's values (not the tag's full option list)
+ */
+ @Transactional
+ public Tag assignTag(@NotBlank String machineId, @NotBlank String key, List values) {
+ TagValidation.validateKey(key);
+ TagValidation.validateValues(values, key);
+ requireMachine(machineId);
+
+ Tag tag = findOrCreateTag(key, values);
+ List assignedValues = upsertAssignment(machineId, tag.getId(), values);
+
+ log.info("Assigned tag '{}' to machine {} with values {}", key, machineId, assignedValues);
+ return Tag.builder()
+ .id(tag.getId())
+ .key(tag.getKey())
+ .description(tag.getDescription())
+ .color(tag.getColor())
+ .values(assignedValues)
+ .entityType(tag.getEntityType())
+ .createdAt(tag.getCreatedAt())
+ .build();
+ }
+
+ /**
+ * Detaches a tag from a device. The tag key itself survives — it stays available for other
+ * devices and in the filter dropdowns; use {@code TagService.deleteTag} to drop the key
+ * everywhere.
+ *
+ * @return {@code true} if the device had the tag, {@code false} if there was nothing to remove
+ */
+ @Transactional
+ public boolean removeTag(@NotBlank String machineId, @NotBlank String tagId) {
+ requireMachine(machineId);
+
+ boolean assigned = tagAssignmentRepository
+ .findByEntityIdAndTagIdAndEntityType(machineId, tagId, DEVICE)
+ .isPresent();
+ if (!assigned) {
+ log.info("Tag {} is not assigned to machine {}, nothing to remove", tagId, machineId);
+ return false;
+ }
+
+ // Aspect-intercepted: publishes the machine's remaining tags before the delete proceeds.
+ tagAssignmentRepository.deleteByEntityIdAndTagIdAndEntityType(machineId, tagId, DEVICE);
+ log.info("Removed tag {} from machine {}", tagId, machineId);
+ return true;
+ }
+
+ private void requireMachine(String machineId) {
+ if (machineRepository.findByMachineId(machineId).isEmpty()) {
+ throw new DeviceNotFoundException("Device not found: " + machineId);
+ }
+ }
+
+ /**
+ * Finds the DEVICE tag for {@code key}, or creates it. On an existing tag any previously unseen
+ * values are appended to its predefined options, so a value typed on one device becomes a
+ * suggestion for the next.
+ */
+ private Tag findOrCreateTag(String key, List values) {
+ Tag existing = tagRepository.findByKeyAndEntityType(key, DEVICE);
+ if (existing == null) {
+ Tag created = tagRepository.save(Tag.builder()
+ .key(key)
+ .values(normalize(values))
+ .entityType(DEVICE)
+ .createdAt(Instant.now())
+ .build());
+ log.info("Created DEVICE tag '{}' (id={})", key, created.getId());
+ return created;
+ }
+
+ List merged = merge(existing.getValues(), values);
+ if (merged.size() != size(existing.getValues())) {
+ existing.setValues(merged);
+ // Aspect-intercepted: refreshes every device already carrying this tag.
+ existing = tagRepository.save(existing);
+ log.info("Appended values {} to existing tag '{}'", values, key);
+ }
+ return existing;
+ }
+
+ /**
+ * Merges {@code values} into the device's assignment, creating it if the device does not carry
+ * the tag yet. Saving through the repository is what triggers the Pinot republish.
+ */
+ private List upsertAssignment(String machineId, String tagId, List values) {
+ Optional existing = tagAssignmentRepository
+ .findByEntityIdAndTagIdAndEntityType(machineId, tagId, DEVICE);
+
+ if (existing.isEmpty()) {
+ TagAssignment saved = tagAssignmentRepository.save(TagAssignment.builder()
+ .entityId(machineId)
+ .tagId(tagId)
+ .entityType(DEVICE)
+ .values(normalize(values))
+ .taggedAt(Instant.now())
+ .build());
+ return saved.getValues();
+ }
+
+ TagAssignment assignment = existing.get();
+ List merged = merge(assignment.getValues(), values);
+ if (merged.size() != size(assignment.getValues())) {
+ assignment.setValues(merged);
+ return tagAssignmentRepository.save(assignment).getValues();
+ }
+ return assignment.getValues();
+ }
+
+ /** Insertion-ordered union — existing values keep their order, new ones are appended. */
+ private static List merge(List current, List added) {
+ LinkedHashSet merged = new LinkedHashSet<>(normalize(current));
+ merged.addAll(normalize(added));
+ return new ArrayList<>(merged);
+ }
+
+ private static List normalize(List values) {
+ return values != null ? values : List.of();
+ }
+
+ private static int size(List values) {
+ return values != null ? values.size() : 0;
+ }
+}
diff --git a/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/DeviceDataFetcher.java b/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/DeviceDataFetcher.java
index 2b5f08a93..84d16645c 100644
--- a/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/DeviceDataFetcher.java
+++ b/openframe-api-service-core/src/main/java/com/openframe/api/datafetcher/DeviceDataFetcher.java
@@ -19,6 +19,7 @@
import com.openframe.api.mapper.GraphQLDeviceMapper;
import com.openframe.api.service.device.DeviceFilterService;
import com.openframe.api.service.device.DeviceService;
+import com.openframe.api.service.device.DeviceTagService;
import com.openframe.api.service.FleetVulnerabilityStatusService;
import com.openframe.api.service.TagService;
import com.openframe.data.document.device.Machine;
@@ -53,6 +54,7 @@ public class DeviceDataFetcher {
private final DeviceService deviceService;
private final DeviceFilterService deviceFilterService;
+ private final DeviceTagService deviceTagService;
private final TagService tagService;
private final FleetVulnerabilityStatusService fleetVulnerabilityStatusService;
private final GraphQLDeviceMapper mapper;
@@ -131,6 +133,22 @@ public Machine updateDeviceNickname(@InputArgument @NotBlank String machineId,
return deviceService.updateNickname(machineId, nickname);
}
+ @DgsMutation
+ public Tag assignDeviceTag(@InputArgument @NotBlank String machineId,
+ @InputArgument @NotBlank String key,
+ @InputArgument List values) {
+ log.debug("Assigning tag '{}' to machineId: {}", key, machineId);
+ return deviceTagService.assignTag(machineId, key, values);
+ }
+
+ @DgsMutation
+ public boolean removeDeviceTag(@InputArgument @NotBlank String machineId,
+ @InputArgument @NotBlank String tagId) {
+ String rawTagId = RELAY.fromGlobalId(tagId).getId();
+ log.debug("Removing tag {} (rawId: {}) from machineId: {}", tagId, rawTagId, machineId);
+ return deviceTagService.removeTag(machineId, rawTagId);
+ }
+
@DgsData(parentType = "Machine", field = "id")
public String machineNodeId(DgsDataFetchingEnvironment dfe) {
Machine machine = dfe.getSource();
diff --git a/openframe-api-service-core/src/main/resources/schema/device.graphqls b/openframe-api-service-core/src/main/resources/schema/device.graphqls
index b02e91312..caaa4b64e 100644
--- a/openframe-api-service-core/src/main/resources/schema/device.graphqls
+++ b/openframe-api-service-core/src/main/resources/schema/device.graphqls
@@ -146,4 +146,13 @@ type InstalledAgent implements Node {
extend type Mutation {
updateDeviceNickname(machineId: String!, nickname: String): Machine!
+
+ # Tag a device, creating the tag key on first use. Values are merged into whatever the
+ # device already carries for that key, so this only ever adds. Returns the tag with
+ # THIS device's values.
+ assignDeviceTag(machineId: String!, key: String!, values: [String!]): Tag!
+
+ # Detach a tag from a device. The tag key itself is kept (use deleteTag to drop it
+ # everywhere). False when the device did not carry the tag.
+ removeDeviceTag(machineId: String!, tagId: ID!): Boolean!
}