ash_pplan

P-PLAN/PROV-O semantics projected into an Ash-native process control plane.

ash_pplan deliberately does not introduce another workflow runtime. Reactor remains the DAG/saga executor. AshPPlan.Reactor.Durable.* adds a native durable ledger around it (no Oban, no Postgres). Ash remains the application action/policy boundary. AshStateMachine remains the persistent resource-lifecycle authority. AshOban/Oban remain the background and temporal activation layer.

ash_pplan fills the semantic/control-plane gaps between those owners: hierarchical process representation, FOND policy validation, compile-checked extension introspection, capability composition, Reactor outcome observations, content-addressed execution evidence, and a native durable run ledger (AshPPlan.Reactor.Durable.*).

Public/process concept Runtime/control-plane projection
p-plan:Plan Reactor definition / builder
p-plan:Step Ash.Reactor step/action binding
p-plan:Variable Reactor input/argument/result
hierarchical decomposition HDDL
nondeterministic policy AshPPlan.FOND
persistent resource lifecycle AshStateMachine + AshPPlan.StateMachine descriptor
application DO boundary authorized Ash generic action
dynamic P-PLAN DO adapter AshPPlan.Action.Run
Reactor outcome AshPPlan.ReactorOutcome planner observation
background/temporal activation AshOban/Oban + AshPPlan.Oban descriptor
semantic execution AshPPlan.Compiler -> Reactor.Builder
durable run ledger (checkpoints, signals, waiters) AshPPlan.Reactor.Durable.Engine over an AshPPlan.Reactor.Durable.Store
durable store (reference) AshPPlan.Reactor.Durable.Store.Ets (single node, non-persistent)
composed capability view AshPPlan.ControlPlane
execution evidence AshPPlan.ExecutionReceipt
release observation CI exact-head qualification
release evidence AshPPlan.ReleaseReceipt
control-plane evidence export AshPPlan.FrontierEvidence
run standing (three-layer verdict, receipt) AshPPlan.Standing (verdict/3, receipt/2), AshPPlan.Standing.Receipt
standing progression AshPPlan.Standing.Ladder
OCEL 2.0 evidence export AshPPlan.Reactor.Durable.LedgerOCEL
durable store (local file) AshPPlan.Reactor.Durable.Store.Dets
counterfactual replay AshPPlan.Reactor.Durable.Counterfactual
in-flight run migration AshPPlan.Reactor.Durable.Migration
policy-driven step outcome AshPPlan.Reactor.Durable.PolicyDriver
FOND policy driver surface AshPPlan.FOND validation/synthesis, driven by AshPPlan.Reactor.Durable.PolicyDriver
verification surface bin/ggen-doctor, bin/ggen-verify, bin/ggen-replay-court, bin/ggen-engine-report

Quickstart

alias AshPPlan.Reactor.Durable.Testing
alias AshPPlan.Reactor.Durable.Store.Ets
alias AshPPlan.Workflow.Runtime
# 1. declare a workflow (Spark DSL or keyword model) with a human-release task
workflow = [name: "release", goal: "released", tasks: [
[id: :select, capability: "Observation.Select", authority: :observe],
[id: :gate, capability: "Human.Approve", after: [:select], authority: :observe]]]
# 2. start a run on the reference store; it parks on the human-release await
{:ok, store} = Ets.start_link()
{:ok, run} = Runtime.run(workflow, %{}, providers: MyApp.Providers, store: store, run_id: "rel-1")
run.observation.state #=> :halted
# 3. signal the release and resume; the recorded steps are replayed, not re-executed
[wait] = Testing.waiting_on(store, "rel-1")
{:ok, done} = Runtime.resume(run, signal: {wait, %{released: true}})
done.observation.state #=> :succeeded
# 4. read the tape: standing checkpoint labels in order
Testing.tape(store, "rel-1")

Providers (MyApp.Providers) realize the capabilities; test/workflow/durable_runtime_test.exs is the runnable version of this flow.

