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 e8a381d5c3 [#13866] docs: Add Domain design (#13867)
e8a381d5c3 is described below
commit e8a381d5c3ce506df018c1aaacf3b442b21ea523
Author: Jerry Shao <[email protected]>
AuthorDate: Tue Oct 27 16:06:15 2026 +0800
[#13866] docs: Add Domain design (#13867)
### What changes were proposed in this pull request?
Add the Domain design doc at `design-docs/domain-design.md`. It proposes
a metalake-level `Domain` object:
- A leaf asset (table, view, fileset, topic, model, semantic model) is a
member when it carries the domain's backing tag, directly or inherited
from its catalog or schema. The backing tag is named explicitly at
creation (a new tag, or an existing (tag, value)) and stored on the
domain row; each (tag, value) backs at most one domain.
- Tag privileges govern membership; new `MANAGE_DOMAIN` / `USE_DOMAIN`
privileges govern the domain itself. Policies keep following tags only.
- One level of movable subdomains, and `DRAFT` / `PUBLISHED` status.
Member listing and the domains of an object report every relation of
each entry: Direct, Inherited, or Via subdomain. Privileges on a domain
also apply to its subdomains.
- REST API, Java/Python clients, read-only MCP tools, and events/audit.
### Why are the changes needed?
Gravitino has no way to group assets from different catalogs into
business domains for discovery and agent retrieval. This doc settles the
design before implementation under epic #13864.
Fix: #13866
### Does this PR introduce _any_ user-facing change?
No. Documentation only.
### How was this patch tested?
No tests needed; documentation only.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
---
design-docs/domain-design.md | 330 +++++++++++++++++++++++++++++++++++++++++++
1 file changed, 330 insertions(+)
diff --git a/design-docs/domain-design.md b/design-docs/domain-design.md
new file mode 100644
index 0000000000..5788fae178
--- /dev/null
+++ b/design-docs/domain-design.md
@@ -0,0 +1,330 @@
+<!--
+ 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: Domain
+
+## Goals
+
+1. **Business grouping**: group assets from any catalog under named domains
(e.g. "Finance") for discovery, browsing and agent retrieval. An asset can
belong to several domains.
+2. **Membership is tags**: a leaf asset is a member when it carries the
domain's backing tag, directly or inherited from its catalog or schema. There
is no separate membership store.
+3. **Separate privileges**: domain privileges govern the `Domain` object; tag
privileges govern membership. Membership grants no access to data.
+4. **One level of subdomains**, each with one parent; a subdomain can be moved.
+5. **Draft and published status**: a `DRAFT` domain is returned only to its
owner and managers, the same on REST, MCP and any UI.
+6. **Inherited membership**: tagging a catalog or schema makes the leaf
objects under it members, through Gravitino's existing tag inheritance.
+
+## Non-Goals
+
+1. **Approval workflow**: adding members, attaching subdomains and publishing
take effect at once; status is a manual switch.
+2. **Data-access grants on domains**: a domain carries no data privileges such
as `SELECT_TABLE` (see Grants and policies).
+3. **Domain-level policies**: domains add no policy mechanism; policies keep
following tags (see Grants and policies).
+4. **Deeper or multi-parent hierarchies**: the storage allows them later
without a data migration.
+5. **Automatic membership**, glossary and metric designs, and the ABAC policy
language. Glossary terms can become members once they are taggable and added to
the member types.
+6. **UI**: this design covers the server, REST APIs, clients and MCP tools.
+
+## Usage Scenario
+
+1. A steward with `MANAGE_DOMAIN` and `CREATE_TAG` on the metalake creates
domain Finance with a new backing tag `finance`. The steward owns both.
+2. The steward creates subdomain Trading under Finance with a new backing tag
`trading`, and grants `APPLY_TAG` on tag `trading` to the trading table owners.
+3. A table owner tags `cat.db.trades` with `trading`. The table is a member of
Trading, and is listed under Finance as "via subdomain Trading". Tagging schema
`cat.db` with `finance` would make every leaf object in it a member of Finance.
+4. A user or agent with `USE_DOMAIN` on Finance (which also covers Trading,
unless denied there) or on Trading browses it and gets the tables they can
access.
+5. Trading is moved under Markets. Its members are now listed under Markets;
nothing is retagged.
+
+## Design
+
+### Data model
+
+```
+Domain { id, name, comment, status: DRAFT | PUBLISHED, properties,
+ backingTagId?, backingTagValue?, audit_info }
+DomainRel { parentId, childId, audit_info } // one edge per subdomain
+```
+
+- Domains live under a metalake. A subdomain and its parent are in the same
metalake.
+- Domain names are unique within a metalake across both levels: top-level
domains and subdomains share one namespace, so a domain is addressed by its
name alone. Domain names follow the same constraints as tag names (up to 128
characters) and may contain `/` (see REST API, Encoding).
+- A unique index on `DomainRel (child_id, deleted_at)` keeps one parent per
domain. `parent` is not stored on `Domain`; the API derives it. Dropping the
index and checks would allow multiple parents later without a data migration.
+- The backing tag is stored on the domain row as the tag's ID and an optional
value, with an index on `(metalake_id, backing_tag_id, backing_tag_value,
deleted_at)` for the reverse lookup "which domain does this (tag, value) back".
`DOMAIN` is not a taggable object type, so the tag code paths are unchanged
(see Alternatives considered).
+- The binding refers to the tag by ID, and every membership path (member
listing, domains of an object) looks up and compares tag IDs, never tag names:
if the tag is deleted, the ID stays on the row and matches nothing, and a new
tag with the same name does not match it, even if it is recreated while a
request is running.
+- Domains, their parent and their backing tag are not cached; every request
reads them from the store, so all servers see a move or a backing-tag change at
once. Caching them later needs a version bump of the affected domain rows and a
changelog entry.
+
+### Backing tag
+
+Each domain has **at most one** backing tag: an existing tag, optionally with
a value (tag values: apache/gravitino#12162). Creating a domain always sets
one; a domain has none only after its backing tag is removed or deleted, until
a new one is set.
+
+- **Explicit at creation**: every create request names the backing tag; there
is no default name derived from the domain. The caller either
+ - creates a new tag: a tag without values (`NO_VALUE`) with the given name,
owned by the caller, which needs `CREATE_TAG`. If a tag with that name exists,
creation fails with 409 `TagAlreadyExistsException`; an existing tag is never
adopted implicitly; or
+ - binds an existing (tag, value), such as `dept=finance`. The caller must
own the tag or the metalake; a missing tag fails with 404.
+
+ A create request without a backing tag, or one that creates a new tag and
also gives a value, fails with 400.
+- **Tag value rules apply as for any assignment**: a tag with a non-empty
allowed-values list can only be bound with one of its values; a `NO_VALUE` tag
only without a value.
+- **No sharing**: following the tag assignment rules, a tag has either one
binding without a value, or bindings with values where each value backs at most
one domain; the two are never mixed. A conflicting binding fails with 409
`BackingTagAlreadyBoundException` (a new `AlreadyExistsException` subclass).
The reverse lookup is therefore one domain per (tag, value).
+- **Matching** follows the tag lookup semantics (`GET /tags/{tag}/objects`). A
backing tag without a value matches every assignment of the tag, with or
without values, like a lookup without `value`. A backing tag with a value
matches assignments whose values contain it, exact and case-sensitive, like
`?value=`; a valueless assignment never matches it. The same rule applies to
member listing and domains of an object, always against the object's effective
assignment.
+- **Changing it**: `PUT /domains/{domain}/backingTag` replaces or removes it
on the server, under the domain and tag row locks. A missing tag fails with
404, a value the tag does not allow with 400, a tag the caller does not own
(nor the metalake) with 403, and a binding that conflicts with another domain
with 409; setting the current tag is a no-op. Assets carrying only the old tag
drop out; nothing is migrated. This endpoint is the only way to change a
backing tag: `DOMAIN` is not tagg [...]
+- **Never renamed**: moving or renaming a domain does not rename its tag; the
hierarchy lives in `DomainRel`. A renamed tag keeps backing the domain, since
the binding is by tag ID.
+- **Deleting the tag** behaves as for any tag: it is allowed, and the tag's
assignments on assets are removed with it. Its domain is left with no backing
tag (`backingTag` reads as empty) and no members until a new one is set; the
membership cannot be recovered by recreating a tag with the same name. Only the
tag deletion event records this.
+- **Separate from domain privileges**: the domain owner and the tag owner are
independent. Managing a domain grants no `APPLY_TAG` on its tag, and
transferring the domain's ownership does not transfer the tag's.
+
+### Subdomains
+
+Every operation that sets a parent (create a subdomain, attach, move) checks:
+
+| # | Constraint |
+| :--- | :------------------------------------------------------------------- |
+| C1 | A domain cannot be its own parent |
+| C2 | The parent is top-level |
+| C3 | The attached domain has no subdomains |
+| C4 | Parent and child are in the same metalake |
+| C5 | At most one parent; attaching a domain that has one is a move |
+| C6 | A `PUBLISHED` domain can only be attached under a `PUBLISHED` parent |
+
+C1–C3 keep the hierarchy one level deep and acyclic, so no cycle detection is
needed.
+
+- **Attach / move** re-points the child's single edge in one transaction.
Attaching to the current parent is a no-op.
+- **Detach** removes the current edge; the child becomes top-level.
+- **Delete** is refused while the domain has subdomains. Deleting removes the
domain row and its own parent edge. The tag and the tags on assets stay.
+- **Membership and scope**: membership comes from backing tags only (see
Membership and inheritance), so an asset tagged only `trading` is a member of
Trading, not of Finance. A top-level domain's *scope* is its own members plus
the members of the subdomains the caller can see; a subdomain's scope is its
own members. Member listing and domains of an object report scope, with the
relation of each entry; policies follow tags only (see Grants and policies).
+
+### Locking
+
+Domain operations use the store's existing locking primitives, in one global
order: the metalake fence (`LiveEndpointService.lockLiveEndpoint`, shared),
then the domain rows involved in ID order, then tag rows (exclusive, as
`lockTagsForAssignment` does). Each row is locked once, in its final mode, so
no lock is upgraded inside a transaction. `DOMAIN` is added to
`LiveEndpointSQLProvider`.
+
+| Operation | Rows locked
| Conflicts it excludes
|
+| :---------------------------- |
:------------------------------------------------------------- |
:-----------------------------------------------------------------------------------------------------------
|
+| Create a top-level domain | metalake (shared) → existing tag (exclusive)
| metalake drop; two domains binding the same (tag, value)
|
+| Create a subdomain | metalake (shared) → parent (shared) →
existing tag (exclusive) | the parent being attached elsewhere (C2)
|
+| Attach / move | child (exclusive) and new parent (shared),
in ID order | the new parent being attached elsewhere (C2); a subdomain
being created under the child (C3); crossing moves |
+| Detach | child (exclusive)
|
|
+| Publish | domain (exclusive) and parent (shared)
| the parent being unpublished
|
+| Unpublish, delete | domain (exclusive)
| a subdomain being created, attached or published under it
|
+| Set or remove the backing tag | domain (exclusive) → tag (exclusive)
| a conflicting binding; the tag being deleted
|
+| Rename, alter | domain (exclusive)
|
|
+
+- Every name-addressed operation resolves the name to an ID, locks the row,
then re-checks that the row still has that name and is not deleted; otherwise
it fails with 404.
+- The parent is read before locking, so it may change before the locks are
held. After locking, the operation re-reads the parent edge from `DomainRel`;
if it changed, it rolls back and retries with the new lock set (a bounded
number of times). C6, the status checks and the inherited authorization are
evaluated against this locked relation.
+- A tag named in a request is re-checked after locking; a missing tag fails
with 404 instead of being skipped.
+- A new backing tag has no row to lock yet; its name is protected by the tag
name's unique key, so a concurrent create with the same name fails with 409.
+- The unique index on `DomainRel` and the domain row's own version (optimistic
locking, never exposed to callers) remain as backstops.
+
+### Status
+
+- New domains are `DRAFT`. The owner or a `MANAGE_DOMAIN` holder publishes or
unpublishes, at any time and without approval.
+- **Visibility is the same on every surface** (REST, MCP, UI). A `DRAFT`
domain is returned only to the users who can manage it, including through its
parent or the metalake (see Access control). Domain APIs do not return it to
anyone else: lists omit it, get returns 404, and it is not in `list_domains`,
member listings, an object's domains or a parent's roll-up. Responses carry
`status`, so a caller that sees drafts can filter them out.
+- **Known channels**: hiding a draft is not confidentiality. Its existence can
still show through:
+ - creating a domain with the same name fails with 409;
+ - its backing tag is an ordinary tag, visible to tag readers and on tagged
assets (use a non-descriptive tag name if that matters);
+ - `parent` is always returned, even when the caller cannot see the parent.
Only the name is exposed; getting the parent still returns 404, and nothing can
be done on it.
+- **Consistency**: publishing a subdomain needs a published parent;
unpublishing needs no published subdomains (C6 covers attach and move).
+- Assets can be tagged into a `DRAFT` domain to prepare it; anyone with
`APPLY_TAG` on its tag can do so.
+- Roll-ups depend on the caller: someone who can see a `DRAFT` subdomain sees
its members under the parent; others do not.
+
+### Membership and inheritance
+
+- **Rule**: an object's effective tags are its own plus its schema's and
catalog's; for the same tag the nearest level wins, and different tags
accumulate. A leaf object (see Member types) is a member of domain D when its
effective tags match D's backing (tag, value). Membership reuses core's
`EffectiveTagResolver`, which applies no tag-privilege filtering: domain
visibility and object access are applied afterwards, so a caller needs no tag
privilege.
+- **Different tags accumulate**: with schema `finance` and table `sales`, the
table is in both Finance and Sales, and a child cannot leave a domain it
inherits.
+- **Values of one tag** backing different domains override each other: with
schema `dept=finance` and table `dept=hr`, the table is only in the `dept=hr`
domain, unless it uses `dept=[finance, hr]`. This is the one case where a child
can leave an inherited domain, and only for bindings with a value; a binding
without a value matches every value.
+- **Member types**: leaf objects only: tables, views, filesets, topics, models
and semantic models (`DomainMember.SUPPORTED_TYPES`). Catalogs and schemas are
never members: one that carries a backing tag passes membership to the leaf
objects under it through tag inheritance. Columns and functions are not members
either: a column or function carrying a backing tag is not listed. A type that
becomes taggable later joins only when it is added to this list. Metrics belong
through their seman [...]
+- **Relations**: a leaf object relates to a domain in one of three ways,
reported with its source by member listing and by domains of an object:
+
+ | Relation | Meaning
| Source |
+ | :------------ |
:------------------------------------------------------------- |
:---------------------------------------- |
+ | Direct | the object itself carries the domain's backing tag
| none |
+ | Inherited | the object inherits the backing tag from its catalog or
schema | the nearest catalog or schema carrying it |
+ | Via subdomain | a visible subdomain of the domain has the object in its
scope | that subdomain |
+
+ Every relation that applies is reported, as a list of (relation, source)
pairs, with no precedence: a table whose schema carries `finance` and which
itself carries `trading` gets `[Inherited from cat.db, Via subdomain Trading]`
under Finance, and an object in two subdomains gets one Via subdomain pair for
each. Clients decide how to display them. Relations are always relative to the
listed domain. An object is a member of the domain itself when its relations
include Direct or Inherited [...]
+- **Domains of an object**: for a leaf object, every domain the caller can see
whose scope contains the object, each with its relations. An object tagged only
`trading` gets Trading (Direct) and Finance (Via subdomain Trading). Catalogs
and schemas do not support this lookup, since they are never members; the
domain a catalog or schema passes down is visible from its tags.
+- **Removing a member**: membership ends when the object no longer carries the
backing tag, by the tag rules. To take an object out of a domain backed by a
tag without a value, remove the tag by name (v1 association request), which
removes all its values on the object; a v2 request removes one (tag, value)
pair. An inherited membership can only be removed on the catalog or schema.
+- **Domain filters**: Phase 1 adds no domain filter to other list APIs. When
one is added, it matches scope, consistent with member listing.
+
+### Member listing
+
+`GET /domains/{domain}/objects` lists leaf objects, expanding catalogs and
schemas:
+
+1. Resolve the backing tags of the domains in the selected scopes (by default
the listed domain's whole scope: itself and its visible subdomains).
+2. List the objects carrying them by the bound tag ID and value. The existing
lookup by tag name (`listMetadataObjectsForTag`) is not reused, since a tag
deleted and recreated with the same name between steps would contribute its
objects; this applies to bindings without a value too. Leaf objects are
candidates as they are; a tagged catalog or schema is replaced by the leaf
objects under it, from Gravitino's store.
+3. Keep the candidates whose effective tags match a selected backing tag by
tag ID (the matching rules above, nearest level wins), so a leaf object that
overrides an inherited value drops out. Only bindings with a value need this
check.
+4. Drop objects the caller cannot access (`filterMetadataObject`),
de-duplicate, and record each member's relations and sources.
+
+- **A view of recorded objects**: membership is defined by the rule above; the
listing shows the objects recorded in Gravitino's store. Objects in an external
catalog that Gravitino has not recorded yet are not listed, so an empty listing
does not mean an object is not a member.
+- **Cost**: a domain backed by a tag on a large catalog lists every recorded
leaf object under it (see Open Questions).
+- **Per caller**: the listing depends on the subdomains and objects the caller
can see, so its length is not a property of the domain. Paging and counts come
in Phase 2; counts follow the listing and depend on the caller too, so they
never reveal assets the caller cannot see.
+
+### Access control
+
+**Domain privileges**
+
+| Privilege | On the metalake | On a
domain |
+| :-------------- | :------------------------------------------------- |
:------------------------------------------------------------------- |
+| `MANAGE_DOMAIN` | Create domains and manage all of them | Edit,
publish, subdomains, backing tag, delete; see it while `DRAFT` |
+| `USE_DOMAIN` | See all published domains and browse their members | See
this domain and browse its members |
+
+`MANAGE_DOMAIN` includes `USE_DOMAIN`; owners and the metalake owner hold
both. There is no separate `CREATE_DOMAIN`. Privileges on a domain, and its
ownership, also apply to its subdomains; metalake-level grants cover every
domain.
+
+- **Access follows the current parent**: moving or detaching a subdomain
changes who can see and manage it, without any grant changing. After Trading
moves from Finance to Markets, Finance's holders lose access to it and Markets'
holders gain it. A Finance manager can detach Trading through that inherited
privilege, and then has none on it. Move and detach events carry the old and
new parent, so an auditor can reconstruct why someone had access.
+
+**What each operation needs**
+
+| Operation | Required
|
+| :----------------------------------- |
:---------------------------------------------------------------------------------------------------------------------------------------
|
+| See a domain | Published: `USE_DOMAIN`. Draft: owner
or `MANAGE_DOMAIN`
|
+| Browse members, domains of an object | See the domain; results filtered by
each object's own privileges; no tag privilege
|
+| Add or remove members | Existing tag authorization
(`CAN_ACCESS_METADATA_AND_TAG`): access to the object and `APPLY_TAG` on the
backing tag. No domain privilege |
+| Create with a new tag | `MANAGE_DOMAIN` and `CREATE_TAG` on
the metalake
|
+| Create with an existing tag | `MANAGE_DOMAIN` on the metalake and
ownership of the tag (or the metalake)
|
+| Create a subdomain | Also `MANAGE_DOMAIN` on the parent
|
+| Set or replace the backing tag | `MANAGE_DOMAIN` and ownership of the
new tag (or the metalake)
|
+| Remove the backing tag | `MANAGE_DOMAIN`
|
+| Attach / move | `MANAGE_DOMAIN` on both the domain
and the new parent
|
+| Detach | `MANAGE_DOMAIN` on the domain,
including through its current parent or the metalake
|
+| Publish, unpublish, edit, delete | `MANAGE_DOMAIN`
|
+
+- **Membership is controlled by tag privileges only**: anyone with read access
to an asset and `APPLY_TAG` on a backing tag can add the asset, without the
asset owner's consent. This is the existing tag behavior; a steward controls
membership by controlling `APPLY_TAG` on the backing tag.
+- Browsing without `VIEW_TAG` is safe because only a tag's owner can bind it
to a domain: exposing that one (tag, value) to the domain's viewers is the
owner's decision, and viewers still see only objects they can access.
+- A user with `MANAGE_DOMAIN` but no `APPLY_TAG` on the tag can manage the
domain but not add members.
+
+**Implementation**
+
+- **One parent resolver**: domain names carry no parent, so the name-based
parent walk (`MetadataObjects.parent`) cannot find it. A single resolver
returns a domain's current parent from `DomainRel` (one lookup, batched when
filtering a list of domains). It is used by the domain endpoints and by the
reused owner and grant checks, `JcasbinAuthorizer#hasSetOwnerPermission` and
`#hasMetadataPrivilegePermission`, which consult it for `DOMAIN` objects where
they walk to the parent. So inherit [...]
+- **Deny still wins**: the three levels, metalake, current parent and domain,
are combined in one check, the same way catalog, schema and table are today,
never as "the domain, else the parent". An owner of any of the three passes.
Otherwise a privilege holds when it is allowed on at least one level and denied
on none, so `DENY USE_DOMAIN` on Trading overrides `ALLOW USE_DOMAIN` on
Finance, and a deny on Finance overrides an allow on Trading.
+- A parent or backing tag named in a request body (`parent` on create and
attach, `backingTag` on create and on `PUT .../backingTag`) is checked
imperatively: `MANAGE_DOMAIN` on the new parent; for an existing tag, ownership
of the tag (or the metalake); for a new tag, `CREATE_TAG` on the metalake.
+
+### Grants and policies
+
+- **No data-access grants on domains.** Since anyone with `APPLY_TAG` on a
backing tag can add an asset, an allow grant on a domain would turn tagging
into granting access without the asset owner's consent. Possible future designs
are listed in Open Questions.
+- **Policies follow tags only.** Domains add no policy mechanism, and there
are no "domain policies". An asset is governed by the policies associated with
the tags it carries, the backing tag included, under the existing tag-policy
semantics (association selectors for all values or one value). A domain passes
no policy to its members or to its subdomains: domains and assets have no
containment relation. An asset listed under Finance only via subdomain Trading
is not governed by the polic [...]
+
+### REST API
+
+Under `/api/metalakes/{metalake}`, following Gravitino conventions:
`?details=` on lists, `PUT` + updates to alter, `PATCH` + a set request to
switch state, `DropResponse` on delete, owners through the owner API.
+
+| Operation | Endpoint
| Request → response
|
+| :----------------------- |
:---------------------------------------------------------------------------- |
:-----------------------------------------------------------------------------------------------------------
|
+| List | `GET /domains?details=false`
| → `NameListResponse` / `DomainListResponse`
|
+| Create | `POST /domains`
| `DomainCreateRequest{name, comment, properties,
backingTag{name, value, create}, parent}` → `DomainResponse` |
+| Get | `GET /domains/{domain}`
| → `DomainResponse`
|
+| Alter | `PUT /domains/{domain}`
| `DomainUpdatesRequest{updates: [rename,
updateComment, setProperty, removeProperty]}` → `DomainResponse` |
+| Publish / unpublish | `PATCH /domains/{domain}`
| `DomainSetRequest{status}` → `DomainResponse`
|
+| Drop | `DELETE /domains/{domain}`
| → `DropResponse` (`false` if absent)
|
+| Attach / move | `PUT /domains/{domain}/parent`
| `DomainParentSetRequest{parent}` →
`DomainResponse` |
+| Detach | `DELETE /domains/{domain}/parent`
| → `DomainResponse` (no-op for a top-level domain)
|
+| Set / remove backing tag | `PUT /domains/{domain}/backingTag`
| `DomainBackingTagSetRequest{backingTag{name,
value}}` (null `backingTag` removes it) → `DomainResponse` |
+| List subdomains | `GET /domains/{domain}/subdomains?details=false`
| → `NameListResponse` / `DomainListResponse`
|
+| List members | `GET /domains/{domain}/objects`
| → `DomainMemberListResponse`
|
+| Domains of an object | `GET
/objects/{type}/{fullName}/domains?details=false` (leaf types only) | →
`NameListResponse` / `DomainListResponse`
|
+| Add / remove members | Existing `POST /objects/{type}/{fullName}/tags`
with the domain's backing tag | `TagValuesAssociateRequest` (v2) or
`TagsAssociateRequest` (v1) |
+
+```
+DomainDTO { name, comment, properties, status, parent, backingTag{name,
value}, audit,
+ relations? } // [{relation, source?}], only on domains of
an object
+DomainMemberDTO { object: MetadataObjectDTO, relations: [{relation, source?}] }
+```
+
+- **Create**: `backingTag.create = true` creates a new tag with the given name
(no `value`); `false` binds the existing (tag, value). Domain, new tag and
parent edge are written in one transaction; on any failure nothing is created,
and no new tag is left behind.
+- **Membership requests**: adding a member with a value needs the v2
`TagValuesAssociateRequest` (`Content-Type:
application/vnd.gravitino.v2+json`), which carries (name, value) pairs.
Removing a tag by name with the v1 `TagsAssociateRequest` removes all its
values on the object. The Java and Python clients already support both.
+- **Parent endpoints** address the child only, matching
`Domain#attachToParent` and `Domain#detachFromParent`; detaching needs no
parent name.
+- **State changes return the domain**: publish, unpublish, attach, move,
detach and backing-tag changes return the updated domain, because the Java API
returns a new `Domain` snapshot from them.
+- **Concurrency**: changes apply to the current state on the server and the
last write wins, as for other Gravitino metadata operations; there is no
version check. Detach removes the current parent edge and removing the backing
tag removes the current one, whatever the caller's snapshot shows; detaching a
top-level domain is a no-op, not an error. Domains are addressed by name, so if
a domain is renamed and a new one takes its old name, later calls with that
name act on the new domain.
+- **Member listing parameters**:
+ - `domains` (repeatable, `?domains=Tax&domains=Trading`; not
comma-separated, since domain names may contain `,`): each name, the listed
domain itself or one of its visible subdomains, selects that domain's scope,
and the result is the union. On Finance with subdomains Tax and Trading, not
setting it or `domains=Finance` lists the whole scope (Finance, Tax and
Trading), `domains=Tax` lists only Tax's members, and
`domains=Tax&domains=Trading` lists Tax's and Trading's. Names that are n [...]
+ - `types` (comma-separated, each a leaf member type) and `nameContains`
(case-insensitive, on the full name). Filters apply to the expanded leaf list,
so `types=TABLE` finds the tables under a tagged catalog.
+ - The listed domain's own members are those whose relations include Direct
or Inherited.
+ - No paging in Phase 1, like the other core list APIs; paging and counts
come in Phase 2.
+- **Errors**:
+ - 404: `NoSuchDomainException` (missing, or a `DRAFT` domain the caller
cannot see); `NoSuchTagException` (a backing tag to bind that does not exist).
+ - 409: `DomainAlreadyExistsException` (create, or rename to an existing
name); `NonEmptyDomainException` (drop with subdomains);
`TagAlreadyExistsException` (a new backing tag whose name exists);
`BackingTagAlreadyBoundException` (a binding that conflicts with another
domain).
+ - 400: `IllegalArgumentException` (C1–C6, status consistency, a value the
tag does not allow, a create request without a backing tag or with both
`create` and a value, a `DOMAIN` target on the generic tag association API).
+ - 403: `ForbiddenException` (binding a tag the caller does not own, nor the
metalake; creating a new tag without `CREATE_TAG`).
+- **Encoding**: tag and domain names may contain `/`, which must be
URL-encoded (`%2F`) in every path segment: `{tag}`, and `{domain}` in
`/domains/{domain}`, `/objects/domain/{domain}/...`, `/owners/domain/{domain}`
and `/permissions/roles/{role}/domain/{domain}/grant|revoke`. The Java, Python
and MCP clients already encode path segments, and Gravitino's Jetty 9.4 /
Jersey stack decodes them into the path parameters. An end-to-end test through
the real server, with authentication and au [...]
+- **Reused**: owners at `/owners/domain/{domain}`; privileges through the role
and permission APIs, with `DOMAIN` as a securable type.
+- **Clients** (Java, Python) mirror the tag clients:
+ - `DomainOperations` on `GravitinoMetalake`: list, get, `createDomain`
(top-level domains only, with an explicit backing tag), alter and delete.
+ - On a `Domain`, each returning a new `Domain` snapshot: `publish()`,
`unpublish()`, `attachToParent(parent)` (attach or move), `detachFromParent()`,
and `updateBackingTag(TagValue)` (`null` removes it; maps to `PUT
/domains/{domain}/backingTag`). Also on a `Domain`: `createSubdomain(...)`
(with an explicit backing tag), `listSubdomains()` / `listSubdomainsInfo()` and
`listMembers(DomainMemberFilter)`.
+ - `SupportsDomains` (`listDomains()`, `listDomainsInfo()`) on tables, views,
filesets, topics, models and semantic models; not on catalogs, schemas, columns
or functions.
+- **Not included**: a batch lookup of the domains of many objects.
+
+### MCP tools
+
+A new read-only `domain` tool category in `mcp-server/`, added to
`SUPPORTED_TOOL_TAGS` (enabled with `--include-tool-tags`):
+
+| Tool | Endpoint
|
+| :-------------------------- |
:-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
|
+| `list_domains` | `GET /domains?details=true`; returns name,
comment, parent, status and backing tag, so an agent can pick a domain
|
+| `get_domain_by_name` | `GET /domains/{domain}`
|
+| `list_metadata_by_domain` | `GET /domains/{domain}/objects`, with
`domains` (a list of scopes to include, the domain itself or its subdomains;
default the whole scope), `types`, `name_contains`; lets an agent look within a
domain by type and name |
+| `list_domains_for_metadata` | `GET /objects/{type}/{fullName}/domains`, for
leaf objects
|
+
+Adding members uses the existing `associate_tag_with_metadata` tool. It keeps
removing tags by name (v1, all values) and is extended to accept (name, value)
pairs and send the v2 request, so value-backed domains work. The MCP server
forwards the caller's `Authorization` header, so the server's visibility rules
apply; with OAuth client-credentials, the agent sees what that service identity
sees.
+
+### Events and audit
+
+- Every domain operation emits pre, success and failure events through a
`DomainEventDispatcher`, and reaches the audit log through the existing
pipeline. Backing-tag changes are domain events. `Domain` and `DomainRel` carry
`audit_info`.
+- Membership is derived, so there is no membership event. It changes through
operations that are already audited by existing events:
+ - tag association changes (`AssociateTagsForMetadataObjectEvent`) on the
asset or on its catalog or schema, and tag deletion;
+ - asset lifecycle and hierarchy changes: creating or dropping an asset under
a tagged catalog or schema, or moving it to another schema (for example
`AlterTableEvent`);
+ - backing-tag changes and subdomain moves, which are domain events.
+
+ No per-member event is emitted.
+- **The default audit log is not enough to reconstruct membership.** It
records each event's common fields and `customInfo` (`JsonAuditFormatter`,
`SimpleFormatterV2`), and tag association events carry no tag names or values
there, so it shows which operations ran but not which (tag, value) changed.
Reconstructing a domain's membership at a point in time needs an event listener
that stores the full events above; this is not a Phase 1 goal. Adding tag
payloads to the audit log would be a [...]
+
+## Alternatives Considered
+
+1. **Tags alone**, with hierarchy and status added to tags. Tags would gain a
lifecycle, a parent and curation privileges that every other tag user has to
understand, and domain privileges (`MANAGE_DOMAIN`, `USE_DOMAIN`) do not fit
tag privileges. A separate `Domain` object keeps the tag model unchanged.
+2. **A dedicated membership table.** It would be a second source of truth next
to tags, and catalog/schema inheritance, `APPLY_TAG` control, tag-based
policies and tag audit events would all have to be rebuilt for it. Membership
by tags reuses all of them.
+3. **`DOMAIN` as a taggable object**, with the backing tag stored as the
domain's row in `tag_relation_meta`. Every tag code path would need a `DOMAIN`
special case (association APIs, tag object listings, policy resolution, name
resolution, cleanup), the reverse lookup would scan all asset assignments of
the tag, and a binding without a value would clash with allowed-values
validation. Storing the binding on the domain row keeps the tag code unchanged.
+
+## Prior Art
+
+Unity Catalog domains ([Domains and
subdomains](https://docs.databricks.com/aws/en/uc-semantics/domains), [Discover
page](https://docs.databricks.com/aws/en/discover/discover-page)) are built the
same way: a domain groups assets by governed tags, an asset joins a domain by
carrying its tag, catalog and schema tags pass down to the assets inside,
parent and subdomain tags are independent, and a subdomain's assets also appear
under its parent. Access policies there are attached to the obje [...]
+
+## Open Questions
+
+1. **Cost of expanding large catalogs**: member listing expands a tagged
catalog or schema to every recorded leaf object under it. Paging (Phase 2)
bounds the response, not the work; is a cheaper strategy needed for very large
catalogs?
+2. **Data-access grants on domains**, if wanted later: deny-only grants
(tagging can only tighten access), or allow grants that also require grant
authority on the asset when tagging. Either works only where Gravitino enforces
authorization itself and adds a membership lookup to every check.
+
+## Task Breakdown
+
+### Phase 0: Prerequisites
+
+- [x] Tag values and constraints (apache/gravitino#12162), tag inheritance
(`EffectiveTagResolver`), listing objects by tag and value, `/` encoding in the
clients.
+- [ ] End-to-end test through the real server, with authentication and
authorization enabled, for `%2F` in `/domains/{domain}`,
`/objects/domain/{domain}/...`, `/owners/domain/{domain}` and the role grant
and revoke paths.
+
+### Phase 1a: Domain, storage and authorization
+
+- [ ] `Domain` API in `api/`; `MetadataObject.Type.DOMAIN` (not taggable)
+- [ ] Storage: `domain_meta` with the backing tag columns and index, and
`domain_rel` (H2, MySQL, PostgreSQL, upgrade scripts)
+- [ ] Manager: create with an explicit backing tag, the binding rules,
constraints C1–C6, status and its consistency checks, locking as in Locking
(with `DOMAIN` in `LiveEndpointSQLProvider`), status-based visibility
+- [ ] Authorization: `MANAGE_DOMAIN` and `USE_DOMAIN`, inherited from the
current parent through one parent resolver (domain endpoints, plus the `DOMAIN`
case in `JcasbinAuthorizer#hasSetOwnerPermission` and
`#hasMetadataPrivilegePermission`), deny over allow across metalake, parent and
domain, imperative checks for a parent or backing tag in a request body
+- [ ] REST endpoints for domains, status, parent and backing tag; events
(`DomainEventDispatcher`)
+- [ ] Tests, including a multi-node test: move or change a backing tag on one
server, then check visibility, inherited access, roll-ups and reverse lookups
on another
+
+### Phase 1b: Membership
+
+- [ ] Membership resolution in `core/` on `EffectiveTagResolver`, returning
the inherited source and matching by tag ID
+- [ ] Lookup of tag relations by tag ID and value (the existing lookup is by
tag name)
+- [ ] Member listing: expansion to leaf objects, relation lists, filters;
domains of a leaf object
+- [ ] REST endpoints and tests
+
+### Phase 1c: Clients, MCP and documentation
+
+- [ ] Java and Python clients
+- [ ] MCP tools, and (name, value) support in `associate_tag_with_metadata`
+- [ ] OpenAPI spec (`docs/open-api/*.yaml`, validated with `./gradlew
:docs:build`) and user docs: explicit backing tags, adding and removing members
by tagging, relations, privileges, `/` encoding and the reverse-proxy note,
tags not renamed on move or rename
+
+### Phase 2: Paging and counts
+
+- [ ] Paging for member listing, and domain asset counts