T-CLV1-gs-status-model-and-trigger-rules
Status: open/ready · Impact: high · Complexity: medium
Implement the status enum and the Airflow-style trigger rules as
graph_scheduler::status, and make graph-scheduler the owner of those two
types — packages/rust/foreman deletes its copies and depends on the crate.
On the v0.1 critical path.
This is the one task in the E3 chain that edits a shipped contract, so it is
supervised rather than autonomous/pr.
| Location | Role today |
|---|---|
packages/rust/foreman/src/dag.rs | Defines NodeStatus (line 85) and TriggerRule (line 108). Its own doc comment calls NodeStatus “exactly the graph-scheduler status enum, and deliberately so” |
packages/rust/foreman/src/run.rs | NodeState.status: NodeStatus — reaches the type from the emitted status API |
packages/rust/foreman/src/protocol.rs | StepOutcome::as_node_status maps the three terminal step outcomes onto NodeStatus |
packages/rust/foreman/build.rs | ontogen gen_clients pipeline; emits generated/bindings.ts for the closure reachable from src/api/v1 |
packages/rust/foreman/tests/codegen_sync.rs | Asserts NodeStatus and TriggerRule are present in the emitted bindings, and pins the NodeStatus union text verbatim |
packages/rust/graph-scheduler/src/status.rs | Does not exist |
Proposed
Section titled “Proposed”src/status.rs in graph-scheduler owns three items.
| Item | Shape |
|---|---|
NodeStatus | Pending (default) / Running / Success / Failed / Skipped, #[serde(rename_all = "snake_case")] |
TriggerRule | The eight Airflow presets, AllSuccess default, #[serde(rename_all = "snake_case")] |
TriggerRule::is_satisfied_by(&self, parents: &[NodeStatus]) -> bool | The closed-form predicate over parent statuses |
Root (empty-parent) conventions follow standard Airflow and are pinned by
T-ROJC’s Layer 2 table: all_* and none_* are vacuously true, one_* are
false.
The state seam
Section titled “The state seam”D-BPD8’s State is a TypeScript union (ReadonlyMap | { done: [...] }) and its
tracker separately takes a status(id) resolver. Rust collapses both into one
trait, which also answers D-BPD8 first-consumer requirement D (the
“derive Status from a typed domain model” adapter pattern):
trait StatusSource { fn status(&self, id: &NodeId) -> NodeStatus; }with implementations for BTreeMap<NodeId, NodeStatus>, a Done(Vec<NodeId>)
newtype giving the id-list sugar, and a StatusFn(F) newtype wrapping any
Fn(&NodeId) -> NodeStatus. The closure case is wrapped rather than blanket-
implemented because impl<F: Fn(&NodeId) -> NodeStatus> StatusSource for F
overlaps every other implementation as far as coherence can tell. A node the
source has never heard of reads Pending, which is what makes an unknown
depends_on target fail safe (requirement C).
What moves, and what does not
Section titled “What moves, and what does not”NodeStatus and TriggerRule move down. Nothing else in foreman::dag does,
and the reason is a hard dependency cycle rather than a preference:
DagNodeDef(foreman/src/dag.rs:51) carriespub step: StepDef(line 56), imported at line 14.StepDefis the work runner’s execution vocabulary — a compiled-in handler registry key, a command line, a child process speaking the step protocol, an agent driver. It belongs to foreman.- So moving
DagNodeDefwould make graph-scheduler nameStepDefand depend on foreman, while foreman depends on graph-scheduler. Cargo rejects that.
DagDef, DagNodeDef, DagEdgeDef, RetryPolicy, BackoffPolicy and
FailurePolicy therefore stay in foreman. They are also not duplicated in
graph-scheduler: D-BPD8’s core is an in-memory Graph built by add_node /
depends_on and queried, whereas DagDef is a serialized wire format carrying
retry, timeout and step payloads. Only NodeStatus and TriggerRule are the
same type in both designs. See the E3 section of
docs/planning/d-vsli-implementation-plan.md for the recorded open question on
whether the split should go further.
foreman’s side of the move
Section titled “foreman’s side of the move”- Add
graph-schedulerto[dependencies]and tomoon.yml’sdependsOn(moon infers no edge from{ workspace = true }). - Delete the two enums from
src/dag.rsand re-export them from the same path soforeman::dag::NodeStatuskeeps resolving for every existing caller and the public API does not break:pub use graph_scheduler::status::{NodeStatus, TriggerRule}; - Leave
build.rs’s pipeline in place and addpool_extra_roots: vec!["../graph-scheduler/src".into()].
The emit stays in foreman. ontogen’s long-tail walker is a syntactic
syn::Item scan of source directories, and pool_extra_roots exists for
precisely this case — its in-source comment describes the roots as
“workspace-sibling crates the consuming crate re-exports types from”. Relative
roots are joined to CARGO_MANIFEST_DIR. Moving the emit into graph-scheduler
was rejected: ontogen has no types-only generator, so graph-scheduler would need
a fabricated src/api/v1 surface to root an emit it has no use for, and it
would take ontogen + biome-fmt build dependencies into a crate that is meant
to stay dependency-light.
tests/codegen_sync.rs is the guard that makes a silent failure impossible: it
asserts the exact emitted union text, so if the pool does not reach the moved
enums the test fails loudly rather than emitting incomplete bindings.
Dependency consequence
Section titled “Dependency consequence”graph-scheduler carries serde and serde_json. D-BPD8’s “zero-dep” claim is
amended to dependency-light, matching packages/rust/intersect, which carries
thiserror. serde is deliberately not an optional feature: the derives
would then sit behind #[cfg_attr(feature = …)], and ontogen’s syntactic walker
reads derive and serde attributes straight from the source text, so a gated
rename_all risks emitting PascalCase variants that disagree with the wire
without any error.
Approach
Section titled “Approach”- Implement
src/status.rs— the two enums andis_satisfied_by— plus theStatusSourcetrait and its three implementations. - Un-ignore the Layer 2 suite in
tests/status.rsand make it green, including the twenty trigger-rule truth-table cases. - Add the
graph-schedulerdependency to foreman’sCargo.tomlandmoon.yml; delete the two enums fromforeman/src/dag.rsand re-export them from that module. - Add
pool_extra_rootstoforeman/build.rs, runcargo check -p foreman, and confirmgenerated/bindings.tscarries unchanged union text (see AC-5: the declarations relocate within the file; the text is what the guards pin). - Run
cargo test -p foremanandmoon run foreman:codegen-drift.
Files to touch
Section titled “Files to touch”| Location | Kind | Change |
|---|---|---|
packages/rust/graph-scheduler/src/status.rs | modify | Replace stubs with the enums, is_satisfied_by, and StatusSource |
packages/rust/graph-scheduler/src/lib.rs | modify | Re-export NodeStatus, TriggerRule, StatusSource |
packages/rust/graph-scheduler/tests/status.rs | modify | Remove #[ignore] from the Layer 2 suite |
packages/rust/foreman/src/dag.rs | modify | Delete both enums; re-export them from graph-scheduler |
packages/rust/foreman/Cargo.toml | modify | Add the graph-scheduler dependency |
packages/rust/foreman/moon.yml | modify | Add graph-scheduler to dependsOn |
packages/rust/foreman/build.rs | modify | Add pool_extra_roots for ../graph-scheduler/src |
Acceptance criteria
Section titled “Acceptance criteria”- AC-1:
graph_scheduler::statusdefinesNodeStatus(five values,Pendingdefault) andTriggerRule(eight presets,AllSuccessdefault), both serializing snake_case. - AC-2:
TriggerRule::is_satisfied_bymatches every row of T-ROJC’s Layer 2 truth table, including the root conventions. - AC-3:
StatusSourceis implemented for a status map, theDoneid-list newtype, and theStatusFnclosure wrapper; an unknown id readsPending. - AC-4:
foreman/src/dag.rsno longer defines either enum and re-exports both, soforeman::dag::NodeStatusandforeman::dag::TriggerRulestill resolve. - AC-5:
packages/rust/foreman/generated/bindings.tscarries the sameNodeStatusandTriggerRuleunion text after the move. Amended at implementation time: the file is not byte-identical — ontogen’s walker emits declarations in discovery order, and reaching the enums throughpool_extra_rootsmoves their position in the file. The union text is character-identical, TS type aliases are order-independent, andtests/codegen_sync.rspins the text either way, so the reordered file is committed as the new baseline. - AC-6:
cargo test -p foremanpasses, including all six tests intests/codegen_sync.rs, andmoon run foreman:codegen-driftreports no drift. - AC-7: The Layer 2 suite in
tests/status.rshas no#[ignore]left andcargo test -p graph-scheduleris green. - AC-8:
DagDef,DagNodeDef,DagEdgeDef,RetryPolicy,BackoffPolicyandFailurePolicyare still defined inforeman/src/dag.rs.
Out of scope
Section titled “Out of scope”- Moving the DAG definition types, retry, backoff or failure policy. Blocked by the
StepDefcycle above. - The frontier query itself (T-I3QP-gs-push-readiness-frontier).
- Any change to
foreman’ssrc/api/v1surface or the emitted client.
Dependencies
Section titled “Dependencies”- T-JVXC-gs-dag-core —
is_satisfied_byreads parent statuses resolved through the DAG core.