For persistence across a restart, use the DETS store: runs, checkpoints, signals, waiters and claim leases survive a stopped or killed store process when a new store is started on the same :path. A corrupted file fails closed at startup, never opens half-readable:

{:ok, store} = AshPPlan.Reactor.Durable.Store.Dets.start_link(path: "/tmp/ash_pplan.dets")
# ... runs, checkpoints and signals are recorded; every write is synced ...
GenServer.stop(store) # or the process is killed
{:ok, restarted} = AshPPlan.Reactor.Durable.Store.Dets.start_link(path: "/tmp/ash_pplan.dets")
AshPPlan.Reactor.Durable.Store.Dets.get_run(restarted, "rel-1") #=> the run is intact
# a corrupt file refuses to open: {:error, {:dets_open_failed, _}}

Limits

v26.10.1 contract

  1. ontology.ttl remains the semantic source of truth.
  2. priv/ggen/ash-pplan-pack/ontology.ttl remains a symlink to that source, so ggen_igniter cannot drift onto a second ontology.
  3. ggen_igniter manufactures the runtime projection catalog and executable P-PLAN plan catalog.
  4. AshPPlan.Compiler validates admitted topology before building a Reactor.
  5. Executable behavior is explicitly bound by semantic step IRI to existing Reactor.Step implementations.
  6. P-PLAN precedence becomes Reactor result dependencies; Reactor remains the scheduler/executor.
  7. AshPPlan.FOND validates or synthesizes candidate strong and strong-cyclic policies without actuating them.
  8. AshPPlan.StateMachine calls the public AshStateMachine contract directly and projects its resolved lifecycle without reproducing lifecycle validation.
  9. AshPPlan.Oban calls the public AshOban introspection contract and derives capability facts from resolved resource configuration rather than extension presence.
  10. AshPPlan.ControlPlane joins already-resolved descriptors; it does not rediscover or execute extension behavior.
  11. Dynamic process execution should normally enter through an authorized Ash generic action using AshPPlan.Action.Run; AshPPlan.execute/5 remains the lower-level engine API.
  12. AshPPlan.ReactorOutcome converts Reactor public results into bounded planner observations.
  13. AshPPlan.Reactor.Durable.Engine replays a run's recorded steps through their implementations, parks on signals and polls, and unwinds on cancel; storage is the AshPPlan.Reactor.Durable.Store behaviour.
  14. ontology/shapes.ttl is executable conformance: ./bin/conform must accept and ./bin/conform-falsify must prove the profile still refuses.
  15. Exact release heads are observable and receiptable through AshPPlan.ReleaseReceipt.
  16. AshPPlan.SA2A (Capability, PolicyCandidate, Provider, Refusal, Replay, SubjectGuard) is an owner-side planner provider for ash_a2a. It projects P-PLAN/FOND/POWL candidates and replay admission; it observes and validates only and grants no DO authority.

Manufacture and qualification

mix deps.get
./bin/conform
./bin/conform-falsify
./bin/manufacture
mix check
./bin/verify-package
./bin/receipt

./bin/gate runs every step above that this machine can run, in CI's order, and reports steps it could not run (for example the pinned-container parse, which needs Docker) as SKIP, never as passed. A local pass is not standing: CI's release-gate job on the exact head is.

bin/conform and bin/conform-falsify need rdflib and pyshacl; ecosystem.lock.toml records the pins CI installs.

The generated source must remain unchanged after manufacture:

./bin/manufacture
git diff --exit-code -- lib/ash_pplan/catalog lib/ash_pplan/workflow lib/ash_pplan/providers

The repository pins its producer identities in ecosystem.lock.toml. CI independently validates the ontology inside the pinned ggen-ecosystem container, regenerates the Elixir catalogs with ggen_igniter, verifies the package from its own contents, and binds the release receipt to the exact checked-out Git head.

