Where an approval raised inside a child execution goes¶
Status: findings / design note. Not shipped behaviour. This page records observations or a proposed design. Nothing here is a product capability unless a linked release note says so.
Editions: OSS, Cloud, Enterprise. Unless stated otherwise, everything on this page ships in OSS.
A child execution that asks a human must not park a subtree forever. This page records what happens today, decides the three open questions, and states what a parent sees when a child ends up waiting for a person, so the parking child of the delegation PRD can be implemented without guessing.
It is a design note, not a feature. Nothing in this page changes behaviour.
The object shapes it refers to are frozen in
docs/guide/flow-delegation-shapes.md.
Verified on main 5b592ece.
Short version¶
Routing needs no change. A child's question already reaches the same humans a
parent's question reaches, because both run as the same account principal and
resolve the same account scoped approval workflow, and because an approval
always carries an expires_at bounded by the account cap, an unanswered
question always ends. A child that asks is therefore an interrupted child, not
a terminal one, and a parent may keep waiting for it until the parent's own
deadline.
Two things still have to be got right, and neither is approval routing:
- A parent must read a parked child's outcome through the execution that
continued it (
flow_execution.resume_execution_id), not off the parked row, which is closed with a terminal status and a nullresult. - The execution that continues a parked run must carry the lineage columns of the run it continues, or a child that parks leaves the delegation tree. That is the one defect this spike found; it is fixed (see the follow up below).
What happens today¶
The call¶
- The agent inside a child calls
ask_userorrequest_approvalover MCP (backend/preloop/services/initialize_mcp.py:647forask_user). ask_userresolves the approval workflow from the account: the workflow named in the tool call'sapproval_workflowargument, else the account's default workflow (initialize_mcp.py:683andbackend/preloop/models/crud/approval_workflow.py:43/:91). Nothing in that resolution reads the calling execution, its flow or its ancestry.require_approval(backend/preloop/services/approval_helper.py:308) creates the request. The requester identity comes from the MCP caller's own context (approval_helper.py:668, built inbackend/preloop/services/approval_attribution.py), whoseflow_execution_idis read from the runtime token'scontext_data(backend/preloop/services/dynamic_fastmcp.py:2014). For a child that is the child's own execution id.- The decision window is resolved before the row exists
(
approval_helper.py:676callingbackend/preloop/services/approval_window.py:95): the tool call'stimeout_seconds, else the child's own flow'sapproval_window_seconds, else the workflow timeout, else the deployment default, always clamped by the account cap (approval_window.py:79, 30 days by default). create_and_notify(backend/preloop/services/approval_service.py:1991) writes theapproval_requestrow and notifies. Recipients are the workflow'sapprover_user_idsplus the members of itsapprover_team_ids(approval_service.py:2699), through each user's own notification preferences, plus whatever webhook channel the workflow configures (approval_service.py:2266). The console and the approval link page serve the same request to anyone in the account who may act on it.- If the window is longer than the short in-process wait, the child parks:
park_request_idis stamped on its row, the orchestrator releases the container and the status becomesWAITING_FOR_HUMAN(backend/preloop/services/approval_park.py:47,:108,:214). The tool call returns theparked_for_humanpayload telling the agent to stop. - The decision, or the expiry sweep (
approval_park.py:488), claims the parked row once and starts a new execution that continues the same agent session (approval_park.py:394). When that continuation finishes, the parked row is closed with the continuation's terminal status andend_time(backend/preloop/models/crud/flow_execution.py:1466). Itsresultstays null: the result is on the continuation.
So where does a child's question go?¶
To the account's approvers, on exactly the same path as a question raised by a
parent or by a flow with no lineage at all. There is no per execution approver
identity to route by, and there does not need to be one, because there is no
per execution owner either: every flow runtime token is minted as the account's
primary user (backend/preloop/services/flow_runtime_token.py:124-150). The
parent's owner and the child's owner are the same person by construction.
"Route it to the parent's owner" is already what happens.
ApprovalWorkflow is account scoped and carries no execution reference
(backend/preloop/models/models/tool_configuration.py:170). The
approval_request row records which execution asked (execution_id), which
API key, which managed agent and which runtime session, and no lineage.
Evidence, and what is missing from it¶
This note is written from the code, and from the park and delegation test
suites that exercise these paths (backend/tests/test_approval_park.py,
backend/tests/a2a/). It is not written from a live child run, because
the tool that lets one execution start another does not exist yet: on
5b592ece nothing writes parent_execution_id, so no child execution can be
produced to observe. The decisions below therefore have to be re-checked the
first time a real run_flow child raises a question, and the pins in
backend/tests/a2a/test_child_approval_routing.py are what will fail if any
of this drifts before then.
The three decisions¶
1. Does a child inherit the parent's resolved approval workflow and requesting subject?¶
No, and nothing needs building.
There is no requesting subject to inherit: both executions authenticate as the account's primary user. A child resolves its workflow the way every caller does, by name or by account default, and the account default is the same workflow the parent would have used.
Inheriting the parent's named workflow would be actively wrong. The
approval_workflow argument on ask_user is a per call choice made by the
agent for one question. Copying it onto a child would route the child's
unrelated question to the channel the parent happened to name for a different
decision, and would do so invisibly.
If a deployment ever wants a delegated subtree to route somewhere specific,
the knob that already exists is the child flow's own configuration: the child
flow can name its workflow, and flow.approval_window_seconds already gives
it its own window. That is a per flow decision an operator can see, not a per
call inheritance rule nobody can see.
2. Should the approval request record the parent and root execution ids?¶
No. Do not add columns to approval_request.
The request already records execution_id, and lineage already lives on
flow_execution (parent_execution_id, root_execution_id,
delegation_depth, backend/preloop/models/models/flow_execution.py:252-259).
A console tree row that wants to show a pending question joins the approval's
execution_id to the execution it is already rendering. One join, no
migration, no backfill of historical approvals, and no second copy of the tree
that can disagree with the first.
This holds on one condition, which is the follow up below: the continuation of a parked child must carry the same lineage as the run it continues, or the join finds an execution that is not on the tree.
3. Is the child's approval window bounded by the parent's remaining child wait?¶
No. Do not clamp it. Bound the parent instead.
The window is already bounded: every request gets an expires_at, no window
may exceed the account cap, and the sweep expires the request and resumes the
run with an explicit expired answer so the agent finishes gracefully
(approval_park.py:488). Nothing parks forever.
Clamping the child's window to the parent's remaining wait would make the same question expire differently depending on who asked for the run, which is the opposite of what a governance window is for: a waiver decision does not become less serious because the caller is impatient. It would also be a silent shortening, visible to the human only as a window that closed early.
The bound that belongs to the parent is the parent's own wait deadline. When it passes and a child is still waiting for a person, the parent stops waiting and reports it. The child keeps its window and keeps its human.
What the parent's completion record says¶
A child that is waiting for a human is reported with the shapes already frozen
in docs/guide/flow-delegation-shapes.md. No new metadata key is needed and
none may be added: that vocabulary is closed.
{
"id": "5c6d7e8f-9a0b-4c1d-8e2f-3a4b5c6d7e8f",
"contextId": "3f1a2b3c-4d5e-6f70-8192-a3b4c5d6e7f8",
"status": {"state": "TASK_STATE_INPUT_REQUIRED", "timestamp": "2026-09-15T10:04:11Z"},
"metadata": {
"preloop.ai/kind": "delegation_task",
"preloop.ai/executionId": "5c6d7e8f-9a0b-4c1d-8e2f-3a4b5c6d7e8f",
"preloop.ai/parentExecutionId": "3f1a2b3c-4d5e-6f70-8192-a3b4c5d6e7f8",
"preloop.ai/rootExecutionId": "3f1a2b3c-4d5e-6f70-8192-a3b4c5d6e7f8",
"preloop.ai/flowId": "9a8b7c6d-5e4f-4031-9283-1a2b3c4d5e6f",
"preloop.ai/flowName": "Repository review",
"preloop.ai/depth": 1,
"preloop.ai/status": "WAITING_FOR_HUMAN",
"preloop.ai/consoleUrl": "/console/flows/executions/5c6d7e8f-9a0b-4c1d-8e2f-3a4b5c6d7e8f"
}
}
The rules the parking child implements, stated so it does not have to decide them:
TASK_STATE_INPUT_REQUIREDis not terminal and not a failure. A child inWAITING_FOR_HUMANhas not finished, has no result artifact, holds no runtime and is not burning budget. A parent still waiting on children must not count it as done.- The question is reached through the child, not through the parent. The
parent's record links to the child execution (
preloop.ai/consoleUrl), and the pending question is on that execution and in the account's approvals queue, where it was delivered to the same humans in the first place. The parent does not restate the question and does not carry the approval request id; that would be a second copy of a record the console already renders. - When the parent's own wait deadline passes with a child still waiting,
the parent resumes and reports that child exactly as above, with
preloop.ai/statusWAITING_FOR_HUMAN. The parent's report says the subtree is with a person, not that it failed and not that it succeeded with no result. The child is not stopped: its window is its own, and stopping it would throw away work already paid for. An operator initiated stop is the other case, and #689 decided it the other way: a stop does end the children, including one waiting for a person, because an operator who stops a tree means the spend to stop too. A deadline is the parent giving up on waiting; a stop is somebody ending the work. - When the decision arrives before the parent's deadline, the child
continues as a different execution row. The parked row is closed with the
continuation's terminal status but its
resultis null (backend/preloop/models/crud/flow_execution.py:1466). A parent reading the child's result must followflow_execution.resume_execution_idto the end of the chain, which may be more than one hop if the child parks twice. Do not copy results backwards onto parked rows: that creates a second source of truth for the one field the whole delegation feature exists to deliver.
The follow up¶
A continuation created for a parked run used to be created with only
flow_id, status and trigger_event_details, so parent_execution_id,
root_execution_id and delegation_depth fell back to null, null and 0. That
was invisible while nothing wrote lineage. The moment delegation writes it, a
child that parks on a question would drop out of its tree: the console tree
loses the branch, a cost rollup keyed on the root undercounts the
continuation, and a depth cap stops counting the hops it already spent.
The fix was small and is one pull request: _start_resume_execution
(backend/preloop/services/approval_park.py) now carries the parked row's
lineage unchanged onto the continuation. Unchanged, not incremented, because a
continuation is the same logical child carrying on, not a new one: the park
link is already expressed by resume_execution_id, and incrementing the depth
would let a question a human answered consume a delegation hop.
Filed and fixed as #707.
Pinned behaviour¶
backend/tests/a2a/test_child_approval_routing.py pins what this note asserts,
so the recommendations cannot quietly stop being true:
- workflow and approver resolution read the account, never the caller's
lineage, and
approval_requestcarries no lineage columns - the window resolver takes no parent argument and every resolved window is bounded by the account cap
WAITING_FOR_HUMANmaps toTASK_STATE_INPUT_REQUIRED, is not terminal and is not in the set of statuses that close a parked parent- a continuation is created with the parked run's trigger context and with its lineage carried across unchanged, including a root run that has none