This is an automated email from the ASF dual-hosted git repository.
jerryshao pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gravitino.git
The following commit(s) were added to refs/heads/main by this push:
new 304ea72afd [#12125] feat(design-docs): Add tag assignment values
design (#12126)
304ea72afd is described below
commit 304ea72afdbc4bd13944fd49e169bc123b513c14
Author: roryqi <[email protected]>
AuthorDate: Wed Jul 29 18:05:07 2026 +0800
[#12125] feat(design-docs): Add tag assignment values design (#12126)
### What changes were proposed in this pull request?
Add a design document for tag assignment values.
The design covers:
- multiple values per tag assignment
- allowedValues on tag creation
- one row per assignment value in tag_relation_meta
- pair-array assignment updates through tagsToAdd and tagsToRemove
- exact value lookup through GET /tags/{tag}/objects?value=...
### Why are the changes needed?
Tag assignments are currently boolean and cannot carry object-specific
values. This design defines the API, storage, client, cache,
authorization, event, and test plan for assignment-level tag values.
Fix: #12125
### Does this PR introduce _any_ user-facing change?
No code or API behavior changes. This PR only adds a design document.
### How was this patch tested?
- git diff --check apache/main...HEAD
- Checked the design document for stale standalone search API references
and placeholders.
---
design-docs/tag-assignment-values.md | 1046 ++++++++++++++++++++++++++++++++++
1 file changed, 1046 insertions(+)
diff --git a/design-docs/tag-assignment-values.md
b/design-docs/tag-assignment-values.md
new file mode 100644
index 0000000000..1bf0375b72
--- /dev/null
+++ b/design-docs/tag-assignment-values.md
@@ -0,0 +1,1046 @@
+<!--
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements. See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership. The ASF licenses this file
+ to you 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.
+-->
+
+# Design of Tag Assignment Values in Gravitino
+
+## Background
+
+Gravitino currently supports tags as metalake-scoped metadata objects. A tag
has a name, comment,
+properties, audit information, and can be associated with metadata objects
such as catalogs,
+schemas, tables, filesets, topics, models, and columns.
+
+The current model has several important limitations:
+
+1. A tag association is boolean. A metadata object either has a tag or does
not have it.
+2. Tag properties are global to the tag. They cannot represent values that
differ by metadata
+ object.
+3. Tags cannot declare the values that are valid for assignments.
+4. Because values do not exist as first-class metadata, users cannot find
metadata objects by tag
+ value.
+
+For example, users can create a tag named `data_domain`, but they cannot assign
+`data_domain = finance` to one table and `data_domain = risk` to another table
without creating
+separate tag names such as `data_domain_finance` and `data_domain_risk`. This
causes tag
+proliferation and makes policy integration harder.
+
+The current relational storage shape is:
+
+```text
+tag_meta
+ tag_id
+ tag_name
+ metalake_id
+ properties
+ audit_info
+
+tag_relation_meta
+ tag_id
+ metadata_object_id
+ metadata_object_type
+ audit_info
+ current_version
+ last_version
+ deleted_at
+```
+
+`tag_relation_meta` already represents the tag assignment between a tag and a
metadata object, but
+it has no field for assignment-level values.
+
+---
+
+## Goals
+
+1. **Assignment-Level Values**: Allow a tag assignment on a metadata object to
carry zero or more
+ string values.
+2. **Clear Inheritance Semantics**: Preserve the current inherited tag
behavior and define how
+ assignment values are inherited or overridden.
+3. **Batch Assignment Updates**: Allow clients to add or remove multiple
tag-value pairs on one
+ metadata object in a single request.
+4. **Allowed Values**: Allow a tag to declare an optional allowed value list
and reject
+ assignment values outside that list.
+5. **Value Lookup**: Allow users to find metadata objects by exact tag value
through indexed
+ storage and the existing tag-associated-object REST/client APIs.
+6. **Client and REST Visibility**: Return assignment values when clients get
or list detailed tags
+ for a metadata object.
+
+---
+
+## Non-Goals
+
+1. **Typed Values**: Values are strings only. Numeric, boolean, and
external-schema validation
+ are outside this design. The allowed value list is a flat string whitelist,
not a typed schema.
+2. **Relation Payload for Non-Tag Relations**: Only tag-to-metadata-object
relations carry values.
+ Other relation types remain unchanged.
+3. **Advanced Value Search**: Value lookup is exact string matching only.
Prefix, substring,
+ regular expression, full-text search, and multi-value matching are outside
this design.
+4. **Standalone Search API**: This design does not add a dedicated tag
assignment search endpoint
+ such as `POST /tags/{tag}/objects/search`. Value lookup is a filter on the
existing tag objects
+ listing API.
+5. **Cross-Tag Boolean Lookup**: Queries such as `data_domain = finance AND
retention = 7y` can be
+ added later on top of single-tag value lookup, but are outside this design.
+6. **Policy Semantics**: This design stores, exposes, and looks up values. How
authorization plugins
+ interpret those values is handled by policy-specific designs.
+7. **Allowed Value Mutation**: Changing or clearing a tag's `allowedValues`
after creation is
+ outside the first implementation.
+8. **Engine Pushdown**: This design does not push tag values to Hive, Iceberg,
JDBC catalogs, or
+ compute engines.
+
+---
+
+## Solution Investigations
+
+| Approach | Pros | Cons | Decision |
+|----------|------|------|----------|
+| Store values in tag `properties` | No storage schema change | Properties are
global to the tag, so different objects cannot have different values | Rejected
|
+| Add a `tag_values` field to `tag_relation_meta` | Preserves the current
one-row-per-assignment shape; minimizes write-path changes | Values are stored
as JSON/CLOB, so exact lookup cannot use a normal relational index | Rejected |
+| Add a separate assignment-value table | Preserves one assignment row per
object/tag while storing each value in an indexed row | Adds a new table and
extra joins even though the value belongs only to the tag relation | Rejected |
+| Add `tag_value` to `tag_relation_meta` and store one row per value | Keeps
values on the existing tag relation table; supports exact value lookup with a
normal index; avoids an extra table | Changes `tag_relation_meta` from one row
per assignment to one row per assignment value, so read paths must group rows
by tag/object | **Chosen** |
+
+---
+
+## Proposal
+
+### Terminology
+
+- **Tag**: The metalake-scoped tag metadata stored in `tag_meta`.
+- **Tag assignment**: The logical relation between one metadata object and one
tag.
+- **Assignment values**: The string values stored on a tag assignment.
+- **Allowed values**: The optional tag-level whitelist that constrains
assignment values for that
+ tag.
+- **Relation value row**: One physical row in `tag_relation_meta`. For valued
assignments, each
+ value has one row. For valueless assignments, one row has `tag_value` set to
`NULL`.
+
+One logical assignment continues to exist for each tuple:
+
+```text
+(tag_id, metadata_object_id, metadata_object_type)
+```
+
+The logical assignment may contain an empty value list or multiple values.
+
+### Value Semantics
+
+Assignment values follow these rules:
+
+1. Values are strings.
+2. In assignment update requests, a pair with an omitted or null `value`
represents a valueless tag
+ assignment row and is stored as `tag_value IS NULL`.
+3. A logical assignment with one active null row means the tag is assigned
without values, matching
+ current tag behavior.
+4. A logical assignment with one or more active non-null rows means the tag is
assigned with those
+ values. Public value arrays omit the null marker.
+5. Blank individual assignment values are invalid. Non-null values are limited
to 256 characters so
+ they can be indexed consistently.
+6. Duplicate pairs in one request are removed while preserving client-provided
order. Defensive read
+ paths also de-duplicate duplicated active rows if storage contains them.
+7. Exact value lookup is case-sensitive, matching Gravitino's existing binary
string comparison.
+8. Adding a value is incremental. It does not replace the other active values
for the same tag and
+ metadata object.
+9. Removing a value removes only that specific pair. Removing the last active
value removes the
+ direct tag assignment unless the same request adds the valueless pair.
+10. A tag may define optional allowed values. Missing allowed values means
assignment values are
+ unrestricted.
+11. An empty allowed value list means the tag can only be assigned without
values.
+12. When allowed values are configured, every non-null assignment value must
be in the allowed
+ value list.
+13. Allowed values are normalized with the same null, blank, length,
duplicate, order, and
+ case-sensitive comparison rules as assignment values.
+
+### Assignment Update Semantics
+
+Assignment updates are expressed as pair deltas. Both `tagsToAdd` and
`tagsToRemove` are arrays of
+objects shaped as `(tag name, assignment value)`. Multiple values for one tag
are represented by
+multiple pairs with the same tag name and different values.
+
+Pair identity is:
+
+```text
+(tag_id, metadata_object_id, metadata_object_type, tag_value)
+```
+
+A null `tag_value` is the valueless pair. Non-null values are normal indexed
values.
+
+The update semantics are:
+
+1. Adding a non-null pair inserts that value row if it is not already active.
Existing active values
+ for the same tag remain active.
+2. Adding an already active pair is an idempotent no-op and does not create
another physical row.
+3. Adding a non-null pair when the same tag has an active valueless row
converts the assignment to
+ valued form by soft-deleting the null row and inserting the requested value
row.
+4. Adding a valueless pair inserts one null row only when the tag has no
active values after the
+ request's removals are applied. If active non-null values remain, the
request fails with
+ `409 Conflict` because the request is ambiguous.
+5. Removing a pair soft-deletes only the matching active row. Removing a
missing pair is an
+ idempotent no-op.
+6. Removing the last active non-null pair leaves no direct assignment for that
tag unless the same
+ request adds the valueless pair.
+7. Duplicate pairs inside `tagsToAdd` or `tagsToRemove` are de-duplicated
while preserving order.
+8. The same exact pair cannot appear in both `tagsToAdd` and `tagsToRemove` in
one request.
+ Violations return `400 Bad Request`.
+9. `tagsToAdd` cannot contain both the valueless pair and non-null value pairs
for the same tag.
+ Clients that need to convert valued assignments to valueless assignments
should remove the
+ active value pairs and add the valueless pair in the same request.
+
+Example sequence for table `t1`:
+
+1. Add `(data_domain, finance)` and `(data_domain, risk)`.
+ Active rows: `finance`, `risk`.
+2. Add `(data_domain, ml)`.
+ Active rows: `finance`, `risk`, `ml`; existing rows are not replaced.
+3. Add `(data_domain, finance)` again.
+ Result: no-op; active rows stay `finance`, `risk`, `ml`.
+4. Remove `(data_domain, risk)`.
+ Active rows: `finance`, `ml`.
+5. Remove `(data_domain, risk)` again.
+ Result: no-op; active rows stay `finance`, `ml`.
+6. Remove `(data_domain, finance)` and `(data_domain, ml)`.
+ Result: no active rows for `data_domain`, so the direct tag assignment is
removed.
+7. Add `(data_domain, null)`.
+ Result: one active valueless row with `tag_value IS NULL`.
+8. Add `(data_domain, finance)`.
+ Result: the null row is soft-deleted and `finance` becomes the active
valued assignment.
+
+### Inheritance Semantics
+
+Inheritance continues to work by metadata hierarchy.
+
+If a child object has a direct assignment for a tag, the direct assignment
wins and inherited
+assignments with the same tag name are not returned. The values returned for
that tag are the values
+from the winning assignment.
+
+```text
+catalog c1: data_domain = ["finance"]
+schema c1.s1: no direct data_domain
+table c1.s1.t1: data_domain = ["risk", "ml"]
+
+list tags for c1.s1:
+ data_domain, inherited=true, values=["finance"]
+
+list tags for c1.s1.t1:
+ data_domain, inherited=false, values=["risk", "ml"]
+```
+
+The implementation should replace the current `Set<TagDTO>` deduplication in
the REST layer with a
+deterministic map keyed by tag name. Tags are added from the requested object
to its ancestors, and
+the first assignment wins. This avoids accidental merging of direct and
inherited values.
+
+### REST API Changes
+
+#### POST /api/metalakes/{metalake}/tags
+
+Create tag accepts an optional `allowedValues` field. Missing or null
`allowedValues` means the tag
+accepts any non-blank assignment value. An empty array means the tag can only
be assigned without
+values.
+
+**Request addition:**
+
+| Field | Type | Required | Description |
+|-------|------|----------|-------------|
+| `allowedValues` | array of string | no | Optional allowed assignment values
for this tag. |
+
+**Example:**
+
+```json
+{
+ "name": "data_domain",
+ "comment": "Business data domain",
+ "allowedValues": ["finance", "risk", "ml"]
+}
+```
+
+#### Allowed Value Mutation
+
+The first implementation does not support changing or clearing `allowedValues`
after tag creation.
+`PATCH /api/metalakes/{metalake}/tags/{tag}` does not gain `setAllowedValues`
or
+`removeAllowedValues` operations in this design. Existing tag alter operations
for current tag
+fields are unchanged.
+
+This avoids the need to validate or rewrite existing assignments when
narrowing a whitelist. A later
+design can add allowed value mutation with explicit conflict handling if
needed.
+
+#### GET /api/metalakes/{metalake}/tags and GET
/api/metalakes/{metalake}/tags/{tag}
+
+Detailed tag responses include `allowedValues` when the tag has a configured
allowed value list. The
+field is absent for unrestricted tags. An empty array means the tag is
configured as valueless-only.
+
+#### POST
/api/metalakes/{metalake}/objects/{metadataObjectType}/{metadataObjectFullName}/tags
+
+Add or remove tag-value pairs for a metadata object. Valueless assignments are
represented by a
+pair whose `value` is omitted or null. Multiple values for one tag are
represented by multiple
+pairs with the same `name` and different `value` fields.
+
+**Request:**
+
+| Field | Type | Required | Description |
+|-------|------|----------|-------------|
+| `tagsToAdd` | array of `TagValuePairRequest` | no | Add these tag-value
pairs incrementally. |
+| `tagsToRemove` | array of `TagValuePairRequest` | no | Remove these
tag-value pairs incrementally. |
+
+`TagValuePairRequest`:
+
+| Field | Type | Required | Description |
+|-------|------|----------|-------------|
+| `name` | string | yes | Tag name. The tag must already exist in the
metalake. |
+| `value` | string or null | no | Assignment value for this pair. Missing or
null represents the valueless row. Non-null values must satisfy the tag's
`allowedValues` constraint. |
+
+**Example:**
+
+```json
+{
+ "tagsToAdd": [
+ {
+ "name": "pii"
+ },
+ {
+ "name": "data_domain",
+ "value": "finance"
+ },
+ {
+ "name": "data_domain",
+ "value": "risk"
+ },
+ {
+ "name": "retention",
+ "value": "7y"
+ }
+ ],
+ "tagsToRemove": [
+ {
+ "name": "deprecated"
+ },
+ {
+ "name": "data_domain",
+ "value": "old"
+ }
+ ]
+}
+```
+
+**Response:** `200 OK`
+
+```json
+{
+ "code": 0,
+ "names": ["pii", "data_domain", "retention"]
+}
+```
+
+**Behavior:**
+
+1. At least one of `tagsToAdd` or `tagsToRemove` must be present.
+2. `tagsToAdd` adds pairs and preserves other active values for the same tag.
+3. `tagsToRemove` removes only the requested pairs. It does not remove all
values for the tag
+ unless every active value pair is listed.
+4. Duplicate pairs inside either array are de-duplicated.
+5. The same exact pair cannot appear in both arrays in the same request.
+6. A tag may appear multiple times in `tagsToAdd` or `tagsToRemove` when each
pair has a different
+ non-null value.
+7. `tagsToAdd` cannot mix the valueless pair and non-null values for the same
tag in one request.
+8. The response returns distinct direct tag names associated with the metadata
object after the
+ operation.
+
+#### GET
/api/metalakes/{metalake}/objects/{metadataObjectType}/{metadataObjectFullName}/tags
+
+Without `details=true`, the endpoint returns distinct tag names. With
`details=true`, it returns
+tag objects with global tag metadata, `allowedValues`, assignment-context
`values`, and the
+`inherited` flag.
+
+**Response with `details=true`:**
+
+```json
+{
+ "code": 0,
+ "tags": [
+ {
+ "name": "data_domain",
+ "comment": "Business data domain",
+ "properties": {},
+ "allowedValues": ["finance", "risk", "ml"],
+ "values": ["finance", "risk"],
+ "audit": {
+ "creator": "gravitino",
+ "createTime": "2026-07-16T00:00:00Z"
+ },
+ "inherited": false
+ }
+ ]
+}
+```
+
+#### GET
/api/metalakes/{metalake}/objects/{metadataObjectType}/{metadataObjectFullName}/tags/{tag}
+
+Returns the tag object for the metadata object, checking ancestors when the
tag is not directly
+assigned. The response includes `allowedValues` and `values` from the direct
or inherited winning
+assignment.
+
+#### GET /api/metalakes/{metalake}/tags/{tag}/objects
+
+Returns metadata objects associated with the tag. The endpoint gains an
optional `value` query
+parameter for exact value lookup; no dedicated `objects/search` endpoint is
added.
+
+**Query parameters:**
+
+| Field | Type | Required | Description |
+|-------|------|----------|-------------|
+| `value` | string | no | Exact assignment value filter. Missing means no
value filter. |
+| `details` | boolean | no | If true, return assignment details, including
values. Default is false. |
+
+**Example:**
+
+```http
+GET /api/metalakes/prod/tags/data_domain/objects?value=finance&details=true
+```
+
+**Response with `details=true`:**
+
+```json
+{
+ "code": 0,
+ "assignments": [
+ {
+ "metadataObject": {
+ "fullName": "catalog1.schema1.table1",
+ "type": "TABLE"
+ },
+ "values": ["finance", "risk"]
+ }
+ ]
+}
+```
+
+**Behavior:**
+
+1. Without `value`, the endpoint returns each metadata object associated with
the tag once, even
+ when the assignment has multiple physical value rows.
+2. With `value`, the endpoint returns each metadata object whose direct
assignment for the tag
+ contains that exact non-null assignment value.
+3. `value` must be non-blank and no longer than 256 characters.
+4. Matching is exact and case-sensitive.
+5. A valueless assignment row is not returned by a value-filtered lookup.
+6. `details=true` returns the full assignment value array for each returned
object, not only the
+ matched value.
+7. The first implementation does not add `ANY`, `ALL`, inherited value lookup,
or multi-value query
+ semantics to this endpoint.
+8. Authorization filtering is applied before returning results. Objects the
caller cannot access are
+ omitted from the response.
+
+### Java Client API
+
+Object tag APIs:
+
+```java
+String[] listTags();
+Tag[] listTagsInfo();
+Tag getTag(String name);
+String[] assignTags(TagValuePair[] tagsToAdd, TagValuePair[] tagsToRemove);
+```
+
+`TagValuePair` represents one assignment pair:
+
+```java
+TagValuePair.of(String name, String value);
+TagValuePair.valueless(String name);
+```
+
+Global tag APIs include allowed value support only on create:
+
+```java
+Tag createTag(String name, String comment, Map<String, String> properties);
+Tag createTag(
+ String name,
+ String comment,
+ Map<String, String> properties,
+ String[] allowedValues);
+Tag alterTag(String name, TagChange... changes);
+```
+
+`alterTag` keeps the existing tag alteration behavior. This design does not
add a Java client
+operation for changing or clearing `allowedValues`.
+
+Tag associated-object APIs add value lookup on the existing object listing API:
+
+```java
+MetadataObject[] objects();
+MetadataObject[] objects(String value);
+```
+
+`assignTags` applies incremental pair deltas. Multiple values for one tag are
passed as multiple
+`TagValuePair` entries. The return value is all tag names directly associated
with the metadata
+object after the operation.
+
+`objects(String value)` returns metadata objects whose direct assignment for
this tag contains the
+exact value. It uses the same associated-object response shape as `objects()`
and does not introduce
+`ANY`, `ALL`, inherited lookup, or a separate search-result model.
+
+`Tag` gains tag-level allowed values and assignment-context values:
+
+```java
+Optional<String[]> allowedValues();
+Optional<String[]> assignmentValues();
+```
+
+`allowedValues()` returns `Optional.empty()` when the tag is unrestricted and
+`Optional.of(...)` when a whitelist is configured. `assignmentValues` is
present only when the tag
+is returned from a metadata-object tag API. A valueless assignment returns
+`Optional.of(new String[0])`. Global tag APIs such as `getTag` return
`Optional.empty()` for
+`assignmentValues`.
+
+### Python Client API
+
+The Python client should mirror the Java client. It supports allowed values on
tag creation, but
+does not add an operation to change or clear `allowed_values` after creation:
+
+```python
+client.create_tag(
+ name="data_domain",
+ comment="Business data domain",
+ allowed_values=["finance", "risk", "ml"],
+)
+
+supports_tags.assign_tags(
+ tags_to_add=[
+ {"name": "data_domain", "value": "finance"},
+ {"name": "data_domain", "value": "risk"},
+ {"name": "pii", "value": None},
+ ],
+ tags_to_remove=[
+ {"name": "deprecated", "value": None},
+ {"name": "data_domain", "value": "old"},
+ ],
+)
+
+tag = client.get_tag("data_domain")
+tag.associated_objects().objects(value="finance")
+```
+
+Detailed tag models expose `allowed_values` for tag-level constraints and
`assignment_values`
+when the tag was loaded from a metadata object. Value-filtered
associated-object lookup returns the
+same metadata object shape as unfiltered associated-object listing. When REST
`details=true` is
+requested, the response includes the full assignment value array for each
returned object.
+
+### Data Model
+
+Allowed values are tag-level metadata. Add `allowed_values` to `tag_meta`:
+
+- The column stores a JSON-serialized array of normalized strings.
+- `NULL` means the tag is unrestricted.
+- `[]` means the tag can only be assigned without values.
+- The column is not indexed because allowed values are used for validation and
display, not value
+ lookup.
+
+Target schema additions:
+
+```sql
+-- MySQL
+ALTER TABLE `tag_meta`
+ ADD COLUMN `allowed_values` MEDIUMTEXT DEFAULT NULL COMMENT 'tag allowed
values';
+
+-- PostgreSQL
+ALTER TABLE tag_meta
+ ADD COLUMN allowed_values TEXT DEFAULT NULL;
+
+-- H2
+ALTER TABLE `tag_meta`
+ ADD COLUMN `allowed_values` CLOB DEFAULT NULL COMMENT 'tag allowed values';
+```
+
+Use `tag_relation_meta` for both logical tag assignments and assignment
values. Add two columns:
+
+- `tag_value`: the assignment value for this physical relation row. `NULL`
means the tag is
+ assigned without values. Non-null user-provided values cannot be blank.
+- `value_order`: the display order for non-null values. Existing rows keep
their order, and new
+ values are appended after the current maximum order.
+
+A logical assignment is represented as follows:
+
+| Logical state | Active rows in `tag_relation_meta` |
+|---------------|------------------------------------|
+| Tag assigned without values | One row with `tag_value IS NULL` and
`value_order = 0` |
+| Tag assigned with values | One row per value, each with non-null `tag_value`
|
+| Tag not assigned | No active rows |
+
+No replacement unique key is added for tag assignment values. The old
logical-assignment unique key
+must be dropped because it would block multiple value rows for the same tag
and object. Duplicate
+rows are prevented by request normalization and transactional write logic, and
detailed reads
+defensively group and de-duplicate rows.
+
+`current_version` and `last_version` are used to avoid full
delete-and-reinsert cycles for repeated
+assignment updates. For a relation value row, `current_version` is the logical
assignment version in
+which the pair first became active. `last_version` is the latest logical
assignment version that
+touched the row. Active rows are still selected by `deleted_at = 0`.
+
+For each changed `(tag_id, metadata_object_id, metadata_object_type)` logical
assignment, the write
+path computes `nextVersion = max(last_version) + 1` under the relation update
lock. Existing pairs
+that remain active keep their `tag_value`, `value_order`, `current_version`,
and `deleted_at`; only
+`last_version` is advanced to `nextVersion`. Newly added pairs are inserted
with
+`current_version = last_version = nextVersion`. Removed pairs keep their
original `current_version`,
+set `last_version = nextVersion`, and set `deleted_at` to the soft-delete
timestamp. If a request is
+entirely idempotent, the write path performs no relation-row update and does
not bump versions.
+
+`value_order` is also maintained incrementally. Existing values keep their
order. Newly added
+non-null values receive increasing order values after the current maximum.
Removing values does not
+renumber the remaining rows. Detailed reads sort by `value_order` and then by
value for stable
+output.
+
+Target MySQL shape for `tag_relation_meta`:
+
+```sql
+CREATE TABLE IF NOT EXISTS `tag_relation_meta` (
+ `id` BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'auto increment
id',
+ `tag_id` BIGINT(20) UNSIGNED NOT NULL COMMENT 'tag id',
+ `metadata_object_id` BIGINT(20) UNSIGNED NOT NULL COMMENT 'metadata object
id',
+ `metadata_object_type` VARCHAR(64) NOT NULL COMMENT 'metadata object type',
+ `tag_value` VARCHAR(256) DEFAULT NULL COMMENT 'tag assignment value',
+ `value_order` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT 'tag assignment
value order',
+ `audit_info` MEDIUMTEXT NOT NULL COMMENT 'tag relation audit info',
+ `current_version` INT UNSIGNED NOT NULL DEFAULT 1 COMMENT 'tag relation
current version',
+ `last_version` INT UNSIGNED NOT NULL DEFAULT 1 COMMENT 'tag relation last
version',
+ `deleted_at` BIGINT(20) UNSIGNED NOT NULL DEFAULT 0 COMMENT 'tag relation
deleted at',
+ PRIMARY KEY (`id`),
+ KEY `idx_tid` (`tag_id`),
+ KEY `idx_mid` (`metadata_object_id`),
+ KEY `idx_trel_tag_value` (`tag_id`, `tag_value`, `deleted_at`),
+ KEY `idx_trel_object` (`metadata_object_id`, `metadata_object_type`,
`deleted_at`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin
+ COMMENT 'tag metadata object relation';
+```
+
+Target PostgreSQL shape for `tag_relation_meta`:
+
+```sql
+CREATE TABLE IF NOT EXISTS tag_relation_meta (
+ id BIGINT GENERATED BY DEFAULT AS IDENTITY,
+ tag_id BIGINT NOT NULL,
+ metadata_object_id BIGINT NOT NULL,
+ metadata_object_type VARCHAR(64) NOT NULL,
+ tag_value VARCHAR(256) DEFAULT NULL,
+ value_order INT NOT NULL DEFAULT 0,
+ audit_info TEXT NOT NULL,
+ current_version INT NOT NULL DEFAULT 1,
+ last_version INT NOT NULL DEFAULT 1,
+ deleted_at BIGINT NOT NULL DEFAULT 0,
+ PRIMARY KEY (id)
+);
+
+CREATE INDEX IF NOT EXISTS tag_relation_meta_idx_tag_id
+ ON tag_relation_meta (tag_id);
+CREATE INDEX IF NOT EXISTS tag_relation_meta_idx_metadata_object_id
+ ON tag_relation_meta (metadata_object_id);
+CREATE INDEX IF NOT EXISTS tag_relation_meta_idx_tag_value
+ ON tag_relation_meta (tag_id, tag_value, deleted_at);
+CREATE INDEX IF NOT EXISTS tag_relation_meta_idx_object
+ ON tag_relation_meta (metadata_object_id, metadata_object_type, deleted_at);
+```
+
+Target H2 shape for `tag_relation_meta`:
+
+```sql
+CREATE TABLE IF NOT EXISTS `tag_relation_meta` (
+ `id` BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'auto increment
id',
+ `tag_id` BIGINT(20) UNSIGNED NOT NULL COMMENT 'tag id',
+ `metadata_object_id` BIGINT(20) UNSIGNED NOT NULL COMMENT 'metadata object
id',
+ `metadata_object_type` VARCHAR(64) NOT NULL COMMENT 'metadata object type',
+ `tag_value` VARCHAR(256) DEFAULT NULL COMMENT 'tag assignment value',
+ `value_order` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT 'tag assignment
value order',
+ `audit_info` CLOB NOT NULL COMMENT 'tag relation audit info',
+ `current_version` INT UNSIGNED NOT NULL DEFAULT 1 COMMENT 'tag relation
current version',
+ `last_version` INT UNSIGNED NOT NULL DEFAULT 1 COMMENT 'tag relation last
version',
+ `deleted_at` BIGINT(20) UNSIGNED NOT NULL DEFAULT 0 COMMENT 'tag relation
deleted at',
+ PRIMARY KEY (`id`),
+ KEY `idx_tid` (`tag_id`),
+ KEY `idx_mid` (`metadata_object_id`),
+ KEY `idx_trel_tag_value` (`tag_id`, `tag_value`, `deleted_at`),
+ KEY `idx_trel_object` (`metadata_object_id`, `metadata_object_type`,
`deleted_at`)
+) ENGINE=InnoDB;
+```
+
+Schema changes add nullable `tag_value`, `value_order`, and `allowed_values`.
The old tag relation
+unique key is dropped and no replacement unique key is added.
+
+Direct value lookup resolves `tag_name` to `tag_id` and uses
`idx_trel_tag_value` when the
+existing tag objects listing API receives a `value` filter. Detailed object
+tag reads load relation rows by `(metadata_object_id, metadata_object_type)`,
use `idx_trel_object`,
+group rows by `(tag_id, metadata_object_id, metadata_object_type)`, ignore
`NULL` values when
+building the public value list, de-duplicate defensive duplicate rows, and
order non-null values by
+`value_order`.
+
+### Storage and Core Changes
+
+The current generic relation API returns target entities and does not carry
relation payload. Tag
+assignment values are payload on tag relations, so the implementation should
add a tag-specific
+relation update path and extend only the tag relation PO/mapper/provider shape.
+
+Tag creation gains allowed value handling:
+
+- `createTag` validates and stores normalized `allowedValues` in
`tag_meta.allowed_values`.
+- Tag alteration does not support changing or clearing `allowedValues` in the
first implementation.
+- Assignment writes load the stored allowed value list and reject non-null
values outside it.
+
+The proposed assignment write flow is:
+
+```text
+Client
+ -> REST MetadataObjectTagOperations
+ -> TagDispatcher / TagManager
+ -> EntityStore tag-specific relation operation
+ -> JDBCBackend
+ -> TagMetaService
+ -> tag_relation_meta
+```
+
+Add a tag relation operation that accepts:
+
+- source metadata object identifier and type;
+- `TagValuePair` entries to add;
+- `TagValuePair` entries to remove.
+
+Because there is no tag relation unique key, the write path must preserve
logical-assignment
+uniqueness with the relation update lock and one storage transaction.
Assignment updates compute a
+pair-level delta instead of replacing the complete value list:
+
+1. Resolve the metadata object id and tag ids for every pair.
+2. Normalize pair arrays: remove duplicate pairs, reject blank names, reject
blank values, validate
+ value length, and preserve pair order.
+3. Reject the same exact pair in both `tagsToAdd` and `tagsToRemove`.
+4. Reject mixed valueless and non-null additions for the same tag.
+5. Load each tag's allowed values and reject non-null added values outside the
allowed value list.
+6. Load active relation rows for affected logical assignments under the
relation update lock.
+7. Apply removals first, soft-deleting only matching active pairs. Missing
pairs are no-ops.
+8. Apply additions, inserting only pairs that are not already active. Active
pairs are no-ops.
+9. Advance `current_version` and `last_version` only for logical assignments
with effective
+ changes, following the version-aware delta rules above.
+10. Return the current direct tag assignments with grouped value arrays.
+
+Adding a non-null pair to a tag with an active null row is a changed logical
assignment: the null row
+is soft-deleted and the non-null row is inserted in the same transaction.
Adding a null pair to a tag
+with active non-null rows is accepted only when the request's removals remove
all remaining
+non-null rows for that tag; otherwise it fails with `409 Conflict`.
+
+Name-only reads must use distinct tag names because a valued assignment can
now have multiple
+physical rows. Detailed reads must group relation rows by logical assignment
before building
+`TagDTO`.
+
+Value-filtered tag object lookup is implemented in `TagMetaService` by
resolving `tag_name` to
+`tag_id`, querying `tag_relation_meta` with `idx_trel_tag_value` for one exact
non-null value,
+grouping rows by metadata object, and de-duplicating defensive duplicates. The
first implementation
+returns direct assignments only and does not expand inherited effective
assignments for value
+lookup.
+
+### Cache Behavior
+
+Changing assignment values must invalidate the same relation cache entries as
adding or removing a
+tag:
+
+1. The metadata-object-to-tag relation cache for the source metadata object.
+2. The tag-to-metadata-object relation cache for every tag referenced by
`tagsToAdd` and
+ `tagsToRemove`.
+
+This is required because a value update changes detailed tag responses even
when the set of tag
+names does not change.
+
+Because allowed value mutation is not supported in the first implementation,
no cache invalidation
+path is needed for changing `allowedValues`. Tag metadata caches load the
stored `allowedValues`
+when a tag is created or read.
+
+The value lookup result itself is not cached in the entity relation cache in
the first
+implementation. It should read from the `tag_relation_meta` value index so
updates are visible after
+the write transaction commits.
+
+### Authorization
+
+No new privilege is introduced.
+
+Creating a tag with allowed values uses the same authorization path as
creating the tag. Changing
+allowed values is not supported in the first implementation. Setting
assignment values uses the
+same authorization expression as tag association:
+
+```text
+CAN_ACCESS_METADATA_AND_TAG
+```
+
+Reading allowed values uses the same authorization path as reading tag
metadata. Reading assignment
+values uses the same authorization path as reading detailed object tags. If a
user cannot load a
+tag, the tag, allowed values, and assignment values are filtered out together.
+
+Value-filtered associated-object lookup requires access to the requested tag
and filters every
+returned metadata object through the same metadata access expression used by
unfiltered object tag
+listing. Unauthorized objects are omitted rather than returned with partial
value data.
+
+### Events
+
+Tag create events should include allowed values through the tag metadata
payload so listeners can
+replicate tag definitions. Existing tag alter events remain unchanged because
allowed value mutation
+is not supported in the first implementation.
+
+The tag association events should expose the incremental pair deltas so
listeners can replicate tag
+assignment state without interpreting a full replacement list. Because this
design does not require
+backward compatibility, the event model can replace string-array assignment
accessors with pair
+arrays.
+
+For example:
+
+```java
+Optional<String[]> allowedValues();
+TagValuePair[] tagsToAdd();
+TagValuePair[] tagsToRemove();
+```
+
+### Error Handling
+
+| Case | Response |
+|------|----------|
+| Tag name is blank | `400 Bad Request` |
+| Allowed value is null, blank, or longer than 256 characters | `400 Bad
Request` |
+| Assignment value is blank or longer than 256 characters | `400 Bad Request` |
+| Assignment value is not in the tag's configured allowed values | `400 Bad
Request` |
+| `tagsToAdd` and `tagsToRemove` contain the same exact pair | `400 Bad
Request` |
+| `tagsToAdd` contains both a valueless pair and non-null value pairs for the
same tag | `400 Bad Request` |
+| Adding a valueless pair while non-null values remain active for the same tag
| `409 Conflict` |
+| `value` query parameter is blank or longer than 256 characters | `400 Bad
Request` |
+| `tagsToAdd` or `tagsToRemove` references a tag that does not exist | `404
Not Found` |
+| `tagsToAdd` targets an already active pair | `200 OK`; no relation row is
inserted |
+| `tagsToRemove` targets a missing pair | `200 OK`; no relation row is deleted
|
+
+---
+
+## User Process
+
+1. User creates a tag with allowed values:
+
+ ```http
+ POST /api/metalakes/prod/tags
+ ```
+
+ ```json
+ {
+ "name": "data_domain",
+ "comment": "Business data domain",
+ "allowedValues": ["finance", "risk", "ml"]
+ }
+ ```
+
+2. User adds two values for the same tag by sending two pairs. The values must
be in the tag's
+ allowed value list:
+
+ ```http
+ POST /api/metalakes/prod/objects/table/lake.sales.orders/tags
+ ```
+
+ ```json
+ {
+ "tagsToAdd": [
+ {
+ "name": "data_domain",
+ "value": "finance"
+ },
+ {
+ "name": "data_domain",
+ "value": "risk"
+ }
+ ]
+ }
+ ```
+
+3. User lists detailed tags for the table:
+
+ ```http
+ GET /api/metalakes/prod/objects/table/lake.sales.orders/tags?details=true
+ ```
+
+4. User finds objects with a matching value through the existing tag objects
lookup API:
+
+ ```http
+ GET /api/metalakes/prod/tags/data_domain/objects?value=finance&details=true
+ ```
+
+5. User incrementally adds another value. Existing values remain active:
+
+ ```json
+ {
+ "tagsToAdd": [
+ {
+ "name": "data_domain",
+ "value": "ml"
+ }
+ ]
+ }
+ ```
+
+6. User removes one value without removing the other values:
+
+ ```json
+ {
+ "tagsToRemove": [
+ {
+ "name": "data_domain",
+ "value": "risk"
+ }
+ ]
+ }
+ ```
+
+7. User removes the remaining values. After this request, the tag is no longer
directly assigned to
+ the table:
+
+ ```json
+ {
+ "tagsToRemove": [
+ {
+ "name": "data_domain",
+ "value": "finance"
+ },
+ {
+ "name": "data_domain",
+ "value": "ml"
+ }
+ ]
+ }
+ ```
+
+8. The tag's allowed value list cannot be changed or cleared after creation in
the first
+ implementation.
+
+---
+
+## Implementation Process
+
+Tag creation with allowed values follows this flow:
+
+```text
+REST request
+ -> validate allowedValues: null/unset allowed, no null or blank entries, max
256 chars
+ -> normalize allowedValues: remove duplicates while preserving order
+ -> store allowedValues as JSON in tag_meta.allowed_values
+ -> return TagDTO with allowedValues
+```
+
+Tag alteration does not add any allowed value change operation in the first
implementation.
+
+Assignment writes follow this flow:
+
+```text
+REST request
+ -> validate TagsAssociateRequest with tagsToAdd/tagsToRemove pair arrays
+ -> parse MetadataObject
+ -> authorize metadata object and tags
+ -> normalize pairs: remove duplicate pairs, reject blank names, reject blank
values and >256 chars
+ -> reject the same exact pair in both add and remove arrays
+ -> reject mixed valueless and non-null additions for the same tag
+ -> load allowedValues for tags being added
+ -> reject added values outside configured allowedValues
+ -> TagManager.assignTagsForMetadataObject(...)
+ -> storage transaction:
+ - resolve metadata object id
+ - resolve tag ids
+ - load active relation rows for affected logical assignments under lock
+ - compute pair-level remove and add deltas
+ - for changed logical assignments, compute nextVersion from
max(last_version) + 1
+ - soft-delete removed pair rows and advance their last_version
+ - insert new pair rows with current_version = last_version = nextVersion
+ - advance last_version for retained active rows in changed logical
assignments
+ - leave idempotent pairs untouched
+ - return current direct tag assignments with grouped values
+ -> invalidate relation caches only for logical assignments with effective
changes
+ -> return NameListResponse
+```
+
+Detailed reads follow this flow:
+
+```text
+REST list/get object tags
+ -> load direct tag relation rows by object id and type
+ -> group direct rows by logical assignment and build value arrays
+ -> load tag metadata, including allowedValues
+ -> load parent tag relation rows and values
+ -> merge by tag name, nearest assignment wins
+ -> set inherited flag
+ -> filter tags through authorization
+ -> return TagDTO with allowedValues and assignment values
+```
+
+Name-only reads follow the same relation lookup but return distinct tag names
so a multi-value
+assignment does not duplicate names.
+
+Value lookup follows this flow:
+
+```text
+REST list tag objects with value filter
+ -> validate one non-blank value, length <= 256
+ -> authorize requested tag
+ -> resolve tag id
+ -> query tag_relation_meta by tag_id and exact non-null tag_value
+ -> group matching rows by metadata object
+ -> resolve metadata object full names
+ -> filter metadata objects through authorization
+ -> return metadata objects, or assignment details when details=true
+```
+
+---
+
+## Task Breakdown
+
+- [ ] Add nullable `allowed_values` to `tag_meta` in MySQL, PostgreSQL, and H2
schema and upgrade
+ scripts.
+- [ ] Add nullable `tag_value` and `value_order` columns to
`tag_relation_meta` in MySQL,
+ PostgreSQL, and H2 schema and upgrade scripts.
+- [ ] Drop the existing tag relation unique key because multiple physical rows
can now belong to
+ one logical tag assignment. Do not add a replacement unique key.
+- [ ] Add value lookup indexes on `tag_relation_meta` for `(tag_id, tag_value,
deleted_at)` and
+ object lookup indexes for `(metadata_object_id, metadata_object_type,
deleted_at)`.
+- [ ] Extend `TagPO`, `TagEntity`, `TagDTO`, `Tag`, mapper methods, SQL
providers, and PO conversion
+ utilities with `allowed_values`.
+- [ ] Extend `TagMetadataObjectRelPO`, mapper methods, SQL providers, and PO
conversion utilities
+ with nullable `tag_value` and `value_order`.
+- [ ] Add `allowedValues` to tag create responses and requests. Do not add
allowed value
+ update requests or `TagChange` variants in the first implementation.
+- [ ] Add `TagValuePairRequest` and extend tag object listing response DTOs as
needed for
+ `details=true` assignment values.
+- [ ] Extend `TagDTO` and `Tag` with assignment-context values.
+- [ ] Add allowed value normalization and validation in tag create and valued
assignment
+ flows.
+- [ ] Add tag-specific relation update support in `SupportsRelationOperations`,
+ `RelationalEntityStore`, `JDBCBackend`, and `TagMetaService`.
+- [ ] Update relation reads so name-only APIs return distinct tag names and
detailed APIs group
+ relation rows into logical assignments with value arrays.
+- [ ] Add indexed direct value lookup support in `TagMetaService` using
`tag_relation_meta`.
+- [ ] Update `TagManager` and `TagDispatcher` with allowed values, valued
assignment, and
+ value-filtered tag object listing operations.
+- [ ] Update REST tag operations to support `allowedValues`, `tagsToAdd` /
`tagsToRemove` pair
+ arrays, values in detailed object tag responses, and `GET
/tags/{tag}/objects?value=...`.
+- [ ] Replace object tag inheritance deduplication with
nearest-assignment-wins merging by tag
+ name.
+- [ ] Update cache invalidation for value-only assignment updates.
+- [ ] Update tag create events to include allowed values, and association
events to include
+ tag-value pair assignment deltas.
+- [ ] Add Java client support for allowed values, `TagValuePair`, `assignTags`,
+ `Tag.assignmentValues()`, and value-filtered associated-object lookup.
+- [ ] Add Python client support for allowed values, tag-value pair assignment
updates, and
+ value-filtered associated-object lookup.
+- [ ] Add unit tests for allowed value validation, request validation, DTO
serialization, storage
+ conversion, and inheritance merging.
+- [ ] Add service tests for allowed value create validation, rejected allowed
value mutation,
+ repeated pair additions and removals, version-aware no-op updates,
insert, update, remove,
+ direct value lookup, distinct name reads, grouped detailed reads, and
indexed value reads.
+- [ ] Add REST tests for allowed values, rejected allowed value mutation,
repeated pair
+ assignments, valued assignments, value lookup validation, direct value
lookup, conflicts, and
+ authorization filtering.
+- [ ] Add client tests for Java and Python allowed values, valued assignment,
and value-filtered
+ associated-object lookup APIs.
+- [ ] Update `docs/open-api/tags.yaml` and validate with `./gradlew
:docs:build`.
+- [ ] Update user-facing tag documentation in
`docs/manage-tags-in-gravitino.md`.