The pack manufacture scripts honor MANUFACTURE_MANIFEST_ROOT to redirect generated ggen manifest output away from the default tmp/; bin/demonstrate sets it to a per-process directory so its regeneration steps replay in isolation.

FOND policy validation

A FOND domain is pure data:

{:ok, domain} =
AshPPlan.fond_domain(
%{
pending: %{attempt: [:pending, :succeeded]},
succeeded: %{}
},
[:succeeded]
)
policy = %{pending: :attempt}
{:ok, %{semantics: :strong_cyclic}} =
AshPPlan.validate_policy(domain, policy, :pending, :strong_cyclic)

Strong validation requires all nondeterministic executions to reach a goal without relying on fairness. Strong-cyclic validation admits retry cycles when every reachable policy state retains a path to a goal under the fairness assumption.

The validator selects or rejects policy structure only. It does not call Reactor, Ash actions, external APIs, queues, or schedulers.

Policies can also be constructed, not only checked. AshPPlan.synthesize_policy(domain, :pending, :strong_cyclic) returns {:ok, policy} — restricted to the policy-reachable states and admitted by AshPPlan.validate_policy/4 — or a typed refusal such as {:error, {:unsolvable, mode, witness_states}}. AshPPlan.FOND.Synthesis.solvable_states/2 exposes the winning region: every state from which a policy of the requested class exists. Synthesis is SELECT/CONSTRUCT only: it never calls Reactor, Ash actions, jobs, queues, or schedulers, and a synthesized policy carries no actuation authority.

TLA+ projection and TLC court

AshPPlan.FOND.to_tla/4 renders the same domain, policy and initial state as a TLA+ module and TLC config (render only; it never runs a checker):

{:ok, %{module: tla, cfg: cfg, module_name: "FONDPolicy"}} =
AshPPlan.FOND.to_tla(domain, policy, :pending, :strong_cyclic)

Each decided non-goal state becomes one action whose body is the disjunction of its nondeterministic outcome branches; goals are absorbing; the checked property is <>Goal. :strong renders no fairness over outcomes (only WF_vars(Next) progress), :strong_cyclic conjoins SF_vars over every outcome branch. test/fond_tla_test.exs runs the pinned TLC 1.7.4 jar (~/.cache/autofde-lab/tla2tools/1.7.4/tla2tools.jar, SHA-256 checked) and requires its verdict to equal validate_policy/4 on every corpus case in both modes.

test/fond_tla_hardening_test.exs adds a second, JVM-free court: AshPPlan.Test.TLAReader re-reads the rendered TLA+ text (and nothing else), decides deadlock and <>Goal under the rendered SF/WF fairness, and must agree with validate_policy/4 on the corpus and on 400 seeded random domains. CI fetches the pinned jar, checks its SHA-256 and sets ASH_PPLAN_REQUIRE_TLC=1, so there the TLC court raises instead of skipping. Module names that are TLA+ reserved words, standard modules or rendered identifiers are refused, and comment text is printable ASCII. MIX_ENV=test mix run bench/fond_tla_bench.exs records render/reader/ validator timings; test/fond_tla_bench_test.exs bounds BEAM reductions (linear growth).

AshStateMachine descriptor

ash_state_machine is a first-class dependency because lifecycle projection is now a supported package capability rather than a dynamically discovered optional surface. AshPPlan.StateMachine calls the public extension API directly so contract drift becomes a compile-time failure instead of a silent adapter degradation.

{:ok, lifecycle} = AshPPlan.state_machine(MyApp.Subscription)
{:ok, domain} = AshPPlan.state_machine_domain(MyApp.Subscription, [:active])
{:ok, next_states} = AshPPlan.state_machine_next_states(subscription)
{:ok, mermaid} = AshPPlan.state_machine_diagram(MyApp.Subscription, :state)

The descriptor distinguishes the full persisted-state universe from AshStateMachine's wildcard universe. Deprecated states remain valid persisted values but are not automatically included in :* expansion. action: :* expands against the concrete Ash update-action universe; no planner action is invented.

