Run Manifest
A Manifest is a JSON document that records what one attempt of a workflow run did: its state, every node's status, row counts and timing, route branch counts, reject totals, the files written, and the parameters the run was started with. Put a Manifest node on the canvas, point it at a file destination, and connect nodes into it. The Manifest covers the nodes you connect and everything upstream of them. When the run ends, Precept writes one document per Manifest node.
The document holds counts, names, types, timings, paths and client-safe error text. It never holds source data. Row values go only in the reject files.
Where it is written
The Manifest node's path is a template. Its tokens are:
| Token | Value |
|---|---|
{{runId}} | The run's ID. |
{{attempt}} | The attempt number, starting at 1. |
{{timestamp}} | The run's admission time. It is the same for every attempt of a run. |
The default is manifests/{{runId}}/attempt-{{attempt}}.json. An unknown token is an error.
Document fields
The schema is manifest.json (version 1.0).
| Field | Meaning |
|---|---|
$schema | The schema URL. |
schemaVersion | "1.0". |
manifest | The Manifest node's nodeId and title. |
run | id, attempt, workflowId (null when unsaved), startedBy (null when unknown), trigger (rerun for an attempt after the first, else watcher for a run a file watcher started, else api; ui is reserved and not sent in 1.0), engine, admittedAt (attempt 1's start, the same for every attempt), startedAt and endedAt (this attempt), state (completed, failed or cancelled) and error (client-safe text or null). |
attempts | Every attempt of the run, including this one: attempt, state, startedAt, endedAt. |
restarts | Worker restarts inside this attempt: generation, at, reason. |
parameters | The parameters the run was started with. |
scope | nodeIds: the nodes the Manifest covers. |
totals | nodes, nodesNotRun, rowsWritten and rejects, summed over the nodes in scope. |
nodes | One entry per node in scope, in execution order. See below. |
stages | The stages that contain at least one node in scope: index, status, nodeIds, ms, peakBytes, spillBytes, blocksDropped and error. |
leftovers | What the run left behind: runDatabase ({ expiresAt } while the run database is kept, or null), tempPaths, stagingTables. This covers the whole run, not only the nodes in scope. |
Node entries
Every node in scope has an entry, so a node never goes missing without notice. An entry has these common fields:
| Field | Meaning |
|---|---|
id, title | The node's ID and title. |
type | The node's role. It is one of the types below, not the canvas type, so you never inspect operation. |
status | completed, failed, cancelled or not_run. |
stage | The stage index. A not_run node has the stage it was planned to run in. |
rows, approximate | The output row count (null when unknown) and whether it is approximate. |
model | The model the node's output rows conform to, by its canonical PDM name vendor/name/Model. Null when the node has no model, or when it cannot be named unambiguously. |
schema | The node's output columns inline, each { name, type }, for a node with no model. It is set exactly when model is null and the node ran in this attempt. It is null for a modeled node (you look its columns up by model), a not_run node, or a node with no recorded figures. An entry never has both a model and a schema. |
timing | ms and scope. A scope of stage means the node shares a fused SQL stage, and ms is the stage's time. A scope of node means the time is the node's own. |
blocks | Timed blocks of work, each { name, startedAt, ms, attrs }. At most 100 per stage. stages[].blocksDropped counts the overflow. |
rejects | total, byRule, file and fileStatus. See Reject files. |
error | Client-safe error text or null. |
details | The type's own fields. |
A not_run node has rows: null and every details field null. Where a field is an array, it is empty instead.
A node's model is resolved when the workflow is compiled:
- A read has the model it was cast to: the model it names, or its read mapping's target model when it maps.
- A read that names neither is cast to the model of its source's file-type rules, and has that model. Each path is matched against the enabled rules in order, as the engine matches them, and the first rule it matches gives its
modelId. The read has that model when every path's rule names the same one. It is null, and the read detects its columns, when the rules name different models, when a path matches no rule or a rule with nomodelId, or when a path is known only at run time: a templated path, a glob, a path expression, or the file a watcher reports. - A transform has its output model. A
callActionordiffActivitynode has the model it names. - A write, route or loop has the model of its upstream node. It is null when its inputs have different models, or it has none. One modeled input and one unmodeled input count as different.
- A node inside a loop body that no edge inside the body leads to has the model of the loop's input, by the same rule.
- A value, expression, file watcher, webhook or API call node has null.
- A model named without its module (a bare name) is qualified only when exactly one loaded module defines it. Otherwise it is null.
A workflow that names a model no installed module defines does not compile: the node shows the error Model "vendor/name/Model" is not installed. (or Model "Name" matches more than one installed module. for an ambiguous bare name), and the run is refused before it starts, so a Manifest only ever names installed models.
A node with a null model that ran carries its columns in schema instead.
Each details shape has its own schema. The nodes array is a union on type.
The details objects are closed (additionalProperties: false). Every other object in the document is open. A new details field is therefore a minor version bump, and a 1.0 validator rejects a document that has one. Validate against the schema version you target.
read
A dataSourceActivity node whose operation is read. Schema: nodes/read.json.
| Field | Type | Meaning |
|---|---|---|
source | { dataSourceId, kind } or null | The data source the node read from. kind is a plain string such as s3. |
files | array | Each file consumed, with its path and, when the file had a trailer, trailer { removed, declared, actual } (declared and actual are null when unknown). Empty when nothing was read. |
decrypted | boolean or null | Whether the run read any decrypted input. It is plan-wide: when any read in the run is decrypted, every read entry has true. A run that read decrypted input never gets a reject rows file (withheld). |
onCastFailure | string or null | What the read did with a value that does not cast to its model's type: fail (the run fails), null (the value is NULL) or reject (a reject row, see Reject files). Null when the read is not cast to a model. |
{
"source": {
"dataSourceId": "ds_s3",
"kind": "s3"
},
"files": [
{
"path": "in/custodian.txt",
"trailer": {
"removed": 1,
"declared": 1200,
"actual": 1200
}
}
],
"decrypted": false,
"onCastFailure": "reject"
}write
A dataSourceActivity node whose operation is write. Schema: nodes/write.json.
| Field | Type | Meaning |
|---|---|---|
target | string or null | Where the output was published. |
format | string or null | The output format, such as csv. |
publish | rename, object, table or null | How the output was published. |
rows | number or null | Rows written; null when the count was lost. |
writtenInAttempt | number or null | The attempt that wrote the output. |
skippedOnResume | boolean or null | True when this attempt skipped the write because an earlier attempt had already done it. |
{
"target": "s3://bucket/out.csv",
"format": "csv",
"publish": "object",
"rows": 800,
"writtenInAttempt": 2,
"skippedOnResume": false
}transform
A transform node. Schema: nodes/transform.json.
| Field | Type | Meaning |
|---|---|---|
evaluatedAs | sql, js or null | How the transform ran. |
jsRules | array | One entry { rule: "*", reason } when the transform ran as JavaScript: the * stands for all its rules, and reason says why it could not run as SQL. It does not list rules one by one. Empty when the transform ran as SQL. |
{
"evaluatedAs": "sql",
"jsRules": []
}route
A route node. Schema: nodes/route.json.
| Field | Type | Meaning |
|---|---|---|
branches | object or null | Rows per branch handle. |
evaluatedAs | sql, js or null | How the conditions ran. |
{
"branches": {
"schwab": 800,
"default": 400
},
"evaluatedAs": "sql"
}callAction
A callAction or activity node. Schema: nodes/callAction.json.
| Field | Type | Meaning |
|---|---|---|
module | string or null | The module called, as vendor/name. Null when the call names no qualified model. |
action | string or null | The action called, as Model.action. Null when the call names no qualified model. |
calls | number or null | Requests this stage run sent; null where the stage cannot count them. An item whose parameters did not resolve sends none. |
failedCalls | number or null | Calls that failed. |
ledgerAnswered | number or null | Calls the call ledger answered from its record, without sending the request again: those an earlier worker of the same attempt made before it was lost. |
driverRows | number or null | Rows that drove the calls. |
{
"module": "acme/crm",
"action": "Account.lookup",
"calls": 8,
"failedCalls": 0,
"ledgerAnswered": 0,
"driverRows": 800
}loop
A loop node. Schema: nodes/loop.json.
| Field | Type | Meaning |
|---|---|---|
iterations | number or null | Items the loop handled in this attempt: the requests it sent (the one that failed included) plus the calls the ledger answered, so a resumed loop still counts the items an earlier worker handled. An item whose parameters did not resolve is not counted. An input path can make one input row several items. Null when the loop body runs as a control step rather than as one API step. |
{
"iterations": 800
}diff
A diffActivity node. Schema: nodes/diff.json.
| Field | Type | Meaning |
|---|---|---|
base | string or null | The base file path as configured on the node, before any template or source path is applied. |
added | number or null | Rows in the target file whose key the base file lacks. |
removed | number or null | Rows in the base file whose key the target file lacks. |
changed | number or null | Rows whose key is in both files with different values. |
The counts cover every difference between the files, even when the node's limit or offset keeps only some of the rows. They are null when the stage could not count them.
{
"base": "out/holdings-yesterday.csv",
"added": 500,
"removed": 100,
"changed": 200
}fileWatcher
A fileWatcher node. Schema: nodes/fileWatcher.json.
| Field | Type | Meaning |
|---|---|---|
matchedFiles | array of strings | The files that triggered the run, by the path the watcher reported. An encrypted file is listed by its own path, never by its decrypted copy. Empty when no file triggered the run. |
{
"matchedFiles": ["in/custodian.txt"]
}ingress
A webhook or apiCall node. details is always {}. Schema: nodes/ingress.json.
{}value
A value node. details is always {}. Schema: nodes/value.json.
{}expression
An expression node. details is always {}. Schema: nodes/expression.json.
{}Attempts and reruns
A run that failed, or was cancelled or stopped, can be rerun with POST /workflow/runs/:runId/rerun. A completed or running run cannot. A rerun is a new attempt of the same run: it keeps the run's ID, its parameters and its identity, so run.startedBy is unchanged. run.admittedAt and the {{timestamp}} path token stay the same across attempts.
Each attempt writes its own document, so attempt-1.json stays as it was and attempt-2.json shows the resumed run. attempts lists every attempt so far.
A write node whose output an earlier attempt already wrote is not written again. Its entry has status: "completed" and skippedOnResume: true, and writtenInAttempt says which attempt wrote it.
Reject files
Each node in scope with at least one reject gets its own file, at the Manifest path without .json, followed by .rejects.<nodeId>.ndjson. For example, manifests/workflow-abc123/attempt-2.rejects.read1.ndjson. A node with no rejects gets no file.
Each line is a JSON object with the columns rule, target, reason, source_ordinal and source_key. source_key is a JSON object. The node ID is implied by the file name.
A read of a CSV file with a model is cast to it. What happens to a value that does not cast to its column's type is the read node's "On cast failure" setting (onCastFailure):
- Fail the run (
fail, the default): the run fails at the first such value, naming the column and the line of the file. - Set NULL (
null): the value is NULL in the output and the run goes on. No reject is recorded. - Report rejects (
reject): the value is NULL in the output, the row is kept, and it is a reject with reasoncast_failed,targetthe column andrulethe kind of value (for exampleas a number). The file is read twice: first for its rejects, then for its output. When more of a column's rows fail than the node's "Fail above" limit (maxRejectRate, a number from 0 to 1, default 0.5) allows, the run fails before anything is written, with an error such asColumn "amount" of "Read trades": 60% of 10 rows could not be cast to DECIMAL(18, 2) (limit 50%). 0 fails on any value that does not cast. The reject rows of a run that failed this way are still written, and its Manifest counts them.
The node's entry references the file in rejects.file (path, format, rows). rejects.fileStatus says why there is, or is not, a file:
fileStatus | Meaning |
|---|---|
written | The file was written. |
none | The node had no rejects. |
disabled | The Manifest node has "Write reject rows" turned off. |
unavailable | The rows could not be read: the run database was gone, or the node did not run, so its rejects are unknown. |
withheld | The run read decrypted input, so reject rows are not written to a new plaintext file. The counts are still written. |
failed | Writing the file failed. |
Worked example
This document covers one node of every type and validates against manifest.json.
{
"$schema": "https://docs.precept.sh/schemas/run-manifest/1.0/manifest.json",
"schemaVersion": "1.0",
"manifest": {
"nodeId": "m1",
"title": "Nightly manifest"
},
"run": {
"id": "workflow-abc123",
"attempt": 2,
"workflowId": "wf_nightly",
"startedBy": "auth0|user1",
"trigger": "rerun",
"engine": "v2",
"admittedAt": "2026-10-21T01:00:00.000Z",
"startedAt": "2026-10-21T01:20:00.000Z",
"endedAt": "2026-10-21T01:21:05.000Z",
"state": "completed",
"error": null
},
"attempts": [
{
"attempt": 1,
"state": "failed",
"startedAt": "2026-10-21T01:00:00.000Z",
"endedAt": "2026-10-21T01:05:00.000Z"
},
{
"attempt": 2,
"state": "completed",
"startedAt": "2026-10-21T01:20:00.000Z",
"endedAt": "2026-10-21T01:21:05.000Z"
}
],
"restarts": [
{
"generation": 2,
"at": "2026-10-21T01:20:30.000Z",
"reason": "worker_lost"
}
],
"parameters": {
"asOf": "2026-10-20"
},
"scope": {
"nodeIds": [
"hook1",
"watch1",
"read1",
"map1",
"route1",
"call1",
"loop1",
"diff1",
"val1",
"expr1",
"sink1"
]
},
"totals": {
"nodes": 11,
"nodesNotRun": 0,
"rowsWritten": 800,
"rejects": 0
},
"nodes": [
{
"id": "hook1",
"title": "Inbound webhook",
"stage": 0,
"status": "completed",
"rows": 1,
"approximate": false,
"model": null,
"schema": [
{
"name": "body",
"type": "JSON"
}
],
"timing": {
"ms": 120,
"scope": "node"
},
"blocks": [],
"rejects": {
"total": 0,
"byRule": {},
"file": null,
"fileStatus": "none"
},
"error": null,
"type": "ingress",
"details": {}
},
{
"id": "watch1",
"title": "Custodian drop",
"stage": 0,
"status": "completed",
"rows": null,
"approximate": false,
"model": null,
"schema": [
{
"name": "path",
"type": "VARCHAR"
}
],
"timing": {
"ms": 120,
"scope": "node"
},
"blocks": [],
"rejects": {
"total": 0,
"byRule": {},
"file": null,
"fileStatus": "none"
},
"error": null,
"type": "fileWatcher",
"details": {
"matchedFiles": [
"in/custodian.txt"
]
}
},
{
"id": "read1",
"title": "Custodian file",
"stage": 0,
"status": "completed",
"rows": 1200,
"approximate": false,
"model": "acme/custody/Position",
"schema": null,
"timing": {
"ms": 120,
"scope": "node"
},
"blocks": [],
"rejects": {
"total": 0,
"byRule": {},
"file": null,
"fileStatus": "none"
},
"error": null,
"type": "read",
"details": {
"source": {
"dataSourceId": "ds_s3",
"kind": "s3"
},
"files": [
{
"path": "in/custodian.txt",
"trailer": {
"removed": 1,
"declared": 1200,
"actual": 1200
}
}
],
"decrypted": false,
"onCastFailure": "reject"
}
},
{
"id": "map1",
"title": "Normalize",
"stage": 1,
"status": "completed",
"rows": 1200,
"approximate": false,
"model": "acme/custody/Holding",
"schema": null,
"timing": {
"ms": 980,
"scope": "stage"
},
"blocks": [],
"rejects": {
"total": 0,
"byRule": {},
"file": null,
"fileStatus": "none"
},
"error": null,
"type": "transform",
"details": {
"evaluatedAs": "sql",
"jsRules": []
}
},
{
"id": "route1",
"title": "Split by custodian",
"stage": 1,
"status": "completed",
"rows": 1200,
"approximate": false,
"model": "acme/custody/Holding",
"schema": null,
"timing": {
"ms": 980,
"scope": "stage"
},
"blocks": [],
"rejects": {
"total": 0,
"byRule": {},
"file": null,
"fileStatus": "none"
},
"error": null,
"type": "route",
"details": {
"branches": {
"schwab": 800,
"default": 400
},
"evaluatedAs": "sql"
}
},
{
"id": "call1",
"title": "Enrich",
"stage": 2,
"status": "completed",
"rows": 800,
"approximate": false,
"model": "acme/crm/Account",
"schema": null,
"timing": {
"ms": 120,
"scope": "node"
},
"blocks": [],
"rejects": {
"total": 0,
"byRule": {},
"file": null,
"fileStatus": "none"
},
"error": null,
"type": "callAction",
"details": {
"module": "acme/crm",
"action": "Account.lookup",
"calls": 8,
"failedCalls": 0,
"ledgerAnswered": 0,
"ledgerAnswered": 0,
"driverRows": 800
}
},
{
"id": "loop1",
"title": "Per account",
"stage": 2,
"status": "completed",
"rows": 800,
"approximate": false,
"model": "acme/crm/Account",
"schema": null,
"timing": {
"ms": 120,
"scope": "node"
},
"blocks": [],
"rejects": {
"total": 0,
"byRule": {},
"file": null,
"fileStatus": "none"
},
"error": null,
"type": "loop",
"details": {
"iterations": 800
}
},
{
"id": "diff1",
"title": "Against yesterday",
"stage": 3,
"status": "completed",
"rows": 800,
"approximate": false,
"model": "acme/custody/Holding",
"schema": null,
"timing": {
"ms": 120,
"scope": "node"
},
"blocks": [],
"rejects": {
"total": 0,
"byRule": {},
"file": null,
"fileStatus": "none"
},
"error": null,
"type": "diff",
"details": {
"base": "out/holdings-yesterday.csv",
"added": 500,
"removed": 100,
"changed": 200
}
},
{
"id": "val1",
"title": "Cutoff",
"stage": 3,
"status": "completed",
"rows": 1,
"approximate": false,
"model": null,
"schema": [
{
"name": "cutoff",
"type": "DATE"
}
],
"timing": {
"ms": 120,
"scope": "node"
},
"blocks": [],
"rejects": {
"total": 0,
"byRule": {},
"file": null,
"fileStatus": "none"
},
"error": null,
"type": "value",
"details": {}
},
{
"id": "expr1",
"title": "Fee",
"stage": 3,
"status": "completed",
"rows": 800,
"approximate": false,
"model": null,
"schema": [
{
"name": "account",
"type": "VARCHAR"
},
{
"name": "fee",
"type": "DOUBLE"
}
],
"timing": {
"ms": 120,
"scope": "node"
},
"blocks": [],
"rejects": {
"total": 0,
"byRule": {},
"file": null,
"fileStatus": "none"
},
"error": null,
"type": "expression",
"details": {}
},
{
"id": "sink1",
"title": "Schwab out",
"stage": 4,
"status": "completed",
"rows": 800,
"approximate": false,
"model": "acme/custody/Holding",
"schema": null,
"timing": {
"ms": 120,
"scope": "node"
},
"blocks": [],
"rejects": {
"total": 0,
"byRule": {},
"file": null,
"fileStatus": "none"
},
"error": null,
"type": "write",
"details": {
"target": "s3://bucket/out.csv",
"format": "csv",
"publish": "object",
"rows": 800,
"writtenInAttempt": 2,
"skippedOnResume": false
}
}
],
"stages": [
{
"index": 1,
"status": "completed",
"nodeIds": [
"map1",
"route1"
],
"ms": 980,
"peakBytes": 0,
"spillBytes": 0,
"blocksDropped": 0,
"error": null
}
],
"leftovers": {
"runDatabase": null,
"tempPaths": [],
"stagingTables": []
}
}