The Plan Lockfile
The Plan Lockfile: Gemfile.lock for workflows. A plan that says "give me text.summarize ~> 1.0" is a WISH; production runs need a FACT. `lock` resolves constraints once and writes plan.lock - exact versions plus a content digest per capability. `run --frozen` resolves nothing: it verifies the world still matches the lockfile and refuses to run anything it didn't agree to - a new version published? ignored until you relock. an implementation edited in place under the same …
Developer Experience
Round 17
André Arko
exit 0
bundle exec ruby examples/plan_lockfile.rb
a real captured run
THE PLAN LOCKFILE (a constraint is a wish; production runs need a fact)
day 1 lock + frozen run: text.summarize 1.1.0, markdown.render 2.3.1 -> "<p>Lock your plans.</p>"
day 30 text.summarize 1.2.0 published; frozen run: text.summarize 1.1.0, markdown.render 2.3.1 (ignored it)
day 31 markdown.render 2.3.1 edited in place (same version, new code):
FROZEN RUN REFUSED: markdown.render 2.3.1: content digest mismatch (locked b867f561da7a, found cc89e08e96e1)
the fix is explicit, one command, and leaves a diff: plan lock --update
relock text.summarize 1.2.0, markdown.render 2.3.1 -> "<p class='tracked'>LOCK YOUR PLANS. TRUST YOUR DEPLOYS.</p>"
(1.2.0 adopted NOW, in a diff someone reviews - not silently on day 30)
three moments, one discipline: the constraint file says what you
can ACCEPT, the lockfile says what you ARE RUNNING, and nothing
moves between them without a human making a diff. the digest is
the underrated half - version numbers are claims, and every
ecosystem eventually meets code that lies about itself. bundler
spent a decade earning these rules; plans that call LLMs and
APIs and each other get to inherit them for the cost of one
JSON file.
source
# frozen_string_literal: true # The Plan Lockfile: Gemfile.lock for workflows. A plan that says # "give me text.summarize ~> 1.0" is a WISH; production runs need a # FACT. `lock` resolves constraints once and writes plan.lock - # exact versions plus a content digest per capability. `run --frozen` # resolves nothing: it verifies the world still matches the lockfile # and refuses to run anything it didn't agree to - a new version # published? ignored until you relock. an implementation edited # in place under the same version number? REFUSED BY DIGEST, # because "same version, different code" is the lie lockfiles exist # to catch. # # bundle exec ruby examples/plan_lockfile.rb # # Runs offline; exits 1 unless frozen runs are deterministic and # drift is refused with a usable message. require class="s">"bundler/setup" require class="s">"agentic" require class="s">"digest" require class="s">"json" require class="s">"tmpdir" Agentic.logger.level = class="y">:fatal # The capability "rubygems.org": versions are immutable... unless # someone edits one in place, which is exactly what we'll do to prove # the digests earn their keep REGISTRY = { class="s">"text.summarize" => { class="s">"1.0.0" => class="s">"->(t) { t[class="y">:text].split('. ').first }", class="s">"1.1.0" => class="s">"->(t) { t[class="y">:text].split('. ').first + '.' }" }, class="s">"markdown.render" => { class="s">"2.3.1" => class="s">"->(t) { \"<p>\#{t[class="y">:text]}</p>\class="s">" }" } } PLAN_REQUIREMENTS = {class="s">"text.summarize" => class="s">"~> 1.0", class="s">"markdown.render" => class="s">"~> 2.3"}.freeze def resolve(requirements) requirements.to_h do |name, constraint| versions = REGISTRY.fetch(name).keys.map { |v| Gem:class="y">:Version.new(v) }.sort best = versions.reverse.find { |v| Gem:class="y">:Requirement.new(constraint).satisfied_by?(v) } raise class="s">"no version of #{name} satisfies #{constraint}" unless best [name, best.to_s] end end def write_lock(path, resolution) entries = resolution.to_h { |name, version| [name, {class="s">"version" => version, class="s">"digest" => Digest:class="y">:SHA256.hexdigest(REGISTRY[name][version])[0, 12]}] } File.write(path, JSON.pretty_generate({class="s">"capabilities" => entries, class="s">"locked_by" => class="s">"plan_lockfile 1.0"})) end # Frozen semantics: verify, never resolve. Every failure names the # capability, what was expected, what was found, and the way out. def frozen_check(path) lock = JSON.parse(File.read(path)) lock[class="s">"capabilities"].filter_map do |name, entry| source = REGISTRY.dig(name, entry[class="s">"version"]) if source.nil? class="s">"#{name} #{entry["versionclass="s">"]} is locked but no longer available" elsif Digest:class="y">:SHA256.hexdigest(source)[0, 12] != entry[class="s">"digest"] class="s">"#{name} #{entry["versionclass="s">"]}: content digest mismatch (locked #{entry["digestclass="s">"]}, found #{Digest:class="y">:SHA256.hexdigest(source)[0, 12]})" end end end def run_plan(path) lock = JSON.parse(File.read(path)) impls = lock[class="s">"capabilities"].to_h { |name, e| [name, eval(REGISTRY[name][e[class="s">"version"]])] } # rubocop:disable Security/Eval -- registry sources are this file's own fixtures orchestrator = Agentic:class="y">:PlanOrchestrator.new(concurrency_limit: 2) summarize = Agentic:class="y">:Task.new(description: class="s">"summarize", agent_spec: {class="s">"name" => class="s">"s", class="s">"instructions" => class="s">"w"}) render = Agentic:class="y">:Task.new(description: class="s">"render", agent_spec: {class="s">"name" => class="s">"r", class="s">"instructions" => class="s">"w"}) orchestrator.add_task(summarize, agent: ->(_t) { impls[class="s">"text.summarize"].call({text: class="s">"Lock your plans. Trust your deploys."}) }) orchestrator.add_task(render, [summarize], agent: ->(t) { impls[class="s">"markdown.render"].call({text: t.previous_output}) }) result = orchestrator.execute_plan [result.task_result(render.id).output, lock[class="s">"capabilities"].map { |n, e| class="s">"#{n} #{e["versionclass="s">"]}" }] end failures = [] puts class="s">"THE PLAN LOCKFILE (a constraint is a wish; production runs need a fact)" puts Dir.mktmpdir(class="s">"plan_lock") do |dir| lockfile = File.join(dir, class="s">"plan.lock") # Day 1: developer locks and deploys write_lock(lockfile, resolve(PLAN_REQUIREMENTS)) output, versions = run_plan(lockfile) puts class="s">" day 1 lock + frozen run: #{versions.join(", class="s">")} -> #{output.inspect}" # Day 30: a new version is published upstream. The frozen run does # not care - determinism means new code enters through a relock, ever REGISTRY[class="s">"text.summarize"][class="s">"1.2.0"] = class="s">"->(t) { t[class="y">:text].upcase }" drift = frozen_check(lockfile) output2, versions2 = run_plan(lockfile) puts class="s">" day 30 text.summarize 1.2.0 published; frozen run: #{versions2.join(", class="s">")} (ignored it)" failures << class="s">"frozen run drifted to a new version" unless versions2 == versions && output2 == output && drift.empty? # Day 31: someone edits 2.3.1 IN PLACE - same version, different code REGISTRY[class="s">"markdown.render"][class="s">"2.3.1"] = class="s">"->(t) { \"<p class='tracked'>\#{t[class="y">:text]}</p>\class="s">" }" drift = frozen_check(lockfile) puts puts class="s">" day 31 markdown.render 2.3.1 edited in place (same version, new code):" drift.each { |d| puts class="s">" FROZEN RUN REFUSED: #{d}" } puts class="s">" the fix is explicit, one command, and leaves a diff: plan lock --update" failures << class="s">"digest drift was not refused" if drift.empty? # The relock: deliberate, reviewable, and the plan runs again write_lock(lockfile, resolve(PLAN_REQUIREMENTS)) output3, versions3 = run_plan(lockfile) puts puts class="s">" relock #{versions3.join(", class="s">")} -> #{output3.inspect}" puts class="s">" (1.2.0 adopted NOW, in a diff someone reviews - not silently on day 30)" failures << class="s">"relock didn't adopt the new version" unless versions3.first.include?(class="s">"1.2.0") end puts puts class="s">" three moments, one discipline: the constraint file says what you" puts class="s">" can ACCEPT, the lockfile says what you ARE RUNNING, and nothing" puts class="s">" moves between them without a human making a diff. the digest is" puts class="s">" the underrated half - version numbers are claims, and every" puts class="s">" ecosystem eventually meets code that lies about itself. bundler" puts class="s">" spent a decade earning these rules; plans that call LLMs and" puts class="s">" APIs and each other get to inherit them for the cost of one" puts class="s">" JSON file." exit(failures.empty? ? 0 : 1)