ash_pplan never mutates the resource. Applications still transition through authorized Ash actions, and AshStateMachine remains authoritative for transition legality, atomic transition behavior, preflight checks, initial-state rules, and diagrams.

AshOban descriptor

AshOban/Oban remain authoritative for workers, schedulers, queues, insertion, retries, uniqueness and durable delivery. ash_pplan observes the resolved DSL through AshOban.Info:

{:ok, oban} = AshPPlan.oban(MyApp.Subscription)
{:ok, capabilities} = AshPPlan.oban_capabilities(MyApp.Subscription)
{:ok, trigger} = AshPPlan.oban_activation(MyApp.Subscription, :renew)

The capability map is configuration-derived. Installing AshOban does not imply that a resource has actor propagation, tenant fan-out, retry delivery, chunking, shared context, or an enabled cron scheduler. Those flags become true only when the resolved resource configuration supplies the corresponding evidence.

AshPPlan.construct_oban_trigger/3 is deliberately a CONSTRUCT boundary:

{:ok, changeset} = AshPPlan.construct_oban_trigger(subscription, :renew)

It delegates to AshOban.build_trigger/3 and does not insert the job. Scheduling and execution stay behind the application's authorized Ash/AshOban DO path.

Composed control plane

{:ok, control_plane} = AshPPlan.control_plane(MyApp.Subscription)

AshPPlan.ControlPlane resolves each extension descriptor once and joins:

Ash action
x AshStateMachine lifecycle membership
x AshOban trigger/schedule membership
x FOND policy surface
x Reactor observation surface
x durable run ledger

The join is descriptive. It maximizes visible combinations before selection without converting observation into authority.

Preferred Ash-native execution boundary

For a dynamically compiled P-PLAN, configure a generic Ash action around AshPPlan.Action.Run:

action :run_plan, :map do
argument :plan_iri, :string, allow_nil?: false
argument :input, :map, allow_nil?: false
run {AshPPlan.Action.Run,
handlers: %{
"https://w3id.org/ash-pplan#AuthorizePayment" => MyApp.AuthorizePaymentStep,
"https://w3id.org/ash-pplan#RenewSubscription" => MyApp.RenewSubscriptionStep
},
plans: ["https://w3id.org/ash-pplan#SubscriptionRenewal"]}
end

This preserves the normal Ash lifecycle: input validation, authorization, actor and tenant context happen before dynamic plan compilation/execution. AshPPlan.Action.Run then delegates the graph to Reactor and returns the observed outcome plus AshPPlan.ExecutionReceipt.

Production rules:

For lower-level engine code, the direct API remains available:

handlers = %{
"https://w3id.org/ash-pplan#AuthorizePayment" => MyApp.AuthorizePaymentStep,
"https://w3id.org/ash-pplan#RenewSubscription" => MyApp.RenewSubscriptionStep
}
{{:ok, result}, receipt} =
AshPPlan.execute(
"https://w3id.org/ash-pplan#SubscriptionRenewal",
handlers,
%{subscription_id: "sub_123"},
%{},
run_id: "renewal-123"
)

The compiler refuses malformed graphs, duplicate steps, dangling predecessors, cycles, missing handlers, invalid handlers, and unbounded terminal fan-out before Reactor execution begins. P-PLAN precedence is represented as real Reactor result dependencies.

Extensibility

Adapters and capability families are host configuration, not forks: config :ash_pplan, :extra_adapters merges additional adapter modules into AshPPlan.Reactor.adapters/0, and config :ash_pplan, :extra_capability_families extends the shipped capability set. The built-in adapter table was revised accordingly: :ultracode was removed (a binding with adapter: :ultracode must now register its own adapter or migrate), and the generic bb_reactor and durable adapters ship in the table. AshPPlan.Reactor.Middleware.Observation observes every step of a run — per-step telemetry events, OpenTelemetry spans and AshPPlan.ProcessEvidence events, plus a run-level event carrying the ExecutionReceipt and its PROV-O N-Triples.

