Source workflow definitions from a remote npm registry, end to end - #126
Open
alexanderguy wants to merge 52 commits into
Open
Source workflow definitions from a remote npm registry, end to end#126alexanderguy wants to merge 52 commits into
alexanderguy wants to merge 52 commits into
Conversation
A code-sourced workflow package names the module whose evaluation produces its definition. Accept that entry alongside interchange.tools, rejecting non-string values at the validation boundary.
Approval records the operator-approved wire hash for a version so a later definition load can re-verify against it. Null is the legitimate pre-approval state, so the column takes no NOT NULL constraint.
The deploy gate, the install-time probe, and re-verify must derive a deployment's content handle from the identical canonical form. Housing the hash and its canonical serializer in one shared module makes that the single source of truth every party imports.
A code-sourced workflow declares where its definition is fetched from. Model the source as a discriminated union so further source kinds can be added without a breaking change.
The install-time probe ships a workflow's needs-surface -- its tool grants, model identity, and structure -- to the hub as plain data, and identity, approval, and re-verify all bind to a content hash of that form. A naive serialization drops the function-valued tool factories and silently loses the grant surface, so the projection reads each factory's declared grants into plain data and fails loud on anything it cannot reify.
Before a workflow's code can be probed it must be pinned. Resolve the full dependency closure to concrete versions and integrity hashes on the hub, reusing the tool-package resolver, reading only registry metadata and never executing package code.
A code-sourced workflow is defined by evaluating its module. Import the declared entry from a materialized package, take the single export that validates as a definition, and reject anything ambiguous or malformed. The loader consumes an already-materialized package so the host layer need not depend on the packaging layer.
The hub needs a connected sidecar to evaluate a workflow and report its needs-surface before any deployment exists. Carry that request and its result on a dedicated frame pair with its own timeout and disconnect cleanup, correlated by request id and kept off the agent-address routing a deployed instance uses.
One asset can hold many workflow definitions, so identity cannot be one definition per asset. Key a definition by its asset paired with the content hash of its wire projection; callers compute that hash and resolve by selector. This removes the one-definition-per-asset index in the same change that teaches every caller to pass a hash, so no window exists where the two disagree.
Discovering a code-sourced workflow's needs-surface means evaluating author code, which must run neither on the hub nor on the sidecar host. Spawn a one-shot child behind the IPC airlock that evaluates the pinned code and returns only the inert projection, grant set, and content hash; reap it on every exit path so a wedged evaluation cannot leak a process.
The needs-surface a probe reports is advisory. Before trusting it, the hub recomputes the content hash over the received projection and rejects a mismatch, and holds every advertised grant to the operator's approval. On approval it freezes the recomputed hash and the advertised grant set as the single source of truth the deploy path stays a subset of, so a workflow can never gain at deploy or run time a grant it did not have at approval.
The deploy hand-off carries the pinned source and the frozen dependency closure instead of an inline definition, along with the hub-approved content hash. The child's identity hash is that approved value rather than a recompute on the sidecar, so what the operator approved and what the deployment runs are bound to the same content. Deploy grants are materialized as a subset of the frozen approved set.
Re-reading and re-walking the definition on every mail-triggered run let a mutated asset blob under a stable id change the grants a run receives. Walk once per deployment and reuse the frozen result, keyed by the content-addressed definition identity, so run grants follow the approved content and nothing downstream re-derives them per message.
Laying a resolved dependency closure out on disk and evaluating its entry module were fused in one method. Split the eval-free layout into its own callable step so a caller can materialize a closure into a package directory without importing any of its code; the loader now composes that step with its unchanged evaluation phase.
The sidecar materializes exactly the closure the hub pinned at approval, verifying each entry's integrity before extraction and resolving nothing anew, then loads the pinned entry. What the deployment runs is the same content the operator approved, down to every transitive dependency.
A definition is read from disk every time a workflow loads -- fresh run, resume, and child-body spawn. Recompute its content hash there and fail closed when it disagrees with the hub-approved hash, so a definition swapped after approval halts the load. Placing the check at the load boundary rather than at run start is what covers resumed runs, whose run-start events never re-fire.
The probe executor was built but never connected, so a live sidecar answered every probe with a rejecting placeholder. Construct the production closure materializer and inject the executor through the orchestrator, so the sidecar materializes a probed workflow's pinned closure, evaluates it in the airlocked child, and returns its needs-surface. The request's single-package cardinality and its entry are validated before the child spawns.
The live-inert projector's test suite uses arktype, declared as a workflow-deploy dev dependency. Record its resolution in the lockfile so a frozen install matches the manifest.
The deploy frame builder recomputed a content hash from the live definition on every path. A source-ref deployment is approved and later re-verified against the hash of its inert projection, and the live and inert forms hash differently, so the child rejected the deployment closed. Carry the frozen hash and the inert projection through unchanged on the source-ref path, and let the live-authored path keep recomputing its own.
The pieces to deploy a code-sourced workflow existed but could not be driven together: approve discarded the inert projection and frozen closure the source-ref deploy needs, and neither it nor the frame builder was reachable outside the package. Return the projection and closure from approve and add one public entrypoint that carries them into the deploy frame, so a caller cannot recompute the hash, re-resolve the closure, or otherwise wire the hand-off wrong.
The tarball reader consumed the response body through the web ReadableStream API, but the registry fetcher returns a Node stream with no such method, so every real registry tarball fetch threw before a byte was read. Read whichever stream shape the body actually is, keeping the streamed size cap and the abort deadline intact on both paths.
The workflow child must project a definition to its inert form to re-verify it on load, but the child's package must not depend on the deploy package where that projector lived. Move it into the workflow package, which the child already depends on, so the projection is reachable without crossing that boundary.
A source-ref deployment materialized the inert projection and tried to run it, but the projection is an approval surface the runtime cannot execute: its agents carry no live inference and its tools are data, not functions. At the load boundary, evaluate the pinned closure to the live definition, re-verify that it projects to the approved hash, and run that. Live-authored deployments keep reading their inert definition unchanged.
One test installs a workflow package from a test npm registry, probes it in the airlocked child, approves and freezes its content hash, deploys it by source reference, has the sidecar re-materialize the pinned closure and evaluate the pinned code, and asserts the deployed workflow runs to completion -- exercising closure resolution, the probe, the gate, source-ref deploy, sidecar apply, load-boundary re-verify, and per-run grant materialization in a single flow.
Evaluating a pinned closure and driving a run to a terminal event is real work that the default per-test timeout does not accommodate once the full suite is competing for the machine. Give the closure-evaluating source-ref tests an explicit timeout in line with the other integration tests so they do not flake under load.
The load-boundary re-verify recomputes the wire hash of the definition a respawn reads and fails closed unless it matches DEFINITION_HASH. The single-step conversation-durability test spawned its warm child with a placeholder DEFINITION_HASH, so the re-verify rejected the respawn and the run stalled. Compute the true wire hash of the seeded workflow.json and feed that, matching how a production restore carries the persisted approvedWireHash.
The workflow runtime's commit-chain and pending-event buffers are module-scoped maps keyed by runId, shared across every run in the process and dropped only when a run drains to terminal. A sibling test fires a 'run-1' run and shuts down without draining it, leaving a buffered RunStarted under that key. The source-ref fresh-trigger test reused the same literal, inherited the stale buffer, read as already-started, and stalled with no schedulable primitives. Give it a per-test unique runId so its chain stays isolated regardless of sibling ordering.
The referenced-definition resolver forced both spawn types through one mandatory approved-hash gate sourced from a map only one type can populate, so every childWorkflow and onTrigger-body spawn failed closed. Split it into two explicit contracts over one shared read+validate helper: an onTrigger body is part of the parent's approval, so its hash rides the signed frame and the body path re-verifies against it; a childWorkflow references a separately-approved asset the parent holds no hash for, so it reads and envelope-validates without a gate -- a gate there could only fail-closed-always, and the asset's integrity is the workflow-kind repo's hub-writes/sidecar-reads authorization plus push-time validation. Thread the parent's frame-carried body hashes from the substrate factory into the suspendable adapter, and persist them on the deployment record so the body re-verify survives a sidecar restart, matching how the top-level approvedWireHash is carried across restart. Document the out-of-band-pin rationale in AUTH.md.
A source-ref deployment's runnable definition is the evaluated pinned closure, not the on-disk inert workflow.json (a non-executable approval surface). The deployment record carried no lineage, so a boot-time restore re-read the inert projection, resolved lineage to live-authored, and would have run the non-executable shape. Persist a lineage discriminator on the record and have restore refuse a source-ref record loudly, keeping the record for a later boot that re-materializes the closure. Full source-ref restart-restore (persist source + closure, re-evaluate on boot) is a separate follow-up.
The source-ref deploy frame computed and carried approvedDeployGrants (a subset of the gate's frozen approved set), but no sidecar code consumed it: the enforced per-step authorization boundary is the deploy-time credentials snapshot, sourced from the operator's config.grants. A security field that is computed, shipped, and ignored claims a guarantee it does not deliver, so remove it -- the wire field, its session-service population, and the now-dead materializeDeployGrantsFromFrozen helper. The deploy-time gate/freeze (the approval decision, and the per-run grant-walk freeze) is unaffected. Enforcing the frozen advertised set as a runtime ceiling is a separate follow-up.
Rebasing onto main renumbered this branch's two workflow-definition migrations after main's credentials migrations, regenerated via drizzle-kit as 0075 (approved_wire_hash) and 0076 (workflow_definition_content_hash). Format the drizzle-emitted meta JSON to satisfy the lint gate, and add the wire_hash column to the workflow-definition parse-row test fixture that main's row shape did not carry.
A sidecar restart refused to bring a source-ref deployment back -- its runnable definition is the evaluated pinned closure, not the on-disk inert workflow.json, so restoring it as live-authored would run the non-executable shape. Persist the source + frozen closure on the deployment record (behind a discriminated union that requires them, plus approvedWireHash, for a source-ref record -- so a restored child re-verifies against the hub-approved pin, not a recompute) and have restore re-run applyFrozenWorkflowClosure and re-spawn as source-ref. The tarball fetch is a cache hit against the surviving content-addressed closure cache, SRI-verified, and time-bounded. Reclaim the deployment's closure instance dir before the re-apply and in undeploy so repeated restarts do not accumulate orphaned materialized closures.
The terminal childWorkflow resolver reads a separately-approved asset with no out-of-band pin to verify against. Spell out why a re-verify gate there is deliberately absent: it would only defend local-disk tamper of SIDECAR_DATA_DIR (out of the sidecar's threat model, one of several equivalent-or-worse loose reads), the hub-writes/sidecar-reads authorization plus push-time validation is the real boundary, and a per-ref frame-delivered pin would regress the reference from late- to early-bound. Whether a childWorkflow should instead be an owned, parent-namespaced sub-workflow is a product decision tracked separately.
The hub's deploy-frame builder spelled the non-source-ref lineage "live" while the sidecar (env, deployment record, spec, run child) spelled it "live-authored" -- one concept, two literals across the wire boundary. Standardize the hub side on "live-authored" so a lineage-equality check reads the same on both sides.
The source pin and its frozen dependency closure are meaningless apart: a source with no closure names bytes the sidecar cannot materialize, a closure with no source is a dependency tree for nothing. Yet they rode the deploy frame, the deploy-frame args, the deployment record, and the deploy spec as two independently-optional fields, so every consumer re-encoded "both present or both absent" as a paired presence check and every fixture could express the illegal half-populated state. Introduce a `SourceRefPin` (co-required `source` + `closure`) in @intx/types/sidecar -- mirroring the probe frame that already co-requires the same two -- and thread it through as one field. `AgentDeployWorkflow` now carries `sourceRef?: SourceRefPin`; the frame-args source-ref arm, the deployment record's source-ref union arm, and `WorkflowDeploySpec` all carry the pin as a unit. Each consumer collapses to a single presence check on the pin, and `buildDeploymentRecord`'s guard drops from three required fields to two (the pin plus the approved hash). The record test now also asserts a half-populated pin is rejected by the pin's own co-requirement, closing the gap the two-field shape left open.
The deploy path and the boot-time restore path each built an identical `applyFrozenWorkflowClosure` call by hand: the deterministic instance dir, the cache root, the two substrate byte caps, and the registry table. Two copies of the same plumbing meant a cap or path change had to land in both, and the deploy copy silently lacked the restore copy's reclaim-first step. Pull the shared apply into one factory-scoped helper, `materializeDeploymentClosure`, a sibling of `buildDeploymentRecord`. Each call site collapses to a single call plus its own result consumption, which stays divergent on purpose: deploy validates the evaluated definition and takes `packageDir`; restore takes `packageDir` and re-carries the pin on its spec. The helper always force-reclaims the instance dir before applying. `deploymentId` is deterministic per agent address, so a redeploy or a restore reuses the same dir and a prior soft-failed deploy or dead process can leave it half-materialized; the rm makes the apply write into a clean dir either way (a no-op on a never-deployed address). This closes a latent gap on the deploy path, which previously did not reclaim. The rm is safe only because no live reader holds the dir when it runs -- a precondition each caller still establishes and states at its own site (single-flight-guarded pre-spawn on deploy; dead prior process, serial pre-connect on restore).
Origin/main added migrations 0075-0078 (workflow-run launch spec, sidecar allocation, run dispatch), colliding with this branch's hand-numbered 0075/0076. The branch's two schema additions -- the `(asset_id, wire_hash)` content-hash identity on `workflow_definition` and the `approved_wire_hash` column on `workflow_definition_version` -- are carried by their schema source changes, so their generated migration artifacts were dropped during the rebase and regenerated once here as a single 0079 off main's 0078 snapshot.
The rebase surfaced four points where main's new code met this branch's changed contracts: - deployWorkflowDefinition dropped `definition` from its params destructure while still hashing it for the content-hash selector. - main's exclusive-allocation path (workflow-allocation-service) called ensureWorkflowDefinitionForAsset with a bare asset id; it now passes the (assetId, wireHash) selector, computing the same computeLiveDefinitionHash as the normal deploy path so both key a definition identically. - the probe tests' accept-any authenticator returned the old `kind: "sidecar"` identity; main renamed the discriminant to `"shared"`. - the orchestrator-probe test omitted main's now-required applyWorkflowRunPack config field; a no-op applier satisfies it since the test exercises only the probe path.
alexanderguy
force-pushed
the
intr-416-source-a-workflow-definition-from-a-remote-npm-registry-end
branch
from
August 7, 2026 18:16
41dca4b to
5af4b87
Compare
Code-sourced workflow code is evaluated on the sidecar, so it matters that the sidecar is not a trust anchor for authorization. Add a section making the model explicit and audited: credentials are decrypted and delivered hub-side (the sidecar holds no cipher), the running code is closure-pinned and re-verified against the hub-approved hash, and the sidecar's capability walk is therefore advisory. The operator's ApprovalSet gates it, and a compromised sidecar can only over-report (declined), under-report (under-provisioned, fails closed), or lie about the projection (re-verify fails closed) -- none of which escalate. State the honest caveats rather than overclaim: the child-side grant gate is defense-in-depth (the sidecar writes grants.json, so it is not a boundary against a compromised sidecar -- but forging a grant yields no new credential material and no new code); the grant list is not a compute/network sandbox (host isolation bounds that); and the advisory list's accuracy is informed-consent integrity, not an escalation barrier.
The child re-verifies its own recompute of the wire projection against the HUB-approved hash. The sidecar sourced that hash as `spec.approvedWireHash ?? computeWireDefinitionHash(spec.definition)`, so an absent hash silently made the sidecar its own hash authority and collapsed the re-verify to a self-check -- the exact integrity downgrade the field exists to prevent. Both feeds into the spawn core always carry it (the hub deploy builder stamps it; a restore re-attaches it from the persisted record), so an absent hash is a wiring bug. Replace the recompute with a fail-loud guard. Every deploy path that fed the spawn core was free-riding on that recompute: - The sidecar unit tests built raw frames with no hash. The shared frame helper now defaults a placeholder (production always stamps one) with an `omitApprovedWireHash` opt-out for the new fail-loud test; the four files that build frames inline stamp a placeholder. - The integration suite's 35 per-test deploy callbacks sent the frame straight through the hub router with no hash. Rather than duplicate the computation in each, the deploy-flow harness -- standing in for the hub -- wraps its `sendAgentDeploy` to stamp the real hash when a workflow frame arrives without one, mirroring what production's `sendMultiStepDeployFrame` does.
`runOneShotProbeChild` raced the result line, `handle.exited`, and the deadline. A probe child writes its result line and then exits promptly, so the buffered-line read and `handle.exited` both become ready at once; when the exit arm won that race a valid, already-written result was discarded and the probe failed spuriously. Exit is not a distinct outcome the line read misses: when the child exits its stdout write end closes, so `readResultLine` settles either with the trailing line or with null (the existing "closed its output" branch). Race only the line against the deadline -- a child that neither writes nor exits is still caught by the deadline -- so all three terminal cases stay covered with the race hazard removed.
`createDbFrozenApprovalWriter` ran the ensure-definition writes and the approved-hash stamp as two separate statements. A crash between them persists a version row with a NULL `approvedWireHash`, which the schema treats as the legitimate "not yet approved" state -- so a half-failed freeze is indistinguishable from an un-approved definition. Wrap both in one `db.transaction` so the freeze is all-or-nothing. The stamp's `WHERE version = FROZEN_VERSION` is hand-coupled to the version the ensure helper projects; a future drift would silently update zero rows and persist no hash. Add `.returning()` and throw unless exactly one row was stamped, so a drift fails loud rather than open.
The wire canonicalizer preserved `undefined`-valued object keys (emitting
an invalid `{"k":undefined}` token) and did not normalize `undefined`
array elements. Its output was therefore not invariant to a JSON
round-trip: the hub hashes a projection parsed off the JSON wire (where
such keys are already gone) while a child hashes the in-memory
projection, so a projection carrying an `undefined`-valued field would
hash differently on the two sides and re-verify would fail on a
legitimately-approved definition.
Mirror `JSON.stringify` for the values JSON cannot represent -- drop
`undefined`/function/symbol object keys, render such array elements as
`null` -- keeping only the deterministic key ordering as the intended
difference. The prior header comment claimed the preservation was
intentional; correct it.
`parseLineage` already guarantees the correlation -- a source-ref env carries `closurePackageDir`, a live-authored one does not -- but the `SpawnTimeEnv` interface declared the two as independent fields (`lineage` plus `closurePackageDir: string | undefined`), so the run child had to re-check "source-ref but no closurePackageDir" with a throw that could never fire. Split the interface into a `SpawnTimeEnvBase` plus a discriminated union on `lineage`, and give `parseLineage` that union as its return type so the correlation is expressed once at the boundary. The run child's dead presence guard is deleted; narrowing on `lineage` now types `closurePackageDir` as a plain string on the source-ref arm.
`materializeClosure` filtered the manifest entries to the host, laid those out, then returned only the store dir -- so `loadManifest` re-derived the same host filter with a second predicate (`entryMatchesHost`) purely to avoid re-emitting the platform-filter debug logs. Two filters that must agree, or the phase-3 load diverges from what was materialized. Return the filtered entries alongside the store dir and load exactly those. The second filter pass and its drift risk are gone; `entryMatchesHost` stays only as the shared helper inside `passesPlatformFilter`.
`tool-materialization` re-exported `readRegistries` solely so the deploy-apply wiring's import path did not have to change; the definition lives in `sidecar-materialization-config`, which `index.ts` already imports it from directly. Delete the pass-through and import `readRegistries` from its home module in `workflow-host-wiring`.
`readWorkflowDefinitionEnvelope` prefixed its read/parse/validate errors "workflow-host verified-definition loader", but it is shared by the gated loaders AND the deliberately-ungated childWorkflow spawn, so an operator debugging an ungated read saw a "verified" label the path does not carry. Relabel those errors "workflow-host definition read"; the re-verify errors that DO gate keep their "verified" wording in the loaders that own them.
The projector enumerates each `AgentDefinition` field rather than spreading it -- deliberately, so it can drop credential-adjacent fields and reify function-valued tool factories. The cost is that a grant-bearing field added to `AgentDefinition` but not added to `projectAgent` would be silently dropped from the inert projection, and thus from the approval hash and the operator's gated view. Record that invariant at `projectAgent` and point to the grant-surface reification tests that lock the current coverage.
Three coupled cleanups to the deploy-frame contracts in @intx/types: - The `WorkflowStep` schema was ten near-identical arms whose per-variant fields were all optional passthrough, so the union validated almost nothing beyond the `kind` discriminant while adding ~110 lines of ceremony. Collapse it to one schema over the ten-kind enum plus the common fields. The load-bearing membership check is preserved; the handful of per-arm scalar validations that were enforced on some arms are intentionally dropped (the wire defers primitive-field validation to @intx/workflow). - `AgentDeployWorkflow` restated the `definition` + `sources` + `approvedWireHash` field set and the byte-identical stepOrder-covered-by-sources narrow that `WorkflowProjectionWithSources` already carried -- a duplication this branch had widened. Express it as `WorkflowProjectionWithSources.and(...extras)` so the shared base and its narrow are defined once. - With the union collapsed, sidecar.ts was still over 1000 lines. Extract the wire-step schema and the projection contracts to a new `wire-workflow.ts`, re-exporting the public names from sidecar.ts so `@intx/types/sidecar` consumers are unaffected. sidecar.ts drops to 918 lines.
loader.ts had grown to ~1930 lines spanning four concerns. Decompose it, leaving loader.ts as the author-code loader (createToolLoader + the interchange-entry import/eval helpers) at ~940 lines: - loader-internal.ts -- the shared failure type (ToolLoaderError), the fetch byte/time caps, and the small cross-cutting helpers. Split out so the two concern modules and loader.ts can all depend on them without an import cycle. - registry-fetch.ts -- building the npm-registry-fetch options, deriving a default tarball URL, and reading a response body under a byte cap. - store-layout.ts -- materializeClosure plus the eval-free closure materialization and per-instance store layout it drives (cache extract, range resolution, hardlink layout, direct-dependency symlinks). loader.ts re-exports every moved public member (ToolLoaderError, the DEFAULT_* caps, buildRegistryFetchOpts, readResponseWithLimit, materializeClosure, storeEntryDir) so `./loader` and package-root consumers are unaffected. The only cross-module cycle is a type-only import of the materializeClosure arg/result shapes back from loader.ts, which has no runtime effect.
createDbFrozenApprovalWriter had no coverage -- its gate-level callers substitute a mock persist -- so the transaction that makes the freeze all-or-nothing was untested. Add a tests/db suite over the real writer: one test asserts a single freeze projects the definition AND stamps the approved hash on its version row (both writes present => committed together, not left NULL-hash), and one asserts a repeat freeze is idempotent. Export createDbFrozenApprovalWriter from @intx/hub-sessions so the harness can reach it. The rowcount guard is documented as not exercised: the ensure step always guarantees a FROZEN_VERSION row before the stamp, so the guard defends a code-level constant drift rather than a reachable data state.
Add a regression guard for the dropped exit race arm: a mock probe child handle whose `exited` is already resolved AND whose stdout carries a valid HMAC-signed result reproduces the write-then-exit race deterministically. With the former exit arm the resolved exit would win the race and discard the buffered result; racing only the line, the executor must return the projection, grants, and hash. The signed line is built from the same envelope/HMAC primitives the child uses, so the test exercises the real parse path.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes INTR-416.
The walking skeleton for code-sourced workflow definitions — it builds every new seam once and proves it with a passing e2e that installs a fixture workflow package from a test registry, probes it, approves and freezes a content hash, deploys by source-ref, and runs it to completion.
The path
ApprovalSet, fails closed on mismatch, and records the approved hash + frozen closure on the version row. Identity moves off the one-def-per-asset projection to an(assetId, wireHash)content hash.Security model
Approval binds to the wire-projection hash (
computeWireDefinitionHashover the inert projection), nothashDefinition/projectForHash, which drop grant-bearing fields.The re-verify barrier is load-bearing only where the approved hash arrives out-of-band from the bytes being checked (documented in
docs/AUTH.md): the source-ref closure eval, the top-level inert read, and onTrigger bodies (whose hash is intrinsic to the parent's approval). AchildWorkflowreferences a separately-approved asset the parent holds no hash for, so that spawn reads and envelope-validates without a gate — its integrity is the workflow-kind repo's hub-writes / sidecar-reads authorization plus push-time validation. Primary runtime grant enforcement remains the deploy-time credentials snapshot.Follow-up disposition
source+closure+approvedWireHashfor a source-ref record (so a restored child re-verifies against the hub-approved pin, not a recompute), which also lets the hand-rolled restore guard be deleted. Orphaned materialized closures are reclaimed before re-apply and on undeploy.spawn-child.ts): the disk-tamper threat is dominated by larger equivalent loose reads on the same data dir (sources.json,grants.json), substantially covered by theworkflow-kind repo's hub-writes/sidecar-reads authorization + push-time validation, and owned by host isolation. Whether a childWorkflow should instead be an owned, parent-namespaced sub-workflow (making a barrier coherent) is a product decision filed as INTR-443.approvedDeployGrantswire field was removed rather than shipped unenforced. The actual guarantee ("a workflow can't acquire run-time grants beyond its approved walk") is already delivered by the per-deployment frozen capability walk (which pins run grants to the content-addressed approved definition) plus the content-hash identity — an explicit cap on top would be a no-op in the honest case.Testing
make allgreen: 6060 tests, 0 failures, including the walking-skeleton e2e and the full integration suite. Rebased ontomain(regenerated the two workflow-definition migrations as0075/0076after main's credentials migrations).