diff --git a/docs/concepts/projects/search.md b/docs/concepts/projects/search.md
index 4750bde12f..871c863ad7 100644
--- a/docs/concepts/projects/search.md
+++ b/docs/concepts/projects/search.md
@@ -10,15 +10,23 @@ Hopsworks supports free-text search to discover machine-learning assets:
- feature groups
- feature views
- training data
+- models
+- deployments
+- jobs
+- datasets
You can use the search bar at the top of your project to free-text search for the names or descriptions of any ML asset.
You can also search using keywords or tags that are attached to an ML asset.
+Keywords apply to feature groups, feature views and training data only, so a keyword filter never matches a job or a dataset.
You can search for assets within a specific project or across all projects in a Hopsworks deployment, including those you are not a member of.
This allows for easier discoverability and reusability of assets within an organization.
To avoid users gaining unauthorized access to data, if a search result is in a project you are **not** a member of, the information displayed is limited to: names, descriptions, tags, asset creator and create date.
If the search result is within a project you are a member of, you are also able to inspect recent activities on the asset as well as statistics.
+Searching across projects you are not a member of is on by default.
+An administrator running a multi-tenant deployment can turn it off by setting the `cross_project_global_search_enabled` cluster variable to `false` in the [Configuration][cluster-configuration] UI, which restricts a cluster-wide search to the projects you can already access.
+
## Tags
A keyword is a single user-defined word attached to an ML asset.
diff --git a/docs/setup_installation/admin/dataset_tag_storage.md b/docs/setup_installation/admin/dataset_tag_storage.md
new file mode 100644
index 0000000000..e200acadd3
--- /dev/null
+++ b/docs/setup_installation/admin/dataset_tag_storage.md
@@ -0,0 +1,219 @@
+# Dataset Tag Storage
+
+## Introduction
+
+Tags attached to a dataset used to be stored as HopsFS extended attributes.
+They are now rows in the Hopsworks database.
+The move makes dataset tags searchable, countable when an administrator asks what still references a tag schema, and deleted in the same transaction as the dataset they belong to.
+
+Nothing about this is visible to a project member: the same tags are read and written through the same API either way.
+It is visible to an administrator, because moving the store on a running cluster is a one-way step that has to be taken deliberately, and because tags attached to individual files inside a dataset do not move.
+
+This page covers what an upgrade does on its own, what the cut-over is, and how to run it.
+
+## What the upgrade does on its own
+
+The upgrade that introduces this feature applies a database migration that audits the tag tables for duplicates and then adds unique keys over them.
+A tag or keyword write landing between the audit and the index creation can be the row that makes the index creation fail, so the chart stops those writes for the length of the migration.
+
+A pre-upgrade hook scales both Payara deployments to zero, waits until their pods are gone, runs the migration, and leaves the deployments at zero for the upgrade's own apply to bring back on the new image.
+The hook fires on the one upgrade that applies the migration and on no other.
+It is measured at 70 seconds against a database holding a million tag values.
+
+Two failure paths are deliberately different.
+A refusal by the pre-migration audit happens before any schema change, so the original replica counts are restored and the upgrade aborts with the cluster running as it was.
+A failure after the schema change has begun leaves the cluster scaled to zero, because starting the old nodes over a half-applied schema is worse than an outage.
+
+Set `hopsworks.tagLifecycle.writeWindow.enabled=false` only if you are taking the write window yourself. Routing traffic away at a load balancer is not enough, because internal clients still reach the API pods directly.
+The chart holds you to it: with the write window disabled, a pre-upgrade check refuses the one upgrade that applies the migration while any API pod is running or a HorizontalPodAutoscaler targets the API deployments.
+
+After the upgrade the cluster keeps reading dataset tags from the extended attributes and writes them to both stores.
+Nothing is lost while you stay in that state, and you can stay in it indefinitely.
+
+## Per-file tags are frozen
+
+Tags could previously be attached to any file or directory inside a dataset.
+Attaching a new tag to a path inside a dataset is now rejected with HTTP 400 and error code 370013.
+Per-file tags were stored outside the database, could not be searched or counted, and would have made the cut-over unbounded.
+
+Tags that were already attached to such paths remain readable and deletable, and the file browser keeps showing them.
+They are not migrated, and they are the reason the tag schema usage report states that its counts cover database-backed references only.
+
+## The activation gate
+
+Two operations are only safe once every node in the cluster runs the new code: replacing a cluster-wide mandatory tag policy, and the dataset tag cut-over.
+Both are refused with HTTP 503 and `ACTIVATION_PENDING` while a rolling upgrade is in progress.
+
+The chart's post-upgrade hook verifies against the Kubernetes API that every pod serving the Hopsworks API belongs to the new rollout, then activates the lifecycle.
+Activation is what the refusal waits for; no timer and no node decides it for itself.
+A fresh install is activated by the post-install hook instead, since there is no old node to wait for.
+
+Check the state at any time:
+
+```bash
+curl -s -H "Authorization: ApiKey $API_KEY" \
+ https://
For each mandatory tag you select the artifact types it applies to.
-A tag schema can be mandatory for any combination of feature groups, feature views, training datasets, models and deployments.
-For example, a `data_owner` schema can be marked mandatory for models and deployments only, leaving feature groups, feature views and training datasets unaffected.
+A tag schema can be mandatory for any combination of feature groups, feature views, training datasets, models, deployments and datasets.
+For example, a `data_owner` schema can be marked mandatory for models and deployments only, leaving the others unaffected.
+
+A [deprecated][tag-schema-lifecycle] schema cannot be registered as mandatory, because nobody is allowed to attach it.
+Conversely, a schema that is registered as mandatory cannot be deprecated until the registrations are removed.
## Enforcement per artifact type
-All five artifact types, feature groups, feature views, training datasets, models and deployments, enforce mandatory tags the same way.
+All six artifact types, feature groups, feature views, training datasets, models, deployments and datasets, enforce mandatory tags the same way.
The create request is validated against the configured mandatory tags.
If any mandatory tag is missing from the tags provided at creation, the artifact is not created and the request is rejected with an HTTP 400 error that lists the missing tag names.
@@ -129,8 +132,45 @@ The `tags` argument takes the same shape as feature group tags: a `{"name": ...,
deployment.save()
```
+=== "Dataset (Python)"
+
+ ```python
+ dataset_api = project.get_dataset_api()
+
+ # data_owner is mandatory for datasets; pass it when creating the dataset
+ dataset_api.mkdir(
+ "transactions_raw",
+ tags=[{"name": "data_owner", "value": "email@hopsworks.ai"}],
+ )
+ ```
+
Omitting a mandatory tag from the `tags` argument rejects the create request with an HTTP 400 error that lists the missing tag names.
+### Datasets
+
+Mandatory tags on datasets apply to the dataset itself, which is a top-level directory in the project's file system.
+They are not applied per file: a policy that had to be satisfied by every uploaded file would make every upload a violation.
+
+The policy applies to datasets a user creates.
+The datasets Hopsworks creates when it provisions a project, such as `Resources`, `Logs` and the training dataset and deployment directories, are exempt, because a cluster-wide policy would otherwise make project creation fail.
+
+A Hopsworks client older than the release that introduced this feature cannot send tags on the create call, so it receives the HTTP 400 listing the missing tags once a dataset policy is enabled.
+This is the same contract feature groups have, and it is why enabling a dataset policy is an explicit administrator action.
+
+A dataset that predates the policy stays valid.
+Its missing tags are reported on read through the `missingMandatoryTags` field, and shown on the dataset page in the UI.
+
+=== "Python"
+
+ ```python
+ dataset_api = project.get_dataset_api()
+
+ dataset = dataset_api.get("transactions_raw")
+ print(dataset.get("missingMandatoryTags", []))
+
+ dataset_api.add("transactions_raw", "data_owner", "email@hopsworks.ai")
+ ```
+
## Missing mandatory tags on pre-existing artifacts
Marking a tag mandatory does not retroactively reject artifacts that already exist without it.
diff --git a/docs/user_guides/fs/tags/tag_schema_lifecycle.md b/docs/user_guides/fs/tags/tag_schema_lifecycle.md
new file mode 100644
index 0000000000..b662880542
--- /dev/null
+++ b/docs/user_guides/fs/tags/tag_schema_lifecycle.md
@@ -0,0 +1,134 @@
+# Tag Schema Lifecycle
+
+## Introduction
+
+A tag schema is defined once and then attached to artifacts across every project on the cluster, as described in the [Tags][tags-guide] guide.
+This guide covers what an administrator can do with a schema afterwards: retire it so that nothing new uses it, and remove it once nothing does.
+
+The JSON definition of a schema cannot be changed after it is created.
+The values already attached were validated against the original definition, and editing it would leave those values unvalidated.
+Create a new schema instead, deprecate the old one, and migrate at your own pace.
+
+Deprecating, restoring and deleting a schema are administrator actions, performed in the `Cluster settings` > `Tag schemas` section or through the REST API.
+Attaching and detaching tag values stays with the project members.
+
+## Deprecate a schema
+
+Deprecation stops a schema from being attached to anything new while leaving everything already attached untouched.
+Use it when a schema is being replaced and you want existing owners to migrate off it without breaking their pipelines.
+
+| Operation on a deprecated schema | Allowed |
+| --- | --- |
+| Read the schema, list schemas, read attached values | Yes |
+| Attach it to an artifact that does not have it | No, rejected with HTTP 409 and error code 370011 |
+| Update the value already attached to an artifact | Yes, so a team can correct data while migrating off |
+| Detach it from an artifact | Yes |
+| Register it as a mandatory tag | No, because a mandatory tag that nobody may attach cannot be satisfied |
+| Delete the schema | Yes, under the rules below |
+| Restore it | Yes |
+
+A schema that is currently registered as a [mandatory tag][mandatory-tags] cannot be deprecated.
+The request is rejected with HTTP 409 and error code 370012.
+Remove the mandatory registrations first, then deprecate.
+
+In the UI, deprecated schemas carry a muted `Deprecated` badge with the date, and the schema detail drawer shows which administrator deprecated it.
+The tag picker on an artifact page no longer offers them, while a deprecated tag that is already attached still renders so it can be edited or removed.
+
+=== "Python"
+
+ ```python
+ import hopsworks
+ from hopsworks_common.core.tag_schemas_api import TagSchemasApi
+
+
+ hopsworks.login()
+ tag_schemas = TagSchemasApi()
+
+ tag_schemas.deprecate("data_privacy_v1")
+
+ # Existing attachments keep working; new ones are refused.
+ schema = tag_schemas.get("data_privacy_v1")
+ print(schema["deprecated"], schema["deprecatedOn"], schema["deprecatedBy"])
+ ```
+
+## Restore a schema
+
+Restoring clears the deprecation and the record of who set it and when.
+Those three fields describe the current state rather than a history, so a restored schema looks like one that was never deprecated.
+The audit log remains the record of the full sequence of actions.
+
+=== "Python"
+
+ ```python
+ tag_schemas.restore("data_privacy_v1")
+ ```
+
+## Check what uses a schema
+
+Before deleting a schema, ask what still references it.
+
+=== "Python"
+
+ ```python
+ usage = tag_schemas.usage("data_privacy_v1")
+
+ print(usage["attachmentCount"]) # values attached across all projects
+ print(usage["mandatoryRegistrationCount"]) # mandatory registrations
+ print(usage["deletable"]) # both counts are zero
+ for ref in usage.get("references", []):
+ print(ref["kind"], ref["projectName"], ref["artifactName"], ref["version"])
+ ```
+
+`references` lists the artifacts holding a value, up to a limit.
+Above that limit the list is omitted and `truncated` is `true`, leaving `attachmentCount` as the answer.
+
+`complete` is always `false`.
+Tags attached to individual files inside a dataset before per-file tags were frozen live in the file system rather than the database, and no query can enumerate them without walking the file system.
+The counts therefore cover database-backed references only.
+This is why the UI wording is "No database-backed references found" rather than "not used".
+
+## Delete a schema
+
+A schema can be deleted once nothing references it.
+The request is refused with HTTP 409 and error code 370010 while any value is attached or any mandatory registration exists, and the error names what to detach.
+
+The check and the deletion run in one transaction against the database, and foreign keys refuse the deletion even if a value is attached between the check and the commit.
+The dialog in the UI is advisory; the database is authoritative, so a delete that looked safe a moment earlier can still be refused.
+
+=== "Python"
+
+ ```python
+ usage = tag_schemas.usage("data_privacy_v1")
+ if usage["deletable"]:
+ tag_schemas.delete("data_privacy_v1")
+ ```
+
+### Force delete
+
+`force=True` deletes the schema together with every value attached to it, in a single transaction, and queues the affected artifacts for reindexing so that search stops returning the deleted tag.
+It is exposed on the REST API and in the Python client, and deliberately not in the UI.
+
+=== "Python"
+
+ ```python
+ tag_schemas.delete("data_privacy_v1", force=True)
+ ```
+
+Above a threshold of attached values, configured cluster-wide and defaulting to 5000, a force delete is refused with HTTP 409 rather than started.
+The error states the count and names the reindex endpoint.
+A partial delete that leaves stale values in the search index is worse than a refusal, and reindexing that many documents is an operation an administrator should schedule.
+
+### Deleting against an older backend
+
+A plain `delete` against a Hopsworks version older than the one described here is refused by the client rather than sent.
+That backend ignores the unknown `force` parameter and deletes every attached value for any delete call, so the safest-looking call would get the most destructive behaviour the old server has.
+The client detects it through the usage endpoint, which shipped in the same release as the refusal, and raises a `RuntimeError` naming `force=True` as the way to proceed deliberately.
+
+## Command line
+
+The same two lifecycle actions are available in the CLI.
+
+```bash
+hops tags deprecate data_privacy_v1
+hops tags restore data_privacy_v1
+```
diff --git a/docs/user_guides/fs/tags/tags.md b/docs/user_guides/fs/tags/tags.md
index 6fc582829c..3ae4950215 100644
--- a/docs/user_guides/fs/tags/tags.md
+++ b/docs/user_guides/fs/tags/tags.md
@@ -1,8 +1,8 @@
-# Tags
+# Tags { #tags-guide }
## Introduction
-Hopsworks feature store enables users to attach tags to artifacts, such as feature groups, feature views, training datasets, models or deployments.
+Hopsworks enables users to attach tags to artifacts, such as feature groups, feature views, training datasets, models, deployments, jobs or datasets.
A tag is a `{key: value}` pair which provides additional information about the data managed by Hopsworks.
Tags allow you to design custom metadata for your artifacts.
@@ -18,9 +18,10 @@ Schemas are defined globally across all projects.
When users attach tags to an artifact, the tag will be validated against a specific schema.
This allows tags to be consistent no matter the project or the team generating them.
-!!! warning "Immutable"
- Tag schemas are immutable.
- Once defined, a tag schema cannot be edited nor deleted.
+!!! warning "Schema definitions cannot be edited"
+ The JSON schema of a tag schema cannot be changed after it is created, because the values already attached were validated against the original definition.
+ A schema can be deprecated so that it accepts no new attachments, and it can be deleted once nothing references it.
+ Both are administrator actions and are described in the [Tag schema lifecycle][tag-schema-lifecycle] guide.
## Step 1: Define a tag schema
@@ -73,7 +74,7 @@ Where the type is a valid primitive type: `string`, `boolean`, `integer`, `numbe
## Step 2: Attach a tag to an artifact
-Once the tag schema has been created, you can attach a tag with that schema to a feature group, feature view, training dataset, model or deployment either using the APIs, or by using the UI.
+Once the tag schema has been created, you can attach a tag with that schema to a feature group, feature view, training dataset, model, deployment, job or dataset, either using the APIs or the UI.
### Using the API
@@ -122,6 +123,62 @@ Finally you can remove a tag from a given artifact by calling the `delete_tag()`
The same APIs work for feature views, training datasets, models and deployments alike.
+#### Jobs
+
+Jobs carry tags through the same three methods, on the `Job` object returned by the job API.
+
+=== "Python"
+
+ ```python
+ job_api = project.get_job_api()
+ job = job_api.get_job("transactions_ingestion")
+
+ job.add_tag("data_privacy", {"business_unit": "Fraud", "pii": True})
+ job.get_tags()
+ job.delete_tag("data_privacy")
+ ```
+
+A job also has a free-text description, which is indexed for search alongside its tags.
+
+=== "Python"
+
+ ```python
+ job.description = "Hourly ingestion of card transactions"
+ job.save()
+ ```
+
+#### Datasets
+
+Tags are attached to a dataset, which is a top-level directory in the project's file system.
+They are reached through the dataset API by path.
+
+=== "Python"
+
+ ```python
+ dataset_api = project.get_dataset_api()
+
+ dataset_api.add("Resources", "data_privacy", {"business_unit": "Fraud", "pii": True})
+ dataset_api.get_tags("Resources")
+ dataset_api.delete("Resources", "data_privacy")
+ ```
+
+Tags can also be attached when the dataset is created.
+
+=== "Python"
+
+ ```python
+ dataset_api.mkdir(
+ "transactions_raw",
+ tags=[{"name": "data_privacy", "value": {"business_unit": "Fraud", "pii": True}}],
+ )
+ ```
+
+!!! warning "Tags on files inside a dataset are frozen"
+ Tags could previously be attached to any file or directory inside a dataset.
+ Attaching a new tag to a path inside a dataset is now rejected with HTTP 400 and error code 370013, because per-file tags were stored outside the database and could not be searched, counted, or governed.
+ Tags that were already attached to such paths remain readable and deletable, and the file browser keeps showing them.
+ Attach the tag to the dataset instead.
+
### Using the UI
You can attach tags to feature groups and feature views directly from the UI.
@@ -135,11 +192,33 @@ From there you can select the tag schema of the tag you want to attach and popul