Durable ledger engine

AshPPlan.Reactor.Durable.* runs a workflow model as a durable ledger. Design derived from mbuhot/magma (MIT per its mix.exs); re-implemented, with no dependency on magma. See docs/NOTICE.md.

{:ok, store} = AshPPlan.Reactor.Durable.Store.Ets.start_link([])
{:ok, run} =
AshPPlan.Reactor.Durable.Engine.start(store, %{
id: "renewal-123",
model: model,
bindings: bindings,
inputs: %{subscription_id: "sub_123"},
context: %{},
parent: nil
})
AshPPlan.Reactor.Durable.Engine.attempt(store, run.id)
# {:completed, result} | {:parked, :waiting | :polling} | {:failed, error}
# | {:rolled_back, status} | :taken | :ended | :not_found
{:ok, _signal} = AshPPlan.Reactor.Durable.Engine.signal(store, run.id, "approved", %{by: "ops"})
AshPPlan.Reactor.Durable.Engine.attempt(store, run.id)

Parts:

Replay semantics: an attempt re-runs the plan; a step with a recorded checkpoint is replayed through its implementation so it lands on the undo stack, and its recorded output is used instead of re-executing the effect. A parked deadline is never recomputed. A terminal run is never re-run.

Delivery boundary: the engine is at-least-once. A crash between running a step's effect and recording its checkpoint re-runs the effect on the next attempt. Steps with external effects must use an idempotency key (for example derived with AshPPlan.Reactor.Durable.Key) so a repeat is harmless.

No definition versioning: a run is rebuilt from the model stored in its record. There is no migration of in-flight runs across model changes.

Nesting composites (group, around, recurse, compose) must not contain durable steps; the verifier refuses them.

The ontology still marks the concrete persistent-continuation projection as a consumer-owned gap; the ETS store does not close it.

Planning artifacts

The repository distinguishes planning for constructing ash_pplan from planning for using ash_pplan downstream:

This separation is intentional: HDDL decomposes intent; FOND controls nondeterministic choices; Ash validates application actions/resource transitions; Reactor executes the graph; receipts observe consequences.

Control-plane evidence export

AshPPlan.FrontierEvidence.from_control_plane/3 is a deterministic FrontierEvidence v1 projection of ash_pplan control-plane data: it takes already-resolved control-plane descriptors and FOND validation results and emits a schema-frontier-evidence/v1, content-addressed (sha256: artifact_hash) evidence envelope carrying a CONSTRUCT authority ceiling, for a downstream admission court. The input descriptors and FOND validation results must already exist; the adapter refuses nothing and actuates nothing — no Ash action, lifecycle transition, Oban insertion/schedule, Reactor execution, or durable run attempt — preserving ash_pplan's descriptive SELECT/CONSTRUCT boundary while making that evidence portable to the court.

Execution-side evidence rides the same boundary: AshPPlan.ProcessEvidence converts an AshPPlan.ExecutionReceipt and its subject into task-level events (task_attempted/task_succeeded/task_failed) and exports pure OCEL 2.0 JSON. When the ex4pm/ash_ex4pm dependencies are present (dev/test until published), the ProcessEvidence.AshEx4pm adapter validates and ingests the same events through Ex4pm's envelope API. Like the projection above, it observes and emits only — it grants no authority.

Release evidence

./bin/receipt

A release receipt binds the exact Git head plus semantic/manufactured source identities. It is evidence, not authority: it publishes, tags and approves nothing.

Canonical case studies

The case studies under docs/case-studies/ are the canonical worked examples of this contract. Honesty rule: every figure in them cites its receipt under receipts/; each case study is reproducible with bin/case-study.

See docs/architecture.md, docs/archive/dfcm-ash-extension-closure.md, docs/semantic-execution.md, the working-backwards press releases for v26.9.6/v26.9.7 under docs/archive/, and the planning artifacts under planning/.