Skip to content

Latest commit

 

History

History
173 lines (136 loc) · 11.6 KB

3.5.3-plan-provider.md

File metadata and controls

173 lines (136 loc) · 11.6 KB

Plan Provider

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 .star program and every Go planning caller builds, assembles, saves, loads, and runs graphs through it. Companion: 3.5.3-plan-provider.status.md.

See also:

Thesis

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:

  1. Detached invocations (phase-8 D5). NewProvider constructs no graph. A plan.<ns>.<method>(...) call mints a detached *op.Invocation registered in the session's op.InvocationRegistry; nothing is rooted until assembly. The graph is materialized once, by AssembleDefinition, from the invocation set.
  2. One name surface, three tiers (phase-8 D12 + I4). plan resolves attributes through three tiers with collision detection — a sub-namespace adapter, a promoted root-provider method, or the provider's own method.
  3. 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.

1. The three-tier attribute surface

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.

2. The invocation model

A plan-mode call does not execute its action; it records the intent:

  1. The call resolves the target method's op.ProviderReceiverType metadata.
  2. Provider.invocation mints 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(), the op.PlanInvocator surface planners use for body-resolution lookups).
  3. 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.

3. Assembly — AssembleDefinition

AssembleDefinition(invocations, slots, onError, onRetry, retryPolicy, transitionPolicy, origin) materializes the sealed graph. The pipeline:

  1. Project — the invocation list becomes the root's children ([]op.ExecutableUnit); on_error= invocations become a *op.Subgraph; the slot map projects to op.Bindings via projectToBinding.
  2. Catalog ownership transfers — the planning ResourceCatalog is captured off the runtime environment (whose reference is cleared) and handed to the graph being constructed.
  3. Seal — op.NewGraph with 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).
  4. 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).
  5. Validate — op.ValidateGraph over the sealed graph (guarded-edge shape, cycles, promise type-check).

4. The lifecycle — Spec, Run, SaveDefinition, LoadDefinition, Clear

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

5. Construction aids — Case, Variable, Item, Origin

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

6. API surface

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 method

7. Usage — both doors

Starlark (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.

8. Status and roadmap

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

9. Open questions

  1. Graph signing at assembly. AssembleDefinition leaves sub-graphs unsigned (no signing client propagated); cli.WriteGraph signs at persist instead. Whether assembly should accept a signer is open (step 46 follow-on). Narrowed 2026-08-17 by the op.SaveGraph / op.SaveTrace split: the checksum moved to the artifact, because LoadTrace refuses 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.
  2. plan.case collision surface. Case lives on the plan provider but constructs a flow.Case — the one tier-3 method whose type belongs to another provider; kept here so the construction surface is uniform.