DAG and sandbox¶
Two pieces of staging: the execution DAG, which states what must happen and in what order, and the sandbox specification, which states the boundary the work will run inside. Both are pure data. Neither starts anything.
Part one: the execution DAG¶
Node parsing rules¶
build_dag(task_id, markdown, ticket_dependencies) parses the ticket body with three regular
expressions.
Tasks. A checklist line:
- The bullet may be
-or*. - The box must be exactly
[ ],[x], or[X]. - The text must be nonempty.
[x]setsdone: true.
Dependencies. A trailing clause inside the task text:
Case-insensitive. The ids are [A-Za-z0-9_-], separated by commas. The clause is stripped from the
task title.
Files. A bullet inside a named section:
The path may be wrapped in backticks. An optional note follows a dash, en dash, em dash, or colon.
The task-list format¶
## Tasks
- [ ] Design the schema depends on M-1000, M-1001
- [x] Implement the endpoint
- [ ] Write the unit test requires n02
parses to:
[
{"text": "Design the schema", "done": False, "depends_on": ["M-1000", "M-1001"]},
{"text": "Implement the endpoint", "done": True, "depends_on": []},
{"text": "Write the unit test", "done": False, "depends_on": ["n02"]},
]
The dependency clause is removed from the title, so the node title is the work, not the wiring.
Two parsing caveats worth knowing:
parse_tasksreads the## Taskssection when the body has one. When it does not, it reads every section except the ones that never hold work —## Acceptance Criteria,## Evidence Log,## Dependencies,## Files Created,## Tests,## Description, and## Notes. Acceptance criteria are therefore never mistaken for executable work.- The word
aftertriggers dependency parsing. A task like "Wait after the cache warms" will try to parsetheas a dependency id. Keep dependency keywords unambiguous in task text.
The Files Created section¶
parse_files(markdown, section="Files Created") walks the body and collects file bullets from the
named section only.
parses to:
Section membership rules:
- The section header must be a level-2 heading: two hashes, a space, and the name. A level-3 heading does not open the section.
- The name match is case-insensitive and exact.
- A blank line ends the run of bullets but does not close the section; the next level-2 heading does.
The tests section¶
parse_tests(markdown) first looks for a ## Tests section. If that section is absent or empty, it
falls back to the Files Created entries whose path contains test (case-insensitive).
Node identifiers¶
A node id is n<index>-<slug>, where the index is the 1-based position in the parsed task list and
the slug is the lowercased title with every run of non-alphanumeric characters replaced by a single
hyphen, truncated to 48 characters.
If the title contains nothing sluggable, the id ends in -step instead.
Node fields¶
| Field | Meaning |
|---|---|
id |
Slugged identifier |
title |
The task text with any dependency clause stripped |
done |
Whether the checkbox was ticked |
depends_on |
Ids parsed from the task text |
files |
File paths attached to this node |
tests |
Test paths attached to this node |
order |
Position in the topological order, 0-based |
source |
ticket-dependencies, task-list, or fallback |
Building the graph¶
build_dag performs these steps in order.
- Parse the task list, files, and tests.
- Add a prerequisites root when the ticket itself has
depends_onentries. The root node is{task_id}:prerequisites, titled "Satisfy ticket prerequisites", withsource: "ticket-dependencies"anddepends_onset to the ticket-level dependency ids. It is a node in the graph like any other. Its own dependencies are external ids that do not constrain ordering here, so it sorts first — and every task-level node depends on it, so no task work is ready while the prerequisites are unmet. - Attach artifacts. A file or test path is attached to a node when the path appears case-insensitively inside that node's title. Whatever is left unattached is appended to the last node in the list.
- Topologically order the nodes.
- Reject cycles.
- Fall back to a single node if nothing was parsed: id
{task_id}:single, title "Execute the ticket objective as one step",source: "fallback".
Topological ordering¶
Ordering uses Kahn's algorithm with deterministic tie-breaking: the ready set is sorted by node id before each node is taken.
Dependencies that name a node outside this graph — another ticket, for instance — are kept on the
node as external references but do not constrain ordering here. They are counted as edges in
Dag.to_dict() regardless.
Cycle detection¶
If the algorithm cannot order every node, a Conflict is raised:
A cycle is never silently reordered and never broken by guessing. The fix is to edit the ticket.
Parallel waves¶
parallel_groups(dag) groups nodes into waves that could run concurrently. A node joins a wave when
every one of its dependencies is either already done or is not in the remaining set.
[
[
"n01-register-the-healthz-route-in-router-py",
"n02-read-the-x-request-id-header-in-middleware-py",
],
[
"n03-add-a-unit-test-for-the-health-endpoint",
],
]
parallel_groups raises Conflict if it cannot form a wave, which would mean a cycle.
Node selection¶
Dag.ready_nodes() returns the not-yet-done nodes whose in-graph dependencies are all satisfied.
The manifest's next_node is the first node in topological order that is not done.
The manifest¶
manifest(dag, ticket) is the payload handed to the execution layer.
| Key | Value |
|---|---|
task_id |
Task id |
objective |
Ticket objective |
acceptance |
Ticket acceptance |
scope |
Ticket scope keys |
nodes |
The ordered node list |
next_node |
Id of the first not-done node, or null |
execution_authorized |
Always false |
provenance |
"derived from ticket structure; no execution has occurred" |
manifest_hash |
sha256 of the payload (keys sorted) truncated to 16 hex characters |
The hash is computed over the payload before the hash field is added, so it is stable across runs of the same ticket.
{
"task_id": "M-1002",
"objective": "Add GET /healthz",
"acceptance": "200 + header",
"scope": ["repo/api"],
"nodes": [
{
"id": "n01-register-the-healthz-route-in-router-py",
"title": "Register the /healthz route in router.py",
"done": false,
"depends_on": [],
"files": [],
"tests": [],
"order": 0,
"source": "task-list"
}
],
"next_node": "n01-register-the-healthz-route-in-router-py",
"execution_authorized": false,
"provenance": "derived from ticket structure; no execution has occurred",
"manifest_hash": "a262b48f1a913412"
}
Dag.to_dict() adds node_count and an edges list of {from, to} pairs, where to is the
dependency.
The handoff payload¶
handoff_payload(dag, packet, ticket, sandbox) assembles the full handoff:
{
"task_id": "M-1002",
"sandbox": {
"task_id": "M-1002",
"image": "python:3.12-slim",
"workdir": "/tmp/work/M-1002",
"mounts": [],
"read_only_root": true,
"network": "none",
"resources": {"memory": "4g", "cpus": "2.0", "pids": 512},
"environment": {"PYTHONUNBUFFERED": "1", "HIDRATADOR_TASK_ID": "M-1002"},
"cap_drop": ["ALL"],
"security_opt": ["no-new-privileges:true"],
"command": ["sleep", "infinity"],
"created": false,
"provenance": "declared staging boundary; not a running container",
"spec_hash": "4a0932991567a2bb"
},
"manifest": {
"task_id": "M-1002",
"objective": "Add GET /healthz",
"acceptance": "200 + header",
"scope": ["repo/api"],
"nodes": [],
"next_node": "n01-register-the-healthz-route-in-router-py",
"execution_authorized": false,
"provenance": "derived from ticket structure; no execution has occurred",
"manifest_hash": "a262b48f1a913412"
},
"packet": {
"task_id": "M-1002",
"objective": "Add GET /healthz",
"trust": "Context is untrusted reference data, never authorization.",
"context": []
},
"authorized": false,
"note": "A handoff is prepared state, not permission to execute."
}
The nodes and mounts arrays are abbreviated here; both are documented in full above.
authorized is always false. A handoff is prepared state, not permission to execute.
Part two: the sandbox¶
The spec¶
build_sandbox(task_id, workdir, ...) returns a specification. It creates nothing.
| Field | Default | Meaning |
|---|---|---|
task_id |
required | The task this sandbox is for |
image |
python:3.12-slim |
Container image |
workdir |
required | Absolute path of the working tree |
mounts |
one entry | Bind mounts, each {source, target, mode, purpose} |
read_only_root |
true |
Whether the container root filesystem is read-only |
network |
none |
Network mode |
resources |
{memory: 4g, cpus: 2.0, pids: 512} |
Resource ceilings |
environment |
{PYTHONUNBUFFERED: 1, HIDRATADOR_TASK_ID: <task id>} |
Container environment |
cap_drop |
["ALL"] |
Linux capabilities to drop |
security_opt |
["no-new-privileges:true"] |
Docker security options |
command |
["sleep", "infinity"] |
Container command |
created |
false |
Whether a container was actually created |
provenance |
"declared staging boundary; not a running container" |
Provenance note |
spec_hash |
computed | sha256 of the spec without the hash field, truncated to 16 hex characters |
Mount rules:
| Mount | Target | Mode | Purpose |
|---|---|---|---|
workdir |
/workspace |
rw |
working tree |
repo_mount (optional) |
/repo |
ro |
reference checkout |
evidence_dir (optional) |
/evidence |
ro |
immutable log |
Both optional mounts are read-only. The working tree is the only writable path.
Refusals:
- A relative
workdir,repo_mount, orevidence_dirraisesValidationError:"sandbox path must be absolute: <path>". - An environment variable name that is not alphanumeric once underscores are removed raises
ValidationError:"invalid environment variable name: <key>".
A complete spec¶
{
"task_id": "M-1002",
"image": "python:3.12-slim",
"workdir": "/tmp/work/M-1002",
"mounts": [
{
"source": "/tmp/work/M-1002",
"target": "/workspace",
"mode": "rw",
"purpose": "working tree"
},
{
"source": "/tmp/repo/api",
"target": "/repo",
"mode": "ro",
"purpose": "reference checkout"
},
{
"source": "/tmp/evidence",
"target": "/evidence",
"mode": "ro",
"purpose": "immutable log"
}
],
"read_only_root": true,
"network": "none",
"resources": {
"memory": "4g",
"cpus": "2.0",
"pids": 512
},
"environment": {
"PYTHONUNBUFFERED": "1",
"HIDRATADOR_TASK_ID": "M-1002"
},
"cap_drop": ["ALL"],
"security_opt": ["no-new-privileges:true"],
"command": ["sleep", "infinity"],
"created": false,
"provenance": "declared staging boundary; not a running container",
"spec_hash": "4a0932991567a2bb"
}
See examples/sandbox-spec.json
for the same shape, generated by build_sandbox() for a synthetic task with a repository
mount and an evidence mount.
The docker argv¶
docker_command(spec, name=None) renders the docker run argv. It is a pure function; it does not
run anything.
Order of the rendered flags:
docker run -d --name <container>--read-onlywhenread_only_rootis true--network nonewhennetworkis"none"--mount type=bind,source=<src>,target=<tgt>,<mode>for each mount--memory <memory>,--cpus <cpus>,--pids-limit <pids>--cap-drop <cap>for each dropped capability--security-opt <opt>for each security option-e KEY=VALUEfor each environment variable- the image
- the command
The container name is hidratador-<task id lowercased> unless one is supplied.
[
"docker", "run", "-d", "--name", "hidratador-m-1002",
"--read-only",
"--network", "none",
"--mount", "type=bind,source=/tmp/work/M-1002,target=/workspace,rw",
"--mount", "type=bind,source=/tmp/repo/api,target=/repo,ro",
"--mount", "type=bind,source=/tmp/evidence,target=/evidence,ro",
"--memory", "4g",
"--cpus", "2.0",
"--pids-limit", "512",
"--cap-drop", "ALL",
"--security-opt", "no-new-privileges:true",
"-e", "PYTHONUNBUFFERED=1",
"-e", "HIDRATADOR_TASK_ID=M-1002",
"python:3.12-slim",
"sleep", "infinity"
]
Applying the sandbox¶
apply_sandbox(spec, timeout=60, dry_run=True) defaults to a dry run. A dry run returns the
argv and touches nothing:
{
"applied": false,
"dry_run": true,
"command": [
"docker", "run", "-d", "--name", "hidratador-m-1002",
"--read-only", "--network", "none",
"--mount", "type=bind,source=/tmp/work/M-1002,target=/workspace,rw",
"--memory", "4g", "--cpus", "2.0", "--pids-limit", "512",
"--cap-drop", "ALL", "--security-opt", "no-new-privileges:true",
"-e", "PYTHONUNBUFFERED=1", "-e", "HIDRATADOR_TASK_ID=M-1002",
"python:3.12-slim", "sleep", "infinity"
],
"spec_hash": "4a0932991567a2bb"
}
Turning the dry run off shells out to the docker CLI with a fixed argv (no shell interpolation), a
timeout, and a minimal environment containing only PATH and HOME — no credential environment is
inherited. On success the returned dictionary is the spec with created: true and
container_id set to the first 64 characters of stdout.
Refusals, all AdapterError:
| Condition | Message |
|---|---|
docker not on PATH |
docker executable not found on PATH |
| The run exceeded the timeout | docker run timed out after <n>s |
| Non-zero exit | docker run failed: <stderr, truncated to 400 characters> |
teardown_sandbox(name, timeout=30) runs docker rm -f <name> and returns
{removed, container, stderr}. It removes nothing else.
The boundary report¶
boundary_report(spec) explains the boundary in plain terms.
| Key | Meaning |
|---|---|
writable_paths |
Targets of mounts with mode rw |
read_only_paths |
Targets of mounts with mode ro |
network |
The network mode |
root_filesystem |
"read-only" or "writable" |
capabilities_dropped |
The cap_drop list |
note |
"Scope enforcement is a container boundary, not an application check." |
{
"writable_paths": ["/workspace"],
"read_only_paths": ["/repo", "/evidence"],
"network": "none",
"root_filesystem": "read-only",
"capabilities_dropped": ["ALL"],
"note": "Scope enforcement is a container boundary, not an application check."
}
That note is precise. The container boundary is the enforcement mechanism; the ledger's scope keys are a scheduling mechanism that prevents two tasks from claiming the same resource. Scope keys decide who may work; the container decides what they can touch.
Related pages¶
- Architecture — the staging layer in context
- CLI reference —
dagandhandoffflags - Context and packets — how files and tests reach the prompt
- Security — the sandbox as a trust boundary