task_flow is optional customer/task orchestration. It is separate from a
processor profile's workflow (variables, secrets and execution limits).
Ordinary cards have no card_task_flows binding and keep the existing behavior.
Adding a graph to a product never changes cards already issued without a graph.
Definition version 1, private-worker transport version 2, profile configuration
revisions and customer-visible progress_steps are separate contracts. This
iteration standardizes authoring and validation without replacing the graph
version or adding process callback states. Ordinary redemptions still use
task_flow: null.
GET /api/task-flows/schema exposes a JSON Schema Draft 2020-12 for structural
tooling. Schema validation alone does not establish unique IDs, compatible
references, attachment authority or successful execution along every path.
extore workflow validate --definition FILE --product FILE and the public SDK's
FlowDefinition use validate_definition for canonical semantic validation.
The offline CLI neither initializes the database nor executes a processor.
Authenticated draft validation is available at
POST /api/admin/task-flows/validate for admin sessions and
POST /api/manage/task-flows/validate for admin/staff sessions with
fulfillment.configure. The strict body is
{definition, product: {mode, parameters, outputs}}; extra keys, stored resource
IDs and saved configuration credentials are not accepted. It returns
{ok: true, definition: canonical_or_null, summary} without issuing cards,
creating jobs or business events, or loading saved profiles/secrets. Normal
authentication may still update session access metadata.
The public schema endpoint uses application/schema+json; authenticated
validation uses JSON. Its summary contains version, entry, node count, counts
by kind and the 256 activation limit. Clients must discard obsolete draft
responses rather than replacing a graph the user has since edited.
FlowDefinition.from_dict(..., product=...) / from_nodes(...) return an
immutable canonical definition; as_dict() returns a fresh copy and to_json()
returns compact JSON text. Its reference and branch helpers construct data,
never evaluate expressions. Invalid graphs raise ValueError. See the
development guide and
validated examples for authoring.
PrivateWorkerClient.execution(scope) binds the convenience FlowExecution
interface to one server-issued, verified scope. started, progress, succeed,
fail and upload require explicit result IDs; they do not persist replay
records, rerun business work, retry transport or extend deadlines. Existing
low-level v2 methods retain their wire format. Process callbacks remain
processing, succeeded, or failed; an application decision to reject is
represented as a declared output and a branch to a rejected end. For queue
management, existing authenticated retry/reject operations remain distinct.
task_flow_definition.validate_definition(value, product) returns a canonical
graph or None. A graph has version: 1, an entry and at most 64 nodes.
Each node has a unique id, a kind and optional translated label.
| Kind | Fields | Behavior |
|---|---|---|
input |
prompt, question, fields, start_policy, show_from, next, optional timeout_seconds and timeout_next |
confirm waits for Start before revealing the question and starting the clock; automatic starts on entry after the first node. |
process |
inputs, outputs, next, failure_next, timeout_seconds, timeout_next |
All process nodes use the product's issued executor: queue, webhook or a catalog processor. |
display |
content, show_from, next, optional timeout |
Shows selected completed results until Continue. |
end |
state, result, message, retryable, needs_review |
Collects final output or ends with failure/rejection. |
References have {node, field}. inputs maps processor parameter names to
completed sources; show_from is an independent display map, never a prefill.
end.result maps final product output names to completed sources. A repeated
node resolves its most recent completed occurrence in the current job attempt.
Input and process next accept a target ID or ordered branches:
{"cases":[{"when":{"source":{"node":"question","field":"answer"},"op":"eq","value":"yes"},"to":"process"}],"default":"failed"}Operations are eq, in and exists. No expression/code evaluation occurs.
Entry is input or display; a fresh redemption never immediately runs a process.
The initial input or display always waits for an explicit Start, including batch redemption.
A run has at most 256 lifetime node activations, including retries and backjumps.
task_flow.freeze_card(c, card_id, product) is called inside card issuance after
the card is inserted. It freezes the graph, final input/output schema, delivery
policy, mode, processor ID and webhook credentials in encrypted storage.
card_snapshot(c, card_id) returns {definition, product, definition_hash} to
trusted server code. Missing binding means legacy behavior. Graphs are limited
to 100,000 UTF-8 bytes, each encrypted plaintext envelope to 200,000 bytes, and
the snapshot plus run step/dispatch ciphertext to 2 MiB per card/run. Positive
storage growth must also pass global/shop storage quota checks.
task_flow.init_schema(c) only adds card_task_flows, task_flow_runs and
task_flow_steps plus their indexes. It changes no existing table columns.
Snapshot/step values use tenant-bound AES-GCM with separate resource AAD.
Equality receipts use a separately derived HMAC key so short sensitive values
do not leave publicly enumerable hashes.
Callers hold the existing BEGIN IMMEDIATE transaction. Every mutation returns
an internal effect:
{row: sqlite.Row, flow: safe_view, terminal?: {...},
expired?: true, duplicate?: true,
accepted?: {node_id, flow_epoch, kind: input|output, fields, values}}
The sole adapter is service.finalize_task_flow(c, effect). It validates/binds
accepted stage files and synchronizes dispatch in the same transaction. Only
an ended flow's terminal may finalize card/job/file/event state, exactly once.
Core process success saves intermediate encrypted output, keeps the card
reserved and leaves jobs.result_json empty. The terminal adapter uses the
frozen final schema, never the merchant's later edited product schema.
terminal is {state, output, result_sources, message, retryable, needs_review}.
result_sources[final_key] is {node, field, flow_epoch, kind} and is derived
only from validated completed steps. File aliases may only promote those exact
stage attachments, not a customer-supplied file ID. Internal effects, secrets,
raw graph and all historical values must never be returned from an HTTP route.
| Function | Contract |
|---|---|
is_flow(c, row) |
True only for an issued card binding. |
initialize(c, row) |
Create a waiting run without starting its entry input/display timer; idempotent. |
preview(c, card_id) |
Safe card-specific initial preview with epoch/revision 0. |
view(c, row, staff=False) |
Safe current schema, selected shown results and metadata-only history. |
start(c, row, epoch, revision) |
Start current input or initial display once; repeats preserve the original deadline. |
answer(c, row, values, epoch, revision) |
Validate string values, store encrypted input and activate next node. Same-answer retry is idempotent. |
continue_display(c, row, epoch, revision) |
Complete a display node and activate its target. |
execution(c, row) |
Active unexpired process context, or None for legacy/nonprocess/invalid input. Never fall back to legacy execution for a flow card. |
claim(c, row, actor, epoch) |
Atomically claim a queued process; lease never extends its node deadline. |
release_claim(c, row, actor, epoch) |
Return the same process to queue without resetting epoch/deadline. |
process_update(c, row, update, epoch, actor=None) |
Require current attempt/epoch/state and the claimed queue actor. Progress cannot renew timers; success only stores a stage result. |
frozen_authority(c, row, epoch, attempt=None) |
Resolve exact historical process signing metadata for receipt replay; never returns inputs or output values. |
expire_due(c, now=None, limit=100) |
Bounded due-flow sweep; return effects for the adapter. |
reset_attempt(c, row) |
After whole customer retry increments job attempt, start a new entry while preserving monotonically increasing epoch/transition count. |
stop_for_outcome(c, row) |
After trusted needs-input/rejected/failed outcome, invalidate execution and clear stored private values. |
cancel(c, row, epoch, revision) |
Invalidate a customer-cancelled flow; started external processing requires merchant review. |
destroy(c, row) |
Remove all stored step values after authorized receipt destruction. |
stage_values(c, row, epoch, kind) |
Trusted exact-stage file validation helper; never public. |
Each activation increments flow_epoch, each mutation increments revision.
Only a whole customer retry increments jobs.attempt. Customer requests use
both epoch and revision; process actions use epoch, attempt and action ID plus
their authenticated actor or signed private-worker authority.
Expired mutations return expired: true instead of updating then throwing.
The HTTP adapter finalizes and commits this effect, then returns 409 or a safe
expired view outside the transaction. Updating then raising inside db() would
roll back expiry. A process timeout directly ends with uncertain failure,
needs_review: true and retryable: false; it does not evaluate or dispatch its
configured timeout target. The safe target ID may be recorded as audit metadata.
Input/display timeouts follow their explicit timeout target.
POST /api/task-flow/start, /answer, /continue (and /cancel) use
{token, card_id?, flow_epoch, expected_revision, values?}. values is a map of
strings: numbers are decimal text, booleans are "true"/"false", and image
lists are a canonical JSON string of IDs. File ownership is checked by the
trusted stage-file adapter before commit. Responses are the ordinary safe
job_view object with a task_flow member, not the core effect.
The safe flow member has enabled, version, definition_hash, attempt,
flow_epoch, revision, phase, current, deadline, server_time, shown,
history and actions. Phases are await_start, input, queued, processing,
display and ended. Before Start only the prompt is visible; question and
input fields appear after Start. Shown results only contain explicitly selected
nonsensitive text/choice/boolean values. File values need an authorized stage
download endpoint and are omitted from this text view. Final delivery content
remains behind the existing reveal/destroy policy.
public_product.task_flow_view uses the issued card's preview(c, card_id).
Each batch card starts independently; creating/submitting a batch does not start
the entry input/display timer. Aladdin's first input uses confirm, later inputs use
automatic, and show_from exposes the prior process answer alongside the next
question without an additional Continue click.
execution returns trusted {job_id,card_id,product_id,shop_id,attempt, flow_epoch,node_id,action_id,mode,params,parameters,outputs,deadline, input_expires_at,processor_id,webhook_url,webhook_secret}. The parameters schema
is the referenced source schema renamed to each target parameter key. Public
queue/CLI DTOs must whitelist the relevant schema/input/action fields after
claiming; they never expose webhook URL or signing credentials. Normal queue
listing does not expose sensitive answers. Secret-bearing execution is never
stored in a plaintext event or job parameter column.
Private-worker HMAC v2 binds shop/product/job/attempt/epoch/node/action and action type. A receipt retry first validates frozen historical authority, signature, nonce and the same accepted body; a new mutation must then match the active execution and deadline. Current shop/product/processor and webhook credential revocation are checked even for historical receipt replay. V1 legacy webhooks retain their existing contract.
flow_worker.sync_dispatch(c, row) creates/cancels an encrypted stable dispatch
for a webhook stage. outbox_once() performs bounded delivery outside a DB
transaction; task_flow_once() sweeps expiry and claims/runs script stages.
private_worker.deliver_v2(context, *, recheck=None) resolves only public HTTPS
destinations with a 15-second DNS limit, then calls the synchronous short-DB
recheck immediately before HTTP. The production closure re-resolves current
execution, epoch, shop, secret, sensitive TTL and deadline. A stable action/body
uses a fresh transport nonce on retry; callback receipts provide idempotence.
Only an input text field may use sensitive: true and
sensitive_ttl_seconds (1..600, default 120). Such values can be referenced only
by process inputs, never by display, branching or final output. Expiry starts
when the answer is received and is capped by the input deadline. Current claimed
processing may read an unexpired value, while later processes cannot reuse it.
Completion, timeout, cancellation, retry/rejection and destruction clear raw
private step values. A persisted input_expires_at lets the bounded sweep erase
expired values without scanning every encrypted task. This clock follows all
remaining sensitive values across intermediate input, display and Start-waiting
nodes; those user steps keep their original deadlines. Retention cleanup also
runs when the shop/card no longer has execution authorization. A queued process whose
sensitive input expired returns to that source input in await_start with a new
epoch; it does not change the whole job attempt. Once external processing has
started, TTL cleanup only erases the input and preserves the original process
deadline. Expired values are not dispatched to a processor. An optional empty
sensitive value is allowed and has no secret expiry.
The stage-file adapter records exact job/attempt/node/epoch/input-or-output
ownership. Upload/download/validation require that scope; a matching field name
in another stage grants no access. accepted values are ephemeral trusted
adapter input and must be stripped before any API serialization.