Skip to content

Phase 8: the workflow-engine foundation for devlore operations - #288

Merged
David-Noble-at-work merged 720 commits into
developfrom
refactor/extract-starlark-from-op.phase-8
Jul 25, 2026
Merged

David-Noble-at-work merged 720 commits into
developfrom
refactor/extract-starlark-from-op.phase-8

Conversation

@David-Noble-at-work

Copy link
Copy Markdown
Contributor

Prototype → foundation. Before this branch, pkg/op could build and run a graph once. This branch makes it foundational infrastructure: a sealed, restartable, compensable, observable graph-execution engine with plan-time authoring in Go and Starlark, a typed provider ecosystem, content-addressed resources, and settled designs for the systems that drive real devlore operations. 59 of 63 tracked steps complete; the rest are deferred by design.

What is now true

  1. The graph is a sealed, immutable, restartable artifact — per-execution RuntimeEnvironment, per-subgraph child executors; graph + trace rehydrates and continues. (1-system-model.md, 2.2-phase-execution.md)
  2. Plans are authored at plan-time (Go or Starlark), validated before anything runs — detached plan.* invocations, the subgraph/choose/gather/wait_until combinators, orphan detection, topological type-check. (2.3-orchestration-primitives.md, 3-operation-namespaces.md)
  3. Failure is a first-class saga contract — compensable actions, the recovery-stack tree, compensation_failed with a restart contract. (compensation-failure-contract.md, 5.2-recovery-serialization.md)
  4. The run has a state machine — RunStatus{Phase, Condition, Reason}, {completed, stopped} × Condition terminals, TransitionPolicy. (2.2, 2.6-execution-policies.md)
  5. Resources carry identity — two-model (location/CAS), Digest/Etag/Addressing, Pending→Active→Gone, the catalog; pass-by-value typed slots. (4-resource-management.md, 2.1-typed-slots.md)
  6. Argument→parameter conversion is one target-driven cascade — op.Convert + SourceConverter/TargetConverter opt-ins (the old source-first path retired). (pkg/op/convert.go)
  7. The provider surface is projected and populated — provider-owned router over brokers; ~16 providers designed + documented. (3.2-projected-provider-api.md, 3.5-provider-catalog.md)
  8. Packaging + integrity run on the sealed graph — writ migrate/deploy, lore, graph signing (pkg/signing), content identity in the trace. (graph-signing.md, 2.4-hermeticity-guarantees.md)
  9. Platform handling is unified — one pkg/platform + per-backend NeedsSudo(). (3.4-platform-package-managers.md)
  10. The run's HTTP surface is pkg/op/server — a run-id → control-plane Router serving commands + SSE events + status. (2.7-control-plane.md)
  11. Settled designs for the surrounding systems — configuration (configuration.md), OpenTelemetry-native eventing (2.8-eventing-infrastructure.md), elevation problem space (6.1-privilege-elevation.md).

Deferred by design (post-PR)

  • Step 38 — elevation policy (design resumes post-PR; implementation gated on the config foundation).
  • Step 50 — eventing implementation (design solidified; built next as an OTel-based Application Observability framework in a dedicated worktree).
  • O3 — pkg/op → pkg/workflow rename (deferred pending closure).

Readiness

Repository-wide make test green (0 FAIL); all apps compile; the phase-8 PR gate (step 24) and test gate (step 18) MET. O1 (conversion design) + O2 (bind retired) settled; test backfill (step 52) complete.

David Noble added 30 commits June 21, 2026 14:10
…ave/load/restart

Captures the step-28 execution-core design worked out this session.

- Chained recovery stacks (option C) -- the resolution to the activation.Stack
  regression: every executor owns its stack, and the per-subgraph stacks form
  one tree linked two ways -- chain UP (parent pointer) so ResultByUnitID walks
  to an ancestor stack for promise resolution, and nest DOWN (PushNested) so
  Unwind cascades compensation. activation.Stack is simply the executor's own
  stack; input resolution walks the chain up, so there is no conflict. Save/
  restore rule: the nesting is durable (serialized in the Trace), the parent
  pointer is transient (re-derived on load) -- which is what makes resume's
  promise resolution and skip-completed work. New "Chained recovery stacks"
  section in the step doc (with diagram); a principle bullet in 2.3.

- Save / load / restart sequence diagram (RUN -> PAUSE -> SAVE -> LOAD ->
  RESTART) with proof points.

- Control plane -- the executor's bidirectional command/event surface: commands
  in (ExecutionControl: pause/stop/step, replacing the pause bool, polled at a
  control-point switch) and events out (HookRegistry + Status + Result),
  including the finding that Status/Result are misfiled on RuntimeEnvironment;
  ownership on the GraphExecutor with the two-axis context-object taxonomy.
  Scoped as plan step 33.

- Combinator design finalized: every combinator IS a subgraph; non-Subgraph
  combinators delegate to flow.Provider.Subgraph; Choose does NOT select; every
  combinator keeps its compensate companion.

- phase-8.md: step-28 row updated to implementation-in-progress with option (C);
  steps 31/32/33 in place.

Docs only; the step-28 implementation code is uncommitted pending the option
(C) fix.
… step-28 design records

Implementation (step 28 item (a), green):
- RecoveryStack gains a transient `parent` pointer; ResultByUnitID now walks UP
  the parent chain (this stack -> parent -> ...) for promise resolution.
  Constructor newRecoveryStack(parent) added.
- newChildExecutor(parentStack) chains the child stack to the parent;
  subgraph.Execute passes the dispatch's stack, so the chain mirrors the
  subgraph tree.
