xmatix metadata exports a tenant's metadata as one seed-pack JSON file and imports such a file back into a tenant. pull writes the file so you can review it in git, push sends a file to the server's tenant import, and diff compares a file with a fresh export. Before you push anything, read What push does. A file pulled with the default settings is a full pack, and pushing a full pack can delete metadata from the tenant.
Overview
xmatix metadata pull [--scope <scope>] [--out <path>] [--mode full|delta] [--version <n>]
xmatix metadata push <file.json> [--yes] [--source-subscription <guid>]
xmatix metadata diff <file.json> [--scope <scope>]
| Command | What it does | Exit code |
|---|---|---|
metadata pull | Exports one scope of the tenant's metadata and writes it, indented, to xmatix/metadata/<scope-name>.json (or --out) | 0 written, 1 failed |
metadata push | Checks that the file is valid JSON, asks for confirmation, then imports the whole file into the tenant | 0 imported, 1 failed or declined |
metadata diff | Exports the same scope again and counts rows that differ from the file, table by table | 0 no drift, 1 drift, 2 error |
The same export and import run inside other commands. snapshot export runs metadata pull, snapshot import pushes every *.json under xmatix/metadata/, and migrate plan exports metadata for its comparison.
When to use it
- Keep your customisations under review. Pull the
TenantOwnedscope after changing the model in the product, commit the file, and review the change in a pull request. - Inspect what a tenant has. Pull a wider scope (
TenantFull,BaseMetadata, a module, package or namespace) to read it. These exports are for reading, not for pushing back. - Move custom metadata to another tenant. Push a
TenantOwnedfile into a tenant whose workspace you have configured, after you have made it a delta pack (Step 3 below). - Check for drift. Compare the committed file's tables with a fresh export. Use the
jqcomparison in Step 5, notmetadata diff, in the current release.
How xMatix handles it
What a seed-pack contains
The file is one JSON object:
mode—fullordelta, taken from--mode(anything other thandeltabecomesfull). This value decides what the tenant does with the file when it is pushed.version— the number passed with--version(default 1). The import records it against the rows it applies. It does not select an older export.generatedAtUtc— the time of the export. It changes on every pull, so the file never stays byte-for-byte the same.deletes— always an empty list in an export.tables— one array per metadata table, keyed by table name (Entity,EntityField,EntityView,EntityLayout,BusinessRuleand so on).referenceMap— present when layouts and similar blobs refer to other metadata by internal id. The import uses it to re-point those references at the receiving tenant's own rows.
The tables cover far more than entities and fields. An export includes picklists, entities, record types, fields, views and their columns, layouts, actions, relations, validations, lookup filters and field mappings. It also includes apps, app pages and navigation, security profiles and their permissions, security roles and teams, record-level security policies and rules, translations, automation scripts with their versions and bindings, custom components, mobile apps, inventory ledger settings and document numbering settings. Reports and dashboards are in it too, along with business rules, approval processes, email templates, data models and datasets, data graphs, archive rules, assignment definitions and the AI catalogue: models, agents, capabilities, intents, workflows and knowledge rules. Rows are sorted by name, so two exports of the same state list rows in the same order.
Some things never travel, because they belong to one tenant:
- Which layout or view an entity opens by default, unless it is a platform-standard binding. Your own bindings are not exported.
- Document print templates you created. Only the platform-standard templates are exported, and template assignments never are.
- Report and dashboard sharing with individual users. Only the "all users" share is exported as such. Sharing with profiles, teams and roles goes by name.
- Run histories, consent records and the preview-team setting of a mobile app.
AI model rows carry their endpoint address and deployment name, but not keys. Review a pulled file for anything you don't want in a repository before you commit it.
Which rows each scope includes
| Scope | Default file name | Rows included |
|---|---|---|
TenantOwned (default) | tenant-owned.json | Custom entities and custom fields created in the product, rows whose name carries the custom _c suffix, and every row an earlier metadata push recorded as tenant-owned. Rows owned by a module or package are left out. |
TenantFull | tenant-full.json | Everything in the tenant, with no ownership filter |
BaseMetadata | base-metadata.json | Rows that no tenant, module or package owns and whose names carry no custom suffix or namespace |
PlatformModule | module-<id>.json | Rows owned by the platform module given in --package-id, or by any module when you leave it out |
PackageOwned | package-<id>.json | Rows owned by the installed package given in --package-id, or by any package when you leave it out |
Namespace | namespace-<ns>.json | Rows whose name starts with the namespace given in --namespace, followed by ., - or _. Without --namespace every table is empty. |
TenantOwned is not "everything you have customised". If you relabel a standard field, edit a standard layout or add a view to a standard entity, that row is still a standard row, so it isn't in the TenantOwned export. A view or layout on a custom entity is included, because its key carries the entity's custom suffix. To see standard rows as your tenant currently has them, pull TenantFull.
What push does
push reads the file, refuses it if it isn't valid JSON, and shows its path and size with the prompt "Continue?". The default answer is no. --yes skips the prompt. Then the server:
- Applies every row in the file through the same seed engine that sets up tenants. Rows are matched by name, so existing rows are updated and missing ones are created. The server does not filter the file by scope or ownership.
- Records every row in the file as tenant-owned. From then on those rows appear in
TenantOwnedexports, even if they came from aTenantFullorBaseMetadatafile. - Returns a summary, which the CLI prints after
Import succeeded.: a status, the number of rows recorded and the message "Tenant-owned metadata import applied successfully."
What happens to rows that are not in the file depends on the file's mode:
delta— nothing. Only the rows in the file are created or updated.full— the seed engine prunes. It deletes every row that an earlier seed-pack apply delivered to the tenant and that is missing from the file. That includes the platform's own setup of the tenant and any modules or packages installed through it. Rows created in the product that no pack ever delivered are not pruned.
metadata pull writes full unless you pass --mode delta, and so does snapshot export. A TenantOwned file holds only custom rows, so pushing it as a full pack asks the tenant to remove standard metadata that isn't in it. Push only delta packs. Pull with --mode delta, or change the file's mode before pushing (Step 3).
--source-subscription <guid> only records which tenant the rows came from, on the ownership records the import writes. It does not change how references resolve. References already travel by name and through the file's referenceMap, so a file from another tenant imports the same way with or without it.
An import never deletes rows just because you removed them from the file, unless the file is a full pack (see above). An exported file's deletes list is always empty.
What diff compares
diff exports the scope you name with --scope (default TenantOwned, always mode full and version 1). It then walks each table in the file and in the export and prints a table of Added (server), Removed (server) and Changed counts. It prints counts only, no row samples. It ends with "No drift." (exit 0) or "Drift detected" and the totals (exit 1). It exits 2 when the file is missing, the export fails, or either side has no tables object.
Rows are matched by an id property when they have one. Exported rows don't have one, so diff matches each row by its position plus its exact JSON text. A pulled file is indented and the fresh export is compact, so every row of every non-empty table counts as removed and added, and diff reports drift right after a pull. Inserting a row also shifts the position of every row after it. Until this changes, compare the tables with jq instead (Step 5).
Prerequisites
- An initialised workspace and a signed-in session (
xmatix init,xmatix login). See Authentication.pullwrites under the workspace root. - The Manage metadata packages capability for the signed-in identity (see Permissions).
jqon the machine, for Steps 3 and 5.- For a push, a workspace configured for the receiving tenant. The server applies the file to the tenant that the workspace's API address belongs to.
Procedure
Step 1 — Pull the tenant-owned metadata
xmatix metadata pull --mode delta
The CLI prints Exporting scope=TenantOwned mode=delta → <path>, then Wrote <path> (<n> bytes). The file is xmatix/metadata/tenant-owned.json. Use --scope for another scope (with --package-id or --namespace where the table above needs one), and --out to write somewhere else.
Step 2 — Review and commit the file
Open the file and check that it holds what you expect: your custom entities and fields under tables.Entity and tables.EntityField, and the views, layouts and rules that belong to them. Check it for endpoint addresses and other values you don't want in a repository. Commit it. Reviewers read the JSON diff like any other change. Every pull changes generatedAtUtc, so ignore that line.
Step 3 — Make sure the file is a delta pack
Before any push, check the mode:
jq -r .mode xmatix/metadata/tenant-owned.json
If it prints full, either pull again with --mode delta or rewrite the mode:
jq '.mode = "delta"' xmatix/metadata/tenant-owned.json > tenant-owned.delta.json
Do this every time after a snapshot export, which always writes full.
Step 4 — Push the file
xmatix metadata push tenant-owned.delta.json
The CLI shows "About to import <path> (<n> bytes) into the current tenant." and asks "Continue?". Answer y. If you decline, it prints "Aborted." and exits 1. Nobody can answer the prompt in a pipeline, so pass --yes there. On success it prints Import succeeded. and the summary. On failure it prints Push failed: with the server's status code and message, and exits 1. The seed engine applies the whole file in one transaction, so if it fails, none of the file's rows are applied.
Step 5 — Check for drift
Compare the committed tables with a fresh export, sorted, so that generatedAtUtc and formatting are ignored:
xmatix metadata pull --out drift-check.json
diff <(jq -S .tables xmatix/metadata/tenant-owned.json) <(jq -S .tables drift-check.json)
No output means the tenant still matches the file. Any lines printed are rows that were changed in the product after the last pull. Export them into a branch and review them. CI recipes has the same check as a scheduled job and a pre-commit hook.
Example
A partner team keeps a tenant's custom model in git. A developer adds a custom entity, Site Survey, with four custom fields and a list view, in the product's data model designer. They run xmatix metadata pull --mode delta. The diff shows one new Entity row, four EntityField rows, an EntityView row and its columns, all named with the _c suffix. The developer commits these on a branch and opens a pull request.
Meanwhile an administrator relabels a standard field on Order. That change is not in the developer's file, because the field is a standard row, and it doesn't appear in the TenantOwned export at all.
After review, the release job checks jq -r .mode (it prints delta) and runs xmatix metadata push xmatix/metadata/tenant-owned.json --yes against the second tenant's workspace. The summary reports the rows recorded. In the second tenant's data model, the team finds Site Survey with its four fields and its view.
Training
Practice exercise
In a sandbox tenant, create a custom entity with two custom fields. Then:
- Run
xmatix metadata pull --mode deltaand find the entity undertables.Entityand its fields undertables.EntityField. - Run
xmatix metadata diff xmatix/metadata/tenant-owned.json. Expected result: drift is reported straight away, with matching Added and Removed counts and Changed 0, because of the formatting difference. - Run the Step 5
jqcomparison. Expected result: no output. - Add a third field in the product and run the comparison again. Expected result: the new field's row is printed.
Quick reference
TenantOwnedholds custom (_c) rows and rows recorded by earlier pushes. Edits to standard rows are not in it.pullandsnapshot exportwritemode: fullunless you pass--mode delta.- Push only delta packs. A full pack deletes earlier-delivered metadata that is missing from the file.
pushapplies every row in the file and records each one as tenant-owned.pushasks "Continue?"; use--yesin pipelines.--source-subscriptionis a label only.diffreports drift on any pulled file; comparejq -S .tablesinstead.generatedAtUtcchanges on every pull.- All three commands need the Manage metadata packages capability.
Permissions
metadata pull, push and diff call the server's metadata package endpoints. They require the Manage metadata packages capability (setup.metadata.packages.manage), the same one that opens Setup → Packages. Without it every call is refused with a 403, which the CLI prints as Pull failed:, Push failed: or Diff failed:. Grant the capability through the identity's security profile. See the security model. The same applies to the metadata steps of snapshot export, snapshot import and migrate plan.
Common problems
Standard entities, fields or layouts disappeared after a push
The file was a full pack ("mode": "full"). On a full pack the seed engine deletes every row that an earlier pack delivered and that is missing from the file. A TenantOwned file contains only custom rows, so standard metadata is removed. Restore the standard metadata through your platform administrator or support channel, and push only delta packs from now on (Step 3).
metadata diff reports drift right after a pull
diff matches rows by position and exact text. A pulled file is indented and the export isn't, so every row looks removed and added. Use the jq -S .tables comparison in Step 5.
push fails with "SeedPack is empty (no table rows and no deletes)."
The file has no rows in any table, which is what a TenantOwned pull returns for a tenant with no custom metadata. There is nothing to import. Check the scope you pulled.
push fails at "Continue?" in a pipeline
The command asks for confirmation, and nobody can answer it in a pipeline. Add --yes. snapshot import and migrate apply don't pass it, so push metadata yourself in pipelines, as in CI recipes.
My change to a standard field is not in the file
TenantOwned holds only custom rows and rows that earlier pushes recorded. Pull TenantFull to see the standard row as your tenant has it. Don't push a TenantFull file back to keep such a change. Every row in it would be recorded as tenant-owned, and as a full pack it would also prune.
Pull, push or diff fails with 403
The signed-in identity lacks the Manage metadata packages capability. Grant it through the identity's security profile.
Common questions
Why push only a TenantOwned file?
Because the import does not filter. It applies every row in the file and then records each one as tenant-owned. A TenantOwned file contains just your custom metadata, so that is all that changes. A TenantFull or BaseMetadata file would rewrite standard rows with whatever the file holds and mark them as yours, so they would show in every later TenantOwned export. Use the wider scopes to read and compare, and keep the file you push to the custom rows you mean to move. Make it a delta pack first.
Does push delete rows I removed from the file?
Not from a delta pack. A delta pack only creates and updates the rows it contains, and an exported file's deletes list is always empty. A full pack is different. It deletes every row an earlier seed-pack apply delivered that is no longer in the file, which can include the tenant's standard metadata. To remove a custom entity or field, delete it in the product and pull again.
How do I put a metadata change through pull-request review?
Make the change in the product, run xmatix metadata pull --mode delta, and commit the file on a branch. The rows are sorted by name, so the diff shows only what changed, plus the generatedAtUtc line. Reviewers read the JSON like any code change. After the merge, a pipeline checks that the mode is delta and runs xmatix metadata push <file> --yes against the target tenant.
Does --version let me pull an earlier state of the tenant?
No. The export is always the tenant's current state. --version only writes the number into the file's version field, and the import records it against the rows it applies. Use your git history to get an earlier state.
