Editorial Workflows

Global document references

Why every document pointer in a workflow carries its location, and how resource aliases keep deployed definitions portable across environments.

Prerelease

A workflow’s own documents (the instance, its guards) live in one place, but the content it governs often lives somewhere else: another dataset, another project, sometimes Canvas or the Media Library. A normal Sanity reference (_ref) only points within a single dataset, so it cannot cross that boundary. The engine works across a whole organization, so everywhere it points at a document it uses a global document reference, a GDR: a reference that carries both which document and where it lives.

One workflow across resources

A launch review can keep its workflow data in one project while the content it coordinates remains in other Sanity resources.

  • Workflow data: the workflows dataset in the operations project.
  • Editorial content: the production dataset in the web project.
  • Campaign assets: the mlcampaign Media Library.

Configure the engine against the workflow resource. The resourceClients resolver admits the two foreign resources and routes their reads.

import {createClient} from '@sanity/client'
import {
  createEngine,
  ENGINE_API_VERSION,
  type ParsedGdr,
} from '@sanity/workflow-engine'

const token = process.env.SANITY_AUTH_TOKEN!

const workflowClient = createClient({
  projectId: 'opsproject',
  dataset: 'workflows',
  apiVersion: ENGINE_API_VERSION,
  token,
  useCdn: false,
})

const editorialClient = createClient({
  projectId: 'webproject',
  dataset: 'production',
  apiVersion: ENGINE_API_VERSION,
  token,
  useCdn: false,
})

const mediaClient = createClient({
  resource: {type: 'media-library', id: 'mlcampaign'},
  apiVersion: ENGINE_API_VERSION,
  token,
  useCdn: false,
})

export const engine = createEngine({
  client: workflowClient,
  workflowResource: {type: 'dataset', id: 'opsproject.workflows'},
  tag: 'production',
  resourceClients: (resource: ParsedGdr) => {
    if (
      resource.scheme === 'dataset' &&
      resource.projectId === 'webproject' &&
      resource.dataset === 'production'
    ) {
      return editorialClient
    }
    if (
      resource.scheme === 'media-library' &&
      resource.resourceId === 'mlcampaign'
    ) {
      return mediaClient
    }
    return undefined
  },
})

The deployed launch-review definition declares subject and heroImage as input fields. Start it with references that preserve each document’s resource identity.

import {
  refDataset,
  refMediaLibrary,
} from '@sanity/workflow-engine'
import {engine} from './engine'

const article = refDataset({
  projectId: 'webproject',
  dataset: 'production',
  documentId: 'launch-article',
  type: 'article',
})

const heroImage = refMediaLibrary({
  resourceId: 'mlcampaign',
  documentId: 'image-1',
  type: 'sanity.asset',
})

await engine.startInstance({
  definition: 'launch-review',
  initialFields: [
    {type: 'subject', name: 'subject', value: article},
    {type: 'doc.ref', name: 'heroImage', value: heroImage},
  ],
})

The workflow stores the references, not copies of the content. Conditions and effects can read both documents through the configured resource clients.

The GDR shape

A GDR is a small object, an id and a type:

import {refDataset} from '@sanity/workflow-engine'

// an article in the "production" dataset of project "yourprojectid"
const subject = refDataset({
  projectId: 'yourprojectid',
  dataset: 'production',
  documentId: 'article-1',
  type: 'article',
})
// → { id: 'dataset:yourprojectid:production:article-1', type: 'article' }

That id is not a bare document id. Everywhere else in Sanity a reference is bare, a document’s _id or the _ref inside a reference, like "article-1". A GDR’s id carries the resource prefix, because the engine has to know which project and dataset (or which Canvas, Media Library, or Dashboard resource) holds the document. The scheme leads:

// A GDR id is a typed URI:
//   dataset:<projectId>:<dataset>:<documentId>
//   canvas:<resourceId>:<documentId>
//   media-library:<resourceId>:<documentId>
//   dashboard:<resourceId>:<documentId>
export type GdrScheme = 'dataset' | 'canvas' | 'media-library' | 'dashboard'
export type GdrUri = `${GdrScheme}:${string}`