- flow.Subgraph binds kwargs -> subgraph.Parameters() and reads activation.Stack
  (the executor's own stack) instead of minting one.
- This is option (C): activation.Stack is the executor's own stack, and input
  resolution walks the chain up -- resolving the activation.Stack overload that
  had broken TestChoose_NotExists / TestChoose_Predicates /
  TestSubgraph_ReturnsRecoveryStack. The tree is back to the known baseline.

Test renames (TestSubject_Scenario standard):
- TestShellCompletionPath -> TestShellCompletionPath_PerShell (cmd/star/cli,
  internal/cli); TestWalkTreePlanned -> TestWalkTree_Planned (devloretest).

Design records (step-28 doc + 2.3 + phase-8.md):
- Chained recovery stacks (up for resolution, down for unwind; nesting durable /
  parent pointer transient -- re-derived on Trace load).
- Compensation gates on the complement, not a resource (closing the open issue):
  the named Compensate companion is the live undo path; fix the base-op latent
  bug where RecoveryStack.Push gates on receipt.Resource() != nil, proven by
  file.Provider.WalkTree (returns a *op.RecoveryStack complement + CompensateWalkTree,
  dead code today). Gate on Complement(), env from the executor, route through
  the action's Undo companion.
- Save/load/restart sequence diagram; combinator design (Choose does NOT select;
  every combinator keeps its companion); control plane (commands in /
  ExecutionControl + events out / HookRegistry+Status+Result, with the
  Status/Result migration); plan steps 31/32/33.

make test: pkg/op + flow + plan ok; only the 4 pre-existing baseline failures
remain (TestBackup_DefaultSuffix, TestCompensation, TestShellCompletionPath_PerShell
[the pwsh/powershell issue, step 25 -- now under its renamed name],
TestWalkTree_Planned [step 22]). Zero new failures.
…companion

Compensation was keyed on receipt.Resource(): RecoveryStack.Push wired an entry
for compensation only when the receipt carried a single Resource, and
invokeCompensateForReceipt read the RuntimeEnvironment off that resource. So a
resource-less complement -- a combinator or file.Provider.WalkTree recovery
stack -- was silently demoted to audit-only, its named Compensate companion dead
code, and compensation rode an implicit nested auto-unwind instead. Gather's
forthcoming []*op.RecoveryStack slice would have been dropped outright.

Gate on the complement, supply the env from the executor, and make the named
companion the live undo path:

- RecoveryStack.Push takes the executor's RuntimeEnvironment and gates on
  Complement() != nil (undo state of any shape -- a resource action's receipt, a
  recovery stack, or a slice).
- invokeCompensateForReceipt is env-parameterized (no resource lookup) and hands
  Receipt.Complement() to Method.Undo. Equivalent on the resource path:
  receipt.Complement() == receipt for a resource action (method.go:545).
- pushAuditReceipt drops the PushNested branch; a *op.RecoveryStack complement
  now rides the audit receipt via Commit, exactly like a resource *Receipt.
- RecoveryStack.Receipts descends into a receipt's *RecoveryStack complement so
  Trace.Summarize still observes subgraph / WalkTree children.
- buildSubStackFromReceiptSlice threads activation.RuntimeEnvironment.
- Stale nest/PushNested doc comments corrected (subgraph.go, graph_executor.go).

CompensateWalkTree / CompensateSubgraph / CompensateChoose / CompensateGather are
now invoked, uniform with resource-action compensation and registry-resolvable
(survives a Trace load).

make test: pkg/op + flow + file compensation green; zero new failures. The 4
standing baseline failures are unchanged -- TestCompensation remains a
pre-existing failure->unwind integration gap (step 28 item (b)), proven not this
change. Build-failed consumers (writ/lore/docgen) are pre-existing WIP
(Assemble rename / op.ImmediateOf removal), untouched here.
…nd 6.1

- 3.2: brokers defined like resources -- self-announce globally
  (op.AnnounceBroker ~ op.AnnounceResource), own contract + config schema on a
  common op.BrokerBase, wired by providers (op.WireBrokers), fresh instance per use
- configuration.md: elevator case study aligned to broker-owned config + wiring
- 6.1: broker-registry section repointed to the resource-mirrored model; the
  config-shape open item resolved
- phase-8 plan: foundation-types item notes the recursive tree; elevator item
  aligned to the broker seam
- configuration.md: typed sections project via a lazy reflection adapter as the
  sealed Mapping, uniform across Config / typed sections / ConfigBase / DataSection;
  struct-valued settings cross as goReceivers through the existing reflection
  framework (marshalReflect) -- not flattened dicts (fixes the projection wording)
- phase-8 plan: star-unification item notes the settled travel form
- configuration.md: the per-setting origin record is a path-keyed override chain --
  SetBy(path) gives the winning layer, History(path) the full chain; stamped by
  appending during the merge; the word "provenance" is gone from the doc
- phase-8 plan: loader item and landed-status reflect the SetBy/History reshaping
The failure->unwind cascade never fired: a failed subgraph's recovery state was
thrown away before it could compensate. Two execution-core defects, both masked
until the compensation gate made combinator receipts compensable.

1. Method.Invoke discarded the complement on ANY dispatch error
   (`if err != nil { return nil, nil, err }`), so the recovery stack a
   compensable forward call returns on failure never reached its audit receipt.
   Now the complement is committed and returned ALONGSIDE dispatchErr, so the
   receipt is compensable and Run's top-level e.stack.Unwind() cascades through
   CompensateSubgraph into the children.

2. invokeCompensateForReceipt resolved the companion via ActionByPath, which
   keys on the Go-qualified ActionName (github.com/.../flow.Provider.Subgraph),
   but a unit that binds its action by name -- the graph root and every
   combinator -- records the dotted name (flow.subgraph) as its action path, so
   the lookup missed. It now falls back to RuntimeEnvironment.ActionByName (the
   same resolver dispatch uses) on a miss.

Both pinned with a live stack dump at the undo seam (instrumentation since
removed; receiver_registry.go is net-unchanged and not staged).

make test: TestCompensation green (was a standing baseline failure), pkg/op
green, zero new failures. Remaining baseline failures unchanged
(TestBackup_DefaultSuffix, TestWalkTree_Planned, TestShellCompletionPath_PerShell);
writ/lore/docgen build failures are pre-existing WIP.
Resume re-entry is settled as a side-effect-free "pseudo replay": Run re-descends
the graph from the root and the restored trace's receipts dictate the descent --
skip any unit with a successful receipt (return its cached result, prune the
completed subtree) and descend only the unfinished spine to the frontier.

The key distinction captured: "do nothing" is not literally passive. The descent
must (1) adopt each subgraph's restored child stack -- option (C) mints a fresh
one otherwise, which would re-run the completed children -- and (2) re-resolve
the per-subgraph variable frames, the one bit of execution state the Trace does
not carry (pure recomputation against the restored stacks). It is a re-walk
rather than a literal jump because ErrPaused unwound the Go call stack back to
Run; only the recovery stack is restorable data.

Scope is increment (X): Run's preamble becomes state-driven (accept
RunStatePaused, keep trace.Stack, use trace.Variables); a per-unit
skip/adopt/fresh guard in node.Execute / subgraph.Execute; pause-receipt
supersession. No replay-map, no work-list rewrite, no frame persistence; the
larger resumable-dispatch-loop rewrite (Y) is deferred.

- step-28 step doc: new "Resume re-entry -- pseudo replay" section; refreshed the
  implementation-status block (option C + compensation gate + failure->unwind now
  landed and committed; (b) designed/ready, (c) pending).
- 2.3 architecture model: resume bullet.
- phase-8.md step-28 row: resume-design-settled note.

Design record only -- no code; implementation of (b) awaits sign-off on this plan.
… correction

Two design-doc updates for step 28, each reflected in the architecture doc (2.3),
the step doc, and the plan row.

1. Resume re-entry (b) settled as a side-effect-free "pseudo replay": Run
   re-descends the graph from the root and the restored trace's receipts dictate
   the descent -- skip any unit with a successful receipt (return its cached
   result, prune the completed subtree), descend only the unfinished spine to the
   frontier. "Do nothing" is not literally passive: the descent must adopt each
   subgraph's restored child stack (option C mints a fresh one otherwise, which
   would re-run completed children) and re-resolve the per-subgraph variable
   frames (the one bit of state the Trace does not carry). Re-walk rather than a
   literal jump because ErrPaused unwound the Go call stack back to Run. Scope is
   increment (X); the resumable-dispatch-loop rewrite (Y) is deferred.

2. Chained-stacks model corrected to match the compensation-gate fix: a
   subgraph's child stack rides DOWN on its parent's audit receipt as that
   receipt's *complement* (committed via Commit), not a separate PushNested
   entry. Unwind cascades by invoking each receipt's Compensate companion, which
   unwinds the complement child stack. PushNested now serves only Gather's
   internal per-item grouping. Updated the chained-stacks bullet (2.3), the
   step-28 chained-stacks section (bullet, ASCII diagram, failure-unwind walk,
   save/restore subsection), and the plan row.

Design records only -- no code. Implementation of (b) awaits sign-off.
Resume a paused run by re-descending the graph and replaying what the trace
already records -- side-effect-free, no re-dispatch of completed work.

- Run is state-driven: it accepts a resumed (RunStatePaused) executor, keeps the
  restored trace.Stack instead of resetting it, and re-publishes trace.Variables
  onto the fresh environment instead of re-binding.
