Status: implemented; documented 2026-07-21 (phase-8 step 39 — the provider had no design doc). The plan provider is the planning API's front door: every
.starprogram and every Go planning caller builds, assembles, saves, loads, and runs graphs through it. Companion:3.5.3-plan-provider.status.md.
See also:
- 3.5-provider-catalog.md — the provider catalog this document is a member of
- 3-operation-namespaces.md — provider model and action namespaces
- 3.2-projected-provider-api.md — how provider methods project to Starlark
- 3.5.2-flow-provider.md — the root provider whose combinators surface flat under
plan.* - 2-execution-graph.md — the sealed
op.Graphthis provider assembles and runs
The plan provider (pkg/op/provider/plan, announced op.RoleModule) is the planning
surface. Its methods run immediately, at script-evaluation time — it is an executing receiver, not a planning
receiver — and what they produce is the plan itself: detached invocations, the sealed *op.Graph assembled from
them, and the save / load / run lifecycle around it.
Three ideas carry the design:
- Detached invocations (phase-8 D5).
NewProviderconstructs no graph. Aplan.<ns>.<method>(...)call mints a detached*op.Invocationregistered in the session'sop.InvocationRegistry; nothing is rooted until assembly. The graph is materialized once, byAssembleDefinition, from the invocation set. - One name surface, three tiers (phase-8 D12 + I4).
planresolves attributes through three tiers with collision detection — a sub-namespace adapter, a promoted root-provider method, or the provider's own method. - The dual front door. Starlark reaches the provider through the projected receiver; Go callers reach the same
invocation machinery through
Provider.Plan(name, args, kwargs)— writ's command family, lore's builder, and the devlore-test runner all plan through it. Both doors produce identical graphs.
Provider.ResolveAttr(name) implements op.AttributeResolver over three tiers, checked in order
(pkg/op/provider/plan/provider.go):
| Tier | Surface | Example | Mechanism |
|---|---|---|---|
| 1 | sub-namespace adapters | plan.file.write_text(...) |
lazy-minted adapter per op.ProviderReceiverType, cached under adaptersMutex |
| 2 | promoted root-provider methods | plan.gather(...), plan.choose(...) |
builtins discovered from op.ReceiverRegistry.RootProviders at construction (+devlore:root=true → flat surface) |
| 3 | the provider's own methods | plan.assemble_definition(...), plan.variable(...) |
the executing-receiver path wrapping plan.Provider as a goReceiver |
Any name collision across the tiers is a construction-time panic naming both providers and the offending method.
The adapter (pkg/op/provider/plan/adapter.go) holds no business logic: plan.<X>.<method>(args, kwargs) splits
reserved kwargs, converts values through the starlarkbridge conversion path, and routes into
Provider.invocation — every dispatch decision lives there and in the method's declared op.Planner.
A plan-mode call does not execute its action; it records the intent:
- The call resolves the target method's
op.ProviderReceiverTypemetadata. Provider.invocationmints a detached*op.Invocation— the unit-to-be, carrying the action name, converted arguments, and slot bindings — and registers it in the session ledger (Provider.InvocationRegistry(), theop.PlanInvocatorsurface planners use for body-resolution lookups).- Bodies (
body=,when=,then=,on_error=) reference other invocations; containment is resolved at assembly.
Provider.Plan(name op.ActionName, args []any, kwargs map[string]any) is the same step from Go — the mirror of a
plan.<name>(...) Starlark call. Live Go callers: cmd/lore/lore/builder.go, cmd/writ/writ/upgrade,
cmd/writ/writ/adopt, cmd/writ/writ/decommission, and the devlore-test runner.
AssembleDefinition(invocations, slots, onError, onRetry, retryPolicy, transitionPolicy, origin) materializes the
sealed graph. The pipeline:
- Project — the invocation list becomes the root's children (
[]op.ExecutableUnit);on_error=invocations become a*op.Subgraph; the slot map projects toop.Bindings viaprojectToBinding. - Catalog ownership transfers — the planning
ResourceCatalogis captured off the runtime environment (whose reference is cleared) and handed to the graph being constructed. - Seal —
op.NewGraphwith the origin (tool-stamped from the planning application's name), catalog, children, policies, and slots: edges materialize, children toposort, canonical content hashes (2-execution-graph.md). - Orphan scan — any registered invocation never rooted (empty parent, not contained by any body) is an error; the joined set is returned whole (phase-8 step 14).
- Validate —
op.ValidateGraphover the sealed graph (guarded-edge shape, cycles, promise type-check).
| Method | Contract |
|---|---|
Spec(programName, rootPath, flags) |
a fresh *op.RuntimeEnvironmentSpec for Run, defaulted from the planning environment (program name, root, application flags) |
Run(graph, spec) |
executes via op.NewGraphExecutor(graph, spec).Run — the run's result is the final dispatch's output (2-execution-graph.md); nil arguments are rejected |
SaveDefinition(graph, path) |
serializes the graph document; format from path's extension (.json / .yaml) |
LoadDefinition(path) |
reconstructs a sealed *op.Graph via op.LoadGraph; format from the extension; the save → load → run round trip is the plan.save / plan.load contract (phase-8 step 13) |
Clear() |
resets the session ledger (op.InvocationRegistry.Reset) — plan-state hygiene between programs |
| Method | Contract |
|---|---|
Case(when, then) |
pairs a when-subgraph with a then-subgraph into a *flow.Case — Choose's construction unit; bodies are invocation lists, singletons, or lambdas desugared to the function.call leaf (3.5.2 §6) |
Variable(name, defaultValue, field) |
an *op.Variable reference resolving at execution time against the run's variable surface; field projects one field of a record value |
Item(field) |
the gather-iteration shorthand — a projected reference to one field of the enclosing gather's per-iteration item (plan.item("name") ≡ plan.variable("item", field="name"); phase-8 step 45) |
Origin(scope) |
an op.Origin carrying the planning scope, stamped onto the assembled graph — a Go-side caller concern; the .star surface never supplies it |
The announced Starlark names and their Go signatures (gen/provider.gen.go; snake_case per surface convention):
// Tier-3 own methods (starlark name — Go method)
// plan.assemble_definition(invocations, slots?, on_error?, on_retry?, retry_policy?, transition_policy?, origin?)
func (p *Provider) AssembleDefinition(invocations []*op.Invocation, slots map[string]any,
onError []*op.Invocation, onRetry []*op.Invocation, retryPolicy *op.RetryPolicy,
transitionPolicy *op.TransitionPolicy, origin op.Origin) (*op.Graph, error)
func (p *Provider) Case(when, then any) (*flow.Case, error) // plan.case(when, then)
func (p *Provider) Clear() error // plan.clear()
func (p *Provider) Item(field string) *op.Variable // plan.item(field)
func (p *Provider) LoadDefinition(path string) (*op.Graph, error) // plan.load_definition(path)
func (p *Provider) Origin(scope string) op.Origin // plan.origin(scope)
func (p *Provider) Plan(name op.ActionName, args []any,
kwargs map[string]any) (*op.Invocation, error) // plan(name, *args, **kwargs) / Go door
func (p *Provider) Run(graph *op.Graph, spec *op.RuntimeEnvironmentSpec) (any, error) // plan.run(graph, spec)
func (p *Provider) SaveDefinition(graph *op.Graph, path string) error // plan.save_definition(graph, path)
func (p *Provider) Spec(programName string, rootPath string,
flags map[string]any) (*op.RuntimeEnvironmentSpec, error) // plan.spec(program_name?, root_path?, flags?)
func (p *Provider) Variable(name string, defaultValue any,
field string) *op.Variable // plan.variable(name, default_value?, field?)
// State management
func (p *Provider) InvocationRegistry() *op.InvocationRegistry // the op.PlanInvocator surface
// Attribute resolution (op.AttributeResolver)
func (p *Provider) ResolveAttr(name string) any // tier 1 adapter | tier 2 promoted builtin | tier 3 own methodStarlark (the devlore-test corpus shape — 60 fixtures drive assemble + run directly):
staged = plan.file.write_text(path="a.txt", text="hello")
graph = plan.assemble_definition([staged])
result = plan.run(graph, plan.spec())Go (the lore-builder / writ-command shape):
resolved, err := runtimeEnvironment.ProviderByType(reflect.TypeFor[plan.Provider]()) // the cached module provider
provider := resolved.(*plan.Provider) // (the lore-builder shape)
invocation, err := provider.Plan(file.WriteText, nil, map[string]any{"path": "a.txt", "text": "hello"})
graph, err := provider.AssembleDefinition([]*op.Invocation{invocation}, nil, nil, nil, nil, nil, origin)
result, err := provider.Run(graph, spec)Both doors converge on the same invocation registry, the same assembly pipeline, and the same sealed graph.
| Surface | State | Notes |
|---|---|---|
Three-tier ResolveAttr + adapters |
implemented | collision-checked at construction; adapters lazy + cached |
Detached invocations + Plan |
implemented | the dual front door; session ledger via InvocationRegistry |
AssembleDefinition |
implemented | orphan scan + ValidateGraph at seal; catalog ownership transfer |
Spec / Run |
implemented | thin over op.NewGraphExecutor |
SaveDefinition / LoadDefinition |
implemented | JSON + YAML by extension; round-trip proven via the lifecycle suite |
Case / Variable / Item / Origin / Clear |
implemented | construction aids; Item is step 45's field projection |
- Graph signing at assembly.
AssembleDefinitionleaves sub-graphs unsigned (no signing client propagated);cli.WriteGraphsigns at persist instead. Whether assembly should accept a signer is open (step 46 follow-on). Narrowed 2026-08-17 by theop.SaveGraph/op.SaveTracesplit: the checksum moved to the artifact, becauseLoadTracerefuses a document without one and the pair has to be total. The signature deliberately did not, because the key is the publisher's — the artifact cannot know which identity is publishing it. So the open question is no longer "where does signing happen" but "who supplies the signer, and when": today the store does, at persist; assembly could, if a signing client were propagated. plan.casecollision surface.Caselives on the plan provider but constructs aflow.Case— the one tier-3 method whose type belongs to another provider; kept here so the construction surface is uniform.