<teammate-message teammate_id="team-lead" summary="Build and deploy notice-lifecycle workflow">
Goal: add a real Sanity Workflows (early access, @sanity/workflow-engine + @sanity/workflow-cli 0.35.x) definition to the public repo ora2az, deploy it to the live Sanity project, and prove it runs. This is for a DEV.to challenge entry ("Legacy Obituaries"); judges give bonus credit for using Workflows.
Repo (local git clone, pushes to public GitHub pyaroslav/ora2az): [REDACTED]/sanity-challenge/ora2az
- Sanity project udyjvgsk, dataset production (public), org o8wfejrgc. The Sanity CLI is logged in on this machine (npx sanity login credential in ~/.config/sanity/config.json), which the workflows CLI can use; env values are in ~/.config/ora2az/env (SANITY_PROJECT_ID, SANITY_DATASET, SANITY_WRITE_TOKEN editor token). Never print token values.
- Schema: studio/schemaTypes/oracleFeature.ts has fields obituary (Portable Text) and reviewStatus ('draft' | 'certified'). Published features with certified obituaries: 47. Three features are currently in the review queue as drafts (drafts.feature_xmltype, drafts.feature_vector-datatype, drafts.feature_flashback-query) with reviewStatus 'draft' and an obituary; their published versions have no obituary.
- Existing human gate: the App SDK desk (desk/src/CertifyButton.tsx) certifies by editDocument(set reviewStatus certified)+publishDocument.
- There is a fact check we already ran by script: an obituary may not mention an Oracle release (e.g. 12.2, 19c, 26ai) or ORA-xxxxx code that is not present in that feature's own data (summary, releases, its mappings/caveats/disputes).
Read the docs FIRST (fetch pages; use https://www.sanity.io/docs/llms/workflows.txt and the pages it links): https://www.sanity.io/docs/workflows , /docs/workflows/getting-started , /docs/workflows/studio-plugin , /docs/workflows/sanity-functions , /docs/workflows/cli-reference , /docs/workflows/cookbook , /docs/workflows/cookbook-editorial-review , /docs/workflows/cookbook-ai-content-pipeline. Also read the installed package types in node_modules (@sanity/workflow-engine/define etc.) — a scratch install exists at /tmp/claude-1000/wf. Follow the real API; do not guess.
Build:
1. workflows/ folder in the repo with its own package.json (deps @sanity/workflow-engine, @sanity/workflow-cli pinned to the versions in /tmp/claude-1000/wf), sanity.workflow.ts, and definitions/notice-lifecycle.ts: a workflow over an oracleFeature document with stages roughly: drafted → fact-checked → certified (terminal), with a retract path back to drafted, and a guard so the obituary field cannot change while the instance is in certified if guards are supported. Model the fact check as an activity/action (automated where the engine allows, otherwise an action fired by a script) and certification as a human action. Keep it small and correct over ambitious.
2. workflows/scripts/fact-check.mjs: reads the instance's document (draft), runs the release/ORA-code check described above via GROQ, and fires the corresponding action (pass → fact-checked, fail → back to drafted with a note) using the CLI or the engine client API, whichever the docs show.
3. Deploy to deployment production bound to udyjvgsk.production (npx sanity-workflows deploy). Start instances for the 3 queued drafts, run the fact check on them so they reach fact-checked, and leave them there for a human to certify (do NOT certify or publish them). Use sanity-workflows inspect/list commands to capture the resulting state.
4. If the docs show a simple way to make certification in the existing App SDK desk fire the workflow's certify action (or to show the workflow stage in the desk), describe it in the README but only implement it if it is small and you can type-check it (npx tsc --noEmit in desk/); do NOT redeploy the desk.
5. workflows/README.md: what it does, stages diagram in text, how to deploy, how to run the fact check, current state (paste CLI output of the 3 instances), and honest limitations (early access 0.x, what is enforced vs not).
Rules: no brand/company names other than Sanity/Oracle/Azure; do not mention any AI vendor or model names in committed files; commit with git -c user.name=pyaroslav -c user.email=<personal-email> commit -m "workflows: …" (no co-author trailers) and git push. Do not modify app/, content/data/, or the published documents' obituary/reviewStatus beyond what starting a workflow instance does. If Workflows turns out to be unavailable for this org/plan (feature gated, 403, etc.), stop, do not fake it, and report the exact error text and which doc page states the requirement.
Report back: what was deployed, the instance states (CLI output), files added, any doc ambiguities you resolved from package source, and any errors.
</teammate-message>
I'll read the Sanity Workflows docs and the installed package types first, then build, deploy, and run the workflow.
{
"query": "select:WebFetch,SendMessage",
"max_results": 2
}
$ cd /tmp/claude-1000/wf && cat package.json && ls node_modules/@sanity/ && ls node_modules/@sanity/workflow-engine node_modules/@sanity/workflow-cli; cat node_modules/@sanity/workflow-engine/package.json | head -80
{
"name": "wf",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@sanity/workflow-cli": "^0.35.0",
"@sanity/workflow-engine": "^0.35.0"
}
}
cli-core
client
eventsource
groq-condition-describe
id-utils
media-library-types
telemetry
types
uuid
workflow-blueprint
workflow-cli
workflow-engine
node_modules/@sanity/workflow-cli:
bin
CHANGELOG.md
dist
LICENSE
node_modules
oclif.manifest.json
package.json
README.md
node_modules/@sanity/workflow-engine:
CHANGELOG.md
DATAMODEL.md
dist
LICENSE
package.json
README.md
{
"name": "@sanity/workflow-engine",
"version": "0.35.0",
"description": "Workflow / BPM engine for Sanity content — define workflows as data, run them as instances against a Sanity client.",
"keywords": [
"bpm",
"groq",
"sanity",
"sanity-io",
"state-machine",
"workflow",
"workflows"
],
"homepage": "https://github.com/sanity-io/workflows/tree/main/packages/workflow-engine#readme",
"bugs": {
"url": "https://github.com/sanity-io/workflows/issues"
},
"license": "MIT",
"author": "Sanity.io <hello@sanity.io>",
"repository": {
"type": "git",
"url": "git+https://github.com/sanity-io/workflows.git",
"directory": "packages/workflow-engine"
},
"files": [
"dist",
"CHANGELOG.md",
"DATAMODEL.md"
],
"type": "module",
"sideEffects": false,
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.cjs",
"default": "./dist/index.js"
},
"./define": {
"import": "./dist/define.js",
"require": "./dist/define.cjs",
"default": "./dist/define.js"
},
"./package.json": "./package.json"
},
"publishConfig": {
"access": "public"
},
"dependencies": {
"@sanity/id-utils": "^1.0.0",
"@sanity/types": "^5.28.0",
"groq-js": "^1.30.2",
"valibot": "^1.4.1",
"@sanity/groq-condition-describe": "0.5.1"
},
"devDependencies": {
"@sanity-labs/client-fake-for-test": "0.12.0",
"@sanity/pkg-utils": "^10.5.2",
"@sanity/telemetry": "^1.1.0",
"vitest": "^4.1.8",
"@sanity/workflow-test-fixtures": "0.0.0"
},
"engines": {
"node": ">=20"
},
"scripts": {
"build": "pkg-utils build --clean",
"docs": "node ../../scripts/run-typedoc.mjs",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit -p tsconfig.json"
}
}
$ curl -sL https://www.sanity.io/docs/llms/workflows.txt | head -c 20000
# Sanity Docs: Workflows
> Markdown index of the Workflows section of the Sanity documentation. Every page below is also available as markdown: append `.md` to its URL or request it with an `Accept: text/markdown` header.
## Workflows
- [Workflows](https://www.sanity.io/docs/workflows): Model, run, and observe content workflows on the Content Lake: definitions, stages, activities, and effects, from editorial review to full automation.
$ mkdir -p /tmp/scratch/scratchpad/docs && cd $_ && for p in workflows workflows/getting-started workflows/studio-plugin workflows/sanity-functions workflows/cli-reference workflows/cookbook workflows/cookbook-editorial-review workflows/cookbook-ai-content-pipeline; do f=$(echo $p | tr / _).md; curl -sL "https://www.sanity.io/docs/$p.md" -o $f; echo "$f $(wc -c <$f)"; done
workflows.md 8870
workflows_getting-started.md 12843
workflows_studio-plugin.md 12745
workflows_sanity-functions.md 20719
workflows_cli-reference.md 47744
workflows_cookbook.md 8542
workflows_cookbook-editorial-review.md 14999
workflows_cookbook-ai-content-pipeline.md 23914
$ cd /tmp/scratch/scratchpad/docs && cat workflows.md workflows_getting-started.md
> For AI agents: the complete Sanity documentation index is available at [https://www.sanity.io/docs/llms.txt](https://www.sanity.io/docs/llms.txt).
# Workflows
Model, run, and observe content workflows on the Content Lake: definitions, stages, activities, and effects, from editorial review to full automation.
#### Start here
[Workflows](https://www.sanity.io/docs/workflows/introduction)
What Workflows is, the core concepts behind it, and which surface to build on.
[Quick start: run your first workflow](https://www.sanity.io/docs/workflows/getting-started)
Define your first workflow in TypeScript, deploy it, and move a Sanity document through its stages.
[Configure and deploy workflow definitions](https://www.sanity.io/docs/workflows/deploy-definitions)
Install and authenticate the workflow CLI, write the sanity.workflow.ts config that binds your definitions to a Sanity resource, and deploy them to one environment or several.
[Run Workflows with Sanity Functions](https://www.sanity.io/docs/workflows/sanity-functions)
Use GROQ-triggered and scheduled Sanity Functions to start workflows, reevaluate conditions, and process queued effects.
[Add Workflows to Sanity Studio](https://www.sanity.io/docs/workflows/studio-plugin)
Install the Workflows plugin in a Sanity Studio, bind it to your deployed definitions, and put workflows in front of editors.
[How early access works](https://www.sanity.io/docs/workflows/prerelease)
What building on Workflows during early access commits you to: one fixed 0.x stack, a stricter contract for stored documents, what the Content Lake does not yet enforce, and the runtime you supply.
#### Model your process
[Definitions, instances, and stages](https://www.sanity.io/docs/workflows/definitions-and-instances)
A definition describes a process. An instance is one run of it, pinned to the definition version it started under and sitting in exactly one stage.
[Fields](https://www.sanity.io/docs/workflows/fields)
Fields carry the typed data belonging to a workflow instance.
[Activities and actions](https://www.sanity.io/docs/workflows/activities-and-actions)
An activity is work scoped to one stage visit. Actions resolve it, write instance state, and queue effects, fired by a caller or automatically by the engine.
[Conditions](https://www.sanity.io/docs/workflows/conditions)
Write GROQ conditions over the engine’s bounded instance snapshot: what the snapshot holds, which sites bind the caller, named predicates, and the start filter and requirements.
[Operations](https://www.sanity.io/docs/workflows/operations)
The write vocabulary: a small set of ops that mutate an instance’s fields and statuses, carried by actions and by effect completions.
[Subworkflows](https://www.sanity.io/docs/workflows/subworkflows)
How a large process composes out of smaller ones: an action spawns a child workflow per row of a query, and a trigger resolves the parent’s activity once they all settle.
[Global document references](https://www.sanity.io/docs/workflows/global-document-references)
Why every document pointer in a workflow carries its location, and how resource aliases keep deployed definitions portable across environments.
#### Run and enforce
[Engine](https://www.sanity.io/docs/workflows/engine)
The library that evaluates and commits Workflow instances.
[Effects and runtimes](https://www.sanity.io/docs/workflows/effects-and-runtimes)
Why the engine queues effects instead of running them, and where the runtime lives: the verbs your code calls, and the drainer that delivers queued work.
[Guards and enforcement](https://www.sanity.io/docs/workflows/guards)
Declare a guard that restricts which mutations a document accepts while an instance occupies a stage, and know what honors it today.
[Actors, tokens, and what's actually enforced](https://www.sanity.io/docs/workflows/actors-and-enforcement)
Who the engine acts as, where a condition can read the caller, and which of the engine’s checks would stop a client that bypasses it.
[History and audit trail](https://www.sanity.io/docs/workflows/history-and-audit-trail)
Understand the durable event history stored on every Workflows instance, what it records, and where its provenance boundary ends.
[Evaluation insights](https://www.sanity.io/docs/workflows/evaluation-insights)
Explain condition outcomes and field proposals from a Workflows evaluation.
#### Build on it
[Workflows in Sanity Studio](https://www.sanity.io/docs/workflows/studio-user-guide)
See where your work stands, complete the tasks a workflow is waiting on, find work assigned to you, and understand a held publish.
[Build a workflow interface with the App SDK](https://www.sanity.io/docs/workflows/app-sdk)
Render live workflow state and commit actions from your own App SDK application: mount a session, handle its states, render activities and fields from the evaluation, and list instances.
[The reactive session](https://www.sanity.io/docs/workflows/reactive-session)
How a reactive session projects one workflow instance for a UI: what keeps it current, what each session state means, and when a preview becomes a commit.
[Reusable UI components](https://www.sanity.io/docs/workflows/ui-components)
Add assignment, date, member, and workflow-diagram controls to a custom Workflows interface.
[Create a workflow-powered Document Action](https://www.sanity.io/docs/workflows/custom-studio-integrations)
Build a custom Submit for review Document Action in Sanity Studio with the @sanity/workflow-studio adapter: read the document's workflow, respect the evaluated verdict, and commit the action.
[Custom reactive adapters](https://www.sanity.io/docs/workflows/custom-reactive-adapters)
Connect Workflows to an unsupported host or data layer by implementing the store-agnostic reactive observer contract.
[Connect an agent over MCP](https://www.sanity.io/docs/workflows/mcp)
Install and authenticate the Workflows MCP server, register it with your agent, address a workflow env
[… output truncated]
$ cd /tmp/scratch/scratchpad/docs && for p in guards activities-and-actions conditions operations prerelease actors-and-enforcement fields deploy-definitions definitions-and-instances app-sdk; do f=$(echo $p).md; curl -sL "https://www.sanity.io/docs/workflows/$p.md" -o $f; echo "$f $(wc -c <$f)"; done; cat workflows_cookbook-editorial-review.md
guards.md 8525
activities-and-actions.md 12374
conditions.md 13134
operations.md 10347
prerelease.md 8565
actors-and-enforcement.md 8402
fields.md 24316
deploy-definitions.md 16954
definitions-and-instances.md 24151
app-sdk.md 23039
> For AI agents: the complete Sanity documentation index is available at [https://www.sanity.io/docs/llms.txt](https://www.sanity.io/docs/llms.txt).
# Cookbook: Editorial review
A four-stage editorial review workflow for Sanity: assignment, drafting, review, and published, driven by human actions in the Studio.
> [!WARNING]
> Early access
> Workflows is in early access, built in public. Read [How early access works](https://www.sanity.io/docs/workflows/prerelease) before you rely on it.
This is the base loop the other Cookbook recipes build on: an editorial review where every step is a human action in the [Studio](https://www.sanity.io/docs/studio). It runs in four stages:

1. **Assignment**: a writer takes the open commission.
2. **Drafting**: the assigned writer writes the article, then submits it for review.
3. **Review**: a desk editor approves it, or sends it back with a reason. A rejection loops back to drafting.
4. **Published**: a terminal stage.
The loop runs assignment → drafting → desk-editor review → publication. Copy edit, fact-check, and SEO are real stages too, but which exist and who owns them varies by organization, so the base loop leaves them out. The Adapting it section shows where they slot in.
## Before you get started
- **Packages**: `@sanity/workflow-engine` and `@sanity/workflow-studio-plugin` for the ready-made human surface. No AI packages and no [Functions](https://www.sanity.io/docs/functions/functions-introduction).
- **The engine** (`createEngine`) and a [dataset](https://www.sanity.io/docs/content-lake/datasets) for its instances; the articles can live in the same dataset.
- **No effect handlers**: every step here is a human action fired from the Studio, so there is nothing to drain and no Function to deploy. The whole runtime is the Studio plus the deployed definition. That makes this the shortest recipe to stand up. The [AI pipeline](https://www.sanity.io/docs/workflows/cookbook-ai-content-pipeline) and [Coordinated release](https://www.sanity.io/docs/workflows/cookbook-coordinated-release) recipes add handlers and Functions on top of this same base.
## Define the workflow
The whole workflow is one `defineWorkflow` call, with the four stages and the fields that fill in as it runs:
```typescript
import {
defineWorkflow,
defineField,
defineStage,
defineActivity,
defineAction,
defineTransition,
} from '@sanity/workflow-engine/define'
export const editorialReview = defineWorkflow({
name: 'editorial-review',
title: 'Editorial review',
initialStage: 'assignment',
fields: [
defineField({type: 'subject', name: 'subject', initialValue: {type: 'input'}, required: true}),
defineField({type: 'actor', name: 'writer'}),
defineField({type: 'actor', name: 'reviewer'}),
defineField({type: 'actor', name: 'approval'}),
defineField({type: 'string', name: 'rejectionReason'}),
],
stages: [
defineStage({
name: 'assignment',
title: 'Assignment',
activities: [
defineActivity({
name: 'assign',
title: 'Assign a writer',
actions: [
defineAction({
name: 'take',
title: 'Take this assignment',
filter: '!defined($fields.writer)', // open until a writer takes it
status: 'done',
ops: [{type: 'field.set', target: {field: 'writer'}, value: {type: 'actor'}}],
}),
],
}),
],
transitions: [defineTransition({name: 'to-drafting', to: 'drafting', when: '$allActivitiesDone'})],
}),
defineStage({
name: 'drafting',
title: 'Drafting',
activities: [
defineActivity({
name: 'write',
title: 'Write the article',
actions: [
defineAction({
name: 'submit',
title: 'Submit for review',
filter: '$fields.writer.id == $actor.id', // only the assigned writer
status: 'done',
}),
],
}),
],
transitions: [defineTransition({name: 'to-review', to: 'review', when: '$allActivitiesDone'})],
}),
defineStage({
name: 'review',
title: 'Review',
activities: [
defineActivity({
name: 'review',
title: 'Review the article',
actions: [
defineAction({
name: 'clear-rejection',
title: 'Clear the previous rejection',
when: 'true', // fires on entry, so a resubmitted draft does not bounce straight back
ops: [{type: 'field.unset', target: {field: 'rejectionReason'}}],
}),
defineAction({
name: 'claim',
title: 'Take this review',
filter: '!defined($fields.reviewer)',
ops: [{type: 'field.set', target: {field: 'reviewer'}, value: {type: 'actor'}}],
}),
defineAction({
name: 'approve',
title: 'Approve',
filter: '$fields.reviewer.id == $actor.id', // only the editor who claimed it
status: 'done',
ops: [{type: 'field.set', target: {field: 'approval'}, value: {type: 'actor'}}],
}),
defineAction({
name: 'reject',
title: 'Reject with reason',
filter: '$fields.reviewer.id == $actor.id',
status: 'done',
params: [{type: 'string', name: 'reason', title: 'Reason', required: true}],
ops: [
[… output truncated]
$ cd /tmp/scratch/scratchpad/docs && cat guards.md activities-and-actions.md prerelease.md
> For AI agents: the complete Sanity documentation index is available at [https://www.sanity.io/docs/llms.txt](https://www.sanity.io/docs/llms.txt).
# Guards and enforcement
Declare a guard that restricts which mutations a document accepts while an instance occupies a stage, and know what honors it today.
> [!WARNING]
> Early access
> Workflows is in early access, built in public. Read [How early access works](https://www.sanity.io/docs/workflows/prerelease) before you rely on it.
A guard is a restriction you declare, stored next to the content it protects. It names which mutations are allowed on which documents while an instance sits in a stage. The engine writes it to the [Content Lake](https://www.sanity.io/docs/content-lake) as its own document when the instance enters that stage.
> [!NOTE]
> **Early access limitation.** The engine and the Studio plugin honor a deployed guard. The Content Lake does not enforce Workflows’ temporary guard documents, so a guard does not stop a client that writes to the lake directly. Use [dataset access control](https://www.sanity.io/docs/content-lake/roles-concepts) when a rule must hold against every writer.
## Declare a guard on a stage
Use a guard when a [document](https://www.sanity.io/docs/content-lake/documents) must not be [published](https://www.sanity.io/docs/content-lake/documents) or otherwise changed during a stage. The guard identifies the protected document and the [mutations](https://www.sanity.io/docs/content-lake/mutations-introduction) it allows or denies.
A stage declares its guards inline. This one denies publishing the subject while the instance sits in review.
```typescript
defineStage({
name: 'review',
guards: [
{
name: 'lock-subject',
match: {idRefs: [{type: 'fieldRead', field: 'subject'}], actions: ['publish']},
// no predicate ⇒ an unconditional deny while this stage holds
},
],
// activities, transitions ...
})
```
A guard is not a condition. [Conditions](https://www.sanity.io/docs/workflows/conditions) gate what the engine itself will do, and they are evaluated in your process against the instance snapshot. A guard is a document that describes a restriction on content, so it stays in place for the whole stage visit rather than for the length of one call.
## Guard lifetime follows the stage visit
The engine creates the guard documents on stage entry and removes them on exit. Draft edits target IDs such as `drafts.article-1`; publishing targets `article-1`. A guard covering both creates a separate document for each target. Re-entering the stage uses the same document IDs.
This sequence diagram traces one guarded publish attempt: where the guard document is written, which surface disables the action, and where a write that skips the engine still lands.

Retracting a guard deletes the document outright. No inactive guard document is left behind, so nothing downstream has to filter out lifted records. Retraction checks the document revision first, so a stale deletion cannot remove a guard that a concurrent stage change has just reactivated.
## Freeze fields and hold publishing
A common guard pattern is a field freeze. An `update` guard targets the subject’s draft ID, such as `drafts.article-1`. While the instance occupies a review stage, this guard allows writes only when the named fields remain unchanged.
```typescript
defineStage({
name: 'review',
guards: [
{
name: 'freeze-review-fields',
match: {idRefs: [{type: 'fieldRead', field: 'subject'}], actions: ['update']},
// allow a write only if it leaves body and title untouched
predicate: '!delta::changedAny((body, title))',
},
],
// activities, transitions ...
})
```
The guard protects content. Conditions on the instance still decide who approves and when the workflow moves.
A publish hold uses `actions: ['publish']` and evaluates creates and updates of the published document. An `unpublish` guard evaluates deletion of the published document. Direct `create` and `delete` actions keep their authored IDs.
Combining actions from multiple groups (direct create/delete, draft edits, and publication) requires reader model 9. [Upgrade Workflows packages](https://www.sanity.io/docs/workflows/upgrade) explains when to upgrade shared runtimes before an affected instance is committed or a definition is deployed.
## What honors a guard today
The Studio plugin reads deployed guards and disables the native [document actions](https://www.sanity.io/docs/studio/document-actions) they deny: publish, unpublish, and delete. The disabled control names the workflow holding the document.
The engine checks deployed guards against its own instance write before it commits, and refuses the whole call rather than writing part of it. That check and the Studio plugin’s are both advisory. Neither one stops a token that can mutate the Content Lake directly, because the lake does not enforce Workflows’ temporary guard documents. See [Actors, tokens, and what’s actually enforced](https://www.sanity.io/docs/workflows/actors-and-enforcement) for the three mechanisms and which one holds.
## Author one guard per concern
Give each rule its own guard with its own name. Every guard whose `match` applies is evaluated on its own, and one denial denies the mutation. No guard can widen what another one denies, and there is no evaluation order to depend on.
## Reference
### Guard declaration
This is the object passed to `defineGuard(...)` in a stage’s `guards` array. The guard is active for that stage visit. Its target reads can identify referenced content or the workflow instance itself.
#### Properties
[… output truncated]
$ cd /tmp/scratch/scratchpad/docs && cat operations.md; sed -n 1,200p conditions.md
> For AI agents: the complete Sanity documentation index is available at [https://www.sanity.io/docs/llms.txt](https://www.sanity.io/docs/llms.txt).
# Operations
The write vocabulary: a small set of ops that mutate an instance’s fields and statuses, carried by actions and by effect completions.
> [!WARNING]
> Early access
> Workflows is in early access, built in public. Read [How early access works](https://www.sanity.io/docs/workflows/prerelease) before you rely on it.
Operations are the small, declarative state changes carried by actions. Author each operation with `defineOp(...)` so its shape is checked where you declare it. [Conditions](https://www.sanity.io/docs/workflows/conditions) decide whether something can happen. Operations record what happened.
They formalize the same idea as [Sanity mutations](https://www.sanity.io/docs/content-lake/mutations-introduction), but they are not raw mutations sent directly to the Content Lake. The engine resolves their targets and values, validates the result against the workflow definition, records history, and applies the change to the instance.
Every operation carried by one action or effect completion runs in declaration order, inside the same atomic [transaction](https://www.sanity.io/docs/content-lake/transactions). The operations, activity status, queued effects, history, and idempotency record either commit together or do not commit at all.
```typescript
defineAction({
name: 'approve',
ops: [
defineOp({
type: 'field.set',
target: {field: 'approvedBy'},
value: {type: 'actor'},
}),
],
status: 'done',
})
```
The action above records the acting identity and completes its activity in the same engine commit. Later conditions immediately see the new field value.
## Values are expressions
An operation’s `value` is a serializable expression, and not always a fixed value. The engine resolves it when the operation runs. A definition can therefore copy fields, read action parameters, stamp the actor or the time, and build nested objects, with no application code assembling the final value.
```typescript
const recordDecision = defineOp({
type: 'field.set',
target: {field: 'decision'},
value: {
type: 'object',
fields: {
approved: {type: 'fieldRead', field: 'approved'},
reason: {type: 'param', param: 'reason'},
decidedBy: {type: 'actor'},
decidedAt: {type: 'now'},
},
},
})
```
## Set and clear fields
Use `field.set` to replace a field value and `field.unset` to clear it. Values may be literals or expressions resolved when the action runs.
```typescript
defineAction({
name: 'change-deadline',
params: [{name: 'deadline', type: 'datetime', required: true}],
ops: [
defineOp({
type: 'field.set',
target: {field: 'deadline'},
value: {type: 'param', param: 'deadline'},
}),
defineOp({type: 'field.unset', target: {field: 'reminderSentAt'}}),
],
})
```
Use `field.setIfMissing` to set a value that is missing, without replacing one that is already there. A list field is never missing. `field.inc` and `field.dec` require an existing number, default the delta to `1`, and reject non-finite or out-of-range results.
```typescript
defineAction({
name: 'retry',
ops: [
defineOp({
type: 'field.setIfMissing',
target: {field: 'attempts'},
value: {type: 'literal', value: 0},
}),
defineOp({type: 'field.inc', target: {field: 'attempts'}}),
],
})
```
A skipped `field.setIfMissing` writes no `opApplied` history row. Arithmetic is not idempotent on its own, so supply an idempotency key when a caller may retry.
Common value expressions read an action parameter, another field, the actor, the current time, stage, or instance reference. The complete operation and value forms are listed in [Reference](https://www.sanity.io/docs/workflows/operations).
## Bound retries
Count before dispatch, retry only while the counter is below the limit, and route the final failure to manual recovery. The shipped boundedRetry example makes three total attempts and never queues a fourth effect.
`field.inc`, `field.dec`, and `field.setIfMissing` are model 6 operations, and they do not raise the reader floor. An older reader still accepts the instance, but does not recognize them. See [Upgrade Workflows packages](https://www.sanity.io/docs/workflows/upgrade) before deploying a definition that uses one.
## Append to a list
Use `field.append` for notes, audit rows, and other list entries.
```typescript
defineAction({
name: 'add-note',
params: [{name: 'body', type: 'string', required: true}],
ops: [
defineOp({
type: 'field.append',
target: {field: 'notes'},
value: {
type: 'object',
fields: {
body: {type: 'param', param: 'body'},
actor: {type: 'actor'},
at: {type: 'now'},
},
},
}),
],
})
```
The `audit` authoring shorthand is a stamped append: it adds the acting identity and time to the row it writes.
## Update or remove matching rows
`field.updateWhere` merges into matching rows of an `array` field. `field.removeWhere` removes matching rows from any list field.
```typescript
defineAction({
name: 'complete-item',
params: [{name: 'itemKey', type: 'string', required: true}],
ops: [
defineOp({
type: 'field.updateWhere',
target: {field: 'todoList'},
where: '$row._key == $params.itemKey',
value: {
type: 'object',
fields: {
status: {type: 'literal', value: 'done'},
},
},
}),
defineOp({
type: 'field.removeWhere',
target: {field: 'watchers'},
where: '$row.id == $actor.id',
}),
],
})
```
#### Properties
**Always available** ($row, $self, $fields, $stage, $now, $context, $effects, $effectStatus, $activities, $allActivitiesDone, $anyActivityFailed)
All operation where expressions.
**Acting action** ($actor)
Available when an acting token rid
[… output truncated]
$ cd /tmp/scratch/scratchpad/docs && grep -n "^#" workflows_cli-reference.md; sed -n '/fire-action/,+60p' workflows_cli-reference.md | head -150
3:# Workflow CLI command reference
14:# Deploy the definitions in one deployment, start a run, then move it along
24:# Deploy the definitions in one deployment, start a run, then move it along
34:# Deploy the definitions in one deployment, start a run, then move it along
44:# Deploy the definitions in one deployment, start a run, then move it along
51:## Select a deployment, a tag, or an instance
70:## Read the output as JSON
80:## Exit codes
93:## Commands
97:### deploy: validate, diff, and ship definitions
106:# Validate offline, diff against what is deployed, then deploy
115:# Validate offline, diff against what is deployed, then deploy
124:# Validate offline, diff against what is deployed, then deploy
133:# Validate offline, diff against what is deployed, then deploy
152:### blueprint generate: generate an experimental runtime
199:### start: create an instance from a deployed definition
250:### fire-action: resolve an activity, or list what can be fired
259:# List what can be fired right now
262:# Fire one action
270:# List what can be fired right now
273:# Fire one action
281:# List what can be fired right now
284:# Fire one action
292:# List what can be fired right now
295:# Fire one action
313:### list: find instances
403:### show: read one instance in full
445:### tail: stream new history entries
483:### diagnose: explain why an instance is not progressing
534:### abort: hard-stop an in-flight instance
578:### set-stage: force an instance into a stage
623:### reset-activity: re-run or skip a failed activity
632:# Re-run it
635:# Or bypass it so the exit transition can fire
642:# Re-run it
645:# Or bypass it so the exit transition can fire
652:# Re-run it
655:# Or bypass it so the exit transition can fire
662:# Re-run it
665:# Or bypass it so the exit transition can fire
680:### definition list: list deployed definitions
720:### definition show: read one deployed definition
760:### definition diff: compare in-code against deployed
800:### definition delete: remove a deployed definition
846:### nuke: delete engine-owned documents
857:# Reset one development environment, after reviewing the printed plan
860:# Delete one finished instance and its guards
867:# Reset one development environment, after reviewing the printed plan
870:# Delete one finished instance and its guards
877:# Reset one development environment, after reviewing the printed plan
880:# Delete one finished instance and its guards
887:# Reset one development environment, after reviewing the printed plan
890:# Delete one finished instance and its guards
904:## Related reading
npx @sanity/workflow-cli fire-action prod.wf-instance.a1b2c3d4e5f6 --activity write --action submit
```
**pnpm**
```shell
# Deploy the definitions in one deployment, start a run, then move it along
pnpm dlx @sanity/workflow-cli deploy --deployment production
pnpm dlx @sanity/workflow-cli start article-review --deployment production
pnpm dlx @sanity/workflow-cli show prod.wf-instance.a1b2c3d4e5f6
pnpm dlx @sanity/workflow-cli fire-action prod.wf-instance.a1b2c3d4e5f6 --activity write --action submit
```
**yarn**
```shell
# Deploy the definitions in one deployment, start a run, then move it along
yarn dlx @sanity/workflow-cli deploy --deployment production
yarn dlx @sanity/workflow-cli start article-review --deployment production
yarn dlx @sanity/workflow-cli show prod.wf-instance.a1b2c3d4e5f6
yarn dlx @sanity/workflow-cli fire-action prod.wf-instance.a1b2c3d4e5f6 --activity write --action submit
```
**bun**
```shell
# Deploy the definitions in one deployment, start a run, then move it along
bunx @sanity/workflow-cli deploy --deployment production
bunx @sanity/workflow-cli start article-review --deployment production
bunx @sanity/workflow-cli show prod.wf-instance.a1b2c3d4e5f6
bunx @sanity/workflow-cli fire-action prod.wf-instance.a1b2c3d4e5f6 --activity write --action submit
```
## Select a deployment, a tag, or an instance
Three flag names appear across the CLI and they do not mean the same thing on every command. `--deployment` and `--tag` are always mutually exclusive, and every command that accepts both rejects the pair. Commands that take an instance id need neither, because an instance id is globally unique and carries its own tag.
| Commands | `--deployment` | `--tag` |
| --- | --- | --- |
| `deploy` | The one deployment to deploy. | Every deployment carrying the tag. A tag is an environment group, so this is a fan-out, not a picker. |
| `start`, `definition diff`, `definition delete` | The one deployment to act on. | The deployment carrying the tag, while the tag names exactly one. |
| `abort`, `set-stage`, `reset-activity`, `fire-action`, `diagnose` | Narrows where the instance is looked up. Optional. | Narrows where the instance is looked up. Optional. |
| `list`, `show`, `tail`, `definition list`, `definition show` | Not accepted. | A query filter, which also narrows which resources are searched. |
| `nuke` | The deployment to reset, or with `--instance`, which deployment to look the instance up in. | The tag to reset, while it names exactly one deployment. Not valid with `--instance`. |
| `blueprint generate` | Not accepted. The command covers the whole config. | Not accepted. The command covers the whole config. |
The `deploy` row is the one that surprises people. On the other commands that select a deployment — `start`, `definition diff`, and `definition delete` — `--tag` resolves to exactly one deployment and errors when it cannot. On `deploy` it deploys every deployment carrying the tag instead. On the instance-keyed commands it is only a narrower: it limits which resources are searched, and never has to resolve to one deployment.
With no selector at all, a command that needs one deployment falls back to the sole deployment when the config declares exactly one. When several are declared, an interactive terminal prompts for one and a non-interactive shell fails with `Multiple deployments configured — pass --deployment or -
[… output truncated]
$ git status --short | head; git log --oneline | head -5; ls; ls scripts studio desk 2>/dev/null; grep -rln "ORA-\|19c\|fact" --include=*.mjs --include=*.ts --include=*.js . 2>/dev/null | grep -v node_modules | head
M agent/eval/export_coroner.py
M app/src/app/coroner/page.tsx
M app/src/data/coroner.json
5cb0bac docs: build log
c09b1dc eval: second-vendor blind grader (Gemini REST), same protocol and seed
9fdcfe0 content: 14 dataset tests against the public API (npm test)
d6aa3fd app: stop wide tables from stretching the page on phones
5224739 app: The Coroner's Reports — replay of all 72 frozen eval runs with tool calls, grades, models, tokens
agent
app
content
desk
docs
LICENSE
press
README.md
scripts
studio
desk:
dist
node_modules
package.json
package-lock.json
README.md
sanity.cli.ts
src
tsconfig.json
scripts:
bringup.sh
create-project.mjs
studio:
dist
node_modules
package.json
package-lock.json
sanity.cli.ts
sanity.config.ts
schemaTypes
tsconfig.json
agent/eval/run.mjs
content/test/dataset.test.mjs
content/draft-obituaries.mjs
studio/schemaTypes/azureTarget.ts
studio/schemaTypes/shared.ts
$ grep -n -i -B3 -A40 "release\|ORA-" content/test/dataset.test.mjs | head -150; ls content content/test
content:
data
draft-obituaries.mjs
FORMAT.md
lib.mjs
node_modules
package.json
package-lock.json
seed.mjs
SLUGS.md
test
validate.mjs
content/test:
dataset.test.mjs
$ grep -n -i "19c\|ORA\|release\|fact" content/test/dataset.test.mjs content/draft-obituaries.mjs content/validate.mjs | head -40; cat studio/schemaTypes/oracleFeature.ts | head -150
$ cat studio/schemaTypes/shared.ts | head -40; ls studio/schemaTypes; grep -n "name:" studio/schemaTypes/mapping.ts studio/schemaTypes/caveat.ts studio/schemaTypes/dispute.ts 2>/dev/null | head -40; sed -n 1,40p content/draft-obituaries.mjs; ls ~/.config/ora2az/; cut -d= -f1 ~/.config/ora2az/env
import {defineField} from 'sanity'
export const ORACLE_VERSIONS = ['11.2', '12.1', '12.2', '18c', '19c', '21c', '23ai', '26ai'] as const
export const ORACLE_EDITIONS = ['SE2', 'EE', 'XE/Free', 'Autonomous', 'Exadata'] as const
export const sourcesField = defineField({
name: 'sources',
title: 'Sources',
type: 'array',
of: [{type: 'reference', to: [{type: 'source'}]}],
validation: (r) => r.min(1).error('Every claim needs at least one source.'),
})
export const summaryField = defineField({
name: 'summary',
title: 'Summary',
type: 'array',
of: [{type: 'block'}],
})
export const versionList = (name: string, title: string) =>
defineField({
name,
title,
type: 'array',
of: [{type: 'string'}],
options: {list: [...ORACLE_VERSIONS]},
})
azureTarget.ts
caveat.ts
dispute.ts
glossary.ts
index.ts
mapping.ts
oracleFeature.ts
pattern.ts
shared.ts
source.ts
studio/schemaTypes/dispute.ts:4: name: 'dispute',
studio/schemaTypes/dispute.ts:8: defineField({name: 'title', type: 'string', validation: (r) => r.required()}),
studio/schemaTypes/dispute.ts:9: defineField({name: 'claimA', type: 'reference', to: [{type: 'caveat'}], validation: (r) => r.required()}),
studio/schemaTypes/dispute.ts:10: defineField({name: 'claimB', type: 'reference', to: [{type: 'caveat'}], validation: (r) => r.required()}),
studio/schemaTypes/dispute.ts:11: defineField({name: 'whatDisagrees', type: 'text', rows: 3, validation: (r) => r.required()}),
studio/schemaTypes/dispute.ts:12: defineField({name: 'resolution', type: 'text', rows: 4, validation: (r) => r.required()}),
studio/schemaTypes/dispute.ts:13: defineField({name: 'resolvedBy', type: 'reference', to: [{type: 'source'}]}),
studio/schemaTypes/caveat.ts:5: name: 'caveat',
studio/schemaTypes/caveat.ts:9: defineField({name: 'mapping', type: 'reference', to: [{type: 'mapping'}], validation: (r) => r.required()}),
studio/schemaTypes/caveat.ts:11: name: 'severity',
studio/schemaTypes/caveat.ts:16: defineField({name: 'statement', type: 'text', rows: 4, validation: (r) => r.required()}),
studio/schemaTypes/caveat.ts:18: name: 'appliesTo',
studio/schemaTypes/caveat.ts:22: defineField({name: 'editions', type: 'array', of: [{type: 'string'}], options: {list: [...ORACLE_EDITIONS]}}),
studio/schemaTypes/caveat.ts:23: defineField({name: 'azureTiers', title: 'Azure tiers / SKUs', type: 'array', of: [{type: 'string'}]}),
studio/schemaTypes/caveat.ts:26: defineField({name: 'evidence', type: 'reference', to: [{type: 'source'}], validation: (r) => r.required()}),
studio/schemaTypes/caveat.ts:27: defineField({name: 'firsthand', title: 'Observed firsthand by the author', type: 'boolean', initialValue: false}),
studio/schemaTypes/mapping.ts:5: name: 'mapping',
studio/schemaTypes/mapping.ts:9: defineField({name: 'oracleFeature', type: 'reference', to: [{type: 'oracleFeature'}], validation: (r) => r.required()}),
studio/schemaTypes/mapping.ts:10: defineField({name: 'azureTarget', type: 'reference', to: [{type: 'azureTarget'}], validation: (r) => r.required()}),
studio/schemaTypes/mapping.ts:12: name: 'fidelity',
studio/schemaTypes/mapping.ts:17: defineField({name: 'effort', type: 'string', options: {list: ['S', 'M', 'L'], layout: 'radio'}}),
studio/schemaTypes/mapping.ts:18: defineField({name: 'rationale', type: 'text', rows: 4, validation: (r) => r.required()}),
studio/schemaTypes/mapping.ts:19: defineField({name: 'steps', title: 'Migration steps', type: 'array', of: [{type: 'block'}]}),
#!/usr/bin/env node
// Drafts an obituary for each Oracle feature with Sanity Agent Actions (runs on Sanity, uses the plan's AI credits).
// The only material the model gets is the feature's own graph neighbourhood, fetched with GROQ. Output goes to the
// draft document; nothing reaches the public site until an editor certifies it in the desk (edit + publish).
// Usage: node content/draft-obituaries.mjs [--only oracle-streams,dbms-job] [--force]
import {createClient} from '@sanity/client'
import {readFileSync, existsSync} from 'node:fs'
import {join} from 'node:path'
const envFile = join(process.env.HOME ?? '', '.config', 'ora2az', 'env')
if (existsSync(envFile)) for (const l of readFileSync(envFile, 'utf8').split('\n')) { const m = l.match(/^([A-Z_]+)=(.*)$/); if (m && m[2]) process.env[m[1]] ??= m[2] }
const argv = process.argv.slice(2)
const only = (argv[argv.indexOf('--only') + 1] ?? '').split(',').filter((s) => argv.includes('--only') && s)
const force = argv.includes('--force')
const client = createClient({projectId: process.env.SANITY_PROJECT_ID, dataset: process.env.SANITY_DATASET ?? 'production', token: process.env.SANITY_WRITE_TOKEN, apiVersion: 'vX', useCdn: false})
const FACTS = `*[_id == $id][0]{
name, category, introducedIn, deprecatedIn, desupportedIn, oracleReplacement,
"survivedBy": *[_type == "mapping" && references(^._id)]{fidelity, effort, rationale, "target": azureTarget->name},
"complications": *[_type == "caveat" && mapping->oracleFeature._ref == ^._id]{severity, statement},
"contested": *[_type == "dispute" && (claimA->mapping->oracleFeature._ref == ^._id || claimB->mapping->oracleFeature._ref == ^._id)]{title, resolution}
}`
const INSTRUCTION = `Write a short newspaper obituary for the Oracle Database feature described in $facts, for a column called "The Legacy Obituaries" that covers Oracle features that did not survive a move to Azure.
Rules:
- Use ONLY facts present in $facts. Do not add releases, products, numbers or claims that are not there.
- Exactly 3 paragraphs of 2 sentences each, 80–110 words total. Dry, respectful, lightly wry.
- Paragraph 1: who it was and what it did in life, and when it was born (introducedIn) if known.
- Paragraph 2: cause of departure — its deprecation/desupport releases if present, otherwise the blocking complication that keeps it from Azure.
- Paragraph 3 is MANDATORY and must begin "It is survived by": name every Azure targe
[… output truncated]
$ cd /tmp/claude-1000/wf/node_modules/@sanity/workflow-engine/dist && ls; grep -n "export declare function define\|export {" define.d.ts | head -40; grep -n "interface WorkflowDeploymentConfig\|interface .*DeploymentConfig" -A40 define.d.ts | head -80; cat ../../workflow-cli/package.json | grep '"version"'
_chunks-cjs
_chunks-es
define.cjs
define.d.cts
define.d.ts
define.js
index.cjs
index.d.cts
index.d.ts
index.js
585:export declare function defineAction(action: AuthoringAction): AuthoringAction;
587:export declare function defineActivity(
605:export declare function defineEffect(effect: AuthoringEffect): AuthoringEffect;
607:export declare function defineEffectDescriptor(
611:export declare function defineField(
621:export declare function defineGroup(group: Group): Group;
623:export declare function defineGuard(guard: AuthoringGuard): AuthoringGuard;
625:export declare function defineOp(op: AuthoringOp): AuthoringOp;
627:export declare function defineStage(stage: AuthoringStage): AuthoringStage;
629:export declare function defineTransition(
646:export declare function defineWorkflow(
666:export declare function defineWorkflowConfig(
2343:export {};
"version": "0.35.0",
$ cd /tmp/claude-1000/wf/node_modules/@sanity/workflow-engine/dist && sed -n 640,700p define.d.ts; grep -n "WorkflowConfig\b\|interface WorkflowConfig\|type WorkflowConfig" define.d.ts | head; grep -n "interface AuthoringGuard" -A30 define.d.ts; grep -n "GuardMatch\b" define.d.ts | head -3
* ```
* defineWorkflow("article-review") failed validation (2 issues):
* - stages[1].transitions[0].to: transition target "ready" is not a declared stage. Known stages: "drafting", "review", "approved"
* - predicates.allActivitiesDone: predicate "allActivitiesDone" shadows the built-in $allActivitiesDone — pick another name
* ```
*/
export declare function defineWorkflow(
definition: AuthoringWorkflow,
): DefinedWorkflow;
/**
* Validate a deploy config — the binding of each definition's logical resource
* handles to physical resources, per environment (tag). Throws a formatted,
* path-prefixed error if the shape is invalid. The CLI collapses the selected
* deployment's bindings via {@link index.resourceAliasesToMap | resourceAliasesToMap} into the
* `resourceAliases` map `deployDefinitions` expands against.
*
* Validates shape only; reader-floor acknowledgement belongs to paths that
* submit definitions (`deployDefinitions`, the CLI deploy and definition-diff
* commands, and blueprint provision). Other commands may load a selected
* deployment with a missing floor.
*
* Each `WorkflowDeploymentInput` carries an acknowledgement; definition-submission
* paths compare it with the submitted definitions. The returned {@link WorkflowConfig}
* is the looser parsed shape.
*/
export declare function defineWorkflowConfig(
config: WorkflowConfigInput,
): WorkflowConfig;
/**
* The hosting declarations {@link DefinedWorkflow} carries for the generator,
* collected out of the authored tree. `kind` is present only when the workflow
* declared one, so an absent `kind` inherits the deployment's. `effects` holds
* one entry per effect that declared a block, keyed by effect name.
*/
declare interface DefinitionRuntime {
kind?: RuntimeKind | undefined;
effects?: Record<string, EffectRuntimeBlock> | undefined;
}
/**
* Declared editability of a field — the generic edit seam's gate. Default
* (absent) is NOT editable: a field is op-only engine working memory unless the
* modeler opens it. The stored form is `true` (editable by anyone within the
* field's scope window) or an EDIT CONDITION — rendered-scope GROQ (`$actor`,
* `$can`, `$attributes`, `$fields`, `$assigned`), checked like an action filter
* to decide who-may-edit. ADVISORY like every engine gate — it disables the
* inline field and explains; a {@link Guard} declares the intended write-lock.
*/
declare type Editable = true | string;
/**
* External work queued for the handler registered under `name`. Names are
* unique within a definition; completed outputs are read as `$effects['<name>']`.
*
* `bindings` resolves GROQ expressions against the action's rendered scope
* when the effect is queued. The handler receives one parameter bag combining
* those values with static `input`. An `input` key overrides a same-named
* binding, including when its value is null.
*
663: * paths compare it with the submitted definitions. The returned {@link WorkflowConfig}
666:export declare function defineWorkflowConfig(
668:): WorkflowConfig;
1683:declare type WorkflowConfig = v.InferOutput<typeof WorkflowConfigSchema>;
1687: * {@link WorkflowDeploymentInput}. `defineWorkflowConfig` accepts this and
1688: * returns the looser {@link WorkflowConfig}.
1690:declare type WorkflowConfigInput = Omit<WorkflowConfig, "deployments"> & {
2264:declare type WorkflowDeployment = WorkflowConfig["deployments"][number];
$ cd /tmp/claude-1000/wf/node_modules/@sanity/workflow-engine/dist && grep -n "WorkflowConfigSchema\|WorkflowDeploymentSchema" define.d.ts | head; grep -n "declare const WorkflowDeploymentSchema" -A60 define.d.ts | grep -n "v\.\(Optional\|Strict\|Object\)\|: v\." | head -40; grep -n "AuthoringGuard\b" define.d.ts | head -3
1683:declare type WorkflowConfig = v.InferOutput<typeof WorkflowConfigSchema>;
1694:declare const WorkflowConfigSchema: v.ObjectSchema<
328:declare type AuthoringGuard = v.InferOutput<typeof AuthoringGuardSchema>;
468: AuthoringGuard,
623:export declare function defineGuard(guard: AuthoringGuard): AuthoringGuard;
$ cd /tmp/claude-1000/wf/node_modules/@sanity/workflow-engine/dist && sed -n 1694,1800p define.d.ts | grep -v "^\s*undefined" | grep -E "^\s{2,8}[a-zA-Z]+:" | head -50; grep -rn "share" define.d.ts | head
define.d.ts:541: * Three other GROQ contexts read a definition and do NOT share this
define.d.ts:1017: * Only the literal and field-read forms are shared.
define.d.ts:1311: * share one namespace, so a predicate redefining a binding would silently
$ cd /tmp/scratch/scratchpad/docs && grep -n -i -A8 "sharing" deploy-definitions.md | head -40; grep -n -A30 "### start:" workflows_cli-reference.md | head -50; grep -n -A40 "### list:" workflows_cli-reference.md | grep -v "^\S*-\s*$" | head -60
124:This config deploys one definition to one resource. The example acknowledges reader model `10`, required by definitions with required content-reference fields. Before using that value with existing workflow data, follow [Upgrade Workflows packages](https://www.sanity.io/docs/workflows/upgrade) and verify every runtime sharing the resource can read model `10`:
125-
126-**sanity.workflow.ts**
127-
128-```typescript
129-import type {WorkflowDeploymentInput} from '@sanity/workflow-engine'
130-import {defineWorkflowConfig} from '@sanity/workflow-engine/define'
131-
132-import {articleReview} from './src/workflows.ts'
--
330:### Definition sharing
331-
332-Definitions are shared with Sanity by default when a deploy creates a new version. Pass `--no-share-defs` to opt out for one invocation. A failed share warns but does not fail the deploy.
333-
334-## Next steps
335-
336-- [Workflow CLI command reference](https://www.sanity.io/docs/workflows/cli-reference) covers every command, flag, selector, and exit code.
337-- [Deployments, tags, and resources](https://www.sanity.io/docs/workflows/deployments-and-resources) explains name against tag, the storage partition that keeps environments apart, and how resource aliases resolve.
338-- [Test a workflow before you deploy it](https://www.sanity.io/docs/workflows/testing) runs a definition against an in-memory bench, with no dataset involved.
199:### start: create an instance from a deployed definition
200-
201-`start` creates a workflow instance from a definition already deployed under the selected deployment. Supply a value for each field the definition declares with `initialValue: {type: 'input'}` using `--field name=value`. Values are parsed as JSON and fall back to a plain string, so a reference field takes a JSON object carrying the global id and type of the document.
202-
203-If the engine creates and primes the instance but its first auto-advance fails, `start` reports the run as started rather than failed, because the instance exists. It settles on the next `tick`, or you can re-run with `--instance-id` set to the reported id, which resumes the earlier attempt instead of creating a duplicate.
204-
205-**npm**
206-
207-```shell
208-npx @sanity/workflow-cli start article-review \
209- --deployment production \
210- --field subject='{"id":"dataset:YOUR_PROJECT_ID:production:article-1","type":"article"}'
211-```
212-
213-**pnpm**
214-
215-```shell
216-pnpm dlx @sanity/workflow-cli start article-review \
217- --deployment production \
218- --field subject='{"id":"dataset:YOUR_PROJECT_ID:production:article-1","type":"article"}'
219-```
220-
221-**yarn**
222-
223-```shell
224-yarn dlx @sanity/workflow-cli start article-review \
225- --deployment production \
226- --field subject='{"id":"dataset:YOUR_PROJECT_ID:production:article-1","type":"article"}'
227-```
228-
229-**bun**
313:### list: find instances
315-`list` lists workflow instances, in-flight only unless you pass `--include-completed`. Untagged it spans every distinct resource the config mentions, and prints a banner per resource when it spans more than one. `--tag` here is a query filter, not a deployment selector: it filters rows and narrows which resources are searched.
317-When the read spans several resources, one the token cannot read is noted in place, the rest still render, and the run exits `1`. `--limit` caps rows per resource at 50 by default; the output says when a page was clipped.
319-To find work assigned to a person or offered to their roles, pass `--assignment-user`, repeat `--assignment-role` for their project roles, and select the `held` and `routed` states. Replace `ACCOUNT_USER_ID` with the person’s account-global user ID, not a project membership ID. Replace `editor` with a project role they hold.
321-**npm**
323-```shell
324-npx @sanity/workflow-cli list --tag prod \
325- --assignment-user ACCOUNT_USER_ID \
326- --assignment-role editor \
327- --assignment-state held --assignment-state routed \
328- --json
329-```
331-**pnpm**
333-```shell
334-pnpm dlx @sanity/workflow-cli list --tag prod \
335- --assignment-user ACCOUNT_USER_ID \
336- --assignment-role editor \
337- --assignment-state held --assignment-state routed \
338- --json
339-```
341-**yarn**
343-```shell
344-yarn dlx @sanity/workflow-cli list --tag prod \
345- --assignment-user ACCOUNT_USER_ID \
346- --assignment-role editor \
347- --assignment-state held --assignment-state routed \
348- --json
349-```
351-**bun**
353-```shell
Docs read; now writing the definition and config.
$ cd /tmp/scratch/scratchpad/docs && grep -n "^##\|^\*\*[a-z]" fields.md | head -60; grep -n -B2 -A12 "delta::" guards.md definitions-and-instances.md | head -30; grep -n -i "terminal" definitions-and-instances.md | head
11:## Field scopes
25:## Common field types
63:## Assignments and role eligibility
111:**review-assignment.ts**
139:## Fields in conditions
180:### When referenced content is deleted
190:## Set initial values
196:### Input values
219:### Literal values
237:### Query values
262:### Field-read values
286:## Choice lists
303:## Validation
322:## Editable fields
345:## Reference
347:### Field declaration
351:#### Properties
353:**name** (string)
357:**type** (Field type)
361:**title / description** (string)
365:**group** (string | string[])
369:**initialValue** (InitialValue)
373:**editable** (boolean | Condition | string[])
377:**roles** (string[])
381:**fields** (FieldShape[])
385:**of** (FieldShape[])
389:**required** (boolean)
393:**types** (string[])
397:**options** (ChoiceOptions)
401:**validation** (ScalarValidation)
405:### Initial-value sources
409:#### Properties
411:**input** ({type: ‘input’})
415:**literal** ({type: ‘literal’, value})
419:**query** ({type: ‘query’, query})
423:**fieldRead** ({type: ‘fieldRead’, field, scope?, path?})
427:### Authoring conveniences
431:#### Properties
433:**todoList** (array field)
437:**notes** (array field)
441:### Options and validation
443:#### Properties
445:**options** ({list: Array<{title: string, value: string | number}>})
449:**validation** ({min?: number, max?: number})
453:### Field types
457:#### Properties
459:**subject** (GlobalDocumentReference | null)
463:**doc.ref** (GlobalDocumentReference | null)
467:**doc.refs** (GlobalDocumentReference[])
471:**release.ref** (ReleaseRef | null)
475:**string** (string | null)
479:**text** (string | null)
483:**number** (number | null)
487:**progress** (number | null)
491:**boolean** (boolean | null)
495:**date** (YYYY-MM-DD | null)
499:**dueDate** (YYYY-MM-DD | null)
503:**datetime** (ISO-8601 string | null)
507:**dueDatetime** (ISO-8601 string | null)
511:**url** (string | null)
guards.md-60- match: {idRefs: [{type: 'fieldRead', field: 'subject'}], actions: ['update']},
guards.md-61- // allow a write only if it leaves body and title untouched
guards.md:62: predicate: '!delta::changedAny((body, title))',
guards.md-63- },
guards.md-64- ],
guards.md-65- // activities, transitions ...
guards.md-66-})
guards.md-67-```
guards.md-68-
guards.md-69-The guard protects content. Conditions on the instance still decide who approves and when the workflow moves.
guards.md-70-
guards.md-71-A publish hold uses `actions: ['publish']` and evaluates creates and updates of the published document. An `unpublish` guard evaluates deletion of the published document. Direct `create` and `delete` actions keep their authored IDs.
guards.md-72-
guards.md-73-Combining actions from multiple groups (direct create/delete, draft edits, and publication) requires reader model 9. [Upgrade Workflows packages](https://www.sanity.io/docs/workflows/upgrade) explains when to upgrade shared runtimes before an affected instance is committed or a definition is deployed.
guards.md-74-
15:
65:## Stages, stage visits, and terminal stages
69:A stage does not have to contain work. It can receive an instance, wait for something outside the workflow, route on immediately, or stand for an outcome. A stage with no outgoing transitions is terminal, and reaching it completes the instance.
144:Triggered actions and transitions can make one another eligible, so the [engine](https://www.sanity.io/docs/workflows/engine) continues the cascade until the instance settles or reaches a terminal stage. The cascade moves one stage per hop, and each hop is a single commit. Within a hop, triggered actions run until none is left to fire, transition selection reads the state they produced, and then the hop is saved. Each hop is its own commit, so the stage an instance moves into runs its own triggered actions in the next hop, and a multi-stage run is saved stage by stage. [Transitions decide movement, and nothing else](https://www.sanity.io/docs/workflows/definitions-and-instances) has the complete edge shape.
567:Set when the instance reaches any terminal state.
$ set -e; W=/home/dev/sanity-challenge/ora2az/workflows; mkdir -p $W/definitions $W/scripts; cat /home/dev/sanity-challenge/ora2az/.gitignore 2>/dev/null; cat /home/dev/sanity-challenge/ora2az/content/package.json; node --version
$ W=/home/dev/sanity-challenge/ora2az/workflows
cat > $W/package.json <<'EOF'
{
"name": "ora2az-workflows",
"private": true,
"type": "module",
"scripts": {
"check": "sanity-workflows deploy --check",
"deploy": "sanity-workflows deploy --deployment production",
"fact-check": "node scripts/fact-check.mjs",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@sanity/client": "^7",
"@sanity/workflow-cli": "0.35.0",
"@sanity/workflow-engine": "0.35.0"
},
"devDependencies": {
"typescript": "^5"
}
}
EOF
cat > $W/tsconfig.json <<'EOF'
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"allowImportingTsExtensions": true
},
"include": ["sanity.workflow.ts", "definitions/**/*.ts"]
}
EOF
cat > $W/sanity.workflow.ts <<'EOF'
import {defineWorkflowConfig} from '@sanity/workflow-engine/define'
import {noticeLifecycle} from './definitions/notice-lifecycle.ts'
// One deployment: the engine stores its definitions, instances and guards in the same
// dataset as the content (udyjvgsk.production), partitioned under the tag "production".
export default defineWorkflowConfig({
deployments: [
{
name: 'production',
tag: 'production',
// Required subject fields need reader model 10. This repo is the only runtime on the resource.
expectedMinReaderModel: 10,
workflowResource: {type: 'dataset', id: 'udyjvgsk.production'},
definitions: [noticeLifecycle],
},
],
})
EOF
cat > $W/definitions/notice-lifecycle.ts <<'EOF'
import {
defineAction,
defineActivity,
defineField,
defineGuard,
defineOp,
defineStage,
defineTransition,
defineWorkflow,
} from '@sanity/workflow-engine/define'
// The life of one obituary ("death notice") on an Oracle feature:
//
// drafted --(fact-check: pass)--> fact-checked --(certify)--> certified
// ^ \__(fact-check: fail, note; stays in drafted) |
// | | |
// +------(send-back)--------+ |
// +--------------------------(retract)------------------------+
//
// `certified` is the resting stage. It keeps one caller-fired `retract` action, so it is
// deliberately not terminal: a terminal stage completes the instance, and the obituary freeze
// guard below only lives while an instance occupies the stage.
export const noticeLifecycle = defineWorkflow({
name: 'notice-lifecycle',
title: 'Obituary notice lifecycle',
description:
'Moves one Oracle feature obituary from draft, through an automated release/ORA-code fact check, to human certification.',
initialStage: 'drafted',
fields: [
defineField({
type: 'subject',
name: 'subject',
title: 'Oracle feature',
types: ['oracleFeature'],
required: true,
initialValue: {type: 'input'},
description: 'The published oracleFeature document whose obituary this instance tracks.',
}),
defineField({type: 'string', name: 'factCheck', title: 'Fact check result', description: '"passed" or "failed".'}),
defineField({type: 'text', name: 'factCheckNote', title: 'Fact check note'}),
defineField({type: 'datetime', name: 'factCheckedAt', title: 'Fact checked at'}),
defineField({type: 'string', name: 'decision', title: 'Last editorial decision', description: '"certified", "sent-back" or "retracted".'}),
defineField({type: 'text', name: 'decisionNote', title: 'Decision note'}),
defineField({type: 'actor', name: 'certifiedBy', title: 'Certified by'}),
],
start: {
requirements: [{type: 'singleSubject', name: 'one-open-notice', title: 'This obituary already has an open workflow'}],
},
stages: [
defineStage({
name: 'drafted',
title: 'Drafted',
description: 'An obituary draft exists. The fact check script runs against the draft document and reports here.',
activities: [
defineActivity({
name: 'fact-check',
title: 'Fact check releases and ORA- codes',
description:
'Every Oracle release and ORA- code the obituary mentions must appear in the feature\'s own data (summary, releases, mappings, caveats, disputes). Fired by workflows/scripts/fact-check.mjs.',
actions: [
defineAction({
name: 'pass',
title: 'Fact check passed',
status: 'done',
params: [{type: 'string', name: 'note', title: 'Note'}],
ops: [
defineOp({type: 'field.set', target: {field: 'factCheck'}, value: {type: 'literal', value: 'passed'}}),
defineOp({type: 'field.set', target: {field: 'factCheckNote'}, value: {type: 'param', param: 'note'}}),
defineOp({type: 'field.set', target: {field: 'factCheckedAt'}, value: {type: 'now'}}),
// A decision left over from an earlier send-back or retraction must not re-route the next visit.
defineOp({type: 'field.unset', target: {field: 'decision'}}),
],
}),
defineAction({
name: 'fail',
title: 'Fact check failed',
// No status: the activity stays active, so the instance stays in `drafted`
// with the note until the obituary is fixed and the check passes.
params: [{type: 'string', name: 'note', title: 'What failed', required: true}],
ops: [
defineOp({type: 'field.set', target: {field: 'factCheck'}, value: {type: 'literal', value: 'failed'}}),
defineOp({type: 'field.set', target: {field: 'factCheckNote'}, value: {type: 'param', param: 'note'}}),
defineOp({type: 'field.set', target: {field: 'factCheckedAt'}, value: {type: 'now'}}),
],
}),
],
}),
],
transitions: [
defineTransition({
name: 'to-fact-checked',
title: 'Fact check passed',
to: 'fact-checked',
when: "$allActivitiesDone && $fields.factCheck == 'passed'",
}),
],
}),
defineStage({
name: 'fact-checked',
title: 'Fact-checked',
description: 'The draft passed the fact check. A human editor certifies it (edit reviewStatus + publish), or sends it back.',
activities: [
defineActivity({
name: 'certification',
title: 'Certify the obituary',
actions: [
defineAction({
name: 'certify',
title: 'Certify',
status: 'done',
params: [{type: 'string', name: 'note', title: 'Note'}],
ops: [
defineOp({type: 'field.set', target: {field: 'decision'}, value: {type: 'literal', value: 'certified'}}),
defineOp({type: 'field.set', target: {field: 'decisionNote'}, value: {type: 'param', param: 'note'}}),
defineOp({type: 'field.set', target: {field: 'certifiedBy'}, value: {type: 'actor'}}),
],
}),
defineAction({
name: 'send-back',
title: 'Send back to draft',
status: 'done',
params: [{type: 'string', name: 'reason', title: 'Reason', required: true}],
ops: [
defineOp({type: 'field.set', target: {field: 'decision'}, value: {type: 'literal', value: 'sent-back'}}),
defineOp({type: 'field.set', target: {field: 'decisionNote'}, value: {type: 'param', param: 'reason'}}),
defineOp({type: 'field.unset', target: {field: 'factCheck'}}),
],
}),
],
}),
],
transitions: [
defineTransition({name: 'to-certified', title: 'Certified', to: 'certified', when: "$fields.decision == 'certified'"}),
defineTransition({name: 'back-to-drafted', title: 'Sent back', to: 'drafted', when: "$fields.decision == 'sent-back'"}),
],
}),
defineStage({
name: 'certified',
title: 'Certified',
description: 'The obituary is certified and published. Its text is frozen while the instance sits here; retract to edit it.',
guards: [
defineGuard({
name: 'freeze-obituary',
title: 'Certified obituary is frozen',
description: 'Allows edits and publishes of the feature only when they leave the obituary unchanged.',
match: {idRefs: [{type: 'fieldRead', field: 'subject'}], actions: ['update', 'publish']},
predicate: '!delta::changedAny((obituary))',
}),
],
activities: [
defineActivity({
name: 'retraction',
title: 'Retract (only if the obituary must change)',
actions: [
defineAction({
name: 'retract',
title: 'Retract to draft',
status: 'done',
params: [{type: 'string', name: 'reason', title: 'Reason', required: true}],
ops: [
defineOp({type: 'field.set', target: {field: 'decision'}, value: {type: 'literal', value: 'retracted'}}),
defineOp({type: 'field.set', target: {field: 'decisionNote'}, value: {type: 'param', param: 'reason'}}),
defineOp({type: 'field.unset', target: {field: 'certifiedBy'}}),
defineOp({type: 'field.unset', target: {field: 'factCheck'}}),
],
}),
],
}),
],
transitions: [
defineTransition({name: 'retract', title: 'Retracted', to: 'drafted', when: "$fields.decision == 'retracted'"}),
],
}),
],
})
EOF
cd $W && cp -r /tmp/claude-1000/wf/node_modules . 2>/dev/null; ls node_modules/.bin | grep -i workflow
sanity-workflows
$ cd /home/dev/sanity-challenge/ora2az/workflows && rm -rf node_modules && npm install --no-audit --no-fund 2>&1 | tail -3 && npx tsc --noEmit && npx sanity-workflows deploy --check 2>&1 | tail -30
Exit code 1
npm error For a full report see:
npm error /home/dev/.npm/_logs/2026-09-28T01_26_52_770Z-eresolve-report.txt
npm error A complete log of this run can be found in: /home/dev/.npm/_logs/2026-09-28T01_26_52_770Z-debug-0.log
[41m [0m
[41m[37m This is not the tsc command you are looking for [0m
[41m [0m
To get access to the TypeScript compiler, [34mtsc[0m, from the command line either:
- Use [1mnpm install typescript[0m to first add TypeScript to your project [1mbefore[0m using npx
- Use [1myarn[0m to avoid accidentally running code from un-installed packages
$ cd /home/dev/sanity-challenge/ora2az/workflows && npm install --no-audit --no-fund 2>&1 | grep -v "^npm error *$" | head -30
npm error code ERESOLVE
npm error ERESOLVE could not resolve
npm error While resolving: @sanity/workflow-blueprint@0.35.0
npm error Found: typescript@5.9.3
npm error node_modules/typescript
npm error dev typescript@"^5" from the root project
npm error Could not resolve dependency:
npm error peerOptional typescript@"^6.0.3 || ^7.0.0" from @sanity/workflow-blueprint@0.35.0
npm error node_modules/@sanity/workflow-blueprint
npm error peer @sanity/workflow-blueprint@"0.35.0" from @sanity/workflow-cli@0.35.0
npm error node_modules/@sanity/workflow-cli
npm error @sanity/workflow-cli@"0.35.0" from the root project
npm error Conflicting peer dependency: typescript@7.0.2
npm error node_modules/typescript
npm error peerOptional typescript@"^6.0.3 || ^7.0.0" from @sanity/workflow-blueprint@0.35.0
npm error node_modules/@sanity/workflow-blueprint
npm error peer @sanity/workflow-blueprint@"0.35.0" from @sanity/workflow-cli@0.35.0
npm error node_modules/@sanity/workflow-cli
npm error @sanity/workflow-cli@"0.35.0" from the root project
npm error Fix the upstream dependency conflict, or retry
npm error this command with --force or --legacy-peer-deps
npm error to accept an incorrect (and potentially broken) dependency resolution.
npm error For a full report see:
npm error /home/dev/.npm/_logs/2026-09-28T01_26_57_650Z-eresolve-report.txt
npm error A complete log of this run can be found in: /home/dev/.npm/_logs/2026-09-28T01_26_57_650Z-debug-0.log
$ cd /home/dev/sanity-challenge/ora2az/workflows && sed -i 's/"typescript": "\^5"/"typescript": "^6.0.3"/' package.json && npm install --no-audit --no-fund 2>&1 | tail -3 && npx tsc --noEmit; echo tsc=$?; npx sanity-workflows deploy --check 2>&1 | tail -30
added 199 packages in 2s
tsc=0
[32m✔[39m 1 definition(s) passed validation (check only — dataset not contacted).
Validation passes. Before deploying, I'll exercise every path in memory using the engine's test bench.
$ cd /tmp/scratch/scratchpad/docs && curl -sL https://www.sanity.io/docs/workflows/testing.md -o testing.md && sed -n 1,140p testing.md
> For AI agents: the complete Sanity documentation index is available at [https://www.sanity.io/docs/llms.txt](https://www.sanity.io/docs/llms.txt).
# Test your workflows
Run the real workflow engine in memory: drive every path of a workflow, control the clock, simulate guard enforcement, and assert on exactly what happens.
> [!WARNING]
> Early access
> Workflows is in early access, built in public. Read [How early access works](https://www.sanity.io/docs/workflows/prerelease) before you rely on it.
The test bench (`@sanity/workflow-engine-test`) runs the real workflow engine against an in-memory [Sanity client](https://www.sanity.io/docs/apis-and-sdks/js-client-getting-started): real `defineWorkflow` definitions, a deterministic clock, actors you choose, and simulated guard enforcement, with no [Sanity project](https://www.sanity.io/docs/platform-management/projects-organizations-and-billing) and no network. A workflow is logic, and the bench lets you exercise its paths before a definition touches a [dataset](https://www.sanity.io/docs/content-lake/datasets).
In this guide, you’ll set up a bench, drive an instance through its stages, test who can act, test what the start picker surfaces, control the clock to test deadlines, prove a guard holds, complete effects, and run the suite in CI.
Prerequisites:
- A TypeScript project with `@sanity/workflow-engine` installed and a workflow definition to test.
- A test runner. The examples use Vitest, but the bench has no runner dependency.
## Set up the bench
Install the bench as a dev dependency, along with Vitest if you don’t have a runner yet:
**npm**
```shell
npm install --save-dev @sanity/workflow-engine @sanity/workflow-engine-test vitest
```
**pnpm**
```shell
pnpm add --save-dev @sanity/workflow-engine @sanity/workflow-engine-test vitest
```
**yarn**
```shell
yarn add --dev @sanity/workflow-engine @sanity/workflow-engine-test vitest
```
**bun**
```shell
bun add --dev @sanity/workflow-engine @sanity/workflow-engine-test vitest
```
Keep @sanity/workflow-engine and @sanity/workflow-engine-test on the same version.
The package exports `createBench`. Every call builds a fresh, fully isolated world: the real engine wired to an in-memory client, a permissive default actor, and a clock the test controls. Nothing leaks between benches, so there is no cleanup to write. A cross-resource workflow declares its sibling datasets with `serveResources`, and the bench serves them straight from its own store, which also admits runtime-supplied refs into them. Anything beyond same-store siblings takes a raw `resourceClients` resolver.
The examples test one editorial review workflow. Its single-subject start requirement prevents another unfinished run of the same definition for the article. The workflow moves from drafting through review to publishing, with a guard over the article copy and an effect that performs the final publish.

**article-review.ts**
```typescript
import {
defineAction,
defineActivity,
defineField,
defineGuard,
defineStage,
defineTransition,
defineWorkflow,
} from '@sanity/workflow-engine/define'
export const articleReview = defineWorkflow({
name: 'article-review',
title: 'Article review',
initialStage: 'drafting',
// Refuse another unfinished run of this definition for the same subject.
start: {
requirements: [
{
type: 'singleSubject',
name: 'one-open-review',
title: 'Review already in progress',
},
],
},
fields: [
defineField({type: 'subject', name: 'subject', required: true, initialValue: {type: 'input'}}),
defineField({type: 'string', name: 'publishOutcome'}),
],
stages: [
defineStage({
name: 'drafting',
activities: [
defineActivity({
name: 'write',
actions: [
defineAction({name: 'submit', title: 'Submit for review', roles: ['writer'], status: 'done'}),
],
}),
],
transitions: [defineTransition({name: 'to-review', to: 'review'})],
}),
defineStage({
name: 'review',
// Freeze the article's copy while the editor reads it.
guards: [
defineGuard({
name: 'freeze-copy',
match: {idRefs: [{type: 'fieldRead', field: 'subject'}], actions: ['update']},
predicate: '!delta::changedAny((body, title))',
}),
],
// The editor's decision is data; the transition triggers read it.
fields: [defineField({type: 'string', name: 'decision'})],
activities: [
defineActivity({
name: 'decide',
actions: [
defineAction({
name: 'approve',
title: 'Approve',
roles: ['editor'],
status: 'done',
ops: [{type: 'field.set', target: {field: 'decision'}, value: {type: 'literal', value: 'approve'}}],
}),
defineAction({
name: 'send-back',
title: 'Send back',
roles: ['editor'],
status: 'done',
ops: [{type: 'field.set', target: {field: 'decision'}, value: {type: 'literal', value: 'send-back'}}],
}),
// A trigger: the engine fires it once the deadline passes.
defineAction({
name: 'expire',
when: '$fields.subject.reviewDeadline <= $now',
status: 'failed',
}),
],
}),
],
$ cd /tmp/scratch/scratchpad/docs && sed -n 140,420p testing.md | grep -v "^\*\*\(pnpm\|yarn\|bun\)"
],
transitions: [
defineTransition({
name: 'to-publishing',
to: 'publishing',
when: "$allActivitiesDone && $fields.decision == 'approve'",
}),
defineTransition({
name: 'to-drafting',
to: 'drafting',
when: "$allActivitiesDone && $fields.decision == 'send-back'",
}),
defineTransition({name: 'to-expired', to: 'expired', when: '$anyActivityFailed'}),
],
}),
defineStage({
name: 'publishing',
activities: [
defineActivity({
name: 'publish',
actions: [
// Entry trigger: queues the publish effect on arrival.
defineAction({
name: 'queue-publish',
when: 'true',
effects: [{name: 'article.publish', bindings: {}}],
status: 'done',
}),
],
}),
],
transitions: [
defineTransition({name: 'to-published', to: 'published', when: "$fields.publishOutcome == 'ok'"}),
],
}),
defineStage({name: 'published'}), // terminal: no outgoing transitions
defineStage({name: 'expired'}),
],
})
```
The fixture seeds the published article for workflow reads and its draft for guarded edits. The test freezes the clock, deploys the definition, and starts an instance:
Both review definitions in this guide declare a required subject, so their deployments acknowledge reader model 10. Before deploying outside the bench, follow [Upgrade Workflows packages](https://www.sanity.io/docs/workflows/upgrade) to update the runtimes that share the workflow resource.
**article-review.test.ts**
```typescript
import {
ActionDisabledError,
type Actor,
} from '@sanity/workflow-engine'
import {createBench, GuardDeniedError, subjectField} from '@sanity/workflow-engine-test'
import {expect, test} from 'vitest'
import {articleReview} from './article-review'
const T0 = '2026-03-01T09:00:00.000Z'
const DAY_MS = 24 * 60 * 60 * 1000
const writer: Actor = {kind: 'person', id: 'wanda', roles: ['writer']}
const editor: Actor = {kind: 'person', id: 'ed', roles: ['editor']}
async function startArticleReview() {
const article = {
_id: 'article-1',
_type: 'article',
title: 'Bench-tested workflows',
body: 'First draft',
reviewDeadline: '2026-03-03T09:00:00.000Z', // two days after T0
}
const bench = createBench({
now: T0,
documents: [article, {...article, _id: 'drafts.article-1'}],
})
await bench.deployDefinitions({
expectedMinReaderModel: 10,
definitions: [articleReview],
})
const {instance} = await bench.startInstance({
definition: 'article-review',
initialFields: [subjectField('article-1', {type: 'article'})],
})
return {bench, instance}
}
test('a new run starts in drafting', async () => {
const {instance} = await startArticleReview()
expect(instance.currentStage).toBe('drafting')
})
```
`deployDefinitions` and `startInstance` are the engine’s own verbs. The bench wraps them and handles the client, scope, and access wiring. `subjectField` builds the conventional `subject` entry pointing at a [document](https://www.sanity.io/docs/content-lake/documents) in the bench’s default [resource](https://www.sanity.io/docs/studio/global-document-reference-type), so you never hand-write [reference URIs](https://www.sanity.io/docs/studio/reference-type). `startInstance` returns an `OperationResult` whose `instance` is the instance document after the start commit and any cascade: the run begins in `drafting`, with the `write` activity active.
Use `subjectField()` for the workflow’s first-class subject. Use `docRefField()` for an ordinary document reference that does not determine applicability. `instancesForSubject` continues to find both current subject fields and legacy required `doc.ref` subjects while definitions are migrated.
## Drive an instance through its stages
Bench verbs take the same arguments as the engine’s, plus an `actor` shortcut. Role gates match the actor’s [roles](https://www.sanity.io/docs/user-guides/roles) literally, so fire each action as someone who holds the role:
```typescript
test('an approved article reaches publishing', async () => {
const {bench, instance} = await startArticleReview()
await bench.fireAction({instanceId: instance._id, activity: 'write', action: 'submit', actor: writer})
expect(await bench.currentStage(instance._id)).toBe('review')
const {instance: after} = await bench.fireAction({
instanceId: instance._id,
activity: 'decide',
action: 'approve',
actor: editor,
})
expect(after.currentStage).toBe('publishing')
})
```
Firing `approve` writes the `decision` field, the `to-publishing` trigger turns true, and the cascade moves the instance in the same commit. When the promise resolves, everything has settled. There is nothing to poll or wait for.
The send-back loop tests the same way:
```typescript
test('a send-back returns the article to drafting', async () => {
const {bench, instance} = await startArticleReview()
await bench.fireAction({instanceId: instance._id, activity: 'write', action: 'submit', actor: writer})
const {instance: after} = await bench.fireAction({
instanceId: instance._id,
activity: 'decide',
action: 'send-back',
actor: editor,
})
expect(after.currentStage).toBe('drafting')
expect(await bench.activityStatus(instance._id, 'write')).toBe('active')
})
```
The instance is back in `drafting` and the `write` activity is active again. The `decision` field is stage-scoped, so the next review round starts with a clean slate.
## Test who can act
`bench.evaluate` computes the read-side verdicts a UI renders from: every action on the current stage, with `allowed` and a structured `disabledReason` when it is not. Evaluate as a specific actor to test both sides of a gate:
```typescript
test('a writer cannot approve, and the verdict says why', async () => {
const {bench,
[… output truncated]
$ cd /tmp/scratch/scratchpad/docs && sed -n 420,520p testing.md | grep -v "^\*\*\(pnpm\|yarn\|bun\)"; cd /home/dev/sanity-challenge/ora2az/workflows && npm view @sanity/workflow-engine-test versions --json | tail -5
`activeGuardsForDocument` previews a denial without writing. `editDocument` attempts the write and throws `GuardDeniedError` when a guard rejects it. This content-edit guard targets the draft, so both calls use `drafts.article-1`:
```typescript
test('the review stage locks the article copy', async () => {
const {bench, instance} = await startArticleReview()
await bench.fireAction({instanceId: instance._id, activity: 'write', action: 'submit', actor: writer})
const active = await bench.activeGuardsForDocument('drafts.article-1')
expect(active.map((guard) => guard.name)).toEqual(['freeze-copy'])
await expect(
bench.editDocument({documentId: 'drafts.article-1', patch: {set: {body: 'Reworded during review'}}}),
).rejects.toThrow(GuardDeniedError)
})
```
The guard’s predicate denies only writes that change `body` or `title`, so a [patch](https://www.sanity.io/docs/content-lake/http-patches) that leaves both alone goes through even during review.
Drive the stage to exit and the guard document is deleted:
```typescript
test('the guard is deleted when the review stage exits', async () => {
const {bench, instance} = await startArticleReview()
await bench.fireAction({instanceId: instance._id, activity: 'write', action: 'submit', actor: writer})
await bench.fireAction({instanceId: instance._id, activity: 'decide', action: 'send-back', actor: editor})
expect(await bench.activeGuardsForDocument('drafts.article-1')).toEqual([])
await bench.editDocument({documentId: 'drafts.article-1', patch: {set: {body: 'Second draft'}}})
})
```
With the review stage exited, no guards remain active and the same edit succeeds.
## Complete effects
Entering `publishing` fires the `queue-publish` trigger, which queues the `article.publish` effect. In production an effect handler picks it up, performs the external work, and reports back; in a test, you play the handler. `listPendingEffects` lists what is queued, and `completePendingEffect` completes an effect by name, with `ops` writing its outcome onto the instance in the same commit:
```typescript
test('completing the publish effect finishes the run', async () => {
const {bench, instance} = await startArticleReview()
await bench.fireAction({instanceId: instance._id, activity: 'write', action: 'submit', actor: writer})
await bench.fireAction({instanceId: instance._id, activity: 'decide', action: 'approve', actor: editor})
const pending = await bench.listPendingEffects({instanceId: instance._id})
expect(pending.map((effect) => effect.name)).toEqual(['article.publish'])
await bench.completePendingEffect({
instanceId: instance._id,
effect: 'article.publish',
status: 'done',
ops: [
{
type: 'field.set',
target: {scope: 'workflow', field: 'publishOutcome'},
value: {type: 'literal', value: 'ok'},
},
],
actor: {kind: 'system', id: 'g-publish-service'},
})
expect(await bench.currentStage(instance._id)).toBe('published')
})
```
The completion applies the `field.set`, the `to-published` transition reads the outcome field and fires in the same cascade, and the instance lands in `published`. An effect reports state through fields, never by flipping an activity’s status. To test the failure path, complete with `status: 'failed'` and assert on where the instance parks.
## Recover a terminal activity
Use resetActivity to test an operator rerunning a failed activity or skipping it so the cascade can continue:
```typescript
const {instance: recovered} = await bench.resetActivity({
instanceId: instance._id,
activity: 'publish',
to: 'active', // use 'skipped' to bypass it
})
expect(recovered.currentStage).toBe('publishing')
```
## Run it in CI
Bench tests are plain unit tests: no Sanity project, no [tokens](https://www.sanity.io/docs/content-lake/http-auth), no network, and no shared state between benches, so they parallelize like the rest of your suite. Run them the same way:
**npm**
```shell
npx vitest run
```
```shell
pnpm dlx vitest run
```
```shell
yarn dlx vitest run
"0.32.0",
"0.33.0",
"0.34.0",
"0.35.0"
]
$ cd /home/dev/sanity-challenge/ora2az/workflows && npm install --no-audit --no-fund -D @sanity/workflow-engine-test@0.35.0 vitest 2>&1 | tail -2; mkdir -p test; cat > test/notice-lifecycle.test.ts <<'EOF'
// In-memory tests of every path through notice-lifecycle (no project, no network). Run: npm test
import {createBench, GuardDeniedError, subjectField} from '@sanity/workflow-engine-test'
import {expect, test} from 'vitest'
import {noticeLifecycle} from '../definitions/notice-lifecycle.ts'
const feature = {_id: 'feature_x', _type: 'oracleFeature', name: 'X', reviewStatus: 'draft'}
const obit = [{_type: 'block', _key: 'a', children: [{_type: 'span', _key: 'b', text: 'It is survived by…'}]}]
async function start() {
const bench = createBench({
now: '2026-09-27T00:00:00.000Z',
documents: [feature, {...feature, _id: 'drafts.feature_x', obituary: obit}],
})
await bench.deployDefinitions({expectedMinReaderModel: 10, definitions: [noticeLifecycle]})
const {instance} = await bench.startInstance({
definition: 'notice-lifecycle',
initialFields: [subjectField('feature_x', {type: 'oracleFeature'})],
})
return {bench, id: instance._id, instance}
}
const fire = (bench: Awaited<ReturnType<typeof start>>['bench'], instanceId: string, activity: string, action: string, params?: Record<string, unknown>) =>
bench.fireAction({instanceId, activity, action, params})
test('starts in drafted', async () => {
const {instance} = await start()
expect(instance.currentStage).toBe('drafted')
})
test('a failed fact check stays in drafted with the note, and a later pass moves on', async () => {
const {bench, id} = await start()
await fire(bench, id, 'fact-check', 'fail', {note: 'mentions 21c'})
expect(await bench.currentStage(id)).toBe('drafted')
expect(await bench.activityStatus(id, 'fact-check')).toBe('active')
await fire(bench, id, 'fact-check', 'pass', {note: 'ok'})
expect(await bench.currentStage(id)).toBe('fact-checked')
})
test('certify reaches certified and freezes the obituary, but not other fields', async () => {
const {bench, id} = await start()
await fire(bench, id, 'fact-check', 'pass')
await fire(bench, id, 'certification', 'certify')
expect(await bench.currentStage(id)).toBe('certified')
expect((await bench.activeGuardsForDocument('drafts.feature_x')).map((g) => g.name)).toContain('freeze-obituary')
await expect(bench.editDocument({documentId: 'drafts.feature_x', patch: {set: {obituary: []}}})).rejects.toThrow(GuardDeniedError)
await bench.editDocument({documentId: 'drafts.feature_x', patch: {set: {name: 'X renamed'}}})
})
test('send-back returns to drafted and needs a fresh fact check', async () => {
const {bench, id} = await start()
await fire(bench, id, 'fact-check', 'pass')
await fire(bench, id, 'certification', 'send-back', {reason: 'tone'})
expect(await bench.currentStage(id)).toBe('drafted')
expect(await bench.activityStatus(id, 'fact-check')).toBe('active')
await fire(bench, id, 'fact-check', 'pass')
expect(await bench.currentStage(id)).toBe('fact-checked') // the old decision does not bounce it back
})
test('retract lifts the freeze and returns to drafted; re-certification works', async () => {
const {bench, id} = await start()
await fire(bench, id, 'fact-check', 'pass')
await fire(bench, id, 'certification', 'certify')
await fire(bench, id, 'retraction', 'retract', {reason: 'new desupport info'})
expect(await bench.currentStage(id)).toBe('drafted')
expect(await bench.activeGuardsForDocument('drafts.feature_x')).toEqual([])
await bench.editDocument({documentId: 'drafts.feature_x', patch: {set: {obituary: []}}})
await fire(bench, id, 'fact-check', 'pass')
expect(await bench.currentStage(id)).toBe('fact-checked')
await fire(bench, id, 'certification', 'certify')
expect(await bench.currentStage(id)).toBe('certified')
})
EOF
node -e "const p=require('./package.json');p.scripts.test='vitest run';p.scripts.typecheck='tsc --noEmit';require('fs').writeFileSync('package.json',JSON.stringify(p,null,2)+'\n')"; sed -i 's#"include": \[#"include": ["test/**/*.ts", #' tsconfig.json; npx vitest run 2>&1 | tail -40
added 24 packages in 2s
RUN v5.0.2 /home/dev/sanity-challenge/ora2az/workflows
❯ test/notice-lifecycle.test.ts (5 tests | 3 failed) 62ms
× certify reaches certified and freezes the obituary, but not other fields 10ms
× send-back returns to drafted and needs a fresh fact check 4ms
× retract lifts the freeze and returns to drafted; re-certification works 5ms
⎯⎯⎯⎯⎯⎯⎯ Failed Tests 3 ⎯⎯⎯⎯⎯⎯⎯
FAIL test/notice-lifecycle.test.ts > certify reaches certified and freezes the obituary, but not other fields
FAIL test/notice-lifecycle.test.ts > send-back returns to drafted and needs a fresh fact check
FAIL test/notice-lifecycle.test.ts > retract lifts the freeze and returns to drafted; re-certification works
FieldValueShapeError: Field entry value shape invalid for "factCheckNote" (text): Invalid type: Expected string but received undefined
❯ validateFieldValue node_modules/@sanity/workflow-engine/dist/_chunks-es/invariants.js:1495:34
❯ applyFieldSet node_modules/@sanity/workflow-engine/dist/index.js:2696:21
❯ applyOp node_modules/@sanity/workflow-engine/dist/index.js:2665:16
❯ runOps node_modules/@sanity/workflow-engine/dist/index.js:2567:32
❯ applyActionFire node_modules/@sanity/workflow-engine/dist/index.js:8553:20
❯ commitAction node_modules/@sanity/workflow-engine/dist/index.js:8617:127
❯ retryCommit node_modules/@sanity/workflow-engine/dist/index.js:6164:20
❯ applyAction node_modules/@sanity/workflow-engine/dist/index.js:12289:13
❯ dispatchGatedWrite node_modules/@sanity/workflow-engine/dist/index.js:12183:18
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/3]⎯
Test Files 1 failed (1)
Tests 3 failed | 2 passed (5)
Start at 20:27:42
Duration 199ms (tests 52%, import 31%, transform 15%, worker 1%)
The bench caught a real bug: optional params can't feed a field.set when omitted. I'll make the pass note required and drop the certify note.
$ cd /home/dev/sanity-challenge/ora2az/workflows && python3 - <<'EOF'
p='definitions/notice-lifecycle.ts'
s=open(p).read()
s=s.replace(""" status: 'done',
params: [{type: 'string', name: 'note', title: 'Note'}],
ops: [
defineOp({type: 'field.set', target: {field: 'factCheck'}, value: {type: 'literal', value: 'passed'}}),""",""" status: 'done',
params: [{type: 'string', name: 'note', title: 'What was checked', required: true}],
ops: [
defineOp({type: 'field.set', target: {field: 'factCheck'}, value: {type: 'literal', value: 'passed'}}),""")
s=s.replace(""" status: 'done',
params: [{type: 'string', name: 'note', title: 'Note'}],
ops: [
defineOp({type: 'field.set', target: {field: 'decision'}, value: {type: 'literal', value: 'certified'}}),
defineOp({type: 'field.set', target: {field: 'decisionNote'}, value: {type: 'param', param: 'note'}}),
""",""" status: 'done',
ops: [
defineOp({type: 'field.set', target: {field: 'decision'}, value: {type: 'literal', value: 'certified'}}),
defineOp({type: 'field.unset', target: {field: 'decisionNote'}}),
""")
open(p,'w').write(s)
t='test/notice-lifecycle.test.ts'
s=open(t).read().replace("'fact-check', 'pass')","'fact-check', 'pass', {note: 'ok'})")
open(t,'w').write(s)
EOF
grep -c "required: true" definitions/notice-lifecycle.ts; npx vitest run 2>&1 | tail -15; npx tsc --noEmit && echo tsc-ok; npx sanity-workflows deploy --check 2>&1 | tail -2
5
RUN v5.0.2 /home/dev/sanity-challenge/ora2az/workflows
Test Files 1 passed (1)
Tests 5 passed (5)
Start at 20:27:51
Duration 197ms (tests 62%, import 25%, transform 12%, worker 1%)
tsc-ok
[32m✔[39m 1 definition(s) passed validation (check only — dataset not contacted).
Tests pass on every path, including the guard. Now the fact-check script; first a look at the three queued drafts.
$ cd /home/dev/sanity-challenge/ora2az/content && set -a && . ~/.config/ora2az/env && set +a && node -e '
import("@sanity/client").then(async ({createClient})=>{
const c=createClient({projectId:process.env.SANITY_PROJECT_ID,dataset:process.env.SANITY_DATASET,token:process.env.SANITY_WRITE_TOKEN,apiVersion:"2025-01-01",useCdn:false,perspective:"raw"});
const r=await c.fetch(`*[_id in ["drafts.feature_xmltype","drafts.feature_vector-datatype","drafts.feature_flashback-query","feature_xmltype","feature_vector-datatype","feature_flashback-query"]]{_id,reviewStatus,"obit":pt::text(obituary),introducedIn,deprecatedIn,desupportedIn}`);
console.log(JSON.stringify(r,null,1));
console.log(await c.fetch(`count(*[_type=="sanity.workflow.instance"])`), await c.fetch(`count(*[_type match "sanity.workflow*"])`));
})'
[
{
"_id": "drafts.feature_flashback-query",
"deprecatedIn": null,
"desupportedIn": null,
"introducedIn": "pre-11.2",
"obit": "Flashback Query (AS OF), introduced before 11.2, let users consult earlier states through AS OF TIMESTAMP and VERSIONS BETWEEN queries. It made the past queryable without requiring every table to plan its memoirs.\n\nIt did not survive the move because Azure SQL Database has no undo-based flashback for arbitrary tables, FLASHBACK TABLE, or transaction-level flashback. Its nearest substitute records history only for opted-in tables and only after system versioning begins.\n\nIt is survived by Azure SQL Database, where it lives on partly through system-versioned temporal tables and SELECT … FOR SYSTEM_TIME. AS OF, FROM/TO, BETWEEN, CONTAINED IN, and ALL cover history-table queries, while point-in-time database restore remains the fallback.",
"reviewStatus": "draft"
},
{
"_id": "drafts.feature_vector-datatype",
"deprecatedIn": null,
"desupportedIn": null,
"introducedIn": "23ai",
"obit": "Oracle’s VECTOR datatype and AI Vector Search, born in 23ai, stored embeddings and supported similarity search. It also offered broader AI Vector Search capabilities, including in-database ONNX model loading.\n\nIts passage to Azure was checked by narrower quarters: Azure SQL permits at most 1,998 float32 dimensions, with float16 still in preview. Vector columns also forgo B-tree and columnstore indexes, key constraints, and Always Encrypted, a notably formal farewell.\n\nIt is survived by Azure Database for PostgreSQL Flexible Server, where it lives on partly through pgvector, and Azure SQL Database, where it likewise lives on partly. Both preserve core vector storage and similarity search, though limits and syntax differ.",
"reviewStatus": "draft"
},
{
"_id": "drafts.feature_xmltype",
"deprecatedIn": null,
"desupportedIn": null,
"introducedIn": "pre-11.2",
"obit": "XMLType, an Oracle datatype introduced before 11.2, spent its career storing XML and supporting Oracle’s XML functions, indexes, and specialized storage options. It served faithfully, if with a taste for structure.\n\nIt was neither deprecated nor desupported, but did not make the Azure move intact. Its Oracle-specific binary and object-relational storage, XMLIndex, and several functions require replacement or rewriting.\n\nIt is survived by Azure Database for PostgreSQL Flexible Server, where it lives on partly through PostgreSQL xml, xpath(), xmltable(), and expression indexes. Azure SQL Database also preserves it partly through SQL Server xml, schema collections, XQuery methods, and XML indexes.",
"reviewStatus": "draft"
},
{
"_id": "feature_flashback-query",
"deprecatedIn": null,
"desupportedIn": null,
"introducedIn": "pre-11.2",
"obit": null,
"reviewStatus": "draft"
},
{
"_id": "feature_vector-datatype",
"deprecatedIn": null,
"desupportedIn": null,
"introducedIn": "23ai",
"obit": null,
"reviewStatus": "draft"
},
{
"_id": "feature_xmltype",
"deprecatedIn": null,
"desupportedIn": null,
"introducedIn": "pre-11.2",
"obit": null,
"reviewStatus": "draft"
}
]
0 0
Deploying the definition to udyjvgsk.production now.
$ cd /home/dev/sanity-challenge/ora2az/workflows && npx sanity-workflows deploy --deployment production --dry-run 2>&1 | tail -20; echo ---; npx sanity-workflows deploy --deployment production 2>&1 | tail -20
+ "name": "retract",
+ "title": "Retracted",
+ "to": "drafted",
+ "when": "$fields.decision == 'retracted'"
+ }
+ ]
+ }
+ ],
+ "start": {
+ "kind": "interactive",
+ "requirements": [
+ {
+ "name": "one-open-notice",
+ "title": "This obituary already has an open workflow",
+ "type": "singleSubject"
+ }
+ ]
+ },
+ "title": "Obituary notice lifecycle"
+ }
---
- Deploying 1 definition(s) to production (production) → udyjvgsk.production…
[32m✔[39m Processed 1 definition(s) · production (production) → udyjvgsk.production
[32m✔[39m created notice-lifecycle v1
Definition sharing: ON (default)
New workflow definition versions are being shared with Sanity to improve Workflows.
Examples:
$ sanity-workflows deploy # share by default
$ sanity-workflows deploy --share-defs # share explicitly; hide this warning
$ sanity-workflows deploy --no-share-defs # opt out
Deployed as v1. Starting instances for the three features (published ids, as the docs require).
$ cd /home/dev/sanity-challenge/ora2az/workflows && for f in feature_xmltype feature_vector-datatype feature_flashback-query; do npx sanity-workflows start notice-lifecycle --deployment production --field subject="{\"id\":\"dataset:udyjvgsk:production:$f\",\"type\":\"oracleFeature\"}" 2>&1 | tail -3; done
- Starting notice-lifecycle…
[32m✔[39m Started production.wf-instance.2e9f8b706af0 — now at drafted
- Starting notice-lifecycle…
[32m✔[39m Started production.wf-instance.1739bb2d4543 — now at drafted
- Starting notice-lifecycle…
[32m✔[39m Started production.wf-instance.3c0dea974deb — now at drafted
$ cd /home/dev/sanity-challenge/ora2az/workflows && npx sanity-workflows list 2>&1 | tail -12; npx sanity-workflows list --json 2>/dev/null | head -60; npx sanity-workflows show production.wf-instance.2e9f8b706af0 --json 2>/dev/null | python3 -c "import json,sys;d=json.load(sys.stdin);print(list(d.keys()));print(json.dumps(d.get('fields'),indent=1)[:1500])"
instance workflow tag stage status updated
production.wf-instance.3c0dea974deb notice-lifecycle production drafted in-flight 9 seconds ago
production.wf-instance.1739bb2d4543 notice-lifecycle production drafted in-flight 15 seconds ago
production.wf-instance.2e9f8b706af0 notice-lifecycle production drafted in-flight 20 seconds ago
{
"instances": [
{
"_id": "production.wf-instance.3c0dea974deb",
"definition": "notice-lifecycle",
"tag": "production",
"currentStage": "drafted",
"status": "in-flight",
"lastChangedAt": "2026-09-28T01:28:38.395Z",
"resource": {
"type": "dataset",
"id": "udyjvgsk.production"
}
},
{
"_id": "production.wf-instance.1739bb2d4543",
"definition": "notice-lifecycle",
"tag": "production",
"currentStage": "drafted",
"status": "in-flight",
"lastChangedAt": "2026-09-28T01:28:32.991Z",
"resource": {
"type": "dataset",
"id": "udyjvgsk.production"
}
},
{
"_id": "production.wf-instance.2e9f8b706af0",
"definition": "notice-lifecycle",
"tag": "production",
"currentStage": "drafted",
"status": "in-flight",
"lastChangedAt": "2026-09-28T01:28:27.861Z",
"resource": {
"type": "dataset",
"id": "udyjvgsk.production"
}
}
],
"truncated": false
}
['_createdAt', '_id', '_rev', '_type', '_updatedAt', 'ancestors', 'context', 'currentStage', 'definition', 'definitionSnapshot', 'effectHistory', 'fields', 'history', 'lastChangedAt', 'minReaderModel', 'modelVersion', 'pendingEffects', 'pinnedContentHash', 'pinnedVersion', 'stages', 'startedAt', 'subworkflows', 'tag', 'workflowResource']
[
{
"_key": "0e69b073d468",
"_type": "subject",
"description": "The published oracleFeature document whose obituary this instance tracks.",
"name": "subject",
"title": "Oracle feature",
"types": [
"oracleFeature"
],
"value": {
"id": "dataset:udyjvgsk:production:feature_xmltype",
"type": "oracleFeature"
}
},
{
"_key": "7e0f8f7640b2",
"_type": "string",
"description": "\"passed\" or \"failed\".",
"name": "factCheck",
"title": "Fact check result",
"value": null
},
{
"_key": "cad09605ef82",
"_type": "text",
"name": "factCheckNote",
"title": "Fact check note",
"value": null
},
{
"_key": "9e7cc5603ecf",
"_type": "datetime",
"name": "factCheckedAt",
"title": "Fact checked at",
"value": null
},
{
"_key": "1c75e705f7dd",
"_type": "string",
"description": "\"certified\", \"sent-back\" or \"retracted\".",
"name": "decision",
"title": "Last editorial decision",
"value": null
},
{
"_key": "b44f1315d818",
"_type": "text",
"name": "decisionNote",
"title": "Decision note",
"value": null
},
{
"_key": "08396bfcba56",
"_type": "actor",
"name": "certifiedBy",
"title": "Certified by",
"value": null
}
]
Instances are live in drafted. Writing the fact-check script now.
$ cat > /home/dev/sanity-challenge/ora2az/workflows/scripts/fact-check.mjs <<'EOF'
#!/usr/bin/env node
// Fact check for the notice-lifecycle workflow.
// For each instance sitting in `drafted`, read the feature's draft obituary and its own data with GROQ,
// then fire `fact-check/pass` or `fact-check/fail` on the instance through the workflows CLI.
// Rule: every Oracle release (e.g. 12.2, 19c, 26ai) and every ORA-nnnnn code the obituary mentions must
// appear in the feature's own data: name, release fields, summary, oracleReplacement, its mappings
// (rationale, steps), the caveats on those mappings, and the disputes that involve them.
//
// Usage (from workflows/):
// node scripts/fact-check.mjs # every in-flight notice-lifecycle instance in `drafted`
// node scripts/fact-check.mjs <instanceId> ... # specific instances
// node scripts/fact-check.mjs --dry # report only, fire nothing
// Needs: `sanity login` (the CLI fires actions as you) and SANITY_WRITE_TOKEN or SANITY_READ_TOKEN
// in the environment or ~/.config/ora2az/env (drafts are not publicly readable).
import {createClient} from '@sanity/client'
import {execFileSync} from 'node:child_process'
import {existsSync, readFileSync} from 'node:fs'
import {join} from 'node:path'
const envFile = join(process.env.HOME ?? '', '.config', 'ora2az', 'env')
if (existsSync(envFile)) for (const l of readFileSync(envFile, 'utf8').split('\n')) { const m = l.match(/^([A-Z_]+)=(.*)$/); if (m && m[2]) process.env[m[1]] ??= m[2] }
const argv = process.argv.slice(2)
const dry = argv.includes('--dry')
const ids = argv.filter((a) => !a.startsWith('--'))
const client = createClient({
projectId: process.env.SANITY_PROJECT_ID ?? 'udyjvgsk',
dataset: process.env.SANITY_DATASET ?? 'production',
token: process.env.SANITY_READ_TOKEN ?? process.env.SANITY_WRITE_TOKEN,
apiVersion: '2025-01-01',
useCdn: false,
perspective: 'raw',
})
const cli = (...args) => JSON.parse(execFileSync('npx', ['sanity-workflows', ...args, '--json'], {encoding: 'utf8', stdio: ['ignore', 'pipe', 'inherit']}))
// Oracle release names (8i..26ai, 9.2..23.x) and ORA- error codes.
const RELEASE = /\b(?:(?:8|9|10|11|12|18|19|21|23|26)(?:ai|i|g|c)|(?:9|10|11|12|18|19|21|23|26)\.\d)\b/gi
const ORA = /\bORA-\d{5}\b/gi
const tokens = (text) => new Set([...(text.match(RELEASE) ?? []), ...(text.match(ORA) ?? [])].map((t) => t.toUpperCase()))
const QUERY = `{
"draft": *[_id == "drafts." + $id][0]{"obituary": pt::text(obituary)},
"published": *[_id == $id][0]{"obituary": pt::text(obituary)},
"facts": *[_id == $id][0]{
name, introducedIn, deprecatedIn, desupportedIn, oracleReplacement,
"summary": pt::text(summary),
"mappings": *[_type == "mapping" && oracleFeature._ref == ^._id && !(_id in path("drafts.**"))]{rationale, "steps": pt::text(steps)},
"caveats": *[_type == "caveat" && mapping->oracleFeature._ref == ^._id && !(_id in path("drafts.**"))]{statement},
"disputes": *[_type == "dispute" && !(_id in path("drafts.**")) && (claimA->mapping->oracleFeature._ref == ^._id || claimB->mapping->oracleFeature._ref == ^._id)]{title, whatDisagrees, resolution}
}
}`
function flatten(v) {
if (v == null) return ''
if (typeof v === 'string') return v
if (Array.isArray(v)) return v.map(flatten).join('\n')
if (typeof v === 'object') return Object.values(v).map(flatten).join('\n')
return String(v)
}
const targets = ids.length
? ids
: cli('list').instances.filter((i) => i.definition === 'notice-lifecycle' && i.currentStage === 'drafted' && i.status === 'in-flight').map((i) => i._id)
if (!targets.length) console.log('No notice-lifecycle instances waiting in `drafted`.')
let failed = 0
for (const instanceId of targets) {
const inst = cli('show', instanceId)
if (inst.currentStage !== 'drafted') { console.log(`${instanceId}: in ${inst.currentStage}, skipped`); continue }
const gdr = inst.fields.find((f) => f.name === 'subject')?.value?.id ?? ''
const featureId = gdr.split(':').pop() // dataset:<project>:<dataset>:<documentId>
const {draft, published, facts} = await client.fetch(QUERY, {id: featureId})
const obituary = draft?.obituary || published?.obituary || ''
let action, note
if (!facts) { action = 'fail'; note = `Feature ${featureId} not found.` }
else if (!obituary.trim()) { action = 'fail'; note = 'No obituary text on the draft or published document.' }
else {
const mentioned = tokens(obituary)
const allowed = tokens(flatten(facts))
const unsupported = [...mentioned].filter((t) => !allowed.has(t))
if (unsupported.length) { action = 'fail'; note = `Not in the feature's own data: ${unsupported.join(', ')}.` }
else { action = 'pass'; note = `Checked ${mentioned.size} release/ORA- mention(s) against the feature's data${mentioned.size ? ': ' + [...mentioned].join(', ') : ''}. All supported.` }
}
if (action === 'fail') failed++
console.log(`${instanceId} (${featureId}): ${action.toUpperCase()} - ${note}`)
if (dry) continue
// --param values are JSON-parsed by the CLI; pass a JSON string so the note always stays a string.
const r = cli('fire-action', instanceId, '--activity', 'fact-check', '--action', action, '--param', `note=${JSON.stringify(note)}`)
console.log(` -> now at ${r.currentStage}`)
}
process.exitCode = failed ? 1 : 0
EOF
cd /home/dev/sanity-challenge/ora2az/workflows && node scripts/fact-check.mjs --dry
production.wf-instance.3c0dea974deb (feature_flashback-query): PASS - Checked 1 release/ORA- mention(s) against the feature's data: 11.2. All supported.
production.wf-instance.1739bb2d4543 (feature_vector-datatype): PASS - Checked 1 release/ORA- mention(s) against the feature's data: 23AI. All supported.
production.wf-instance.2e9f8b706af0 (feature_xmltype): PASS - Checked 1 release/ORA- mention(s) against the feature's data: 11.2. All supported.
Dry run passes all three. I'll normalize token display and sanity-check the rule against the 47 certified obituaries to catch false positives.
$ cd /home/dev/sanity-challenge/ora2az/workflows && sed -i "s/.map((t) => t.toUpperCase()))/.map((t) => (\/^ORA-\/i.test(t) ? t.toUpperCase() : t.toLowerCase())))/" scripts/fact-check.mjs && grep -n "const tokens" scripts/fact-check.mjs && cat > /tmp/scratch/scratchpad/allcheck.mjs <<'EOF'
import {createClient} from '@sanity/client'
import {readFileSync} from 'node:fs'
const src = readFileSync('scripts/fact-check.mjs','utf8')
const RELEASE = eval(src.match(/const RELEASE = (.*)/)[1]); const ORA = /\bORA-\d{5}\b/gi
const tokens = (text) => new Set([...(text.match(RELEASE) ?? []), ...(text.match(ORA) ?? [])].map((t) => t.toLowerCase()))
const Q = src.match(/const QUERY = `([\s\S]*?)`/)[1]
for (const l of readFileSync(process.env.HOME+'/.config/ora2az/env','utf8').split('\n')) { const m = l.match(/^([A-Z_]+)=(.*)$/); if (m && m[2]) process.env[m[1]] ??= m[2] }
const c = createClient({projectId:'udyjvgsk',dataset:'production',token:process.env.SANITY_WRITE_TOKEN,apiVersion:'2025-01-01',useCdn:false,perspective:'raw'})
const flatten=(v)=>v==null?'':typeof v==='string'?v:Array.isArray(v)?v.map(flatten).join('\n'):typeof v==='object'?Object.values(v).map(flatten).join('\n'):String(v)
const ids = await c.fetch(`*[_type=="oracleFeature" && defined(obituary) && reviewStatus=="certified" && !(_id in path("drafts.**"))]._id`)
let bad=0
for (const id of ids){ const {published,facts}=await c.fetch(Q,{id}); const m=tokens(published.obituary), a=tokens(flatten(facts)); const u=[...m].filter(t=>!a.has(t)); if(u.length){bad++;console.log(id,u)} }
console.log('checked',ids.length,'unsupported',bad)
const t = tokens('Deprecated in 12cR2? no: 12.2, desupported 19c; raised ORA-01555 and ORA-1 in 2.5 hours, 23ai')
console.log([...t])
EOF
cp /tmp/scratch/scratchpad/allcheck.mjs ./_allcheck.mjs && node _allcheck.mjs; rm _allcheck.mjs
41:const tokens = (text) => new Set([...(text.match(RELEASE) ?? []), ...(text.match(ORA) ?? [])].map((t) => (/^ORA-/i.test(t) ? t.toUpperCase() : t.toLowerCase())))
checked 47 unsupported 0
[ '12.2', '19c', '23ai', 'ora-01555' ]
The rule matches all 47 certified obituaries with no false positives. Running the fact check for real.
$ cd /home/dev/sanity-challenge/ora2az/workflows && node scripts/fact-check.mjs; echo exit=$?; npx sanity-workflows list 2>&1 | tail -5
production.wf-instance.3c0dea974deb (feature_flashback-query): PASS - Checked 1 release/ORA- mention(s) against the feature's data: 11.2. All supported.
-> now at fact-checked
production.wf-instance.1739bb2d4543 (feature_vector-datatype): PASS - Checked 1 release/ORA- mention(s) against the feature's data: 23ai. All supported.
-> now at fact-checked
production.wf-instance.2e9f8b706af0 (feature_xmltype): PASS - Checked 1 release/ORA- mention(s) against the feature's data: 11.2. All supported.
-> now at fact-checked
exit=0
instance workflow tag stage status updated
production.wf-instance.2e9f8b706af0 notice-lifecycle production fact-checked in-flight 4 seconds ago
production.wf-instance.1739bb2d4543 notice-lifecycle production fact-checked in-flight 11 seconds ago
production.wf-instance.3c0dea974deb notice-lifecycle production fact-checked in-flight 20 seconds ago
All three reached fact-checked. Capturing CLI state and verifying content documents were untouched and instance docs aren't public.
$ cd /home/dev/sanity-challenge/ora2az/workflows && S=/tmp/scratch/scratchpad; npx sanity-workflows list 2>&1 | sed 's/\x1b\[[0-9;]*m//g' > $S/list.txt; npx sanity-workflows show production.wf-instance.2e9f8b706af0 2>&1 | sed 's/\x1b\[[0-9;]*m//g' > $S/show.txt; npx sanity-workflows fire-action production.wf-instance.2e9f8b706af0 2>&1 | sed 's/\x1b\[[0-9;]*m//g' > $S/fa.txt; npx sanity-workflows definition list 2>&1 | sed 's/\x1b\[[0-9;]*m//g' > $S/defs.txt; cat $S/list.txt $S/show.txt $S/fa.txt $S/defs.txt; echo; echo PUBLIC:; curl -s "https://udyjvgsk.apicdn.sanity.io/v2025-01-01/data/query/production?query=count(*%5B_type%20match%20%22sanity.workflow*%22%5D)"; echo; curl -s "https://udyjvgsk.api.sanity.io/v2025-01-01/data/query/production?query=*%5B_id%20in%20%5B%22feature_xmltype%22%2C%22feature_vector-datatype%22%2C%22feature_flashback-query%22%5D%5D%7B_id%2CreviewStatus%2C%22o%22%3Adefined(obituary)%7D"
instance workflow tag stage status updated
production.wf-instance.2e9f8b706af0 notice-lifecycle production fact-checked in-flight 14 seconds ago
production.wf-instance.1739bb2d4543 notice-lifecycle production fact-checked in-flight 22 seconds ago
production.wf-instance.3c0dea974deb notice-lifecycle production fact-checked in-flight 30 seconds ago
notice-lifecycle v1 production.wf-instance.2e9f8b706af0
Stage: fact-checked
Started: 2026-09-27 20:28:26
Completed: —
Tag: production
Stages:
• drafted (exited 2026-09-27 20:30:17)
✔ fact-check [done]
• fact-checked (current)
● certification [active]
notice-lifecycle v1 production.wf-instance.2e9f8b706af0
Stage: fact-checked
Started: 2026-09-27 20:28:26
Completed: —
Tag: production
Actions on stage 'fact-checked':
✔ certification → certify (Certify) [activity active]
✔ certification → send-back (Send back to draft) [activity active]
params: reason:string
Fire one with:
fire-action production.wf-instance.2e9f8b706af0 --activity <activity> --action <action>
workflow title tag stages in flight instances created
notice-lifecycle v1 Obituary notice lifecycle production 3 3 3 2026-09-28T01:28:17Z
PUBLIC:
{"query":"count(*[_type match \"sanity.workflow*\"])","result":0,"syncTags":["s1:ZRuFXw"],"ms":2}
{"query":"*[_id in [\"feature_xmltype\",\"feature_vector-datatype\",\"feature_flashback-query\"]]{_id,reviewStatus,\"o\":defined(obituary)}","result":[{"_id":"feature_flashback-query","o":false,"reviewStatus":"draft"},{"_id":"feature_vector-datatype","o":false,"reviewStatus":"draft"},{"_id":"feature_xmltype","o":false,"reviewStatus":"draft"}],"syncTags":["s1:IE+6Gw","s1:kZ53Dw","s1:ZELP4g"],"ms":2}
Now step 4: checking the App SDK doc and the desk's certify button for a small integration.
$ cd /tmp/scratch/scratchpad/docs && sed -n 1,200p app-sdk.md | grep -v "^\*\*\(pnpm\|yarn\|bun\)"; cat /home/dev/sanity-challenge/ora2az/desk/src/CertifyButton.tsx /home/dev/sanity-challenge/ora2az/desk/package.json
> For AI agents: the complete Sanity documentation index is available at [https://www.sanity.io/docs/llms.txt](https://www.sanity.io/docs/llms.txt).
# Build a workflow interface with the App SDK
Render live workflow state and commit actions from your own App SDK application: mount a session, handle its states, render activities and fields from the evaluation, and list instances.
> [!WARNING]
> Early access
> Workflows is in early access, built in public. Read [How early access works](https://www.sanity.io/docs/workflows/prerelease) before you rely on it.
This page is for applications outside Studio. To build a Document Action, tool, pane, input, or dialog inside Studio, see [Create a workflow-powered Document Action](https://www.sanity.io/docs/workflows/custom-studio-integrations). [Choose an integration](https://www.sanity.io/docs/workflows/reactive-session) compares the packages.
When the Studio plugin does not fit the interface you need, use `@sanity/workflow-sdk` to build the workflow experience in an App SDK application. It supplies live workflow state and the engine operations that change it.
This guide follows one application path: connect an instance, render its current work, commit user actions and field edits, then add instance lists. Use [Reusable UI components](https://www.sanity.io/docs/workflows/ui-components) for assignment and date controls. For the underlying live-data model, see [The reactive session](https://www.sanity.io/docs/workflows/reactive-session).
## Install the runtime
Use `@sanity/sdk` and `@sanity/sdk-react` 3.1 or later in the 3.x line. Install matching SDK versions and one matching version for every `@sanity/workflow-*` package.
SDK 3.1.0 can resolve `@sanity/mutate` 0.18.1, which can leave document reads pending with Sanity client 8. Before installing, merge this override into your application’s root package-manager configuration:
**package.json (npm)**
```json
{
"overrides": {
"@sanity/sdk": {
"@sanity/mutate": "0.18.2"
}
}
}
```
```yaml
overrides:
'@sanity/sdk@3>@sanity/mutate': 0.18.2
```
Install the App SDK adapter, shared controls, and their host dependencies:
**npm**
```shell
npm install @sanity/sdk@^3.1 @sanity/sdk-react@^3.1 \
@sanity/ui@^4.2.1 react@^19.2.7 react-dom@^19.2.7 \
styled-components@^6.4.2 @sanity/workflow-components \
@sanity/workflow-engine @sanity/workflow-react @sanity/workflow-sdk
```
```shell
pnpm add @sanity/sdk@^3.1 @sanity/sdk-react@^3.1 \
@sanity/ui@^4.2.1 react@^19.2.7 react-dom@^19.2.7 \
styled-components@^6.4.2 @sanity/workflow-components \
@sanity/workflow-engine @sanity/workflow-react @sanity/workflow-sdk
```
```shell
yarn add @sanity/sdk@^3.1 @sanity/sdk-react@^3.1 \
@sanity/ui@^4.2.1 react@^19.2.7 react-dom@^19.2.7 \
styled-components@^6.4.2 @sanity/workflow-components \
@sanity/workflow-engine @sanity/workflow-react @sanity/workflow-sdk
```
```shell
bun add @sanity/sdk@^3.1 @sanity/sdk-react@^3.1 \
@sanity/ui@^4.2.1 react@^19.2.7 react-dom@^19.2.7 \
styled-components@^6.4.2 @sanity/workflow-components \
@sanity/workflow-engine @sanity/workflow-react @sanity/workflow-sdk
```
`@sanity/workflow-react` supplies the shared reactive hooks used in this guide. The UI examples use Sanity UI 4 and must render inside its [theme provider](https://www.sanity.io/docs/workflows/ui-components).
Run `npm ls @sanity/sdk @sanity/mutate` or `pnpm why @sanity/mutate`. Confirm that SDK 3 resolves Mutate 0.18.2; other dependency branches may use different versions. Commit the configuration and updated lockfile.
Keep the override until your SDK release requires Mutate 0.18.2 or later and excludes 0.18.1. After removing it, reinstall and verify the dependency tree again.
## How workflow data reaches your UI
Reading a workflow instance document is not enough to render its UI. What the current user can see and do also depends on referenced content, guards, and field previews, and those inputs can change while the screen is open.
- The engine loads the instance and evaluates its definition against the content it references.
- The App SDK adapter watches those documents and feeds changes back into the engine.
- The session exposes the latest evaluation: stages, activities, fields, actions, and the reasons they are or are not available.
- Your controls call the session’s commit verbs. Successful commits update the instance and produce a new evaluation.
## Create a live session
Create one workflow session for the instance currently open in a detail view. The session keeps that instance's evaluation current and provides the operations that change it. Pass the engine and instance ID to `useWorkflowSession`. Use the hooks in [List workflow instances](https://www.sanity.io/docs/workflows/app-sdk) to discover instances without creating a session for every row.
> [!WARNING]
> Do not edit workflow instances as content
> Do not use `useEditDocument` or other content-editing APIs to change workflow instance documents. They bypass engine transactions and can break the audit trail or leave the instance internally inconsistent. Use the session’s operations instead. See [Instance writes are transactions](https://www.sanity.io/docs/workflows/engine).
**src/WorkflowPanel.tsx**
```tsx
import {useClient} from '@sanity/sdk-react'
import {createEngine} from '@sanity/workflow-engine'
import {useWorkflowSession} from '@sanity/workflow-sdk'
import {useMemo} from 'react'
const PROJECT_ID = 'YOUR_PROJECT_ID'
// The engine's state lives in its own dataset, separate from content.
const ENGINE_DATASET = 'workflows'
function WorkflowPanel({instanceId}: {instanceId: string}) {
const client = useClient({apiVersion: '2026-04-29'})
const engine = useMemo(
() =>
createEngine({
client: client.withConfig({dataset: ENGINE_DATASET}),
workflowResource: {type: 'dataset', id: `${PROJECT_ID}.${ENGINE_DATASET}`},
tag: 'main',
}),
[client],
)
const session =
[… output truncated]
$ cd /home/dev/sanity-challenge/ora2az/workflows/node_modules/@sanity/workflow-engine/dist && grep -n "instancesForSubject\|refDataset\|ENGINE_API_VERSION" index.d.ts | head; grep -n "instancesForSubject" -B30 index.d.ts | grep -n "^\S*-\s*\*" | tail -25; cd /home/dev/sanity-challenge/ora2az && git status --short desk; ls desk/src
3822:export declare const ENGINE_API_VERSION = "2026-04-29";
3869: * client onto `ENGINE_API_VERSION`, on the raw namespace as much as
4805: * it with {@link gdrUri}, {@link gdrFromResource}, or {@link refDataset}.
4864: * `TType` preserves the `type` literal the constructors ({@link refDataset},
7041:export declare function refDataset<TType extends string = string>({
9255: * {@link ENGINE_API_VERSION}. A declared `apiVersion` other than that version
9335: * `withConfig({apiVersion: ENGINE_API_VERSION})`, so the caller's
9339: * {@link ENGINE_API_VERSION} already; older dated versions fail
App.tsx
CertifyButton.tsx
config.ts
CountsStrip.tsx
desk.css
Desk.tsx
FeatureRow.tsx
FeatureTable.tsx
$ cd /home/dev/sanity-challenge/ora2az/workflows/node_modules/@sanity/workflow-engine/dist && grep -n "^ [a-zA-Z]*(\|^ [a-zA-Z]*<" index.d.ts | sed -n 1,200p | grep -i "subject\|instance\|fireAction\|list" | head -30
1274: constructor(args: { instanceId: string; limit: number });
1554: constructor(args: { instanceId: string; attempts: number });
5675: constructor(args: { instanceId: string; detail?: string });
5747: fireAction(args: {
8075: constructor(args: { instanceId: string; cause: unknown });
$ cd /home/dev/sanity-challenge/ora2az/workflows/node_modules/@sanity/workflow-engine/dist && grep -n "ForSubject\|ForDocument\|listInstances" index.d.ts | head -20
2354:export declare interface DefinitionsForDocumentArgs {
2356: * The LOADED candidate document — unlike {@link InstancesForDocumentArgs},
3663: * belongs to `definitionsForDocument`, and an inapplicable definition
3750: instancesForDocument: (
3751: args: InstancesForDocumentArgs,
3753: /** The startable half of {@link Engine.instancesForDocument}: the latest
3759: definitionsForDocument: (
3760: args: DefinitionsForDocumentArgs,
5785:export declare interface InstancesForDocumentArgs {
5923: * engine verb `definitionsForDocument` does). Throws when `document` carries
7811: * `definitionsForDocument` derivation and the Studio start control).
8893: * read-side visibility rule, see `definitionsForDocument`.
9117: instancesForDocument: (
9118: rawArgs: InstancesForDocumentArgs & EngineScopeArgs,
9121: * The startable half of {@link workflow.instancesForDocument}: every
9136: definitionsForDocument: (
9137: rawArgs: Clocked<DefinitionsForDocumentArgs & EngineScopeArgs>,
10702: * `listInstances` GROQ sees freshly-created children), immediate in the
$ cd /home/dev/sanity-challenge/ora2az/workflows/node_modules/@sanity/workflow-engine/dist && sed -n 3740,3760p index.d.ts; sed -n 5785,5830p index.d.ts; sed -n 5740,5784p index.d.ts; grep -n "export declare function createEngine" -A5 index.d.ts; grep -n "interface CreateEngineOptions\|interface EngineOptions" -A40 index.d.ts | grep -E "^\S+-\s{2}[a-zA-Z]+\??:" | head
) => Promise<MutationGuardDoc[]>;
/** Spawned children of a parent instance, optionally filtered by the
* spawning activity. Sorted by `startedAt` asc. */
children: (args: ChildrenArgs) => Promise<WorkflowInstance[]>;
/** The reverse of {@link Engine.subscriptionDocumentsForInstance}: every
* in-flight instance whose watch-set includes `document` (a resource-qualified
* GDR URI). For a non-reactive, content-change-driven runtime deciding which
* instances a changed doc should `tick`. Matches the same ref set the forward
* watch-set uses (self, ancestors, current-stage `doc.ref`/`subject`/`doc.refs`/`release.ref`)
* via the shared `collectWatchRefs`. Sorted by `startedAt` asc. */
instancesForDocument: (
args: InstancesForDocumentArgs,
) => Promise<WorkflowInstance[]>;
/** The startable half of {@link Engine.instancesForDocument}: the latest
* deployed version of every definition that applies to the LOADED candidate
* document — startable, the `subject`-kind entry accepts its `_type`, and
* `start.filter` (browse-time-pure — `$fields` never binds) passes. All
* matches, name ascending; advisory — a start picker's filter, never
* enforcement. */
definitionsForDocument: (
args: DefinitionsForDocumentArgs,
export declare interface InstancesForDocumentArgs {
/** A resource-qualified GDR URI; a bare id is rejected. */
document: GdrUri;
}
/**
* The guard filter for a SET of instances in one datasource — the single
* definition every per-instance read delegates to ({@link instanceGuardQuery}).
* Reactive adapters subscribe it as ONE shared live query per resource for
* every co-mounted session, fanning results back out per `sourceInstanceId`,
* so a document's guard subscription count doesn't grow with its instance
* count. Deterministically ordered, matching {@link guardsForResource}.
*/
export declare function instancesGuardQuery(
instanceIds: readonly string[],
): CompiledQuery;
/**
* The instance-list GROQ for a {@link InstancesQueryFilter}, ordered by
* `startedAt` ascending (descending + sliced under
* {@link InstancesQueryFilter.limit}). Adapters subscribe to it;
* {@link workflow.query}-style one-shot reads fetch it — both see the same
* rows.
*/
export declare function instancesQuery(args: {
tag: string;
filter?: InstancesQueryFilter;
}): CompiledQuery;
/**
* Narrows an instance-list read. All conditions AND together; an empty
* filter means "every in-flight instance in the engine's resource".
*/
export declare interface InstancesQueryFilter {
/**
* Only instances that may reference this document (resource-qualified GDR
* URI). The lake-side predicate matches the reactive watch-set's
* workflow, open-stage, activity, ancestor, live-child, and own-id references.
*/
document?: GdrUri;
/**
* The multi-document form of {@link InstancesQueryFilter.document}: one
* predicate matching instances that reference any of the given docs,
* for consumers discovering instances across many open documents at once.
* Merged with `document` when both are set. Callers may defensively recheck
* with {@link instanceWatchesDocument}. A defined but empty
* metadata checks; a guard predicate using `->` reads its target
* through the bound engine client. */
evaluate(): Promise<WorkflowEvaluation>;
/** Advance the instance against the held content: cascade auto-transitions,
* deploy guards, queue effects, commit with `ifRevisionId`. */
tick(): Promise<OperationResult>;
/** Fire an action against an activity, gated on the held content, then cascade. */
fireAction(args: {
activity: string;
action: string;
params?: Record<string, unknown>;
}): Promise<OperationResult>;
/** Edit a declared-editable field against the held content (the generic edit
* seam), gated on the held projection, then cascade. Stages its own
* optimistic preview on entry and drops the target's previews when the
* commit settles — success lands the reloaded value, failure reverts the
* projection to the held committed state. */
editField(args: {
target: EditFieldTarget;
mode?: EditMode;
value?: unknown;
}): Promise<OperationResult>;
/** Stage an optimistic preview of a field edit: {@link InstanceSession.evaluate}
* projects against the held instance with staged previews applied, so field
* values and the advisory verdicts derived from them move instantly, while
* everything the engine writes (stage, activity statuses, history) stays
* committed by construction — previews apply `field.*` ops only. Set/unset
* replace the target's staged rows (last write wins); appends accumulate.
* Tolerant where commits are loud: a target that doesn't resolve in the
* current stage (it moved under a mounted editor), a closed edit window
* (a skipped activity's field), a value that doesn't fit the field's
* shape yet (a half-typed date), or a ref outside the declared resource
* surface stages an INERT preview — no echo, no throw; the commit at the
* semantic boundary is the loud surface. Never persisted — commit via
* {@link InstanceSession.editField}. */
previewField(args: {
target: EditFieldTarget;
mode?: EditMode;
value?: unknown;
}): void;
/** Drop staged previews for one target — or all, when omitted — reverting
* the projection to the held committed state on the next evaluate. */
discardFieldPreview(target?: EditFieldTarget): void;
}
1891:export declare function createEngine<Client extends WorkflowClient>(
1892- args: CreateEngineArgs<Client>,
1893-): Engine;
1894-
1895-/**
1896- * The {@link EngineScopeArgs} scope pinned at construction, plus the
A small desk hook is feasible with just @sanity/workflow-engine (no UI packages). I'll try it and keep it only if the desk type-checks cleanly.
$ cd /home/dev/sanity-challenge/ora2az/desk && npx tsc --noEmit && echo BASE-OK; cat src/config.ts; grep -n "CertifyButton" src/*.tsx; cp package.json package-lock.json /tmp/scratch/scratchpad/
BASE-OK
// Environment variables prefixed SANITY_APP_ are inlined into the browser
// bundle by the Sanity CLI (dev server and `sanity build`).
// https://www.sanity.io/docs/app-sdk/sdk-configuration#environment-variables
export const projectId: string = process.env.SANITY_APP_PROJECT_ID ?? ''
export const dataset: string = process.env.SANITY_APP_DATASET ?? 'production'
src/CertifyButton.tsx:10:interface CertifyButtonProps {
src/CertifyButton.tsx:16:export function CertifyButton({handle, status, hasObituary}: CertifyButtonProps) {
src/FeatureRow.tsx:3:import {CertifyButton} from './CertifyButton'
src/FeatureRow.tsx:62: <CertifyButton handle={handle} status={status} hasObituary={hasObituary} />
$ cd /home/dev/sanity-challenge/ora2az/desk && npm install --no-audit --no-fund @sanity/workflow-engine@0.35.0 2>&1 | tail -2 && cat > src/workflow.ts <<'EOF'
// Mirrors a desk certification onto the notice-lifecycle workflow (see ../../workflows).
// Advisory like everything in Workflows early access: the content write has already happened
// when this runs, and a missing instance is not an error.
import type {SanityClient} from '@sanity/client'
import {createEngine, ENGINE_API_VERSION} from '@sanity/workflow-engine'
import {dataset, projectId} from './config'
const DEFINITION = 'notice-lifecycle'
function engineFor(client: SanityClient) {
return createEngine({
client: client.withConfig({apiVersion: ENGINE_API_VERSION}),
workflowResource: {type: 'dataset', id: `${projectId}.${dataset}`},
tag: 'production',
})
}
/** Fire `certify` (fact-checked -> certified) or `retract` (certified -> drafted) on the feature's open instance. */
export async function syncWorkflow(client: SanityClient, documentId: string, next: 'certified' | 'draft'): Promise<string | null> {
const engine = engineFor(client)
const publishedId = documentId.replace(/^drafts\./, '')
const instances = await engine.instancesForDocument({document: `dataset:${projectId}:${dataset}:${publishedId}`})
const open = instances.find((i) => i.definition === DEFINITION)
if (!open) return null
if (next === 'certified' && open.currentStage === 'fact-checked') {
await engine.fireAction({instanceId: open._id, activity: 'certification', action: 'certify'})
return 'certified'
}
if (next === 'draft' && open.currentStage === 'certified') {
await engine.fireAction({instanceId: open._id, activity: 'retraction', action: 'retract', params: {reason: 'Un-certified in the desk'}})
return 'drafted'
}
return open.currentStage
}
EOF
python3 - <<'EOF'
p='src/CertifyButton.tsx'
s=open(p).read()
s=s.replace(""" publishDocument,
useApplyDocumentActions,""",""" publishDocument,
useApplyDocumentActions,
useClient,""")
s=s.replace("""import type {ReviewStatus} from './FeatureRow'
""","""import type {ReviewStatus} from './FeatureRow'
import {syncWorkflow} from './workflow'
""")
s=s.replace(""" const apply = useApplyDocumentActions()
""",""" const apply = useApplyDocumentActions()
const client = useClient({apiVersion: '2026-04-29'})
""")
s=s.replace(""" await result.submitted()
""",""" await result.submitted()
// Publish first, then move the workflow: entering `certified` deploys a guard that
// freezes the obituary, so the publish that carries it must land before that.
await syncWorkflow(client, handle.documentId, next).catch((err) => {
setError(`Saved, but the workflow did not move: ${err instanceof Error ? err.message : String(err)}`)
})
""")
open(p,'w').write(s)
EOF
git diff --stat .; npx tsc --noEmit && echo DESK-TSC-OK
added 14 packages, removed 6 packages, and changed 2 packages in 2s
desk/package-lock.json | 337 ++++++++++++++++++++++++++-------------------
desk/package.json | 1 +
desk/src/CertifyButton.tsx | 8 ++
3 files changed, 208 insertions(+), 138 deletions(-)
DESK-TSC-OK
Desk type-checks. Checking the lockfile churn and that the desk still builds.
$ cd /home/dev/sanity-challenge/ora2az/desk && git diff package-lock.json | grep -E '^[-+]\s+"node_modules/' | head -40; npm ls @sanity/client @sanity/mutate 2>&1 | head -20
+ "node_modules/@sanity/cli/node_modules/@sanity/groq-condition-describe": {
+ "node_modules/@sanity/cli/node_modules/@sanity/groq-condition-describe/node_modules/groq-js": {
+ "node_modules/@sanity/cli/node_modules/@sanity/workflow-cli": {
+ "node_modules/@sanity/cli/node_modules/@sanity/workflow-cli/node_modules/@sanity/client": {
+ "node_modules/@sanity/cli/node_modules/@sanity/workflow-engine": {
+ "node_modules/@sanity/cli/node_modules/@sanity/workflow-engine/node_modules/@sanity/client": {
+ "node_modules/@sanity/cli/node_modules/@sanity/workflow-engine/node_modules/@sanity/types": {
+ "node_modules/@sanity/cli/node_modules/@sanity/workflow-engine/node_modules/groq-js": {
+ "node_modules/@sanity/cli/node_modules/get-it": {
+ "node_modules/@sanity/cli/node_modules/nanoid": {
+ "node_modules/@sanity/cli/node_modules/readable-stream": {
+ "node_modules/@sanity/cli/node_modules/through2": {
- "node_modules/@sanity/workflow-cli": {
- "node_modules/@sanity/workflow-cli/node_modules/@sanity/client": {
- "node_modules/@sanity/workflow-cli/node_modules/get-it": {
- "node_modules/@sanity/workflow-cli/node_modules/nanoid": {
- "node_modules/@sanity/workflow-cli/node_modules/readable-stream": {
- "node_modules/@sanity/workflow-cli/node_modules/through2": {
ora2az-desk@1.0.0 /home/dev/sanity-challenge/ora2az/desk
├─┬ @sanity/sdk-react@3.5.0
│ ├── @sanity/client@8.7.0
│ └─┬ @sanity/types@6.16.0
│ └── @sanity/client@8.7.0 deduped
├─┬ @sanity/sdk@3.5.0
│ ├── @sanity/client@8.7.0 deduped
│ └─┬ @sanity/mutate@0.18.2
│ └── @sanity/client@8.7.0 deduped
├─┬ @sanity/workflow-engine@0.35.0
│ └─┬ @sanity/types@5.31.2
│ └── @sanity/client@7.27.0
└─┬ sanity@6.16.0
├─┬ @sanity/access-ui@6.16.0
│ └── @sanity/client@8.7.0 deduped
├─┬ @sanity/cli@8.13.0
│ ├─┬ @sanity/cli-core@3.8.1
│ │ └── @sanity/client@8.7.0 deduped
│ ├── @sanity/client@8.7.0 deduped
│ ├─┬ @sanity/import@7.0.4
$ cd /home/dev/sanity-challenge/ora2az/desk && ls -la dist | head -3; S=/tmp/scratch/scratchpad; SANITY_APP_PROJECT_ID=udyjvgsk SANITY_APP_DATASET=production timeout 280 npx vite --version >/dev/null 2>&1; cat sanity.cli.ts | head -30
$ cd /home/dev/sanity-challenge/ora2az/desk && S=/tmp/scratch/scratchpad; SANITY_APP_ORGANIZATION_ID=o8wfejrgc SANITY_APP_PROJECT_ID=udyjvgsk timeout 280 npx sanity build $S/deskbuild -y 2>&1 | sed 's/\x1b\[[0-9;]*m//g' | tail -15; du -sh $S/deskbuild 2>/dev/null
Including the following environment variables as part of the JavaScript bundle:
- SANITY_APP_ORGANIZATION_ID
- SANITY_APP_PROJECT_ID
- SANITY_APP_DATASET
- Clean output folder
✔ Clean output folder (0ms)
- Building Sanity application
✔ Build Sanity application (419ms)
1.3M /tmp/scratch/scratchpad/deskbuild
Desk builds. I'll let the desk agent know I touched its files, then write the README.
{"to":"desk-appsdk","summary":"Heads-up on desk file changes","message":"Heads-up: I edited desk files for the Workflows integration (not redeployed). desk/src/CertifyButton.tsx now calls syncWorkflow...
{
"success": true,
"message": "Message sent to desk-appsdk's inbox",
"msg_id": "7cd8912f-cf39-440f-85fa-57727dd984aa",
"routing": {
"sender": "workflows-builder",
"senderColor": "pink",
"target": "@desk-appsdk",
"targetColor": "purple",
"summary": "Heads-up on desk file changes",
"content": "Heads-up: I edited desk files for the Workflows i…"
}
}
$ cd /home/dev/sanity-challenge/ora2az/workflows && for i in 2e9f8b706af0 1739bb2d4543 3c0dea974deb; do npx sanity-workflows show production.wf-instance.$i --json 2>/dev/null | python3 -c "
import json,sys;d=json.load(sys.stdin);f={x['name']:x.get('value') for x in d['fields']}
print(d['_id'],d['currentStage'],f['subject']['id'].split(':')[-1],f['factCheck'],f['factCheckedAt'],'|',f['factCheckNote'])"; done; npx sanity-workflows show --help 2>&1 | grep -i include
production.wf-instance.2e9f8b706af0 fact-checked feature_xmltype passed 2026-09-28T01:30:16.338Z | Checked 1 release/ORA- mention(s) against the feature's data: 11.2. All supported.
production.wf-instance.1739bb2d4543 fact-checked feature_vector-datatype passed 2026-09-28T01:30:08.353Z | Checked 1 release/ORA- mention(s) against the feature's data: 23ai. All supported.
production.wf-instance.3c0dea974deb fact-checked feature_flashback-query passed 2026-09-28T01:29:59.549Z | Checked 1 release/ORA- mention(s) against the feature's data: 11.2. All supported.
$ sanity-workflows show INSTANCEID [--tag <value>] [--include
--include=<option>... [default: ] Optional sections to include in rendered
$ sanity-workflows show wf-instance.abc123 --include history
$ cd /home/dev/sanity-challenge/ora2az/workflows && npx sanity-workflows show production.wf-instance.2e9f8b706af0 --include history 2>&1 | sed 's/\x1b\[[0-9;]*m//g' | tail -25
notice-lifecycle v1 production.wf-instance.2e9f8b706af0
Stage: fact-checked
Started: 2026-09-27 20:28:26
Completed: —
Tag: production
Stages:
• drafted (exited 2026-09-27 20:30:17)
✔ fact-check [done]
• fact-checked (current)
● certification [active]
History:
[2026-09-27 20:28:26] Stage entered
[2026-09-27 20:30:16] Action fired
[2026-09-27 20:30:16] Op applied
[2026-09-27 20:30:16] Op applied
[2026-09-27 20:30:16] Op applied
[2026-09-27 20:30:16] Op applied
[2026-09-27 20:30:16] Activity status changed
[2026-09-27 20:30:16] Op applied
[2026-09-27 20:30:17] Stage exited
[2026-09-27 20:30:17] Transition fired
[2026-09-27 20:30:17] Stage entered
Writing the README.
$ cat > /home/dev/sanity-challenge/ora2az/workflows/README.md <<'EOF'
# Workflows: the obituary notice lifecycle
A Sanity Workflows definition (`notice-lifecycle`) that tracks each Legacy Obituary from draft to certified. It runs on `@sanity/workflow-engine` and `@sanity/workflow-cli` 0.35.0, which are early access. It is deployed to project `udyjvgsk`, dataset `production`, under deployment and tag `production`.
The workflow does not replace the desk. The desk still does the content write: it sets `reviewStatus` and publishes. The workflow adds three things on top.
- **An automated fact-check gate.** A notice cannot reach `fact-checked` until the fact-check script passes it.
- **A recorded history.** Every stage, action, operation and actor is stored on the instance.
- **A freeze on certified text.** A guard freezes the obituary while the notice sits in `certified`.
## Stages
```
fact-check/fail (note; stays here, activity stays open)
+------+
| v
start ----> [ drafted ] --fact-check/pass--> [ fact-checked ] --certification/certify--> [ certified ]
^ ^ | |
| +---certification/send-back----+ |
+--------------------------------retraction/retract--------------------------+
certified: guard "freeze-obituary" on the feature (draft edits and publishes)
allows a write only if it leaves `obituary` unchanged.
```
| Stage | Activity | Actions | Fired by |
| --- | --- | --- | --- |
| `drafted` | `fact-check` | `pass` (note), `fail` (note) | `scripts/fact-check.mjs` |
| `fact-checked` | `certification` | `certify`, `send-back` (reason) | an editor: the desk Certify button, or the CLI |
| `certified` | `retraction` | `retract` (reason) | an editor: the desk Un-certify button, or the CLI |
- **The subject** is the published `oracleFeature` document. The engine rejects draft ids as subjects. The fact check reads that document's draft, where the pending obituary lives.
- **One open notice per feature.** A `singleSubject` start requirement blocks a second in-flight instance for the same feature.
- **`fail` leaves the activity open.** The instance stays in `drafted` with `factCheck = "failed"` and the note, and the next run of the script can pass it.
- **`certified` is deliberately not terminal.** A terminal stage completes the instance, and a stage's guards live only while an instance occupies it. Keeping `retract` available keeps the freeze in force.
- **Routing reads fields.** Decisions are written to the `decision` field and transitions read it. `pass` clears an old decision so a returning notice is not bounced straight back.
## Files
- `definitions/notice-lifecycle.ts` holds the definition.
- `sanity.workflow.ts` binds it to `udyjvgsk.production`, tag `production`, and acknowledges reader model 10, which a required subject needs.
- `scripts/fact-check.mjs` runs the fact check and fires `pass` or `fail`.
- `test/notice-lifecycle.test.ts` covers every path with the in-memory test bench: fail then pass, certify with the guard denying an obituary edit and allowing others, send-back, and retract then re-certify.
- `../desk/src/workflow.ts` mirrors desk certification onto the workflow. See "Desk integration" below.
## Deploy
```sh
cd workflows
npm install
npm test # in-memory bench, no network
npx sanity-workflows deploy --check # validate offline
npx sanity-workflows deploy --deployment production --dry-run
npx sanity-workflows deploy --deployment production
```
The CLI authenticates with your `sanity login` session, or with `SANITY_AUTH_TOKEN` in CI.
## Start a notice and run the fact check
Start an instance with the published feature id:
```sh
npx sanity-workflows start notice-lifecycle --deployment production \
--field subject='{"id":"dataset:udyjvgsk:production:feature_xmltype","type":"oracleFeature"}'
```
Then run the fact check:
```sh
node scripts/fact-check.mjs # every in-flight notice waiting in `drafted`
node scripts/fact-check.mjs --dry # report only
node scripts/fact-check.mjs production.wf-instance.2e9f8b706af0
```
The rule is that every Oracle release and every ORA-nnnnn code in the obituary must appear in the feature's own data. Releases are names like 12.2, 19c or 26ai. The feature's own data is its name, release fields, summary and in-Oracle replacement. It also includes its mappings' rationale and steps, the caveats on those mappings, and the disputes involving them. The script reads with GROQ and fires the action through `sanity-workflows fire-action`, so the action is attributed to the logged-in user. It reads drafts, so it needs `SANITY_WRITE_TOKEN` or `SANITY_READ_TOKEN` from the environment or `~/.config/ora2az/env`.
The same rule was run in dry mode against the 47 published certified obituaries, and all 47 passed.
## Certify
Certification is a human act. In the desk, Certify sets `reviewStatus` to certified and publishes. It then fires `certification/certify` on the feature's open instance, but only when that instance is in `fact-checked`. Un-certify fires `retraction/retract` when the instance is in `certified`. From a terminal, run:
```sh
npx sanity-workflows fire-action <instanceId> # list what can be fired
npx sanity-workflows fire-action <instanceId> --activity certification --action certify
npx sanity-workflows fire-action <instanceId> --activity certification --action send-back --param reason='"tone"'
```
The order matters. Publish first, then fire `certify`. Entering `certified` deploys the freeze guard, and the publish that carries the new obituary would change `obituary`.
## Current state (2026-09-27)
`notice-lifecycle` v1 is deployed. The three queued drafts each have an instance. The fact check passed all three, and they are waiting in `fact-checked` for a human to certify. The script has not certified or published anything.
```
$ npx sanity-workflows definition list
workflow title tag stages in flight instances created
notice-lifecycle v1 Obituary notice lifecycle production 3 3 3 2026-09-28T01:28:17Z
$ npx sanity-workflows list
instance workflow tag stage status updated
production.wf-instance.2e9f8b706af0 notice-lifecycle production fact-checked in-flight 14 seconds ago
production.wf-instance.1739bb2d4543 notice-lifecycle production fact-checked in-flight 22 seconds ago
production.wf-instance.3c0dea974deb notice-lifecycle production fact-checked in-flight 30 seconds ago
$ node scripts/fact-check.mjs
production.wf-instance.3c0dea974deb (feature_flashback-query): PASS - Checked 1 release/ORA- mention(s) against the feature's data: 11.2. All supported.
-> now at fact-checked
production.wf-instance.1739bb2d4543 (feature_vector-datatype): PASS - Checked 1 release/ORA- mention(s) against the feature's data: 23ai. All supported.
-> now at fact-checked
production.wf-instance.2e9f8b706af0 (feature_xmltype): PASS - Checked 1 release/ORA- mention(s) against the feature's data: 11.2. All supported.
-> now at fact-checked
$ npx sanity-workflows show production.wf-instance.2e9f8b706af0 --include history
notice-lifecycle v1 production.wf-instance.2e9f8b706af0
Stage: fact-checked
Started: 2026-09-27 20:28:26
Completed: —
Tag: production
Stages:
• drafted (exited 2026-09-27 20:30:17)
✔ fact-check [done]
• fact-checked (current)
● certification [active]
History:
[2026-09-27 20:28:26] Stage entered
[2026-09-27 20:30:16] Action fired
[2026-09-27 20:30:16] Op applied
[2026-09-27 20:30:16] Op applied
[2026-09-27 20:30:16] Op applied
[2026-09-27 20:30:16] Op applied
[2026-09-27 20:30:16] Activity status changed
[2026-09-27 20:30:16] Op applied
[2026-09-27 20:30:17] Stage exited
[2026-09-27 20:30:17] Transition fired
[2026-09-27 20:30:17] Stage entered
$ npx sanity-workflows fire-action production.wf-instance.2e9f8b706af0
Actions on stage 'fact-checked':
✔ certification → certify (Certify) [activity active]
✔ certification → send-back (Send back to draft) [activity active]
params: reason:string
```
| Instance | Feature | Stage | Fact check |
| --- | --- | --- | --- |
| `production.wf-instance.2e9f8b706af0` | `feature_xmltype` | fact-checked | passed, mentions 11.2 |
| `production.wf-instance.1739bb2d4543` | `feature_vector-datatype` | fact-checked | passed, mentions 23ai |
| `production.wf-instance.3c0dea974deb` | `feature_flashback-query` | fact-checked | passed, mentions 11.2 |
The CLI prints local times, which are UTC-5 here. Stored timestamps are UTC.
## Desk integration
`desk/src/workflow.ts` builds an engine from the App SDK client with `createEngine`. It finds the feature's open instance with `engine.instancesForDocument` and fires `certify` or `retract` after the desk's publish. This adds one dependency, `@sanity/workflow-engine` 0.35.0. It type-checks with `npx tsc --noEmit`. **The live desk has not been redeployed**, so it does not do this yet. Until it is, fire `certify` from the CLI after certifying in the desk.
The docs also describe a richer route: `@sanity/workflow-sdk` with `useWorkflowSession` renders the stage, activities and action verdicts live in an App SDK app. That needs `@sanity/workflow-react`, `@sanity/workflow-components`, `@sanity/ui` and `styled-components`, so it was left out.
## Limitations
- **Early access.** This is on 0.x packages, and a minor version can break the API. The packages are pinned to exactly 0.35.0. Stored instances are pinned to definition v1, so redeploying creates v2 and leaves these instances on v1.
- **Nothing is enforced against a direct writer.** The docs say every engine check is advisory, and the Content Lake does not enforce guard documents yet. The freeze is honored by the engine and by the Studio Workflows plugin, which this project does not install. The App SDK desk does not read guards, so it can still change a frozen obituary. The real hard boundary is dataset access control.
- **The gate is not a lock on publishing.** Nothing stops someone publishing an obituary that never passed the fact check. The workflow records whether it did.
- **The workflow is not wired to all 47 certified obituaries.** They were certified before it existed and have no instances.
- **Nothing runs in the background.** The engine is a library. The fact check runs when someone runs the script. No Sanity Function drains effects or ticks instances, and this definition declares no effects or clock conditions, so none is needed.
- **The fact check is narrow.** It checks release names and ORA- codes only, not other claims. It does not recognise forms like "12cR2" or "Release 2".
- **Workflow state lives in the content dataset.** Its ids contain a dot, so public, unauthenticated queries do not return them. A public count of `sanity.workflow*` documents returns 0.
- **Definition sharing was on**, which is the CLI default. Deploying sent the definition, which is public in this repo anyway, to Sanity. Use `--no-share-defs` to opt out.
EOF
cd /home/dev/sanity-challenge/ora2az && git status --short
M desk/package-lock.json
M desk/package.json
M desk/src/CertifyButton.tsx
M workflows/definitions/notice-lifecycle.ts
M workflows/package-lock.json
M workflows/package.json
M workflows/tsconfig.json
?? desk/src/workflow.ts
?? workflows/README.md
?? workflows/scripts/
?? workflows/test/
Some workflow files show as modified, so someone already committed an earlier version of them. Checking.
$ git log --oneline -6 --stat | head -40; git status -sb | head -2
f0cbc60 press: walkthrough with certified obituaries
press/site-walkthrough.gif | Bin 9841250 -> 11868324 bytes
1 file changed, 0 insertions(+), 0 deletions(-)
47efaef press: desk queue, Coroner screenshots
press/coroner-index.png | Bin 0 -> 755820 bytes
press/coroner-q13.png | Bin 0 -> 987925 bytes
press/desk-queue.png | Bin 0 -> 179203 bytes
3 files changed, 0 insertions(+), 0 deletions(-)
3577829 app: say precisely which text is authored, drafted, and certified
app/src/app/about/page.tsx | 2 +-
app/src/app/layout.tsx | 2 +-
workflows/definitions/notice-lifecycle.ts | 176 ++
workflows/package-lock.json | 3302 +++++++++++++++++++++++++++++
workflows/package.json | 19 +
workflows/sanity.workflow.ts | 18 +
workflows/tsconfig.json | 12 +
7 files changed, 3529 insertions(+), 2 deletions(-)
3e63b9c Coroner's Reports (replay of all 72 eval runs), 14 dataset tests, second-vendor grader, build log, phone layout fix
agent/eval/export_coroner.py | 50 +++++++++++++++++++++++
agent/eval/grade_second.mjs | 59 +++++++++++++++++++++++++++
app/package-lock.json | 13 ++++++
app/package.json | 1 +
app/src/app/coroner/[id]/page.tsx | 84 +++++++++++++++++++++++++++++++++++++++
app/src/app/coroner/page.tsx | 59 +++++++++++++++++++++++++++
app/src/app/globals.css | 25 +++++++++++-
app/src/app/layout.tsx | 1 +
app/src/data/coroner.json | 1 +
app/src/lib/coroner.ts | 15 +++++++
app/tsconfig.json | 16 ++++++--
content/package.json | 5 ++-
content/test/dataset.test.mjs | 77 +++++++++++++++++++++++++++++++++++
docs/build-log.md | 20 ++++++++++
14 files changed, 419 insertions(+), 7 deletions(-)
cecc963 app: exact-fidelity epitaph reads 'lives on unchanged'
app/src/components/Badge.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
92e4d58 build: studio @types/node; app layout typed without generated route types (fresh clones type-check)
app/src/app/layout.tsx | 3 ++-
studio/package-lock.json | 18 ++++++++++++++++++
studio/package.json | 1 +
## main...origin/main
M desk/package-lock.json
An earlier commit by another agent swept in my first drafts of four workflow files. My commit completes them. Checking for disallowed names, then committing only my files.
$ grep -rniE "claude|anthropic|openai|gpt|gemini|llm|microsoft|google|github" workflows --include=*.ts --include=*.mjs --include=*.md --include=*.json --exclude-dir=node_modules desk/src/workflow.ts desk/src/CertifyButton.tsx | grep -v package-lock | head; git add workflows desk/package.json desk/package-lock.json desk/src/CertifyButton.tsx desk/src/workflow.ts && git status --short && git -c user.name=pyaroslav -c user.email=<personal-email> commit -q -m "workflows: notice-lifecycle definition (drafted -> fact-checked -> certified, retract, obituary freeze guard), fact-check script, bench tests, desk certify sync" && git push 2>&1 | tail -2; git log --oneline -1
M desk/package-lock.json
M desk/package.json
M desk/src/CertifyButton.tsx
A desk/src/workflow.ts
A workflows/README.md
M workflows/definitions/notice-lifecycle.ts
M workflows/package-lock.json
M workflows/package.json
A workflows/scripts/fact-check.mjs
A workflows/test/notice-lifecycle.test.ts
M workflows/tsconfig.json
To https://github.com/pyaroslav/ora2az.git
f0cbc60..88a3f8c main -> main
88a3f8c workflows: notice-lifecycle definition (drafted -> fact-checked -> certified, retract, obituary freeze guard), fact-check script, bench tests, desk certify sync
Pushed. Last check: confirming the deployed definition matches the committed code.
$ cd /home/dev/sanity-challenge/ora2az/workflows && npx sanity-workflows definition diff --deployment production 2>&1 | sed 's/\x1b\[[0-9;]*m//g' | tail -5
at Help.showHelp (/home/dev/sanity-challenge/ora2az/workflows/node_modules/@oclif/core/lib/help/index.js:269:27)
at handle (/home/dev/sanity-challenge/ora2az/workflows/node_modules/@oclif/core/lib/errors/handle.js:46:28)
at /home/dev/sanity-challenge/ora2az/workflows/node_modules/@oclif/core/lib/execute.js:60:53
at process.processTicksAndRejections (node:internal/process/task_queues:103:5)
at async file:///home/dev/sanity-challenge/ora2az/workflows/node_modules/@sanity/workflow-cli/bin/run.js:6:1
$ cd /home/dev/sanity-challenge/ora2az/workflows && npx sanity-workflows deploy --deployment production --dry-run 2>&1 | sed 's/\x1b\[[0-9;]*m//g' | tail -4
Summary:
unchanged notice-lifecycle v1 (production.notice-lifecycle.v1)
✔ no changes — running deploy would be a no-op
{"to":"team-lead","summary":"Workflows deployed; 3 instances at fact-checked","message":"The notice-lifecycle workflow is deployed to udyjvgsk.production, and all three queued drafts are waiting in fa...
{
"success": true,
"message": "Message sent to team-lead's inbox",
"msg_id": "3a008ca9-68b4-466a-8b7d-9e9bf0fa3af7",
"routing": {
"sender": "workflows-builder",
"senderColor": "pink",
"target": "@team-lead",
"targetColor": "red",
"summary": "Workflows deployed; 3 instances at fact-checked",
"content": "The notice-lifecycle workflow is deployed to udyj…"
}
}
The notice-lifecycle workflow is live on Sanity project udyjvgsk, dataset production. All three queued drafts have instances, passed the fact check, and are waiting in fact-checked for a human to certify. Nothing was certified or published. The work is pushed to GitHub as commit 88a3f8c. Workflows was available on this org with no gating or permission errors.
What was deployed. The workflow runs drafted, then fact-checked, then certified. A retract action returns a certified notice to drafted. A guard freezes the obituary text while a notice sits in certified. That stage is deliberately not terminal. A terminal stage would complete the instance and remove the freeze. A second deploy in dry-run mode reports no changes, so the live definition matches the committed code.
Instance states from the workflows CLI:
| Instance | Feature | Stage |
|---|---|---|
production.wf-instance.2e9f8b706af0 |
feature_xmltype |
fact-checked |
production.wf-instance.1739bb2d4543 |
feature_vector-datatype |
fact-checked |
production.wf-instance.3c0dea974deb |
feature_flashback-query |
fact-checked |
Desk integration. The desk's Certify button now fires the workflow's certify action after it publishes. Un-certify fires retract. It adds one package, the desk type-checks, and a test build succeeds. The desk was not redeployed, so the live desk does not do this yet. I told the desk agent about the edit.
Doc gaps I resolved from the package source and tests:
- The workflow's subject must be the published document id, so the fact check reads the draft itself.
- An optional action parameter left out crashes the action. The tests caught this before deploy, so the fact-check note is now required.
- The workflows CLI needs TypeScript 6 or later.
- The documented definition diff command crashed, so I used a dry-run deploy for the drift check.
Limitations, all stated in the README:
- Advisory only. The docs say every engine check and guard is advisory during early access.
- The desk ignores the freeze. It does not read guards, so it can still change a frozen obituary.
- Publishing is not blocked. An obituary can still be published without passing the fact check. The workflow records the gate but does not enforce it.
- Order matters. Publish first, then fire certify, because entering certified switches on the freeze.
- Older obituaries are not tracked. The 47 already-certified obituaries have no workflow instances.
- The definition was shared with Sanity. Definition sharing was left at the CLI default, which is on, so deploying shared the workflow definition with Sanity.
Another agent's commit. It swept in my first drafts of four workflow files while I was still working. My commit completes them.
Files are in [REDACTED]/sanity-challenge/ora2az/:
- workflows/definitions/notice-lifecycle.ts
- workflows/sanity.workflow.ts
- workflows/scripts/fact-check.mjs
- workflows/test/notice-lifecycle.test.ts
- workflows/README.md
- workflows/package.json
- desk/src/workflow.ts
- desk/src/CertifyButton.tsx
<teammate-message teammate_id="team-lead" summary="Rebase before pushing; repo neutrality rule">
Heads-up before you commit: I force-pushed main a moment ago (history rewritten to 3e63b9c). Before your first commit run git fetch origin && git reset --keep origin/main if your local main is behind (you have not committed yet, so this only moves the pointer), then commit and push normally — never force-push. Also, repo rule: no committed file, path or commit message may contain the words "claude" or "anthropic" (case-insensitive), including in file names like CLAUDE.md; run git grep -Iil -E 'claude|anthropic' -- ':!**/package-lock.json' before each commit and fix any hit.
</teammate-message>
My commit was already pushed before this message arrived, so I'll check where it sits against the rewritten main.
$ git fetch origin 2>&1 | tail -2; git log --oneline -8 origin/main; echo ---; git log --oneline -3 main; git merge-base --is-ancestor 88a3f8c origin/main && echo "88a3f8c IS on origin/main"; git grep -Iil -E 'claude|anthropic' origin/main -- ':!**/package-lock.json'; git log origin/main --format=%B | grep -ci -E 'claude|anthropic'
88a3f8c workflows: notice-lifecycle definition (drafted -> fact-checked -> certified, retract, obituary freeze guard), fact-check script, bench tests, desk certify sync
f0cbc60 press: walkthrough with certified obituaries
47efaef press: desk queue, Coroner screenshots
3577829 app: say precisely which text is authored, drafted, and certified
3e63b9c Coroner's Reports (replay of all 72 eval runs), 14 dataset tests, second-vendor grader, build log, phone layout fix
cecc963 app: exact-fidelity epitaph reads 'lives on unchanged'
92e4d58 build: studio @types/node; app layout typed without generated route types (fresh clones type-check)
d8f8357 docs: note Studio needs a Sanity login; dataset is public
---
88a3f8c workflows: notice-lifecycle definition (drafted -> fact-checked -> certified, retract, obituary freeze guard), fact-check script, bench tests, desk certify sync
f0cbc60 press: walkthrough with certified obituaries
47efaef press: desk queue, Coroner screenshots
88a3f8c IS on origin/main
0
{"to":"team-lead","summary":"Commit already on top of rewritten main; clean","message":"My workflows commit 88a3f8c was pushed before your message arrived, and it already sits on top of the rewritten ...
{
"success": true,
"message": "Message sent to team-lead's inbox",
"msg_id": "cb71c7a3-1dad-4420-850e-5979233e4ea6",
"routing": {
"sender": "workflows-builder",
"senderColor": "pink",
"target": "@team-lead",
"targetColor": "red",
"summary": "Commit already on top of rewritten main; clean",
"content": "My workflows commit 88a3f8c was pushed before you…"
}
}
My workflows commit, 88a3f8c, was pushed before the team lead's heads-up arrived, and it already sits on top of the rewritten main. Nothing needed resetting or fixing.
I've told the team lead all of this and have no further commits pending.