- RecoveryStack.receiptByUnitID + supersede: single-stack receipt lookup and
  removal (the parent chain is for resolution; a unit's own receipt is local).
- node.Execute / subgraph.Execute carry a per-unit guard. A unit already holding
  a successful receipt is replayed (cached result returned, not re-dispatched).
  A subgraph holding an ErrPaused receipt is the in-progress spine: it adopts its
  restored child stack (re-parented to the enclosing stack) so completed children
  are present and the descent resumes at the frontier, and supersedes the stale
  receipt so the fresh completion replaces it. Otherwise it runs fresh. On a
  fresh run no prior receipt exists, so the guard is a no-op.
- newChildExecutor takes the caller-built child stack -- newRecoveryStack(parent)
  for a fresh dispatch, or the adopted restored stack on resume.

Tests (lifecycle_api_test.go): TestGraphPauseResume_ViaPublicAPI (flat) and
TestGraphPauseResumeNested_ViaPublicAPI (recursive adopt across a nested
flow.subgraph) -- pause mid-graph via a lifecycle hook, resume from the Trace on
a fresh executor, both side effects present, file.mkdir completed == 2 (no node
re-dispatched).

Scope: this is the IN-PROCESS increment (pause -> Trace() -> ResumeExecutor ->
resume, same process). Still required to close step 28: the full
save->load->resume serialize round-trip (a deserialized receipt complement is
untyped today; adopt detection moves to Err() != nil), Catalog capture/restore
in Trace (c), and Gather resume (N-dispatch). See the step doc's "Remaining for
step 28" section.

make test: pkg/op + pkg/op/provider/plan green; zero new failures (only the
standing baseline TestBackup_DefaultSuffix / TestWalkTree_Planned /
TestShellCompletionPath_PerShell, plus pre-existing writ/lore/docgen build
breaks). Plan + step doc updated.
A paused run's Trace now survives save -> reload -> resume on disk.

The recovery stack owns a provider-agnostic execution-state envelope
(receiptEnvelope) read off the Receipt interface, so a reloaded receipt restores
what resume needs -- unit_id, action, result, status, and a reconstructed
*RecoveryStack complement -- regardless of which provider produced it. This
closes a gap where leaf provider receipts (e.g. file.Receipt) serialized only
their compensation URIs and dropped unit_id/result/status, so a reloaded trace
could neither identify a completed unit (skip) nor return its result (promise).
Putting the execution state on the stack keeps the burden off provider authors:
a new or maintained provider cannot forget to encode resume state.

The envelope carries the dotted action (e.g. "file.mkdir"), not the Go-qualified
action_path -- compensation resolves the companion from action via the
ActionByName fallback, keeping the long Go path out of the document.

- RecoveryStack.MarshalJSON / MarshalYAML emit the envelope; UnmarshalJSON
  reconstructs the tree recursively (nested sub + receipt envelopes), rebuilding
  a stack-shaped complement as *RecoveryStack.
- Adopt detection moves from errors.Is(Err(), ErrPaused) to Err() != nil --
  serialize-safe, since a reloaded error is a plain message and a resumable trace
  carries only pause-spine error receipts.

Test (lifecycle_api_test.go): TestGraphSaveLoadResume_ViaPublicAPI -- pause
mid-graph, write the Trace to disk, reload via document.ReadFile, resume on a
fresh executor -> completes, no node re-dispatched. In-process resume tests stay
green.

Scope: happy-path save->load->resume. Still required to close step 28: YAML
trace deserialization, compensation-after-resume (reconstruct each provider's
concrete receipt + resource), cross-pause promise fidelity, Catalog in Trace (c),
Gather resume, and the Starlark variant. See the step doc's "Remaining for
step 28".

make test: pkg/op + pkg/op/provider/plan green; zero new failures (only the
standing baseline + pre-existing writ/lore/docgen build breaks). Plan + step doc
updated.
…reference by URI

Resume must reconstruct the live ResourceCatalog whatever the case; the open fork
was how. Option B (confirmed for full fidelity): the Trace serializes the
ResourceCatalog and the recovery stack references ledger entries by URI; resume
rebuilds the live catalog from the saved ledger and resolves each receipt's
result/boundary/source against it.

This unifies two formerly-separate remaining items -- compensation-after-resume
and "(c) catalog capture/restore" -- into one ledger-centric mechanism. A
file.Receipt is already a reference record: its compensation state is three
*Resource references (the produced resource, boundary, source) plus the scalars
recoveryID/recoveryDigest. boundary/source can be discovered, not produced (a
Backup's source is the pre-existing original, with no production receipt), so
rebuilding the ledger from production receipts is lossy in exactly the entries
compensation needs -- saving the ledger is full-fidelity. The reference form is
the URI: what result already carries, self-describing in the document, resolved
via the namespace.

Two gen-registered factories at the op<->provider seam: resource-from-URI reuses
the AnnounceResource constructor (typeID-keyed, no new registry);
receipt-from-references is a new AnnounceReceipt, resolved by action -> provider.

Docs only -- design settled, implementation pending:
- step-28 doc: new "The resource ledger across resume" section; the Remaining
  list unifies compensation-after-resume + catalog capture under Option B; the
  (c) proof point marked resolved by the design.
- phase-8.md: step-28 row records the settled Option B design + reworked
  Remaining list.
…ences by id

Shadowing makes a URI a non-unique key. The catalog is an append-only ledger and
Shadow re-catalogs an existing URI through catalogLocked (revival after Gone, a
producer shadowing a discovery, re-observation), minting a fresh id per
generation. byID distinguishes every generation; the URI->id namespace tracks
only the current one, so Discover(uri)/Current(uri) resolve to the current
generation and a superseded one is reachable solely by Lookup(id). Referencing a
receipt's resource by URI would yield the wrong generation when that resource was
shadowed before the pause -- only the id pins it, and only the full saved ledger
holds every generation.

So Option B is corrected: the Trace serializes the ResourceCatalog as all
generations keyed by resource id (each entry {id, uri, producerID, state} +
nextID; the URI rebuilds the Resource object, the id is restamped as identity,
byID/ns/states replay in append order), and the recovery-stack envelope
references result/boundary/source by id, resolved on resume via Lookup(id).

This also corrects an earlier framing: file.Receipt.hydrate reconstructs
boundary/source from their encoded URIs, so the discovered-not-produced case did
not by itself force the full ledger -- shadowing does.

Docs only -- design settled, implementation pending:
- step-28 doc: "The resource ledger across resume" section rewritten onto id
  references + the shadowing rationale; (c) proof point + Remaining list + anchors
  updated.
- phase-8.md: step-28 row's ledger-in-Trace design corrected to id references.
…drate

A paused run's resource ledger now serializes into the Trace and rebuilds on
resume with every generation keyed by its catalog id -- the foundation for
referencing receipts by id (B3) so shadowed generations resolve to the exact
resource the receipt captured, not the current one.

B1 -- ledger save:
- ResourceCatalog.Snapshot() projects every generation in append order
  ({id, uri, producerID, state}) plus the observation index and nextID into a
  serializable ResourceLedgerSnapshot (SUPPORTING TYPES).
- Trace gains Catalog *ResourceLedgerSnapshot.
- GraphExecutor snapshots the ledger in Run's teardown when the run pauses (the
  environment is nil'd after Run, so Trace() -- called by the host afterward --
  would otherwise have nothing to read); Trace() emits the live snapshot during a
  run and the captured one after. ResumeExecutor seeds it from trace.Catalog.

B2 -- id-preserving rehydrate:
- receiverRegistry.ResourceConstructorByTypeID resolves a resource constructor
  from the Go type id carried in a URI fragment, so op rebuilds a provider's
  Resource without importing the provider.
- ResourceLedgerSnapshot.Rehydrate rebuilds the live catalog: each generation is
  reconstructed from its URI (ExtractTagSpecific splits off the `specific` part
  the constructor accepts -- buildCandidate rejects the full tag URI), with
  interning disabled so ResourceCatalog.restoreEntry stamps the saved id rather
  than minting a fresh one. Append order reproduces the namespace's
  current-generation pointer.
- Run's resume branch rehydrates trace.Catalog and installs it as the env's
  catalog (a fresh clone would lose superseded shadow generations).

Tests (lifecycle_api_test.go): TestResourceLedgerRehydrate_PreservesIDs (snapshot
-> rehydrate -> Lookup(savedID) returns the same URI); TestGraphSaveLoadResume
now asserts the ledger snapshot round-trips and resumes with rehydration active.

make test: pkg/op + pkg/op/provider/plan green; zero new failures (only the
standing baseline + pre-existing writ/lore/docgen build breaks). Plan + step doc
record B1+B2; remaining for step 28 narrowed to B3.
…eEncoded

A resumed-then-failed run now rolls back its pre-pause work. A reloaded receipt is
reconstructed into its concrete provider type, its resource references resolved by
id against the rehydrated ledger, and its compensation re-armed -- so unwinding the
resumed stack compensates work the pause-time run completed.

Receipt.RestoreEncoded(env, bytes) is a method on the Receipt interface: the
ReceiptBase default restores base execution state + the *RecoveryStack complement
(subsuming the former unmarshalReceiptEnvelope free function so every receipt
inherits it via embedding), and file.Receipt overrides it to resolve its
resource/boundary/source ids via ResourceCatalog.Lookup. The env arrives as a
parameter, matching how pkg/op injects the runtime environment everywhere (always a
constructor parameter, never a setter); it replaces file.Receipt's old
UnmarshalJSON/UnmarshalYAML/hydrate, which required a pre-seeded env-bearing
resource and resolved by URI (a shadowed generation shares its URI, so resolution
must be by id).

No receipt registry. op cannot construct a *file.Receipt, and json.Unmarshal cannot
decode into an interface, so it reflect.News the concrete type first -- read off the
Compensate companion op already resolves by action. A complement is one of three
shapes (isLegalCompensableComplement): a Receipt, a []Receipt, or a *RecoveryStack.
B3 reconstructs the single-Receipt shape (file.*/archive.Extract/service.*), where
the forward method returns (result, receipt, error) and the companion takes that
receipt -- so the receipt is its own complement; MarshalYAML emits the `receipt`
sub-field only for that self-complement case. []Receipt complements
(pkg.Install/Remove/Upgrade) are a follow-up: carrying no sub-field, such a trace
resumes without that receipt's compensation rather than failing. *RecoveryStack
complements (file.WalkTree, flow.*) ride the complement field, restored by the base.

- file.Receipt: id-based MarshalYAML (resource_id/boundary_id/source_id + recovery
  key/digest); RestoreEncoded override; lookupResource helper.
- recovery_stack: envelope gains a `receipt` sub-field for resource receipts;
  recoveryEntry retains the raw envelope at load; a resume-time rearm pass
  reconstructs concrete receipts, reinstates the self-complement framework-level (a
  resource receipt is its own complement -- Commit sets it on the produce path), and
  binds compensation; reconstructReceipt + receiptTypeForAction.
- method.Method.complementType reads the companion's last parameter type.
- graph_executor: Run's resume branch calls rearm after Rehydrate.

Test (lifecycle_api_test.go): TestGraphResumeThenFail_RollsBack_ViaPublicAPI --
pause after a mkdir, save, reload, resume, fail the un-run frontier, assert the
pre-pause directory is rolled back.

End-to-end lifecycle (lifecycle_e2e_test.go): TestLifecycle_ViaGoAPI drives the Go
executor through run-to-completion, pause+resume, and fail+rollback;
TestLifecycle_ViaStarlark drives execution from a .star via plan.run through
run-to-completion and fail+rollback -- a failure inside plan.run unwinds and
compensates automatically (the same Run() path). Pausing a live run is the pending
out-of-process eventing API, not a synchronous-script concern, so pause+resume is
exercised only on the Go side.

make test: pkg/op + pkg/op/provider/plan green; zero new failures (only the standing
baseline + pre-existing writ/lore/docgen build breaks). Step doc + plan record B3 as
built and the verified companion-is-the-receipt identity.
…ng items

The step-28 doc and the phase-8 plan now record the committed end-to-end lifecycle
suite (lifecycle_e2e_test.go) and reframe the remaining work in light of the
eventing-API model:

- Implemented/coverage: TestLifecycle_ViaGoAPI drives the Go executor through
  run-to-completion, pause+resume, and fail+rollback; TestLifecycle_ViaStarlark
  builds, saves, loads, and runs via plan.run through run-to-completion and
  fail+rollback -- a failure inside plan.run unwinds and compensates on the same
  Run() path, so rollback holds whether the run is launched from Go or Starlark.

- Remaining item reframed: Starlark build/save/load/execute + rollback is now done
  (the e2e suite), and Starlark-driven pause/resume is the eventing API (step 33),
  not a synchronous-script variant -- so the old "Starlark resume variant" collapses
  to replacing TestSubgraph_ReturnsRecoveryStack. The []Receipt-complement
  compensation-after-resume follow-up is also recorded in the plan's remaining list.

Docs only; no code change.
…ction

Record the corrected design for sub-step 10. B3 made receipt reconstruction
JSON-byte-bound (Receipt.RestoreEncoded([]byte) + json.Unmarshal; raw-bytes
entry.encoded), which can reconstruct only a JSON-decoded trace. That is out of step
with the recorded requirement (graph-signing.md): the graph AND its execution trace
serialize to three formats (JSON, YAML, Protobuf), and verify decodes a trace from
any format, re-canonicalizes, and verifies -- one signature verifies in any format.

The step-28 doc gains a "Format-neutral trace reconstruction" section: reconstruction
consumes a decoded value (not bytes); the codec owns field decode while the receipt
keeps a format-neutral id-resolution hook; type discovery stays registry-free (action
-> Compensate companion), which is exactly what lets Protobuf reconstruct without
google.protobuf.Any. Remaining item (1) is reframed from "YAML trace deserialization"
to this redesign in both the step doc and the phase-8 plan.

Also brings the step-28 doc and the phase-8 plan into sync (status + remaining list).
Docs only; design settled, implementation not started.
…mes decoded values

Reconstruction consumed JSON bytes (Receipt.RestoreEncoded([]byte) + json.Unmarshal;
raw-bytes entry.encoded), so only a JSON-decoded trace could rebuild its receipts.
That is out of step with the recorded requirement (graph-signing.md): the graph and
its execution trace serialize across JSON, YAML, and Protobuf, and verify decodes a
trace from any format, re-canonicalizes, and verifies. This makes reconstruction
format-neutral -- the read-side mirror of the already-neutral write side (MarshalYAML
builds a both-tagged shape, MarshalJSON wraps it).

Receipt.RestoreEncoded(env, base ReceiptData, fields map[string]any) now consumes
codec-decoded values, never bytes: the recovery stack decodes the envelope through
whichever codec read the trace, the base execution state arrives as a ReceiptData,
and the receipt's id-reference sub-field as a format-neutral map.

- recovery_stack: a codec-decoded recoveryEntryData shape; UnmarshalJSON and the new
  UnmarshalYAML both decode into it and share one fromEntries builder; entry.encoded
  ([]byte) becomes entry.restore (*receiptRestore, the decoded envelope retained for
  re-arm); rearm + reconstructReceipt take the decoded base + fields.
- receipt: ReceiptBase.RestoreEncoded copies the decoded base across (no json); the
  interface signature carries ReceiptData + map[string]any.
- file.Receipt: RestoreEncoded reads its ids from the decoded map (stringField) and
  Lookups them; no json.Unmarshal.

Type discovery stays registry-free (action -> Compensate companion), which is exactly
what lets Protobuf reconstruct without google.protobuf.Any -- the concrete type comes
from the action name every codec carries, not a type-URL registry.

Test: TestGraphResumeThenFail_RollsBack_ViaPublicAPI runs the resume-then-fail
rollback through both JSON and YAML traces (a YAML-loaded trace must reconstruct its
recovery stack and receipts identically and still roll back).

make test: pkg/op + plan green; zero new failures (only the standing baseline +
pre-existing writ/lore/docgen build breaks). Step doc + phase-8 plan record sub-step
10 as implemented and bring the step-28 status + remaining list current.
…pe-id design

A post-resume consumer that depends on a pre-pause producer's result must receive it
at the concrete type its parameter needs. After format-neutral reload that result is
the producer's reloaded (untyped) value -- a resource comes back as its tag-URI
string. This lands the resource case and records the full design for all result types.

Resource case: Convert step 6 (tryConstructResource) resolves a value that is a known
resource URI through the rehydrated catalog (Current(uri) -> Lookup(id)), returning
the full restored generation, before any fresh construction -- and the file
constructor would anyway reject the tag URI. Path strings / unknown URIs miss the
catalog and construct as before, so live runs (whose resource slots are already typed
and never reach step 6) are unaffected.

Test: TestGraphResumePromiseFidelity_ViaPublicAPI -- a mkdir producer -> file.exists
consumer with a promise across the pause -- passes through both JSON and YAML.

Design (step-28 doc, "Cross-pause promise fidelity" section): full fidelity for ALL
result types via a produced-type-id. The source result type is authoritative (it
survives an any/interface slot); a combinator's static return is any, so capture
reflect.TypeOf(product) at Commit and record a canonical type-id string (the resource
URI id scheme generalized to composites) in the receipt. rearm resolves the id to a
reflect.Type via an index over method product-return types and Converts the reloaded
value into it; a struct<->map hydration step in Convert is the conversion engine.
Design settled, implementation not started.

make test: pkg/op + plan green; zero new failures (only the standing baseline +
pre-existing writ/lore/docgen build breaks).
…1 engine)