The type is the document’s schema type, carried so a reader knows what it points at without a second lookup.

Build one with the per-scheme constructors (refDataset, refCanvas, refMediaLibrary, refDashboard), and call extractDocumentId to get the bare _id back when you need it (for a *[_id == $id] query, say). A bare id passed where a GDR belongs is rejected at compile time, so a location-less id can never reach a filter or a stored field. The one deliberate exception: a literal initialValue seed in a definition may name a bare id, and the engine roots it at the workflow’s home resource when the instance materializes the field.

Use stable dataset document IDs

A dataset GDR identifies the logical document. Pass its stable ID, such as article-1. Do not pass drafts.article-1 or versions.<release>.article-1. The workflow perspective selects the draft, published document, or release version to read.

You meet global document references wherever a workflow names a document: the subject passed at start, doc.ref and doc.refs fields, release.ref, and the ancestor chain. A start.filter reads the loaded candidate document. Start requirements read the caller-supplied input fields.

Compare global document references directly; do not assemble resource-qualified strings. Use a single-subject start requirement when only one unfinished run of the same definition may exist for a subject. See Conditions for start filters, requirements, and their available values.

In the rendered scope the instance refers to itself as $self, its own GDR. That location-carrying id is also what lets a workflow read content it does not live next to: when a filter dereferences a subject in another dataset, the engine routes the read to a client for that GDR’s resource. A resourceClients resolver you give createEngine wins when it returns one; otherwise the engine derives a sibling client from the workflow client’s own credentials. A single-dataset setup never notices; a cross-dataset or cross-project one works with zero extra wiring on an org-capable token. You add resourceClients for two reasons: to give a foreign resource different credentials, and to declare it as part of the write surface, since a runtime-supplied ref may only target a resource resourceClients serves. A read-only derived sibling does not declare it. A client that cannot derive siblings makes a foreign GDR fail loudly instead of reading the wrong place.

The same shape flows into effects. When you bind a referenced id into an effect (bindings: {assetId: '$fields.asset._id'}), the value the handler receives is that document’s GDR URI, which it turns into a bare id with extractDocumentId. And $fields.asset._id resolves from the stored reference itself, so identity reads never load the referenced document at all. Content reads ($fields.asset.title, say) dereference into the loaded document, and the engine reaches the Media Library by deriving a sibling client from the workflow client’s own credentials; wire resourceClients when that resource needs different credentials. You must also serve the resource to declare it as a write target for Media Library refs.

The engine exposes GDRs directly because it is organization-wide by design. Writing a doc.ref value or wiring up resourceClients means working with GDRs explicitly.

Runtime-supplied refs are checked against that declared surface before they enter field state. A doc.ref, doc.refs, or release.ref value can arrive from a start initialFields value, an action’s param-sourced op value, an editField value, or an effect-completion op. It must target the workflow’s own resource or a resource resourceClients serves; a ref to any other resource aborts the commit with RefResourceUndeclaredError, and nothing is written. Refs the deployed definition already carries are exempt, because deploy vetted them: literal initialValue seeds, type: 'query' texts, spawn with projections, and an op value that binds no parameter. Like every engine check, this is advisory. The Content Lake is the only enforcement point. This check makes a misdirected ref fail at the write that introduces it instead of at a later read.

Resource aliases

Resource aliases make references portable. A GDR is physical: it names an exact project and dataset (or resource). For a definition you deploy across environments, you rarely want that baked in. So a definition can reference content through a named alias instead (@content: followed by the document id) and bind that alias to a physical resource at deploy time: deployDefinitions({expectedMinReaderModel: 4, definitions, resourceAliases: {content: {type: 'dataset', id: 'yourprojectid.production'}}}). Every @content: reference expands to a physical GDR at deploy, so the deployed definition holds only physical refs, and moving to another environment is deploying the same source with a different map. Bare ids and physical GDRs are unchanged, so a single-resource setup needs no aliases at all.

Next steps

  • Concepts: the core model these references plug into.
  • Effects and runtimes: how GDR-valued bindings reach effect handlers.
  • Reference: every construct spelled out exactly, each field, type, default, and option.

Visiting agent?

Was this page helpful?