Convert reconstructed only scalars, resources, and same-kind containers (slice->slice,
map->map); a struct reloaded as map[string]any hit the error step, and so did any
slice/map whose elements were structs. This adds the conversion engine the
produced-type-id design depends on: two fallback steps before the error step.

- Step 9, struct hydration: a map source into a struct target -- allocate the struct,
  fill each exported field from the map by its json/yaml tag (or field name),
  recursing every field value through Convert. Because steps 3/4 already recurse
  slices and maps, []Struct, map[string]Struct, *Struct, nested structs, and
  struct-with-Resource-fields all compose.
- Step 8, text unmarshal: a string source into a target (or its pointer) implementing
  encoding.TextUnmarshaler -- time.Time and friends reconstruct from their reloaded
  RFC 3339 string.

Both are fallbacks reached only after every existing step declines, so nothing that
previously converted changes -- they convert only what used to error.

Tests (convert_struct_test.go, framework-level, no provider dependency): map->struct
(nested, int-from-float64, slices/maps of structs), []struct, map[string]struct,
*struct, and time.Time from its reloaded string.

This is Slice A of the produced-type-id design (step-28 doc, "Cross-pause promise
fidelity"); the trace wiring (Slice B: canonicalID, capture at Commit, the result_type
envelope field, the resolver index over method product types, rearm retyping) remains.

make test: pkg/op green; zero new failures (only the standing baseline + pre-existing
writ/lore/docgen build breaks).
…d Go type

Slice B of the produced-type-id design (Slice A landed the Convert engine). A reloaded
result is untyped -- the codec drops the Go type -- so restore retypes it to the
concrete type it was produced as, authoritative even when a combinator's static return
is `any`.

- Capture: ReceiptBase gains resultType; Commit stamps canonicalIDOf(result).
  canonicalID (resource.go) generalizes typeIDOf to recurse on */[]/map/array, using
  full import paths (reflect.Type.String()'s package basename would collide).
- Serialize: a result_type string on ReceiptData, the marshal envelope, and the decode
  shape; threaded through Snapshot/Restore/RestoreEncoded and file.Receipt's override;
  a ResultType() accessor on the Receipt interface (inherited from ReceiptBase).
- Resolve: receiverRegistry.ProductTypeByID builds {canonicalID(method.ResultType()):
  type} once over every action method's product type -- complete by construction.
- Retype: rearm's retypeResult resolves the recorded id and Converts the reloaded
  result in place, eager, so the stack is type-faithful before anything reads it.

Scoped to struct/scalar/resource: retypeResult leaves a result it cannot reconstruct
as-is rather than failing the resume. A content-addressable observation
(file.Observation embeds op.ResourceBase; its URI is sha256 over its observed state) is
deliberately NOT retyped -- it round-trips by re-observe-and-verify, recorded as its
own resume-correctness slice (step-28 doc): resume re-runs each observation at its
resumption point(s) -- 1+, N for a paused gather -- and asserts the content-hash URI
matches, surfacing drift; on deserialize the observed resource must be in the catalog.

Tests: canonicalID (named/pointer/slice/map/composites); struct hydration via the
Convert engine (Slice A); the produced-type-id round trip via the resource
promise-fidelity test. A struct-result end-to-end is gated on a test action (no
provider yields a non-resource struct).

make test: pkg/op + plan green; zero new failures (only the standing baseline +
pre-existing writ/lore/docgen build breaks).
…tection

Re-observe-and-verify (resumption-point drift detection) is not step-28 work. It is a
drift-detection capability of the reconciliation framework, so the step-28 doc keeps the
design but flips its status to deferred, and phase-8's remaining list drops it. Step 28's
remaining work is now gather resume, the TestSubgraph_ReturnsRecoveryStack replacement,
and []Receipt-complement compensation-after-resume.

reconciliation.md gains a note: op's content-addressable observations -- observe(resource)
returns an *Observation whose URI is sha256 over the observed state -- are a re-execution-
free drift primitive (re-observe, compare the fresh content-hash URI to a stored
observation; a mismatch is drift), complementing receipt-backed ReconcileX and the
filesystem-based path.

For now observations are treated as ordinary structs: the produced-type-id's struct
conversion covers them structurally and retypeResult leaves their serialized URI as-is.
Observations are serializable artifacts in their own right and are expected to gain
dedicated JSON/YAML/Protobuf serialization interfaces -- the same format-neutral
requirement as graphs and traces (graph-signing.md).

Docs-only; no code change.
…ent-shape decisions

New design doc, file-mutation-receipts.md. file.write_text, file.write_bytes, and archive.extract are one
operation -- write bytes to a target (create or update) -- and delete is its mirror. One forward core
(WriteFile/DeleteFile/MakeDir/RemoveDir, streaming via io.Copy for zero-copy), one self-describing receipt
that always names its own undo (CompensateFileMutation), one undo that dispatches on the receipt's kind.

This resolves archive's no-op rollback: archive.extract stops hand-building receipts and loops the file
provider's WriteFile/MakeDir, whose receipts self-declare their compensation companion -- so stack.Unwind()
compensates them and resume rebuilds them as *file.Receipt regardless of dispatcher. CompensateExtract
collapses to stack.Unwind().

Settled decisions:
- Explicit create|update|delete (file) / create|delete (dir) kind on the receipt.
- remove/remove_all/unlink fold in: single mutation -> one receipt; batch -> *RecoveryStack (remove_all
  loops DeleteFile/RemoveDir, the same shape as archive.extract).
- Directories are first-class (MakeDir/RemoveDir) for archive's and remove_all's standalone/empty dirs; the
  boundary still handles dirs incidental to a file write.
- The compensation action is always set by the operation, with no dispatch-derived default and no override --
  collapsing CompensateWriteText/WriteBytes/Remove/RemoveAll/Unlink/Mkdir into the one CompensateFileMutation.

Plan/design updates:
- step-28 doc: Complement-shape restriction section (decided 2026-06-26) -- complements restrict to
  *Receipt | *RecoveryStack, the []Receipt shape removed, and flow.Gather's []*op.RecoveryStack collapses to a
  single nested *RecoveryStack (this work is the run-up to Gather resume); archive's per-file-identity open
  question resolved via the file-mutation design; open question 3 (Push's env parameter) resolved -- deferred
  to Unwind; cross-pause promise fidelity (sub-step 11) synced to its Implemented bullet.
- phase-8.md: row 28 remaining reframed as the run-up to flow.gather (the complement-shape restriction plus the
  file-mutation/archive groundwork feed Gather resume); new step 34 queued -- RecoveryStack.Unwind takes the
  runtime environment, Push/PushNested drop it.
- 5.2-recovery-serialization.md: complement-restriction + receipt-names-its-own-undo notes.

Docs-only; no code change.
…ism, slice 1 partial)

First verified slice of the unified file-mutation receipts mechanism (see
docs/plans/extract-starlark-from-op/phase-8/file-mutation-receipts.md).

- Streaming write core: file.Provider.write takes io.Reader and copies via io.Copy (constant memory, and
  the kernel copy_file_range/sendfile fast path for file-to-file); WriteText/WriteBytes wrap their content
  in strings.NewReader.
- file.Receipt gains an explicit kind (MutationKind: create/update/delete file, create/delete dir) with
  Kind()/SetKind(), serialized as `kind` in MarshalYAML and read in RestoreEncoded.
- prepareWrite stamps MutationCreateFile / MutationUpdateFile on every write's receipt, so write/Copy/Move
  writes carry their kind.

Verified in the worktree: `make build` compiles lore+star (pkg/op + file clean), only the standing cmd/writ
break remains; `make test` shows pkg/op + file green except the standing TestBackup_DefaultSuffix. No new
failures.

Remaining (see the doc's Implementation status section): the exported WriteFile/DeleteFile/MakeDir/RemoveDir
surface and CompensateFileMutation close slice 1; then the compensation-action seam (slice 2, cross-provider)
and the archive rewrite (slice 3). archive/provider.go WIP is intentionally not in this commit.

Docs: file-mutation-receipts.md gains an Implementation status / handoff section (verification model +
done/next/remaining); phase-8.md notes slice 1 in progress.
…l, methods land with consumers

Records the design decisions settled this session for the unified file-mutation receipts mechanism
(docs/plans/extract-starlark-from-op/phase-8/file-mutation-receipts.md).

- Decision 5: no +devlore:internal flag. Step 24's invariant — announced iff activation-record-first — is
  the announce discriminator; the four mutation methods carry no *op.ActivationRecord (they are mechanism,
  not dispatchable actions), so they are excluded by absence, not a bespoke flag. The discriminator is gated
  on step 24 giving the ~17 existing activation-record-free file actions their leading activation record.
- Decision 6: env comes from the resource, not the provider (step 26 shape). The methods take a *Resource
  (CompensateFileMutation a receipt that holds one) and source env from it; until step 26 they ride the
  still-present p.RuntimeEnvironment() helpers (option 1 — functionally identical today).
- Slice 1 closes at items 1-3 (committed). Items 4-5 are re-sliced to land with their consumers:
  CompensateFileMutation -> slice 2 (the seam's routing is its first caller); the four exported mutation
  methods -> slice 3 (with the archive rewrite that consumes them, sequenced after step 24).

phase-8.md step 28 row updated to match.
…sationAction

Records the settled design for the file-mutation compensation seam
(docs/plans/extract-starlark-from-op/phase-8/file-mutation-receipts.md).

- Two-field split on op.ReceiptBase: forwardAction (the provider method that dispatched -- audit / Trace
  tally) and compensationAction (identifies the compensator -- the undo). Retires the action/actionPath
  short-vs-canonical framing; the rename makes the two roles explicit.
- The receipt's CONSTRUCTOR stamps compensationAction, because the type knows its undo (a *file.Receipt is
  always undone by the file provider), so archive-built file receipts name file's undo for free. Commit
  stamps forwardAction from the dispatching unit and falls back to compensationAction only when the
  constructor left it empty -- a transitional fallback (correct where dispatcher == creator), dropped at
  slice 2b.
- Compensation resolves via a compensator-name index (every Compensate* method by its dotted name); the
  lookup resolves compensationAction through it, falling back to the existing forward->.undo path. File's
  per-action Compensate companions collapse into CompensateFileMutation (dispatching on Kind), and the
  "compensable forward requires a Compensate<Name> companion" registration check relaxes.
- Slicing: slice 2 = file + framework; slice 2b finishes the cross-provider migration and drops the
  fallback; slice 3 = archive rewrite consuming the exported mutation methods.
…atingAction

Slice-2 step 1 of the file-mutation compensation seam (foundation only -- no behavior change).
See docs/plans/extract-starlark-from-op/phase-8/file-mutation-receipts.md.

The single action/actionPath pair carried two unrelated jobs; split the names to the two roles, using the
saga literature's forward / compensating terminology (Garcia-Molina & Salem, "Sagas", 1987):

- ReceiptBase.action -> forwardAction: the provider method that dispatched (audit / Trace.Summarize tally).
- ReceiptBase.actionPath -> compensatingAction: identifies the compensator that undoes the receipt.

Renamed across the Receipt interface, the ReceiptBase getters (Action()/ActionPath() ->
ForwardAction()/CompensatingAction()), the ReceiptData wire shape, and the recovery-stack envelope
(receiptEnvelope/recoveryEntryData). Wire keys: action -> forward_action, action_path ->
compensating_action. The five provider receipts' op.ReceiptData literals follow; their standalone
flat-marshaler `action` keys are left as-is for now (a separate format, addressed later).

Behavior is unchanged: Commit still stamps both from the dispatching unit, and the compensation lookup
still keys on compensatingAction (the renamed actionPath). make build clean (cmd/writ standing break only);
make test shows no new failures.
… independently

Slice-2 step 2 of the file-mutation compensation seam. Behavior-preserving groundwork for the
constructor-stamped compensator identity (slice-2 step 4).
See docs/plans/extract-starlark-from-op/phase-8/file-mutation-receipts.md.

- Commit: forwardAction is always stamped from the dispatching unit (the audit identity);
  compensatingAction is stamped from the unit only when the constructor left it empty -- a transitional
  fallback for not-yet-migrated providers (whose dispatcher is their own creator), removed at slice 2b.
  No behavior change yet: no constructor stamps compensatingAction until slice 4, so the fallback always
  fires and both fields equal the dispatch action, as before.
- The recovery-stack envelope (receiptEnvelope / recoveryEntryData) gains a compensating_action key.
  MarshalYAML writes it, fromEntries restores it independently (no longer aliased to forward_action), and
  reconstructReceipt reads compensatingAction. compensatingAction now round-trips on its own wire field.

make build clean (cmd/writ standing break only); make test shows no new failures (pkg/op + plan
lifecycle/resume suites pass).
David Noble added 25 commits July 22, 2026 22:06
…e tree

Both docs inventoried a deleted layout -- internal/execution (with flow/ and
provider/ under it), internal/writ + five subpackages, internal/lore,
internal/starlark, internal/host, internal/shell -- and package-reference.md
additionally listed per-symbol elements (public/private functions per
package), a maintenance mode that had rotted wholesale.

Rewritten from a full enumeration of the live tree (pkg/internal/cmd package
docs greped, not recalled):

- package-hierarchy.md: the three-layer map (cmd -> internal -> pkg) with the
  dependency rule (pkg never imports internal/cmd; op is the hub with the
  op-free capability packages beneath it) and the current tree -- writ's
  twelve command subpackages, star's eleven tool providers, the 14
  pkg/ capability packages;
- package-reference.md: a one-line-per-package roster (58 packages across
  three grouped tables), cross-linked to the owning architecture docs;
  symbol-level reference is explicitly delegated to go doc.

Decision (c) settled: compact and HAND-MAINTAINED; a generator is a possible
future charter if churn warrants (recorded in both banners and the step doc).
Step-51 charter + phase-8 row advanced (slices 1-7 landed; slice 8 --
8-rust-migration, decision (b) -- remains).
…rical

Decision (b) settled (the 3.3 precedent): the port draft argues from the
pre-op world -- Tombstone, action_reflect.go, ActionRegistry, Result/
Compensator as bare any aliases -- and several of its motivating complaints
were since answered IN GO: typed receipts + the Compensator interface (steps
40/42), reflect-once baked adapters (step 43), the sealed graph and
compiler-checked action names (step 32). Since a port's premise is "describe
the system to port," any real Rust effort must re-ground against the landed
architecture; the banner says so and the body is retained unedited as the
record of the original reasoning. En route: its plan-doc link fixed
(../plans/rust-migration.md -- the old target never existed).

Index entry -> Historical. With this, ALL 8 STEP-51 SLICES ARE LANDED and all
three chartered decisions are settled (3.3 archived; package docs compact
hand-maintained with generation deferred; 8 historical). What remains of step
51 is its exit gate: the architecture coherence verification plus the
in-flight design-document inventory. Step-51 charter + phase-8 row updated.
…d; COMPLETE

Three scripted passes over all 96 docs/architecture files:

1. Links + anchors (GitHub-slug-accurate): one real break -- 3.1's three
   fragments pointed at 3.2's renamed "Provider Registration" heading --
   fixed to #registration.
2. Stale-vocabulary census (full pre-op pattern set; archived/historical
   exempt): all rewritten-doc hits are deliberate history mentions. Two docs
   OUTSIDE the original debt inventory caught:
   - 3.1-provider-loading: the module-loading half is current (tree-verified
     @devlore///With()/loader cache) but the lifetime taxonomy
     (LifetimeStateless/Phase/Session: zero hits) and descriptor Registration
     Examples never landed -- status banner applied naming the landed model
     (environment-scoped construction; announce-with-metadata); the full
     rewrite recorded as step-51 residual debt;
   - 2.4-hermeticity: the command-layer to-do targeted deleted
     internal/writ/commands.go -- supersession note maps each of its four
     items to where the concern landed (steps 33/47/48: run index replacing
     receipts+StateView, deploy/snapshot packages).
3. Path-rot check (every pkg/internal/cmd path in the 16 not-rewritten docs):
   star-extensions' file table cited the folded-away cmd/star/extension/
   package -- fixed (application.go owns discovery + the registry); 6.1's one
   dead citation is already self-flagged in place and left as its own record.

Gate deliverable 3 recorded in the step doc: the in-flight design inventory,
14 rows (eventing 2.8, the control plane's events half, elevation + brokers,
distributed/planner visions, both unimplemented appendices, the sidecar,
3.1's lifetime proposal, the loop construct, deferred compensation shapes,
the Merkle digest, star's planned items) with summary, status, and design +
plan links.

STEP 51 COMPLETE 2026-07-22. Residual debt: the 3.1 lifetime-half rewrite
(unchartered; charter on demand). Phase-8 row updated.
Frame elevation's problem space as an opening section of 6.1, grounded in
the real provider surfaces:

- Substrate 1, command execution -- shell.Provider.Exec, powershell.Provider.Exec,
  pkg.Provider.Install/Remove/Upgrade, service.Provider.Start/Stop/Enable/... . The
  "sometimes elevated, sometimes not" is already encoded as per-backend NeedsSudo()
  (systemd true, launchd user-agent false, MacPorts true), and pkg/platform's
  runShellCommand inlines `sudo -n` under a standing TODO(elevation) this design
  closes. Poses two questions: how do we manage these processes, and how do we
  elevate when we must.
- Substrate 2, network access -- appnet.Provider.Download (today bare
  http.DefaultClient). Any provider method that needs one can be passed an elevation
  token; elevation.Provider is the token source (mint/lease a SecurityToken from a
  broker, bridge by-value, audit, release).
- The bridge: IdentityAssumption is shared across both substrates; HostEscalation
  serves only command execution. Elevation acquires a capability; the substrate
  consumes it.

Step-38 charter and the phase-8 parent row record the framing and flip step 38
to in-progress. Design only -- implementation is a later step.
Structure-only reorganization -- every function body byte-identical; zero
behavior change. Verified: make build clean, full make test green, vet clean,
gofmt clean. Per the section-by-section audit against
noblefactor-ops/docs/guides/go-style-guidelines.md:

- SS1: interface guards added (Compensator -- the relationship that was
  invisible without one -- plus json.Marshaler/Unmarshaler); supporting types
  (receiptRestore, recoveryEntry + its methods, recoveryEntryData + its
  codecs) collected into a SUPPORTING TYPES region at the end; single-file
  free functions (errStatus, receiptTypeForAction, reconstructReceipt,
  retypeResult) into a HELPER FUNCTIONS region after it;
- SS2/SS3: the full region hierarchy imposed -- EXPORTED (State management:
  Err/Len/Result/Stamp/UnitID; Behaviors: Compensate..Unwind) then UNEXPORTED
  (fromEntries..supersede), alphabetical within each group, exported and
  unexported no longer interleaved;
- SS4: Returns sections added to Len/MarshalJSON/MarshalYAML; errStatus given
  a full block; Returns keys back-ticked (toCompensate/receiptOrNil/
  recoveryStackOrNil/Receipts);
- SS8: the three over-120 lines rewrapped (Push doc, the reconstructReceipt
  call in rearm, the NestedStackByUnitID doc);
- SS10: the two multi-file free functions moved to helpers.go --
  invokeCompensateForReceipt (also called from receipt.go; its stale
  "RecoveryStack.Push's pre-bound closure" doc corrected to the real caller,
  ReceiptBase.Compensate) with ignoreNotCompensable (its only dependent),
  and newRecoveryStack (called from graph_executor.go + subgraph.go);
  helpers.go slots them alphabetically and gains the errors import.

Pre-existing and untouched: rearm's gocognit 23 (body unchanged) and the
IDE whitespace lint on the SS8-mandated blank-after-signature pattern, which
the whole repo carries.
Structure-only reorganization -- every function body byte-identical; zero
behavior change. Verified: make build clean, full make test green, vet clean,
gofmt clean. Per the section-by-section audit against
noblefactor-ops/docs/guides/go-style-guidelines.md:

- SS1: interface guards added (Compensator -- the relationship that was
  invisible without one -- plus json.Marshaler/Unmarshaler); supporting types
  (receiptRestore, recoveryEntry + its methods, recoveryEntryData + its
  codecs) collected into a SUPPORTING TYPES region at the end; single-file
  free functions (errStatus, receiptTypeForAction, reconstructReceipt,
  retypeResult) into a HELPER FUNCTIONS region after it;
- SS2/SS3: the full region hierarchy imposed -- EXPORTED (State management:
  Err/Len/Result/Stamp/UnitID; Behaviors: Compensate..Unwind) then UNEXPORTED
  (fromEntries..supersede), alphabetical within each group, exported and
  unexported no longer interleaved;
- SS4: Returns sections added to Len/MarshalJSON/MarshalYAML; errStatus given
  a full block; Returns keys back-ticked (toCompensate/receiptOrNil/
  recoveryStackOrNil/Receipts);
- SS8: the three over-120 lines rewrapped (Push doc, the reconstructReceipt
  call in rearm, the NestedStackByUnitID doc);
- SS10: the two multi-file free functions moved to helpers.go --
  invokeCompensateForReceipt (also called from receipt.go; its stale
  "RecoveryStack.Push's pre-bound closure" doc corrected to the real caller,
  ReceiptBase.Compensate) with ignoreNotCompensable (its only dependent),
  and newRecoveryStack (called from graph_executor.go + subgraph.go);
  helpers.go slots them alphabetically and gains the errors import.

Pre-existing and untouched: rearm's gocognit 23 (body unchanged) and the
IDE whitespace lint on the SS8-mandated blank-after-signature pattern, which
the whole repo carries.
Six summaries wrapped onto a second line, violating SS4's one-line-title rule.
Each is split into a one-line title plus an elaboration paragraph, preserving
every word of meaning:

- ResultByUnitID (the parent-chain search moves to the elaboration)
- supersede (the receipt-or-substack detail moves down)
- recoveryEntry.toCompensate (the compensator-selection detail moves down)
- recoveryEntryData.UnmarshalJSON (the structural-discrimination detail moves
  down)
- recoveryEntryData.UnmarshalYAML (the entries-key detail moves down)
- helpers.go ignoreNotCompensable (the declines-to-compensate rationale moves
  down)

Verified by a mechanical sweep (first doc line followed by a text line =
violation): both files now report clean. gofmt clean; make build clean.
Comment-only -- zero behavior change.
Cleanliness pass over 6.1-privilege-elevation.md; prose/structure only, no
design change:

- :210 -- "provenance/integrity" -> "integrity + authenticity" (drops the
  banned term "provenance"); paragraph rewrapped (was 141 cols);
- :270-271 -- "env-specific" -> "environment-specific", "env-config" ->
  "environment-config" (banned "env" abbreviation); rewrapped (was 121);
- :280-281 -- rewrapped (was 129);
- :140 -- code comment "env vars" -> "environment variables";
- :184 -- YAML comment "<env>" -> "<environment>".

No non-table line over 120; no residual "provenance" or "env" abbreviations.
…gn resumes post-PR

Fold the configuration-foundation dependency into the step-38 charter and set
the step aside; its design completes after phase 8's PR to develop.

- New "Configuration dependency" section: elevation.ProviderConfig assumes the
  recursive-tree config foundation, which is designed but largely unimplemented
  -- pkg/devconfig still carries the flat predecessor (Config = map[string]Section,
  config.go:45; no ConfigBase/Path), no loader/overlay/${} converter; the
  recovery is configuration plan item 1 (unstarted). elevator/config.go is a
  freestanding stub carrying the environment-keyed shape 6.1 supersedes. So the
  design proceeds now (against the settled config design); the implementation
  (a later step) is gated on the config reshape + loader.
- Status flipped in-progress -> deferred in the charter (frontmatter + body) and
  the phase-8 parent row; a set-aside note added to both.

Design only; no code change.
Design-only (implementation deferred to a post-PR step). The five open
decisions in 2.8 are settled, worked interactively and corroborated by an
external review:

1. slog is the primary logging API; status.Narrator / result.Pipeline sit
   above it as a tee handler (local render + otelslog copy to OTel).
2. Categories = narrations, diagnostics, operational events; operational events
   bracket the other two (span brackets its correlated log records). Attribute
   names frozen OTel-native (verified vs semconv 1.43.0): event.name,
   log.iostream, code.function.name, instrumentation scope, trace_id/span_id --
   dropping the invented event.category / stream / log.classification.
3. Eventing = OpenTelemetry; the Collector is the router (no hand-built sink
   registry), with a bounded in-process tap for terminal narration and the live
   control-plane Subscribe() watch.
4. Metrics via the Collector connector by default; in-app Metrics API reserved.
5. Hook reconciliation: keep OnNode*/OnSubgraph*, add OnRunStatusChanged
   (run-level, every phase/condition transition); nested subgraphs via
   OnSubgraph*; sub-events as event.name records; inline emits route through the
   callback.

Plus a Filesystem-persistence section (Collector fileexporter default;
stdoutlog->file fallback; the JSON exporter MUST target a separate file, never
the program's real stdout/stderr).

2.2: added the run-state-machine mermaid diagram (the machine was documented but
undiagrammed). A RunStatus conflict surfaced during the work was resolved by
reverting the session's PreflightFailed proposal -- the settled + landed model
(pre-flight failure -> execution_failed + ReasonPreflightFailed) stands.

Status companion, step-50 charter, and the phase-8 row flipped to
design-solidified; implementation deferred.
…e comment

Two steps the status table flagged as "remaining" were stale metadata, not work:

- Step 51: the phase-8 row's status cell opened with "in-progress" while its own
  body declared "STEP 51 COMPLETE 2026-07-22" (and the charter frontmatter reads
  COMPLETE) -- leading label corrected to complete.
- Step 23: the Merkle-root directory digest ALREADY landed 2026-07-18
  (file.Directory.Digest, directory.go:105; merkleRoot at :316). The charter
  frontmatter's "slices 3-4 pending" contradicted its own body ("all four slices
  landed; suite green") -- aligned to COMPLETE; the phase-8 row's stale
  "remains a must-land deliverable before phase-8 closes" -> landed, with refs.

Plus file/resource.go: the base file.Resource.Digest doc comment still said the
Merkle scheme was "deferred until that split (step 23)" -- the split shipped and
file.Directory implements it, so the base erroring for directories is correct by
design, not a deferral. Also Link -> SymbolicLink and the orphaned link ref
removed. Comment-only; gofmt clean.
…ixtures

Direct method-level tests to the step-24 bar, filling the gaps the step-39
matrices surfaced. Full `make test` green, 0 FAIL.

Go units:
- plan (1-3): Plan error door (no-dot / unknown-provider / unknown-method /
  malformed non-string kwarg), Origin scope-stamping, Clear ledger reset.
- file (4-7): WalkTree/CompensateWalkTree fold + compensation, Name/Parent/Root
  edges, Move forward-path, RemoveAll subtree; (8) observe measurement fields
  (Size/Mode/ModTime) beyond the existing Exists-only unit.
- git (14-15): Checkout/Pull (argv via the dry-run narration seam -- they have
  no argv hook) + Observe state facts.
- encryption (17): CompensateEncryptFile x3 mirroring the decrypt analogs.
- function (18): Call arg/kwarg passing, error propagation, non-callable.
- powershell (11): Exec success/failure/empty-command, pwsh-gated via requirePwsh.

Fixtures (run via explicit Go runners in devloretest/backfill_test.go -- fixtures
are not glob-discovered): file.find (9), template.render_bytes (13).

Re-dispositioned (the .star harness cannot stage these; reasons in the step doc):
observe fixture (Entry-typed input), wait_until edges (10, proven at executor
level; match-after-N needs mutable per-poll state), powershell (12) / appnet (16)
fixtures (no fixture-level skip for pwsh/network).

Charter + phase-8 row record the disposition; the per-provider status-doc
gap-cell sweep is the final Exit step (pending).
…s; COMPLETE

The final step-52 Exit item: every per-provider status companion's gap cell now
names the landed test(s) and file (or records the re-disposition + reason,
pointing to step 52's Disposition). The adjacent Go/Starlark coverage cells and
the Outstanding sections were made coherent in the same pass -- no cell or
section still frames a step-52-landed item as missing/absent/"none". Verified:
zero step-52 staleness across all nine docs (3.5.2/3/4/7/9/10/12/13/14).

Notable: powershell's doc had a hard contradiction (still called it "the only
provider with zero coverage") -- now past-tense; file's WalkTree/Name/Parent/Root
Go cells and function's Call cell were bare `—` against landed tests -- now
populated.

Step 52 flips to COMPLETE (Exit satisfied: rows delivered/re-dispositioned, test
names greppable, make test green, status-doc gap cells swept). Phase-8 row
updated.
MarshalYAML: invert the entry guard (skip-nil -> append-non-nil) plus a blank
line; behavior-identical (same non-nil compensators collected). Doc-comment
grammar only elsewhere (commas, "at resumption time", "reparsed", a sentence
split). No behavior change; gofmt clean; make test green.
O1 was settled months ago and only left open in the ledger. The target-driven
direction (one lookup, no source-first dispatch, no fallback) shipped as step 15's
op.Convert cascade + the SourceConverter/TargetConverter opt-in interfaces
(complete 2026-07-03), and O2 removed the old source-first path (done 2026-06-15).
op.Convert (convert.go) is the single conversion path; there is no unmarshaler.go
and no ToUnmarshaler. The five "open questions" are answered by that implemented
design.

- O1 section rewritten as a SETTLED record pointing at step 15 + O2 + op.Convert;
  the stale "current source-first pipeline" narrative and the five open questions
  removed.
- O1 row + the exit-blocker line updated: O1 settled, O2 code-done, only O3 remains.
- Corrected the eliminated term: this is CONVERSION (op.Convert), not marshaling
  (marshaling is reserved for JSON/YAML serialization).
…rected

O2 was code-done 2026-06-15 and only left open in the ledger. The section
rewritten as a CODE-DONE record: the ~900 lines of source-first dispatch (the
Unmarshaler interface + ToUnmarshaler + 11 unmarshal_*.go files) are gone,
replaced by the target-driven op.Convert (see O1); the surviving names are the
starlarkbridge data types, adapters, and runtime. Dropped the stale exit-criterion,
open-questions, and 19-file to-do inventory.

- O2 row status corrected: "conversion design (O1) is settled" (was "the
  marshaling-design questions tied to O1 remain open").
- Fixed one conversion-context use of the eliminated term in step 25's row
  (the fn wrapper "converts the Go args -> starlark ... converts the result back
  to Go"). Remaining "marshal" hits are legitimate JSON/YAML serialization or
  historical file/symbol names.
…outer

The package is the HTTP wire listener for op runs -- it serves control commands
AND the SSE event stream AND status polling, so "controlhttp" undersold it;
"server" is the accurate name (and it grows the eventing endpoints under step 50).

The primary type is a stateless run-id -> plane router (the doc already said so)
and it is the http.Handler served externally, so Server -> Router: accurate, no
server.Server stutter, idiomatic (mux.Router / chi.Router). The receiver stays
`s` because the http handlers already take `r *http.Request` / `w`.

- package controlhttp -> package server; server.go -> router.go, server_test.go
  -> router_test.go.
- Nothing outside the package imported it, so no consumer updates.
- Verified: make build clean, make test green (0 FAIL).
…rected

O2 was code-done 2026-06-15 and only left open in the ledger. The section
rewritten as a CODE-DONE record: the ~900 lines of source-first dispatch (the
Unmarshaler interface + ToUnmarshaler + 11 unmarshal_*.go files) are gone,
replaced by the target-driven op.Convert (see O1); the surviving names are the
starlarkbridge data types, adapters, and runtime. Dropped the stale exit-criterion,
open-questions, and 19-file to-do inventory.

- O2 row status corrected: "conversion design (O1) is settled" (was "the
  marshaling-design questions tied to O1 remain open").
- Fixed one conversion-context use of the eliminated term in step 25's row.
- Rides along (same file): the phase-8 status-row reference pkg/op/controlhttp ->
  pkg/op/server (the rename below).
The doc references for the package rename: controlhttp -> server, Server -> Router,
server.go -> router.go. Rosters (package-hierarchy, package-reference) + prose
(step-36/50, 3-operation-namespaces + status, 2.7-control-plane status). Two prose
knock-ons: "command server" -> "command surface" (avoid "server's server"; the
doc's own vocabulary), and bare controlhttp -> backticked `pkg/op/server` (bare
"server" is ambiguous). No controlhttp reference remains anywhere in the repo.
The install step piped the golangci-lint installer from the moving `master`
ref, so a mismatched binary/checksum pair failed verification and aborted the
whole quality-gate job before any lint or test ran.

Pin the installer to the v2.10.1 release tag -- the tag's install.sh installs
the matching v2.10.1 binary, so the checksum is self-consistent -- and wrap it
in a 3x retry to ride out transient network blips. v2.10.1 is the version
already in use locally; it lints the whole repo clean.
… methods

The knowledge-extract job crashed with "NoneType is not iterable". The goast
provider initialized its result slices with `var result []T`, so a no-match
query returned a nil Go slice. The Starlark bridge converts a nil slice to
None (not an empty list), and extract.star then iterated it -- e.g. a scope
with no matching calls, a zero-arg call's args, an empty struct's fields.

Initialize every Starlark-facing slice as `[]T{}` so "present but empty"
round-trips to an empty Starlark list. Covers the six exported result lists
(Calls, Composites, ConstGroups, Funcs, Methods, Structs) and the seven nested
lists that become list-valued fields (call args, callable params, composite
elems, const-group constants, struct fields, Deps.Files, Metrics.Files). The
internal accumulators that never reach Starlark are left as-is.

Verified: knowledge extract runs clean over the whole repo; make test-race
green (0 FAIL, 0 races); ./build/star lint go ./... clean; make vet clean.
In a linked git worktree, .git is a gitdir-pointer file, not a directory, so
opening .git/info/exclude fails with ENOTDIR. loadPatterns treated only
ErrNotExist as "no patterns", so NewTracker propagated the error and every
file.find call (and with it star lint shell) died in a worktree checkout.

Treat ENOTDIR like a missing file: the exclude layer is skipped, in-tree
.gitignore files still apply. Resolving the gitdir indirection to load the
main repository's info/exclude is possible follow-up work.

Also in pkg/gitignore while touching it: a regression test for the worktree
case, a Returns section on Tracker.Root, doc comments on the test functions,
and removal of the unused assertContains/assertNotContains helpers.
…ries

The phase-8 file provider returns []Entry (file.Regular values) from find;
the lint-shell, lint-go-style, and lint-markdown extensions still called
sorted() directly on the result, which dies with '"<" not supported between
"Regular" values' -- the quality-gate Lint Shell failure on PR #288. Derive
the path per entry with source_path.rel() before sorting, the idiom
lint-copyright already uses.

Also fix lint-go-style's line 47: ast.check_compliance is a method and was
iterated without being called ("builtin_function_or_method value is not
iterable"). The bug was unreachable until discovery worked again.
A compliant source file produced a nil []ComplianceViolation, which the
Starlark bridge converts to None, so lint go-style crashed with "NoneType
value is not iterable" on any clean package. Same class as c232a89; this
method was missed because the lint-go-style call-site bug made the path
unreachable. Initialize the accumulator as []ComplianceViolation{}.
… and APIs

The knowledge-extract job failed at extract.star:927: the migrate source
moved from internal/writ/migrate to cmd/writ/writ/migrate. Beyond the path,
three more breaks were latent behind that first failure:

- internal/execution is retired with the old engine. Drop the execution
  scan and engine-graph.json generation (graph.go, *Op Name() methods,
  NodeStatus/SlotValue/GraphState no longer exist anywhere), and reduce
  build_ops_knowledge to an explicit not-yet-implemented note (*Service
  structs exist nowhere in the tree). Regenerating an engine schema from
  the pkg/op graph model is separate design work.
- find_files in both validate.star files: file.find rejects patterns
  outside the scoped root (the registry checkout is out-of-tree in CI) and
  now returns resource entries. Use file.glob -- every pattern is
  single-star, and glob is unconfined -- and derive source_path.abs().
- yaml.parse now fails the run on malformed input and its Resource has no
  .parsed attribute; drop the parsed-None guard and return
  result.valid/result.errors directly.

Verified against a scratch copy of devlore-registry: extract --domain all,
package validate, and knowledge validate all exit 0; the only content change
is the systems-reference.yaml source-path metadata line. A deliberately
corrupted lifecycle.yaml fails package validate with "1 of 10 files failed
validation", proving discovery and validation are live.
@David-Noble-at-work
David-Noble-at-work merged commit 57a9fd2 into develop Jul 25, 2026
2 checks passed
@David-Noble-at-work
David-Noble-at-work deleted the refactor/extract-starlark-from-op.phase-8 branch July 25, 2026 06:59
David-Noble-at-work added a commit that referenced this pull request Sep 30, 2026
…tc/os-release before it closes it (#986)

* fix(writ): plan for #959, writ detects the Linux distribution

detectDistro closes /etc/os-release before reading it, so DISTRO has been
empty on every Linux host since 57a9fd2. The plan: readDistro(path)
opens, defers the close, and scans; tests that go through the file and
fail first; the adopt test that pinned the bug refuses a word no host
detects; PR B's owed ticks. Lane 18 of #949; #944 follows as lane 19.

Claude-Session: https://claude.ai/code/session_01DuNfZFyfxBoD9pdaiDEMrY

* fix(writ): writ detects the Linux distribution; detectDistro reads /etc/os-release before it closes it

detectDistro closed /etc/os-release between the open and the scan, so
every read failed and the DISTRO segment has been empty on every Linux
host since 57a9fd2 (#288): no directory with a distribution suffix
matched anywhere. It now calls readDistro(path), which defers the close
straight after the open.

The path is the seam the tests use: readDistro runs against os-release
fixtures (Ubuntu with its ID_LIKE, Debian, a quoted ID, an unknown ID,
no ID, no file), and TestDetectSegments checks DISTRO on a Linux host
whose os-release carries an ID. Both failed against the old close and
pass now. The adopt test that pinned the bug refused "Ubuntu", which is
matched on Ubuntu now; it refuses NoSuchPlatform instead.

DISTRO stays the ID field, capitalized: `debian` matches `.Debian`. The
lineage, so that Ubuntu also matches `.Debian`, is #944, the next PR.

PR B's plan takes its two owed ticks, the installers jobs on #984 and
the site's sync, and is complete.

Resolves #959

Claude-Session: https://claude.ai/code/session_01DuNfZFyfxBoD9pdaiDEMrY
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant