From 3a09236bfb2d54ea9d19c501676e67799e531772 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 23:48:37 +0000 Subject: [PATCH 01/24] docs: gentle deadline example (matz round 16) A time-budgeted plan whose optional tasks decline by name when the clock runs low - essentials always served, garnish as time allows, lateness as a menu decision instead of an outage. Round 16 bench drawn by lottery from all fifty personas. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FeZLdZ3M4q4cho4DZPfSkF --- docs/perspectives/round-16/01-matz.md | 64 +++++++++++++++++++++ examples/gentle_deadline.rb | 83 +++++++++++++++++++++++++++ 2 files changed, 147 insertions(+) create mode 100644 docs/perspectives/round-16/01-matz.md create mode 100644 examples/gentle_deadline.rb diff --git a/docs/perspectives/round-16/01-matz.md b/docs/perspectives/round-16/01-matz.md new file mode 100644 index 0000000..7c6c6e1 --- /dev/null +++ b/docs/perspectives/round-16/01-matz.md @@ -0,0 +1,64 @@ +# Round 16 field notes — Matz declines the garnish + +*Built: `examples/gentle_deadline.rb` — a time-budgeted plan whose +optional tasks bow out by name when the clock runs low, so the meal +is always served and nobody sees an error page.* + +## What I built and why + +Most deadline code is violent. A timeout fires and everything dies — +the user receives an error at 30.0 seconds that could have been a +perfectly good answer at 29. The violence comes from a modeling +failure, not a timing one: we tell the computer *when* we must be +done but never *what matters most*, so when time runs out it +executes everything equally, which is to say it executes the render +step for the crime of coming after the pull quotes. + +The kitchen knows better. A cook running late does not cancel +dinner; a cook declines the garnish: + +``` +a leisurely evening (500ms): served 6 courses - garnish and all +a hurried lunch (160ms): served 3 courses in 122ms, completed + declined with regrets: related links; pull quotes; summary haiku + (the meal was still served - nobody saw an error page) +``` + +The mechanism is one question, asked politely before each *optional* +task: is there comfortably time for me AND the essentials still +owed? The `essentials_owed` sum is the important half — an optional +task must never spend the time a required task downstream will need. +If the answer is no, it declines *by name* and returns +`:declined_with_regrets`, and the plan flows on to what matters. + +## Why gentleness is a data-model feature + +I have spent this whole series saying kindness is anticipating the +need before the failure, and here the anticipation is a single +boolean: `essential:`. Mark the garnish as garnish, and lateness +becomes a *menu decision* instead of an outage. Refuse to mark it, +and every deadline is a coin flip about which task gets murdered +mid-simmer. The framework needed nothing new for this — payload +booleans and a monotonic clock — which pleases me most of all: the +graciousness lives in how the plan is *written*, not in machinery. + +Note also what the declined tasks are: still *successes*, carrying a +value that says what happened. A decline is not a failure — the +failure ledger stays clean for things that actually broke, and the +regrets list is its own honest report. + +## Notes + +- The 0.01s of slack in the comfort check is hospitality arithmetic: + promising to be exactly on time is how one becomes late. +- A follow-up thought for the room (a soft ask, not a demand): an + `optional:` marking at `add_task` that the scheduler itself + understands could make this pattern first-class - declined tasks + reported in their own state rather than smuggled through outputs. + +## Verdict + +Deadlines are not the enemy of graciousness; treating every task as +equally essential is. Two lines of menu-thinking turned a timeout +from a guillotine into a maître d' — and the hurried lunch was +served, on time, with regrets instead of a stack trace. diff --git a/examples/gentle_deadline.rb b/examples/gentle_deadline.rb new file mode 100644 index 0000000..5cb2f9f --- /dev/null +++ b/examples/gentle_deadline.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +# The Gentle Deadline: most deadline code is violent - a timeout +# fires, everything dies, the user gets an error page at 30.0 +# seconds that could have been a good-enough answer at 29. This plan +# knows which of its tasks are ESSENTIAL and which are garnish, and +# when the time budget runs low it starts declining the garnish - +# politely, by name, with the meal still served on time. +# +# bundle exec ruby examples/gentle_deadline.rb +# +# Runs offline; the same dinner is cooked twice, hurried once. + +require_relative "../lib/agentic" + +Agentic.logger.level = :fatal + +# Course list: essential tasks make the meal; optional ones make it lovely +COURSES = [ + {name: "stock: fetch data", essential: true, cost: 0.04}, + {name: "main: analyze", essential: true, cost: 0.06}, + {name: "garnish: related links", essential: false, cost: 0.05}, + {name: "garnish: pull quotes", essential: false, cost: 0.05}, + {name: "dessert: summary haiku", essential: false, cost: 0.03}, + {name: "serve: render answer", essential: true, cost: 0.02} +].freeze + +def cook(budget_seconds) + deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + budget_seconds + orchestrator = Agentic::PlanOrchestrator.new(concurrency_limit: 1) + declined = [] + previous = nil + + COURSES.each do |course| + task = Agentic::Task.new(description: course[:name], agent_spec: {"name" => course[:name], "instructions" => "cook"}) + orchestrator.add_task(task, previous ? [previous] : [], agent: ->(_t) { + remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC) + # The gentle part: an optional course checks whether there is + # comfortably time for it AND the essentials still to come, and + # bows out by name instead of being murdered mid-simmer + essentials_owed = COURSES.drop(COURSES.index(course) + 1).select { |c| c[:essential] }.sum { |c| c[:cost] } + if !course[:essential] && remaining < course[:cost] + essentials_owed + 0.01 + declined << course[:name] + next :declined_with_regrets + end + + sleep(course[:cost]) + "#{course[:name]} ready" + }) + previous = task + end + + started = Process.clock_gettime(Process::CLOCK_MONOTONIC) + result = orchestrator.execute_plan + [result, Process.clock_gettime(Process::CLOCK_MONOTONIC) - started, declined] +end + +puts "THE GENTLE DEADLINE (essential courses always; garnish as time allows)" +puts + +[["a leisurely evening", 0.5], ["a hurried lunch", 0.16]].each do |occasion, budget| + result, elapsed, declined = cook(budget) + served = result.results.values.count { |r| r.successful? && r.output != :declined_with_regrets } + puts " #{occasion} (budget #{(budget * 1000).round}ms):" + puts format(" served %d courses in %dms - status: %s", served, (elapsed * 1000).round, result.status) + if declined.any? + puts " declined with regrets: #{declined.join("; ")}" + puts " (the meal was still served - nobody saw an error page)" + else + puts " everything made it, garnish and all" + end + puts +end + +puts " the design is one question asked politely: before an OPTIONAL" +puts " task starts, is there comfortably time for it AND the essentials" +puts " still owed? if not, it declines BY NAME and the plan flows on." +puts " compare the violent alternative - a global timeout that kills" +puts " the render step because the pull quotes ran long, serving the" +puts " user an error instead of a plainer dinner. deadlines are not" +puts " the enemy of graciousness; treating every task as equally" +puts " essential is. mark the garnish as garnish, and lateness becomes" +puts " a menu decision instead of an outage." From be7ce6582ab9cf22d9811eafeeaf50cce31db4cc Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 23:49:58 +0000 Subject: [PATCH 02/24] docs: railway plan example (solnic round 16) A fourteen-line Result monad over TaskResult/TaskFailure: plan steps compose with bind, the first failure switches downstream steps onto the bypass carrying full testimony - failure as composition, not interruption. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FeZLdZ3M4q4cho4DZPfSkF --- docs/perspectives/round-16/02-solnic.md | 63 ++++++++++++++++++ examples/railway_plan.rb | 85 +++++++++++++++++++++++++ 2 files changed, 148 insertions(+) create mode 100644 docs/perspectives/round-16/02-solnic.md create mode 100644 examples/railway_plan.rb diff --git a/docs/perspectives/round-16/02-solnic.md b/docs/perspectives/round-16/02-solnic.md new file mode 100644 index 0000000..6fc647f --- /dev/null +++ b/docs/perspectives/round-16/02-solnic.md @@ -0,0 +1,63 @@ +# Round 16 field notes — Piotr Solnica lays the rails + +*Built: `examples/railway_plan.rb` — a fourteen-line Result monad +over `TaskResult`/`TaskFailure`, composing plan steps with `bind` so +the first failure switches everything downstream onto the bypass +track.* + +## What I built and why + +Seven of my rounds on this bench were about contracts — the +boundaries. The lottery sent me back, so this time: the *middle*. +How do outcomes compose between the boundaries? Ruby's native +answer is rescue blocks and `return unless`, which is to say: +failure as *interruption*. dry-monads' answer — the one I've spent a +decade advocating — is failure as *composition*: + +```ruby +run_task("validate", order) { ... } + .bind { |o| run_task("price", o) { ... } } + .bind { |o| run_task("invoice", o) { ... } } +``` + +Read it top to bottom; that reading IS the control flow. The empty +cart diverts at `validate`, and price/invoice **never run** — not +because anyone checked, but because `Failure#bind` returns itself. +No rescue pyramid, no nil creeping past step two, no defensive +conditionals (Avdi's timid pipeline, solved from the algebra side +instead of the barricade side — same destination, different proof). + +## The framework had already done the hard part + +The monad is fourteen lines because `TaskResult`/`TaskFailure` are +already Result values in street clothes — round 1's "failure as +data" decision, paying rent again fifteen rounds later. The diverted +train doesn't carry a string or a nil; it carries a first-class +`TaskFailure` with type, message, timestamp, and the retryable +verdict, which means the *end of the line* can still make policy +decisions (retry the whole railway? park it?) with full testimony. +A monad over rich failure values is worth ten monads over `:error`. + +What I deliberately left out: do-notation, `fmap`, `or_else`, the +whole algebra. The discipline that survives translation into any +codebase is the small part — **failures compose, they don't +interrupt** — and a pattern demo that requires a gem's worth of +combinators teaches the gem, not the pattern. + +## Notes + +- `Railway.from(result, task)` is the single lift point between the + orchestrator's world and the railway's — one seam, easy to test, + easy to delete if the team decides monads aren't their dialect. + Patterns should be cheap to adopt AND cheap to abandon. +- Each step here is its own one-task plan for demo clarity; in real + use you'd lift a multi-task plan's terminal result the same way, + or bind across plans — the composition is indifferent to what's + inside each step, which is the point of it. + +## Verdict + +The checkout reads as three binds, the empty cart was diverted with +its full testimony intact, and the monad cost fourteen lines because +the framework modeled failure as data from round one. Railways are +what result objects grow up into when you let them. diff --git a/examples/railway_plan.rb b/examples/railway_plan.rb new file mode 100644 index 0000000..a1910a5 --- /dev/null +++ b/examples/railway_plan.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +# The Railway Plan: dry-monads taught Ruby that failure handling is +# COMPOSITION, not rescue blocks - a pipeline of steps where success +# rides the happy track and the first failure switches every later +# step onto the bypass, carrying WHY. TaskResult/TaskFailure are +# already Result values in street clothes; this gives them bind, so +# a plan's outcome composes like a railway instead of nesting like +# an if-tree. +# +# bundle exec ruby examples/railway_plan.rb +# +# Runs offline; one train arrives, one is politely diverted. + +require_relative "../lib/agentic" + +Agentic.logger.level = :fatal + +# The whole monad: Success carries a value through bind; Failure +# short-circuits, carrying the failure to the end of the line +module Railway + Success = Struct.new(:value) do + def bind = yield(value) + + def success? = true + end + + Failure = Struct.new(:failure) do + def bind = self # the bypass track: later steps never run + + def success? = false + end + + # Lift a plan's task outcome onto the railway + def self.from(result, task) + task_result = result.task_result(task.id) + task_result.successful? ? Success.new(task_result.output) : Failure.new(task_result.failure) + end +end + +def run_task(description, payload, &work) + orchestrator = Agentic::PlanOrchestrator.new(retry_policy: {max_retries: 0, retryable_errors: []}) + task = Agentic::Task.new(description: description, agent_spec: {"name" => description, "instructions" => "w"}, payload: payload) + orchestrator.add_task(task, agent: ->(t) { work.call(t.payload) }) + Railway.from(orchestrator.execute_plan, task) +end + +# Three steps of a checkout, each a real journaled-able plan task, +# composed with bind - read it top to bottom, that IS the control flow +def checkout(order) + run_task("validate", order) { |o| + raise Agentic::Errors::LlmInvalidRequestError, "cart is empty" if o[:items].empty? + + o + }.bind { |o| + run_task("price", o) { |ord| ord.merge(total_cents: ord[:items].sum { |i| i[:cents] }) } + }.bind { |o| + run_task("invoice", o) { |ord| "invoice ##{ord[:id]}: #{ord[:total_cents]} cents" } + } +end + +puts "THE RAILWAY PLAN (bind, not rescue)" +puts + +happy = checkout({id: 41, items: [{cents: 1200}, {cents: 350}]}) +puts " a full cart:" +puts " -> #{happy.success? ? happy.value : happy.failure.message}" +puts + +sad = checkout({id: 42, items: []}) +puts " an empty cart:" +puts " -> diverted at: #{sad.failure.type.split("::").last} - #{sad.failure.message}" +puts " (price and invoice never ran - no nil checks asked, none needed)" +puts + +puts " what the railway buys, precisely: the checkout reads as three" +puts " binds top to bottom, and that reading IS the control flow - no" +puts " rescue pyramid, no `return unless`, no nil creeping past step" +puts " two. the diverted train still carries a first-class TaskFailure" +puts " (type, message, timestamp, retryable verdict), because the" +puts " framework already models failure as data; the monad is just" +puts " fourteen lines acknowledging it. Success/Failure with bind is" +puts " all you need for the pattern - do-notation is nicer, but the" +puts " discipline (failures COMPOSE, they don't interrupt) is the part" +puts " that survives translation into any codebase." From 38b4f2c666105ed19a864166f820b5b8ac986b03 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 23:51:52 +0000 Subject: [PATCH 03/24] docs: schedule equivalence prover example (eregon round 16) The same plan run at concurrency 1/2/8 with outputs required identical; a smuggler plan fails the proof by encoding a race through a shared array, and the fix is an edge. The prover's own first bug - state leaking across runs - was the lesson in miniature. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FeZLdZ3M4q4cho4DZPfSkF --- docs/perspectives/round-16/03-eregon.md | 71 +++++++++++++++ examples/schedule_equivalence.rb | 109 ++++++++++++++++++++++++ 2 files changed, 180 insertions(+) create mode 100644 docs/perspectives/round-16/03-eregon.md create mode 100644 examples/schedule_equivalence.rb diff --git a/docs/perspectives/round-16/03-eregon.md b/docs/perspectives/round-16/03-eregon.md new file mode 100644 index 0000000..643d3a3 --- /dev/null +++ b/docs/perspectives/round-16/03-eregon.md @@ -0,0 +1,71 @@ +# Round 16 field notes — Benoit Daloze proves the schedule away + +*Built: `examples/schedule_equivalence.rb` — the same plan run at +concurrency 1, 2, and 8, with outputs required identical; plus a +smuggler plan that fails the proof by talking through a shared +array, and the boring fix.* + +## What I built and why + +Last time the lottery seated me I pinned semantics across +*implementations*. Same theorem this time, with schedules standing +in for VMs: a plan's declared meaning is its dependency graph, which +implies a promise almost nobody tests — **outputs must not depend on +the schedule**. If running at concurrency 8 gives a different answer +than concurrency 1, the graph is not the whole truth; some meaning +is being smuggled outside the declared edges. + +``` +honest plan across 1/2/8: identical outputs - EQUIVALENT +smuggler plan: + concurrency 1 sum => "7 (a won the race)" + concurrency 2 sum => "7 (b won the race)" + DIVERGED +``` + +The smuggler has the *same graph* as the honest plan — same tasks, +same `needs:` — plus one shared array on the side. Its sum task +reads `ledger.first`: who arrived first. At concurrency 1 the +schedule is just insertion order, so `:a` always wins; under +parallelism the race decides. The output literally encodes the +winner of a race, which is meaning traveling through no edge any +tool can see — the forest drawing, the spec generator, the merge +tool would all certify this plan while it lies to them. + +## The prover's own bug was the lesson in miniature + +My first smuggler never diverged: I created the ledger *outside* the +per-run builder, so all three runs shared one array and +`ledger.first` was forever the very first run's `:a`. The +contraband channel had contraband of its own — state leaking across +what should have been independent experiments. The fix (fresh ledger +per run) is the same fix the example preaches: scope your state to +the unit that owns it. A prover that can't manage its own sharing +has no business auditing anyone else's; ninth round running that the +tool corrects the author first. + +The fix for real smugglers is always the same and always boring: +**whatever the shared state was whispering, say it with an edge.** +`needs:` hands the sum exactly the values it may know, and the graph +becomes the whole truth again — at which point every schedule is as +good as every other, which is precisely what lets the orchestrator +choose freely. + +## Notes + +- Races are shy under observation: the prover retries the smuggler + several times, because a race that happens to land identically + proves nothing. Absence of divergence is evidence, not proof — + which is why the *honest* plan gates the exit code and the + smuggler is a demonstration. +- What I'd want next (soft ask): a deterministic-schedule mode in + the orchestrator — seeded task interleaving — so this prover could + *enumerate* schedules instead of sampling them. Property testing + needs reproducible counterexamples. + +## Verdict + +A plan isn't correct until its outputs are a function of its graph, +and now there's a prover that asks. The honest plan is equivalent +under every schedule; the smuggler was caught encoding a race; and +the fix fit in one `needs:`. Schedules are just VMs wearing clocks. diff --git a/examples/schedule_equivalence.rb b/examples/schedule_equivalence.rb new file mode 100644 index 0000000..54baab2 --- /dev/null +++ b/examples/schedule_equivalence.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +# Schedule Equivalence: a plan's declared meaning is its dependency +# graph - which implies a PROMISE nobody usually tests: outputs must +# not depend on the schedule. Run the same plan at concurrency 1, 2, +# and 8; if the outputs differ, the plan has an undeclared dependency +# smuggled through shared state. This prover runs both an honest plan +# and a smuggler, and shows the exact fix. +# +# bundle exec ruby examples/schedule_equivalence.rb +# +# Runs offline; exits 1 only if the HONEST plan proves schedule-dependent. + +require_relative "../lib/agentic" + +Agentic.logger.level = :fatal + +CONCURRENCIES = [1, 2, 8].freeze + +def outputs_under(concurrency, &builder) + orchestrator = Agentic::PlanOrchestrator.new(concurrency_limit: concurrency) + tasks = builder.call(orchestrator) + result = orchestrator.execute_plan + tasks.to_h { |name, task| [name, result.task_result(task.id).output] } +end + +def equivalence_verdict(&builder) + runs = CONCURRENCIES.to_h { |c| [c, outputs_under(c, &builder)] } + baseline = runs[CONCURRENCIES.first] + divergent = runs.reject { |_, outputs| outputs == baseline }.keys + [runs, divergent] +end + +# --- the honest plan: all communication travels the declared edges -------------- +honest = lambda do |o| + a = Agentic::Task.new(description: "count_a", agent_spec: {"name" => "a", "instructions" => "w"}) + b = Agentic::Task.new(description: "count_b", agent_spec: {"name" => "b", "instructions" => "w"}) + sum = Agentic::Task.new(description: "sum", agent_spec: {"name" => "s", "instructions" => "w"}) + o.add_task(a, agent: ->(_t) { + sleep(rand * 0.01) + 3 + }) + o.add_task(b, agent: ->(_t) { + sleep(rand * 0.01) + 4 + }) + o.add_task(sum, needs: {a: a, b: b}, agent: ->(t) { t.needs[:a] + t.needs[:b] }) + {a: a, b: b, sum: sum} +end + +# --- the smuggler: same shape, but tasks ALSO talk through a shared array -------- +def smuggler_plan + lambda do |o| + ledger = [] # the contraband channel: order of arrival becomes meaning (fresh per run) + a = Agentic::Task.new(description: "count_a", agent_spec: {"name" => "a", "instructions" => "w"}) + b = Agentic::Task.new(description: "count_b", agent_spec: {"name" => "b", "instructions" => "w"}) + sum = Agentic::Task.new(description: "sum", agent_spec: {"name" => "s", "instructions" => "w"}) + o.add_task(a, agent: ->(_t) { + sleep(rand * 0.01) + ledger << :a + 3 + }) + o.add_task(b, agent: ->(_t) { + sleep(rand * 0.01) + ledger << :b + 4 + }) + # The sin: reading who arrived FIRST - information no edge declares + o.add_task(sum, needs: {a: a, b: b}, agent: ->(t) { + "#{t.needs[:a] + t.needs[:b]} (#{ledger.first} won the race)" + }) + {a: a, b: b, sum: sum} + end +end + +puts "SCHEDULE EQUIVALENCE (outputs must not know the schedule)" +puts + +_, honest_divergent = equivalence_verdict(&honest) +puts " honest plan across concurrency #{CONCURRENCIES.join("/")}:" +puts " #{honest_divergent.empty? ? "identical outputs under every schedule - EQUIVALENT" : "DIVERGED at #{honest_divergent.join(", ")}"}" +puts + +# The smuggler needs several attempts because races are shy under observation +diverged = false +5.times do + runs, divergent = equivalence_verdict(&smuggler_plan) + next if divergent.empty? + + diverged = true + puts " smuggler plan (same graph, plus a shared array on the side):" + runs.each { |c, outputs| puts format(" concurrency %-2d sum => %s", c, outputs[:sum].inspect) } + puts " DIVERGED: at concurrency 1 the schedule is the insertion order," + puts " so :a always wins; under parallelism the race decides. the" + puts " output encodes WHO WON A RACE - meaning that travels outside" + puts " every declared edge." + break +end +puts " (smuggler raced identically this run - rerun to catch it; races are shy)" unless diverged +puts +puts " the fix is always the same and always boring: whatever the shared" +puts " state was whispering, SAY IT WITH AN EDGE - needs: hands the sum" +puts " exactly the values it may know, and the graph becomes the whole" +puts " truth. ruby/spec taught me that 'works on this implementation'" +puts " means nothing until the behavior is pinned across VMs; same" +puts " theorem here with schedules for VMs: a plan isn't correct until" +puts " its outputs are a function of its GRAPH, and this prover is how" +puts " you find the plans that are secretly functions of the clock." +exit(honest_divergent.empty? ? 0 : 1) From fdd245c02fe9aebad640fa9119a2cc0440449026 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 23:53:17 +0000 Subject: [PATCH 04/24] docs: configurable cops example (bbatsov round 16) The contract cops grown a config layer: per-cop enable/disable and params, comments as recorded decisions, and RuboCop's pending policy - new cops never fire until the team signs, so upgrades can't redden a build by surprise. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FeZLdZ3M4q4cho4DZPfSkF --- docs/perspectives/round-16/04-bbatsov.md | 66 ++++++++++++++++ examples/configurable_cops.rb | 98 ++++++++++++++++++++++++ 2 files changed, 164 insertions(+) create mode 100644 docs/perspectives/round-16/04-bbatsov.md create mode 100644 examples/configurable_cops.rb diff --git a/docs/perspectives/round-16/04-bbatsov.md b/docs/perspectives/round-16/04-bbatsov.md new file mode 100644 index 0000000..171eadc --- /dev/null +++ b/docs/perspectives/round-16/04-bbatsov.md @@ -0,0 +1,66 @@ +# Round 16 field notes — Bozhidar Batsov writes the minutes + +*Built: `examples/configurable_cops.rb` — the contract cops grown a +config layer: enable/disable per cop, parameters instead of +hardcoded taste, and new cops arriving `pending` so an upgrade can +never redden a build without the team's signature.* + +## What I built and why + +Round 11 I built the cops; the lottery sent me back to build the +part that actually keeps the peace. RuboCop's deepest lesson was +never any single cop — it's the `.yml`. A style guide nobody can +configure isn't a guide, it's a style *fight* on a delay timer, and +the fight re-runs in every code review until someone writes the +decision down where a build can read it: + +``` +same contract, two teams: +team A (defaults + opted into pending cop): 4 offenses +team B (Max: 8, EnumOrder off, pending off): 1 offense +``` + +Team B's config is the artifact I care about. `Max: 8` sits next to +a comment explaining *why* ("seven required inputs and we've MET our +capability"), blame-able to a person and a date. `EnumOrder: +Enabled: false` records that their enums are ordered by freight +class, not alphabet — a real reason, now written where the linter +reads it instead of re-argued wherever reviewers meet. Hardcoded +taste creates rebels; configurable taste creates a paper trail. +**The style guide is the conversation; the config file is its +minutes.** + +## Pending is the load-bearing status + +The policy that saved RuboCop's users a thousand ruined mornings: +**new cops arrive `pending`** and fire only when a team opts in. +`Lint/UntypedField` ships in this "release," and for team B it fired +zero times — not because it's wrong (it's load-bearing! untyped +fields lose schema projection), but because a linter upgrade must +never turn a green build red by surprise. Team A read the release +notes and signed; team B will get there on their own schedule. +Trust, once spent on a surprise red build, does not refund — the +pending policy is how a linter stays upgradeable for a decade. + +Implementation notes: `Enabled` defaults derive from cop status +(stable on, pending off), per-cop params merge over `defaults:`, and +the whole config engine is a dozen lines because the cops from round +11 already took `(spec, params)` — parameterize your checks from day +one and the config layer is a merge, not a rewrite. + +## Notes + +- Deliberately absent: `inherit_from`. Real teams need config + inheritance (org defaults + team overrides); it's the natural next + twenty lines and the example says so by omission rather than by a + half-implementation. +- The comment IN team B's YAML is part of the design. Config without + rationale rots into cargo cult; the `.yml` should read like + minutes, not like output. + +## Verdict + +One contract, two teams, two verdicts, zero fights — every +divergence a recorded decision with a name on it, and the new cop +politely waiting for signatures. Style guides earn adoption by being +configurable and keep it by never surprising anyone on upgrade day. diff --git a/examples/configurable_cops.rb b/examples/configurable_cops.rb new file mode 100644 index 0000000..5982dba --- /dev/null +++ b/examples/configurable_cops.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +# Configurable Cops: a style guide nobody can configure is a style +# FIGHT on a delay timer. RuboCop's deepest lesson isn't any single +# cop - it's the .yml: enable/disable per cop, parameters instead of +# hardcoded taste, and (the policy that saved a thousand upgrades) +# NEW COPS ARRIVE PENDING - they never fire until the team opts in, +# so a linter update can't turn a green build red by surprise. +# +# bundle exec ruby examples/configurable_cops.rb +# +# Runs offline; one contract, two teams, two verdicts, zero fights. + +require_relative "../lib/agentic" +require "yaml" + +# The defendant, unchanged between teams +CONTRACT = { + name: "quote_shipping", description: "", version: "1.0.0", + inputs: { + mode: {type: "string", required: true, enum: %w[sea air road]}, + weight_kg: {type: "number", required: true, min: 0, max: 5_000}, + a: {type: "string", required: true}, b: {type: "string", required: true}, + c: {type: "string", required: true}, d: {type: "string", required: true}, + ref: {} + } +}.freeze + +# Cops carry status (:stable or :pending) and read their params from config +COPS = { + "Documentation/Description" => { + status: :stable, + check: ->(s, _p) { s[:description].to_s.empty? ? ["capability has no description"] : [] } + }, + "Style/EnumOrder" => { + status: :stable, + check: ->(s, _p) { s[:inputs].select { |_, d| d[:enum] && d[:enum] != d[:enum].sort }.map { |k, _| "input :#{k} enum is not sorted" } } + }, + "Metrics/RequiredInputCount" => { + status: :stable, defaults: {"Max" => 5}, + check: ->(s, p) { + required = s[:inputs].count { |_, d| d[:required] } + (required > p["Max"]) ? ["#{required} required inputs (Max: #{p["Max"]})"] : [] + } + }, + "Lint/UntypedField" => { + status: :pending, # arrived in this release: fires ONLY if the team opts in + check: ->(s, _p) { s[:inputs].select { |_, d| d[:type].nil? }.map { |k, _| "input :#{k} has no type" } } + } +}.freeze + +def inspect_with(config_yaml, spec) + config = YAML.safe_load(config_yaml) || {} + COPS.flat_map do |name, cop| + cop_config = config.fetch(name, {}) + enabled = cop_config.fetch("Enabled", cop[:status] == :stable) + next [] unless enabled + + params = (cop[:defaults] || {}).merge(cop_config.except("Enabled")) + cop[:check].call(spec, params).map { |offense| [name, offense] } + end +end + +TEAM_A = <<~YAML + # team A: defaults, plus we opted into the new pending cop + Lint/UntypedField: + Enabled: true +YAML + +TEAM_B = <<~YAML + # team B: we have seven required inputs and we've MET our capability; + # raising Max is a decision, recorded here, reviewable in git blame + Metrics/RequiredInputCount: + Max: 8 + Style/EnumOrder: + Enabled: false # our enums are ordered by freight class, not alphabet +YAML + +puts "CONFIGURABLE COPS (same contract, two teams, two configs)" +puts +[["team A (defaults + opted into pending cop)", TEAM_A], + ["team B (raised Max, disabled EnumOrder, pending cop stays off)", TEAM_B]].each do |team, config| + offenses = inspect_with(config, CONTRACT) + puts " #{team}: #{offenses.size} offense(s)" + offenses.each { |cop, offense| puts format(" %-30s %s", cop, offense) } + puts +end + +puts " the pending status is the load-bearing idea: Lint/UntypedField" +puts " shipped in this release, and for team B it fired ZERO times -" +puts " not because it's wrong but because a linter update must never" +puts " turn a green build red without the team's signature. team A" +puts " signed. and look at what team B's config really is: a RECORD OF" +puts " DECISIONS - 'Max: 8' with a comment, blame-able to a person and" +puts " a date, instead of the same argument re-fought in every review." +puts " hardcoded taste creates rebels; configurable taste creates a" +puts " paper trail. the style guide is the conversation; the config" +puts " file is its minutes." From d004fc06bf6f1c948a24567cdecae80a4b5c026f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 23:54:39 +0000 Subject: [PATCH 05/24] docs: spend ledger example (noelrap round 16) LLM spend as integer cents in a journal-backed ledger: affordability checked before each spend, budget exhaustion classified retryable (tomorrow has a new budget), and the money trail sharing the work trail's fsynced file. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FeZLdZ3M4q4cho4DZPfSkF --- docs/perspectives/round-16/05-noelrap.md | 69 ++++++++++++++ examples/spend_ledger.rb | 113 +++++++++++++++++++++++ 2 files changed, 182 insertions(+) create mode 100644 docs/perspectives/round-16/05-noelrap.md create mode 100644 examples/spend_ledger.rb diff --git a/docs/perspectives/round-16/05-noelrap.md b/docs/perspectives/round-16/05-noelrap.md new file mode 100644 index 0000000..7a3dfe0 --- /dev/null +++ b/docs/perspectives/round-16/05-noelrap.md @@ -0,0 +1,69 @@ +# Round 16 field notes — Noel Rappin balances the books + +*Built: `examples/spend_ledger.rb` — LLM spend as integer cents in a +journal-backed ledger, with affordability checked before each spend +and budget exhaustion classified retryable. The invoice balances to +the cent; the overdraft never happens.* + +## What I built and why + +I wrote a whole book about taking people's money, and its saddest +chapters are all the same chapter: a team that treated money as a +number instead of as *money*. LLM plans spend real dollars per task +now, and I watched fifteen rounds of this series journal durations, +verdicts, and damage — everything except the invoice. So: + +``` +stopped at: budget: polish:tone costs $9.50 but only $5.10 remains +item amount running +classify:batch $2.40 $2.40 +draft:responses $18.75 $21.15 +review:drafts $18.75 $39.90 +TOTAL $39.90 (budget $45.00) +``` + +Three rules from every payments postmortem I've ever read, all +enforced here: + +1. **Integer cents.** `0.1 + 0.2 != 0.3` is cute trivia everywhere + except billing, where it's a lawsuit. Floats round your money + eventually, and eventually is audit season. Every price, every + sum, every comparison in this ledger is an Integer. +2. **Check affordability BEFORE the spend.** `afford!` runs at the + top of the task, not the bottom — a budget that only notices + overdrafts is a historian, not a control. The plan stopped at + $39.90 of $45.00 *because polish:tone was never bought*, which is + the entire point: the overdraft that didn't happen is invisible + in every metric except the one that matters. +3. **Budget exhaustion is retryable.** The stop raises with a + transient error class on purpose: tomorrow has a new budget. + Round 8's dead letter office will *requeue* this task instead of + parking it next to the revoked API keys — the taxonomy the series + built for failures turns out to classify money problems too. + +## One file, two trails + +The design decision I'd defend hardest: spends and declines are +journal events (`:spend`, `:spend_declined`) in the *same fsynced +file* as the task lifecycle. "What did this run cost" and "what did +this run do" are the same replay — no reconciliation job between a +metrics store and a billing store, which is where money numbers go +to diverge. When the auditor asks, the answer is one file, and it +was durable before each task returned. + +## Notes + +- The running-balance column in the invoice isn't decoration; it's + the human-verifiable proof of summation. Ledgers you can't check + by eye get checked by nobody. +- Soft ask for the room: a `before_task_execution`-adjacent hook + that can *veto* scheduling would let the budget stop the task + before the agent is even built, rather than raising from inside + it. The raise works; a veto would be cleaner accounting. + +## Verdict + +The journal learned denominations: integer cents, pre-spend checks, +retryable budget stops, and an invoice that balances on sight. Take +my money — but only up to the budget, and give me the receipt in +the same file as the work. diff --git a/examples/spend_ledger.rb b/examples/spend_ledger.rb new file mode 100644 index 0000000..2e14489 --- /dev/null +++ b/examples/spend_ledger.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +# The Spend Ledger: LLM plans spend real money, and money has rules +# older than software - integer cents (floats round YOUR money, never +# theirs), a ledger where every entry has a description, and a budget +# that stops the spending BEFORE the overdraft, not in the postmortem. +# The journal already receipts every task; this makes the receipts +# denominate. +# +# bundle exec ruby examples/spend_ledger.rb +# +# Runs offline; the invoice at the end balances to the cent. + +require_relative "../lib/agentic" +require "tmpdir" + +Agentic.logger.level = :fatal + +# Price list in INTEGER CENTS. 0.1 + 0.2 != 0.3 is a cute trivia +# question everywhere except billing, where it's a lawsuit. +PRICES = { + "fetch:tickets" => 0, # api call, free tier + "classify:batch" => 240, # cheap model + "draft:responses" => 1875, # expensive model, long output + "review:drafts" => 1875, + "polish:tone" => 950, + "render:report" => 0 +}.freeze + +BUDGET_CENTS = 4_500 + +class SpendLedger + attr_reader :entries + + def initialize(budget_cents:, journal:) + @budget_cents = budget_cents + @journal = journal + @entries = [] + end + + def spent_cents = @entries.sum { |e| e[:cents] } + + def remaining_cents = @budget_cents - spent_cents + + # The affordability check runs BEFORE the work: a budget that only + # notices overdrafts is a historian, not a control + def afford!(description, cents) + if cents > remaining_cents + @journal.record(:spend_declined, description: description, cents: cents, remaining_cents: remaining_cents) + raise Agentic::Errors::LlmRateLimitError, # budget exhaustion is transient: tomorrow has a new budget + "budget: #{description} costs #{format_cents(cents)} but only #{format_cents(remaining_cents)} remains" + end + + @entries << {description: description, cents: cents} + @journal.record(:spend, description: description, cents: cents, remaining_cents: remaining_cents) + end + + def format_cents(cents) = format("$%.2f", cents / 100.0) + + def invoice + puts format(" %-20s %10s %12s", "item", "amount", "running") + running = 0 + @entries.each do |e| + running += e[:cents] + puts format(" %-20s %10s %12s", e[:description], format_cents(e[:cents]), format_cents(running)) + end + puts format(" %-20s %10s (budget %s)", "TOTAL", format_cents(spent_cents), format_cents(@budget_cents)) + end +end + +journal = Agentic::ExecutionJournal.new(path: File.join(Dir.tmpdir, "agentic_spend.jsonl")) +File.delete(journal.path) if File.exist?(journal.path) +ledger = SpendLedger.new(budget_cents: BUDGET_CENTS, journal: journal) + +orchestrator = Agentic::PlanOrchestrator.new( + concurrency_limit: 1, lifecycle_hooks: journal.lifecycle_hooks, + retry_policy: {max_retries: 0, retryable_errors: []} +) +previous = nil +PRICES.each do |name, cents| + task = Agentic::Task.new(description: name, agent_spec: {"name" => name, "instructions" => "w"}) + orchestrator.add_task(task, previous ? [previous] : [], agent: ->(_t) { + ledger.afford!(name, cents) + "#{name} done" + }) + previous = task +end + +puts "THE SPEND LEDGER (budget #{ledger.format_cents(BUDGET_CENTS)}, prices in integer cents)" +puts +result = orchestrator.execute_plan +puts " plan status: #{result.status}" +failed = result.results.values.find { |r| !r.successful? } +puts " stopped at: #{failed.failure.message}" if failed +puts +puts " the invoice (from the ledger, balances to the cent):" +ledger.invoice +puts +state = Agentic::ExecutionJournal.replay(path: journal.path) +declined = state.events.count { |e| e[:event] == "spend_declined" } +puts " journal receipts: #{state.events.count { |e| e[:event] == "spend" }} spends, #{declined} declined - the money" +puts " trail and the work trail live in ONE fsynced file, so 'what did" +puts " this run cost' and 'what did this run do' are the same replay." +puts +puts " three rules from every payments postmortem I've read: INTEGER" +puts " CENTS (floats round your money eventually, and eventually is" +puts " audit season); check affordability BEFORE the spend (a budget" +puts " that only notices overdrafts is a historian); and classify" +puts " budget-stop as RETRYABLE - tomorrow has a new budget, so the" +puts " dead letter office requeues it instead of parking it with the" +puts " revoked keys. the plan stopped at #{ledger.format_cents(ledger.spent_cents)} of #{ledger.format_cents(BUDGET_CENTS)}, which is" +puts " the entire point: the overdraft that didn't happen is invisible" +puts " in every metric except the one that matters." From 2495ccbad56b9568382a67693ed9018160d6cbed Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 23:56:18 +0000 Subject: [PATCH 06/24] docs: shadow traffic example (eileencodes round 16) Scientist-style rehearsal: v1 serves, v2 shadows the same inputs, mismatches journaled with the request attached, shadow output asserted to feed nothing. The one disagreement is exactly the case a blind cutover would have paged on. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FeZLdZ3M4q4cho4DZPfSkF --- docs/perspectives/round-16/06-eileencodes.md | 69 ++++++++++++++ examples/shadow_traffic.rb | 99 ++++++++++++++++++++ 2 files changed, 168 insertions(+) create mode 100644 docs/perspectives/round-16/06-eileencodes.md create mode 100644 examples/shadow_traffic.rb diff --git a/docs/perspectives/round-16/06-eileencodes.md b/docs/perspectives/round-16/06-eileencodes.md new file mode 100644 index 0000000..cc82182 --- /dev/null +++ b/docs/perspectives/round-16/06-eileencodes.md @@ -0,0 +1,69 @@ +# Round 16 field notes — Eileen Uchitelle rehearses in production + +*Built: `examples/shadow_traffic.rb` — v1 serves every request while +v2 runs in the shadow on the same inputs: results compared, +mismatches journaled, users untouched. The cutover decision becomes +a table.* + +## What I built and why + +Upgrading load-bearing infrastructure at GitHub scale taught me the +rule I trust most: **the safest replacement is the one that never +answers until it's proven.** Blind cutovers bet the pager; staging +bets that staging resembles production (it doesn't; production's +inputs are weirder than anyone's fixtures). The move that works is +the scientist pattern — serve from the incumbent, run the candidate +beside it on real traffic, compare, throw the candidate's answer +away: + +``` +6 requests served by v1; 6 shadowed by v2 +agreement: 5/6 (83%) latency: v1 p50 0.01ms, v2 p50 1.17ms +mismatch: "Please resend my invoice" - served: general | candidate: billing +``` + +And there it is — the mismatch is *exactly the case that would have +paged* after a blind cutover. v2 casts a broader net ("invoice"), +which is either the bug finally fixed or a regression introduced. +The shadow can't tell you which; what it guarantees is that a +**human decides with the example in hand**, before a single user +was reclassified. That's the entire trade: rehearsal buys you the +argument with evidence instead of the incident with apologies. + +## The discipline clauses + +Three rules make shadowing safe rather than merely interesting, and +the example enforces each: + +1. **The shadow's output feeds nothing.** Asserted on every request + — the plan raises if v2's answer ever reaches serving. A shadow + that leaks is a cutover you didn't schedule. +2. **Shadow failures can't fail the plan.** The candidate crashing + is *data* (it goes in the report), never an outage. +3. **Comparisons land in the journal** — the same fsynced file the + recovery and audit tooling already read. The cutover meeting + replays a file; nobody's memory is load-bearing. + +The plan shape made this almost free: `shadow` depends on `serve` +via `previous_output`, so the comparison has both answers without +any side channel — the declared edge IS the comparison wire (eregon +would note the shadow is schedule-independent for exactly this +reason). + +## Notes + +- Latency rides along in the same report (v2 is 100x slower here) — + agreement without latency is half a cutover decision, as the + capacity-planning seat keeps reminding this series. +- At real scale you'd sample (shadow 1% of traffic), cap shadow + concurrency with its own limiter so the rehearsal can't starve + the show, and diff structured outputs field-by-field. All three + are additive; none change the shape. + +## Verdict + +v2 rehearsed on live traffic, disagreed once, and the disagreement +is now a line in a table with the request attached — reviewed by a +person, felt by no one. Rehearse in production, serve from the +incumbent, cut over on evidence. Scale isn't bravery; it's +choreography. diff --git a/examples/shadow_traffic.rb b/examples/shadow_traffic.rb new file mode 100644 index 0000000..0655a37 --- /dev/null +++ b/examples/shadow_traffic.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +# Shadow Traffic: the safest way to replace a component at scale is +# to never let the replacement answer. The OLD implementation serves +# every request; the NEW one runs beside it in the shadow - same +# inputs, measured and compared, its results thrown away. Users feel +# nothing; you collect a mismatch report and a latency comparison, +# and the cutover decision becomes a table instead of a leap. +# +# bundle exec ruby examples/shadow_traffic.rb +# +# Runs offline; v2 disagrees on exactly the case that would've paged. + +require_relative "../lib/agentic" +require "tmpdir" + +Agentic.logger.level = :fatal + +# v1 serves production today +V1 = ->(text) { text.match?(/refund|charge/i) ? "billing" : "general" } + +# v2 is the candidate: faster on average, and subtly different +V2 = lambda do |text| + sleep(0.001) + return "billing" if text.match?(/refund|charge|invoice/i) # broader net + "general" +end + +JOURNAL = File.join(Dir.tmpdir, "agentic_shadow.jsonl") +File.delete(JOURNAL) if File.exist?(JOURNAL) +journal = Agentic::ExecutionJournal.new(path: JOURNAL) + +REQUESTS = [ + "I want a refund for order 8", + "How do I reset my password?", + "You charged me twice", + "Please resend my invoice", # <- the divergence + "What are your business hours?", + "Refund the duplicate charge" +].freeze + +# One plan per request: the serve task answers; the shadow task runs +# the candidate on the same input and RECORDS, never serves +mismatches = [] +latencies = {v1: [], v2: []} + +REQUESTS.each_with_index do |text, i| + orchestrator = Agentic::PlanOrchestrator.new(lifecycle_hooks: journal.lifecycle_hooks) + serve = Agentic::Task.new(description: "serve:#{i}", agent_spec: {"name" => "v1", "instructions" => "serve"}) + shadow = Agentic::Task.new(description: "shadow:#{i}", agent_spec: {"name" => "v2", "instructions" => "shadow"}) + + orchestrator.add_task(serve, agent: ->(_t) { + t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC) + answer = V1.call(text) + latencies[:v1] << Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0 + answer + }) + # The shadow depends on serve's output so it can COMPARE - and its + # own output is deliberately unused by anything downstream + orchestrator.add_task(shadow, [serve], agent: ->(t) { + t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC) + candidate = V2.call(text) + latencies[:v2] << Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0 + if candidate != t.previous_output + mismatches << {request: text, served: t.previous_output, candidate: candidate} + journal.record(:shadow_mismatch, description: "shadow:#{i}", served: t.previous_output, candidate: candidate) + end + candidate + }) + result = orchestrator.execute_plan + # What the user received came from v1, always: + raise "shadow leaked into serving!" unless result.task_result(serve.id).output == V1.call(text) +end + +puts "SHADOW TRAFFIC (v1 serves; v2 rehearses; users feel nothing)" +puts +puts format(" %d requests served by v1; %d shadowed by v2", REQUESTS.size, REQUESTS.size) +puts format(" agreement: %d/%d (%.0f%%)", REQUESTS.size - mismatches.size, REQUESTS.size, + (REQUESTS.size - mismatches.size) * 100.0 / REQUESTS.size) +puts format(" latency: v1 p50 %.2fms, v2 p50 %.2fms", latencies[:v1].sort[2] * 1000, latencies[:v2].sort[2] * 1000) +puts +puts " the mismatch report (the whole reason to shadow):" +mismatches.each do |m| + puts " #{m[:request].inspect}" + puts " served: #{m[:served]} | candidate: #{m[:candidate]}" +end +puts +state = Agentic::ExecutionJournal.replay(path: JOURNAL) +puts " #{state.events.count { |e| e[:event] == "shadow_mismatch" }} mismatch(es) journaled - the cutover meeting reads a table," +puts " not a hunch. and the mismatch is EXACTLY the case that would have" +puts " paged after a blind cutover: v2 casts a broader net ('invoice')," +puts " which is either the bug fixed or the regression introduced - the" +puts " shadow can't tell you which, but it guarantees a HUMAN decides" +puts " with the example in hand, before a single user was reclassified." +puts " the discipline that makes this safe at scale: the shadow's output" +puts " feeds NOTHING (asserted every request), shadow failures can't" +puts " fail the plan, and the comparison is journaled where the recovery" +puts " and audit tooling already live. rehearse in production, serve" +puts " from the incumbent, cut over on evidence." From f5cfc3c8fa764d00315dcd9c934161050ff16077 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 23:57:47 +0000 Subject: [PATCH 07/24] docs: discovery testing example (searls round 16) Outside-in in three acts: fakes for collaborators that don't exist force narrow interfaces into existence, realization leaves the caller untouched, and one discovered seam turns out to fit an entire orchestrated plan behind it - with round 12's papers check keeping the surviving fakes honest. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FeZLdZ3M4q4cho4DZPfSkF --- docs/perspectives/round-16/07-searls.md | 69 +++++++++++++++ examples/discovery_testing.rb | 108 ++++++++++++++++++++++++ 2 files changed, 177 insertions(+) create mode 100644 docs/perspectives/round-16/07-searls.md create mode 100644 examples/discovery_testing.rb diff --git a/docs/perspectives/round-16/07-searls.md b/docs/perspectives/round-16/07-searls.md new file mode 100644 index 0000000..e26da98 --- /dev/null +++ b/docs/perspectives/round-16/07-searls.md @@ -0,0 +1,69 @@ +# Round 16 field notes — Justin Searls discovers the design + +*Built: `examples/discovery_testing.rb` — outside-in in three acts: +fakes for collaborators that don't exist yet, wishes hardening into +interfaces, and a discovered seam that turned out to fit an entire +orchestrated plan behind it.* + +## What I built and why + +Round 12 I policed doubles that had drifted from reality. The +lottery sent me back for the other half of the sermon — the half +people skip: doubles aren't primarily an *isolation* tool, they're a +*discovery* tool. Most folks fake collaborators that already exist. +The better trick is faking collaborators that **don't exist yet**, +and letting test pressure tell you what interfaces you wish you had: + +``` +act 1: triager orchestrates two DISCOVERED interfaces ok +act 2: triager unchanged as fakes become real (seam held) ok +act 3: router realized as a two-task plan, same interface ok +fakes still match reality: #classify, #route ok +``` + +Act 1 is the design session. Writing `TicketTriager` top-down +forced two wishes into existence — `classify(text) → label` and +`route(id, label) → receipt` — not sketched on a whiteboard but +*extruded under test pressure*, sized exactly to what the caller +needs and nothing more. That's why discovered interfaces come out +narrow: nobody wishes for a config hash. + +Act 2 is the proof the seam was right: the classifier became real +and the triager **didn't change**. If realizing a collaborator +forces edits upstream, the double didn't discover a design; it +hid the absence of one. + +## Act 3 is why this belongs in this repo + +The discovered interface is *indifferent to what stands behind it*. +`route(id, label)` was a two-line fake; it became a **two-task +orchestrated plan** — enqueue, then notify, with the framework's +whole apparatus (journaling, retries, the graph) available behind a +seam the triager never renegotiated. This is Sandi's duck seam and +my discovery workflow shaking hands: discovery testing finds the +narrow waist, and the plan framework is exactly the kind of heavy +machinery you want *behind* a narrow waist rather than threaded +through your domain. + +And the last checks keep round 12's law: fakes that outlive their +realization must show their papers (method + parameter shapes +against the real class), or they quietly start vouching for a +design that moved. Discovery and verification are one workflow — +wish, realize, verify, forever. + +## Notes + +- The fakes are anonymous classes, five lines each, because + discovery fakes should be *cheap to throw away* — a fake with a + factory and a builder DSL has become an investment someone will + defend in review. +- Deliberate omission: no mocking framework. `Class.new` and a + `#parameters` check carry the whole discipline; the tooling is + optional, the workflow isn't. + +## Verdict + +Two interfaces discovered under pressure, one realized as a plain +class, one as an entire plan — with the top of the design never +edited after act 1. The doubles were scaffolding; the interfaces +they discovered are the building, and the building held. diff --git a/examples/discovery_testing.rb b/examples/discovery_testing.rb new file mode 100644 index 0000000..2ec7f03 --- /dev/null +++ b/examples/discovery_testing.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +# Discovery Testing: most people use test doubles to ISOLATE code +# that already exists. The better trick is using them to DISCOVER +# code that doesn't: start at the top with fakes for collaborators +# you haven't designed yet, let the failing test tell you what +# interfaces you wish existed, then descend one level and make each +# wish real. The doubles are scaffolding; the interfaces they +# discovered are the building. +# +# bundle exec ruby examples/discovery_testing.rb +# +# Runs offline; three acts, each act's checks actually execute. + +require_relative "../lib/agentic" + +Agentic.logger.level = :fatal + +CHECKS = [] +def check(claim, ok) = CHECKS << [claim, ok] + +# --- ACT 1: the top, with everything below it imaginary ------------------------- +# We want a TicketTriager. What does it NEED? We don't know yet - so +# we write the fakes we WISH existed, and the wishes become the design. +class TicketTriager + def initialize(classifier:, router:) + @classifier = classifier + @router = router + end + + def triage(ticket) + label = @classifier.classify(ticket[:text]) # <- wish #1: classify(text) -> label + @router.route(ticket[:id], label) # <- wish #2: route(id, label) -> receipt + end +end + +fake_classifier = Class.new { + def classify(text) = "billing" +}.new +fake_router = Class.new { + attr_reader :routed + + def route(id, label) + (@routed = [id, label]) && "receipt-#{id}" + end +}.new + +triager = TicketTriager.new(classifier: fake_classifier, router: fake_router) +receipt = triager.triage({id: 7, text: "refund please"}) +check("act 1: triager orchestrates two DISCOVERED interfaces", receipt == "receipt-7" && fake_router.routed == [7, "billing"]) + +# --- ACT 2: descend one level - realize the classifier, router stays fake ------- +# The wish list from act 1 is now a spec: classify(text) -> label. +class KeywordClassifier + def classify(text) = text.match?(/refund|charge/i) ? "billing" : "general" +end + +check("act 2: real classifier honors the discovered interface", + KeywordClassifier.new.classify("refund please") == "billing" && + KeywordClassifier.new.classify("hello") == "general") + +triager = TicketTriager.new(classifier: KeywordClassifier.new, router: fake_router) +check("act 2: triager unchanged as fakes become real (the seam held)", + triager.triage({id: 8, text: "you charged me twice"}) == "receipt-8") + +# --- ACT 3: realize the router AS A PLAN - the seams become tasks ---------------- +class PlanRouter + def route(id, label) + orchestrator = Agentic::PlanOrchestrator.new + enqueue = Agentic::Task.new(description: "enqueue:#{id}", agent_spec: {"name" => "q", "instructions" => "w"}) + notify = Agentic::Task.new(description: "notify:#{id}", agent_spec: {"name" => "n", "instructions" => "w"}) + orchestrator.add_task(enqueue, agent: ->(_t) { "#{label}-queue" }) + orchestrator.add_task(notify, [enqueue], agent: ->(t) { "receipt-#{id} (#{t.previous_output})" }) + orchestrator.execute_plan.task_result(notify.id).output + end +end + +triager = TicketTriager.new(classifier: KeywordClassifier.new, router: PlanRouter.new) +check("act 3: router realized as a two-task plan, same interface", + triager.triage({id: 9, text: "refund"}) == "receipt-9 (billing-queue)") + +# The final honesty pass: every fake must still match the real thing +# it stood in for (round 12's verifier, kept in the toolbox) +[[fake_classifier, KeywordClassifier], [fake_router, PlanRouter]].each do |fake, real| + real.instance_methods(false).each do |m| + matches = fake.respond_to?(m) && fake.method(m).parameters.map(&:first) == real.instance_method(m).parameters.map(&:first) + check("fakes still match reality: ##{m}", matches) + end +end + +puts "DISCOVERY TESTING (the fakes are scaffolding; the interfaces are the building)" +puts +CHECKS.each { |claim, ok| puts format(" %-4s %s", ok ? "ok" : "FAIL", claim) } +failures = CHECKS.count { |_, ok| !ok } +puts +puts " read the acts as a design session, because that's what they were:" +puts " act 1 wrote fakes for collaborators that DIDN'T EXIST, and the" +puts " messages we wished for (classify(text), route(id, label)) became" +puts " the design - discovered under test pressure, not drawn on a" +puts " whiteboard. act 2 made one wish real without touching the" +puts " triager: the seam held, which is the proof the seam was right." +puts " act 3's payoff is the plan-shaped one: a discovered interface is" +puts " INDIFFERENT to whether a lambda, a class, or a whole orchestrated" +puts " plan stands behind it - route(id, label) became two tasks and" +puts " nobody upstream knew. and the last checks keep the round-12" +puts " rule: fakes that outlive their realization must show their" +puts " papers, or they quietly start vouching for a design that moved." +exit(failures.zero? ? 0 : 1) From 2a726b1bacc5d4590e239796a69923323e9d40e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 23:59:15 +0000 Subject: [PATCH 08/24] docs: progress channel example (palkan round 16) Plan progress broadcast to N subscribers, each declaring its backpressure policy: latest_wins drops stale frames for dashboards, every_event buffers to a bound and disconnects loudly for auditors, and publish never blocks the plan. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FeZLdZ3M4q4cho4DZPfSkF --- docs/perspectives/round-16/08-palkan.md | 65 ++++++++++++++ examples/progress_channel.rb | 110 ++++++++++++++++++++++++ 2 files changed, 175 insertions(+) create mode 100644 docs/perspectives/round-16/08-palkan.md create mode 100644 examples/progress_channel.rb diff --git a/docs/perspectives/round-16/08-palkan.md b/docs/perspectives/round-16/08-palkan.md new file mode 100644 index 0000000..b564289 --- /dev/null +++ b/docs/perspectives/round-16/08-palkan.md @@ -0,0 +1,65 @@ +# Round 16 field notes — Vladimir Dementyev names the backpressure + +*Built: `examples/progress_channel.rb` — plan progress broadcast to +N subscribers where every subscriber declares its backpressure +policy: `:latest_wins` for dashboards, `:every_event` (bounded, loud +disconnect) for auditors. A deliberately awful slow consumer proves +both.* + +## What I built and why + +Round 12 I profiled where plan time goes; the lottery brought me +back for the AnyCable half of my life: what happens when you +broadcast that progress to *subscribers*, and one of them is slow. +Because one always is — a laggy browser tab, a stalled websocket, a +consumer doing synchronous writes — and the moment it happens, your +"real-time layer" quietly becomes either a memory leak or a brake +on the thing it's observing. Years of cable operations distill to +one rule: **every channel names its backpressure policy.** + +``` +plan: 10 tasks in 557ms (publish never blocked it) +dashboard latest_wins holding 1 frame; 19 stale frames dropped +auditor every_event DISCONNECTED at buffer 8 - gaps unacceptable +firehose every_event alive and current (it kept draining) +``` + +The two policies are two *promises*, and the demo shows why mixing +them up hurts people. A dashboard doesn't need history — it needs +the truth *now* — so `:latest_wins` drops stale frames without +ceremony; 19 frames died and the dashboard is perfectly informed. +An auditor is the opposite: a record with silent holes is worse +than no record (the holes will be exactly where the incident was), +so `:every_event` buffers to a bound and then **disconnects +loudly** — an absent auditor gets reconnected by an alarm; a +quietly lossy one gets discovered by a subpoena. + +## The publisher's vow + +The third promise belongs to the publisher: `publish` never blocks +and never raises into the plan. The lifecycle hooks run on the +task's own fiber (the framework's docs say so plainly), so anything +slower than a hash insert there is instrumentation taxing the work +it measures — the channel absorbs or sheds *by policy*, in a mutex +window sized to an array operation. The plan finished in 557ms with +a subscriber in ruins behind it, which is the whole point: +observers may suffer; the observed may not. + +## Notes + +- The buffer limit is 8 on purpose — laughably small, so the demo + *shows* the disconnect. Production numbers are bigger; the shape + of the promise is identical. +- Natural extensions, all additive: per-subscriber threads doing + real IO, a `:sample` policy (every Nth event) for metrics, and + resumable auditors that reconnect with a journal replay to fill + their gap — the journal already has everything they missed, which + is a lovely property this repo gets for free. + +## Verdict + +Ten tasks, three subscribers, one deliberate disaster: the +dashboard stayed current by forgetting, the auditor failed loudly +rather than lie, the firehose earned its keep by draining, and the +plan never felt any of it. Name your backpressure policy, or +production will name it for you — and its name will be "incident." diff --git a/examples/progress_channel.rb b/examples/progress_channel.rb new file mode 100644 index 0000000..e5d6c77 --- /dev/null +++ b/examples/progress_channel.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +# The Progress Channel: broadcasting plan progress to N subscribers +# is easy until one subscriber is slow - then your "real-time" layer +# quietly becomes a memory leak or a brake on the plan itself. +# AnyCable years distilled to one rule: every channel names its +# BACKPRESSURE POLICY. This one offers two - :latest_wins for +# dashboards (drop stale frames), :every_event for auditors (bounded +# buffer, disconnect on overflow) - and proves each under a slow +# subscriber. +# +# bundle exec ruby examples/progress_channel.rb +# +# Runs offline; the slow subscriber is deliberately awful. + +require_relative "../lib/agentic" + +Agentic.logger.level = :fatal + +class ProgressChannel + Subscriber = Struct.new(:name, :policy, :queue, :dropped, :dead, keyword_init: true) + BUFFER_LIMIT = 8 + + def initialize + @subscribers = [] + @lock = Mutex.new + end + + def subscribe(name, policy:) + subscriber = Subscriber.new(name: name, policy: policy, queue: [], dropped: 0, dead: false) + @lock.synchronize { @subscribers << subscriber } + subscriber + end + + # Publish never blocks and never fails the publisher - the plan's + # fiber is doing real work; the channel absorbs or sheds, by policy + def publish(event) + @lock.synchronize do + @subscribers.each do |sub| + next if sub.dead + + case sub.policy + when :latest_wins + sub.dropped += sub.queue.size + sub.queue.clear + sub.queue << event + when :every_event + if sub.queue.size >= BUFFER_LIMIT + sub.dead = true # an auditor with gaps is worse than no auditor + sub.queue.clear + else + sub.queue << event + end + end + end + end + end + + def hooks + { + task_slot_acquired: ->(task_id:, task:, waited:) { publish({at: :start, task: task.description}) }, + after_task_success: ->(task_id:, task:, result:, duration:) { publish({at: :done, task: task.description}) } + } + end +end + +channel = ProgressChannel.new +dashboard = channel.subscribe("dashboard", policy: :latest_wins) # slow: renders at its own pace +auditor = channel.subscribe("auditor", policy: :every_event) # must see everything or nothing +firehose = channel.subscribe("firehose", policy: :every_event) # fast consumer, drains promptly + +orchestrator = Agentic::PlanOrchestrator.new(concurrency_limit: 2, lifecycle_hooks: channel.hooks) +previous = nil +10.times do |i| + task = Agentic::Task.new(description: "step-#{i}", agent_spec: {"name" => "s", "instructions" => "w"}) + orchestrator.add_task(task, previous ? [previous] : [], agent: ->(_t) { + # the fast consumer drains during the plan; the others just... don't + firehose.queue.clear + sleep(0.055) + :ok + }) + previous = task +end + +started = Process.clock_gettime(Process::CLOCK_MONOTONIC) +orchestrator.execute_plan +elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started + +puts "THE PROGRESS CHANNEL (backpressure is a policy, and policies have names)" +puts +puts format(" plan: 10 sequential tasks in %dms (publish never blocked it)", (elapsed * 1000).round) +puts +puts format(" %-11s %-13s %s", "subscriber", "policy", "outcome after a slow session") +puts format(" %-11s %-13s holding %d frame (the LATEST); %d stale frames dropped", + dashboard.name, dashboard.policy, dashboard.queue.size, dashboard.dropped) +puts format(" %-11s %-13s DISCONNECTED at buffer %d - it fell behind and gaps were unacceptable", + auditor.name, auditor.policy, ProgressChannel::BUFFER_LIMIT) +puts format(" %-11s %-13s alive and current (it kept draining)", firehose.name, firehose.policy) +puts +puts " the two policies are two PROMISES, and mixing them up is how" +puts " real-time layers hurt people: a dashboard promised every-event" +puts " buffers unboundedly behind one laggy browser tab until the" +puts " publisher OOMs; an auditor promised latest-wins silently has" +puts " holes exactly where the incident was. so the subscriber DECLARES" +puts " which lie it can live with - stale (dashboard) or absent" +puts " (auditor, disconnected LOUDLY so someone reconnects it) - and" +puts " the publisher never blocks either way, because the plan's fibers" +puts " have real work to do and instrumentation must never be the" +puts " brake. name your backpressure policy or it names itself in" +puts " production, and its name will be 'incident'." From 8e63ca9fd365bb326607f835303c80e66ce69a5c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 00:00:42 +0000 Subject: [PATCH 09/24] docs: kill switch example (jnunemaker round 16) Per-capability kill switches checked at use time: instant and deploy-free, per-organ rather than global, non-retryable by decree so retries can't out-vote the human, and every flip audited (who, why) in the same journal as the work. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FeZLdZ3M4q4cho4DZPfSkF --- docs/perspectives/round-16/09-jnunemaker.md | 64 ++++++++++++ examples/kill_switch.rb | 108 ++++++++++++++++++++ 2 files changed, 172 insertions(+) create mode 100644 docs/perspectives/round-16/09-jnunemaker.md create mode 100644 examples/kill_switch.rb diff --git a/docs/perspectives/round-16/09-jnunemaker.md b/docs/perspectives/round-16/09-jnunemaker.md new file mode 100644 index 0000000..9f152c2 --- /dev/null +++ b/docs/perspectives/round-16/09-jnunemaker.md @@ -0,0 +1,64 @@ +# Round 16 field notes — John Nunemaker installs the big red button + +*Built: `examples/kill_switch.rb` — per-capability kill switches +checked at use time: instant, no deploy, non-retryable by decree, +with an audit trail of who pressed and why in the same journal as +the work.* + +## What I built and why + +Flipper taught me that feature flags are really two products +wearing one API. Flags answer "who should get this?" — percentages, +actors, groups, the growth side (I built that here in round 13). +Kill switches answer the grimmer operational question: **"how fast +can a human make this STOP?"** Every capability that touches money, +email, or someone else's API needs the second product, and the +requirements are written in pager ink: + +``` +tuesday 09:14, email:send KILLED mid-incident: + email:send is KILLED (by oncall-dana: provider duplicating sends, INC-2291) + summarize still ran; verdict journaled retryable: false +tuesday 11:40, restored +audit trail: killed by oncall-dana (INC-2291) / restored by oncall-dana +``` + +Four design decisions, each one an incident I've lived: + +1. **Checked at use time.** No deploy, no restart — the next task + sees the flip. Two minutes of incident is a story; twenty (the + deploy pipeline's length) is a postmortem. +2. **Per-capability, not global.** The summarizer kept working while + email went dark. Dark the organ, not the patient — a global + panic button gets pressed exactly once, and then never again, + because it hurt too much. +3. **Killed calls fail with a NON-retryable verdict.** A human said + stop; the retry machinery must not out-vote her. The verdict + flows into the journal, so round 8's dead letter office *parks* + these instead of hammering a bleeding provider — the series' + failure taxonomy and the red button snap together with no + adapter. +4. **Every flip records who and why.** The switch nobody remembers + pressing is the outage nobody can end. The audit trail lives in + the same fsynced journal as the work, so "what was killed during + this run" is part of the run's own replay. + +## Notes + +- The guard is a lambda wrapping a lambda — the duck-typed `agent:` + seam again, doing for operations what it did for architecture in + rounds 9 and 11. Cross-cutting concerns keep costing one wrapper. +- Production wants the switch state shared across processes (Redis, + a DB — this registry is per-process, and says so). The *shape* — + use-time check, hopeless verdict, audited flips — survives any + storage. +- Restore is as audited as kill. Incidents end, and "who turned it + back on" is tomorrow's first question. + +## Verdict + +The digest lost only its risky organ, the retry machinery obeyed +the human, and the whole incident reads back out of one journal — +press, park, restore, all with names attached. Flags decide who +gets a feature; switches decide how fast you can take one away. +Build both; press calmly. diff --git a/examples/kill_switch.rb b/examples/kill_switch.rb new file mode 100644 index 0000000..87d379d --- /dev/null +++ b/examples/kill_switch.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +# The Kill Switch: feature flags answer "who should get this?"; +# kill switches answer a grimmer question - "how fast can a human +# make this STOP?" Every capability that talks to money, email, or +# an external API needs a big red button: instant, global, requiring +# no deploy, leaving an audit trail of who pressed it and why. Two +# minutes of incident is a story; twenty is a postmortem. +# +# bundle exec ruby examples/kill_switch.rb +# +# Runs offline; an incident is simulated, the button is pressed. + +require_relative "../lib/agentic" +require "tmpdir" + +Agentic.logger.level = :fatal + +class KillSwitches + def initialize(journal:) + @journal = journal + @killed = {} + @lock = Mutex.new + end + + # Killing takes WHO and WHY - a red button with no audit trail + # becomes a mystery outage six months later + def kill!(capability, by:, reason:) + @lock.synchronize { @killed[capability] = {by: by, reason: reason} } + @journal.record(:kill_switch, description: capability, actor: by, reason: reason, state: "killed") + end + + def restore!(capability, by:) + @lock.synchronize { @killed.delete(capability) } + @journal.record(:kill_switch, description: capability, actor: by, state: "restored") + end + + def killed?(capability) = @lock.synchronize { @killed.key?(capability) } + + # The guard wraps an agent: killed capabilities fail fast with a + # HOPELESS verdict - retrying a kill switch is defying the human + def guard(capability, agent) + lambda do |task| + if killed?(capability) + info = @lock.synchronize { @killed[capability] } + raise Agentic::Errors::LlmAuthenticationError, # non-retryable: a human said stop + "#{capability} is KILLED (by #{info[:by]}: #{info[:reason]})" + end + agent.call(task) + end + end +end + +journal = Agentic::ExecutionJournal.new(path: File.join(Dir.tmpdir, "agentic_kill.jsonl")) +File.delete(journal.path) if File.exist?(journal.path) +switches = KillSwitches.new(journal: journal) + +def run_digest(switches, journal) + orchestrator = Agentic::PlanOrchestrator.new( + lifecycle_hooks: journal.lifecycle_hooks, retry_policy: {max_retries: 0, retryable_errors: []} + ) + summarize = Agentic::Task.new(description: "summarize", agent_spec: {"name" => "s", "instructions" => "w"}) + email = Agentic::Task.new(description: "email:digest", agent_spec: {"name" => "e", "instructions" => "w"}) + orchestrator.add_task(summarize, agent: switches.guard("llm:summarize", ->(_t) { "42 tickets summarized" })) + orchestrator.add_task(email, [summarize], agent: switches.guard("email:send", ->(t) { "emailed: #{t.previous_output}" })) + orchestrator.execute_plan +end + +puts "THE KILL SWITCH (how fast can a human make it stop?)" +puts + +result = run_digest(switches, journal) +puts " monday, all switches closed:" +puts " digest ran: #{result.results.values.map(&:output).last.inspect}" +puts + +# TUESDAY, 09:14 - the email provider is duplicating sends. INCIDENT. +switches.kill!("email:send", by: "oncall-dana", reason: "provider duplicating sends, INC-2291") +result = run_digest(switches, journal) +failed = result.results.values.find { |r| !r.successful? } +puts " tuesday 09:14, email:send KILLED mid-incident:" +puts " digest status: #{result.status}" +puts " #{failed.failure.message}" +puts " summarize still ran (only the risky capability is dark);" +puts " verdict journaled retryable: #{failed.failure.retryable?.inspect} - the dead letter office" +puts " will PARK these, not hammer a bleeding provider with retries." +puts + +switches.restore!("email:send", by: "oncall-dana") +result = run_digest(switches, journal) +puts " tuesday 11:40, provider fixed, switch restored:" +puts " digest ran: #{result.results.values.map(&:output).last.inspect}" +puts + +state = Agentic::ExecutionJournal.replay(path: journal.path) +flips = state.events.select { |e| e[:event] == "kill_switch" } +puts " the audit trail (same journal as the work):" +flips.each { |f| puts format(" %-14s %-9s by %-12s %s", f[:description], f[:state], f[:actor], f[:reason]) } +puts +puts " design notes written in pager ink: the switch is checked at USE" +puts " time (no deploy, no restart - the next task sees it); killing is" +puts " per-CAPABILITY, not global (summarize kept working; dark the" +puts " organ, not the patient); killed calls fail with a NON-RETRYABLE" +puts " verdict because a human said stop and the retry machinery must" +puts " not out-vote her; and every flip records who and why, because" +puts " the switch nobody remembers pressing is the outage nobody can" +puts " end. flags ask who should get a feature. switches answer how" +puts " fast you can take one away. build both; press calmly." From 5649a3c7ff805c1946a4169a49a9f78a8b471fa7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 00:02:56 +0000 Subject: [PATCH 10/24] docs: release rehearsal example (hsbt round 16) Build, manifest+version audit, clean-GEM_HOME install, and boot of the installed package with the repo off the load path. The rehearsal's first run caught its own contaminated stage: RUBYOPT under bundle exec smuggled the repo back onto the child's path. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FeZLdZ3M4q4cho4DZPfSkF --- docs/perspectives/round-16/10-hsbt.md | 67 ++++++++++++++++++ examples/release_rehearsal.rb | 99 +++++++++++++++++++++++++++ 2 files changed, 166 insertions(+) create mode 100644 docs/perspectives/round-16/10-hsbt.md create mode 100644 examples/release_rehearsal.rb diff --git a/docs/perspectives/round-16/10-hsbt.md b/docs/perspectives/round-16/10-hsbt.md new file mode 100644 index 0000000..9cea752 --- /dev/null +++ b/docs/perspectives/round-16/10-hsbt.md @@ -0,0 +1,67 @@ +# Round 16 field notes — Hiroshi Shibata rehearses release day + +*Built: `examples/release_rehearsal.rb` — the full ceremony: build +the gem, audit the manifest and version, install into a clean +GEM_HOME, and boot the installed package with the repo pointedly off +the load path. Exit 1 if the artifact can't do its job.* + +## What I built and why + +Maintaining RubyGems is mostly one lesson at scale: **every gem +that breaks on install worked perfectly in its own repo.** The repo +is not the gem. The gem is whatever the gemspec packages, installed +somewhere your working directory can't help it, booted by a Ruby +that owes you nothing — and the day to discover a file missing from +that package is today, on this machine, not release day in a +stranger's CI: + +``` +act 1 - gem build: ok (594KB) +act 2 - manifest audit: 437 files packaged; lib coverage complete + version: gemspec 0.2.0 == Agentic::VERSION 0.2.0 - agree +act 3 - clean install: ok (GEM_HOME=gem_home) +act 4 - boot from the package: "the package works" +``` + +Each act rehearses a classic release-day wound: files added without +`git add` (invisible to `git ls-files`-based manifests — act 2 +diffs `lib/**/*.rb` against the packaged list); version.rb bumped +while something still pins the old number (act 2 compares both +sources of truth); and the implicit load-order dependency that only +your spec_helper ever satisfied (act 4 boots the *installed* gem +and runs a real plan through it — which is exactly how this repo's +round-11 `require "time"` bug would have been caught before any +user met it). + +## The rehearsal's first run caught the rehearsal + +Act 4 failed on its first execution — and the failure is the best +paragraph in these notes. Under `bundle exec`, `RUBYOPT` smuggles +`bundler/setup` into every child process, and Bundler put **this +repo's lib/ right back on the load path** — the probe was praising +a package it had never loaded. The tripwire I'd written on a hunch +(assert the loaded path is under the temp GEM_HOME) is what caught +it, and the fix is env hygiene: scrub `RUBYOPT`, `RUBYLIB`, and the +`BUNDLE_*` variables before spawning. This is not a niche gotcha — +it's the *default* contamination of every "clean room" test run +from a Bundler project, and half the "works in CI, breaks for +users" mysteries I've triaged reduce to it. Rehearsals must audit +their own stage first. + +## Notes + +- `--ignore-dependencies` on install keeps the rehearsal offline + (deps resolve from the host gem path in act 4). A networked + variant that installs dependencies too is the fuller ceremony — + run that one nightly, this one on every push. +- The gem is 594KB and 437 files, most of which are examples and + field notes. A leaner `spec.files` would ship faster installs; + noted as a maintainer's choice, not a defect — but the number is + now printed where someone can choose. + +## Verdict + +Four acts, one genuine catch (the rehearsal's own contaminated +stage), and a certificate that the *package* — not the repo — can +boot and run a plan. Rehearse the ceremony in CI and release day +becomes a tag, not an event. diff --git a/examples/release_rehearsal.rb b/examples/release_rehearsal.rb new file mode 100644 index 0000000..ae25fab --- /dev/null +++ b/examples/release_rehearsal.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +# The Release Rehearsal: your repo is not your gem. The gem is +# whatever the gemspec PACKAGES, installed into a clean GEM_HOME, +# required by a Ruby that has never seen your working directory - +# and the day to discover a file missing from the package is today, +# on this machine, not release day in someone's CI. Build, audit, +# install, boot: the full ceremony, rehearsed. +# +# bundle exec ruby examples/release_rehearsal.rb +# +# Runs offline; exits 1 if the packaged gem can't do its job. + +require_relative "../lib/agentic" +require "open3" +require "rbconfig" +require "tmpdir" + +failures = [] +stage = Dir.mktmpdir("agentic_rehearsal") + +# --- act 1: build the actual artifact -------------------------------------------- +out, err, status = Open3.capture3("gem", "build", "agentic.gemspec", "--output", File.join(stage, "rehearsal.gem")) +if status.success? + puts " act 1 - gem build: ok (#{File.size(File.join(stage, "rehearsal.gem")) / 1024}KB)" +else + failures << "build failed" + puts " act 1 - gem build FAILED: #{err.lines.last&.strip || out.lines.last&.strip}" +end + +# --- act 2: audit the manifest - every lib file must be aboard ------------------- +spec = Gem::Specification.load("agentic.gemspec") +packaged = spec.files +missing = Dir["lib/**/*.rb"].reject { |f| packaged.include?(f) } +version_ok = spec.version.to_s == Agentic::VERSION +failures << "manifest missing #{missing.size} lib file(s)" if missing.any? +failures << "version drift" unless version_ok +puts " act 2 - manifest audit: #{packaged.size} files packaged; lib coverage #{missing.empty? ? "complete" : "MISSING #{missing.take(3).join(", ")}"}" +puts " version: gemspec #{spec.version} == Agentic::VERSION #{Agentic::VERSION} - #{version_ok ? "agree" : "DRIFT"}" + +# --- act 3: install into a clean GEM_HOME ---------------------------------------- +gem_home = File.join(stage, "gem_home") +_, err, status = Open3.capture3( + {"GEM_HOME" => gem_home}, + "gem", "install", "--local", "--no-document", "--ignore-dependencies", + File.join(stage, "rehearsal.gem") +) +if status.success? + puts " act 3 - clean install: ok (GEM_HOME=#{File.basename(gem_home)})" +else + failures << "install failed" + puts " act 3 - install FAILED: #{err.lines.last&.strip}" +end + +# --- act 4: boot the INSTALLED gem, far from this repo --------------------------- +# The child's load path knows the temp GEM_HOME (for our gem) and the +# host gem path (for dependencies) - and pointedly NOT this repo's lib/ +probe = <<~RUBY + gem "agentic" + require "agentic" + raise "loaded from the repo, not the package!" if Agentic.method(:run).source_location.first.include?("/lib/agentic") && !Agentic.method(:run).source_location.first.include?("gem_home") + orchestrator = Agentic::PlanOrchestrator.new + task = Agentic::Task.new(description: "boot", agent_spec: {"name" => "b", "instructions" => "w"}) + orchestrator.add_task(task, agent: ->(_t) { "the package works" }) + print orchestrator.execute_plan.task_result(task.id).output +RUBY +# Scrub the inherited environment: under `bundle exec`, RUBYOPT +# smuggles bundler/setup into every child, which would quietly put +# THIS REPO back on the load path - the exact contamination the +# rehearsal exists to prevent (and its first run caught) +clean_env = { + "GEM_HOME" => gem_home, + "GEM_PATH" => "#{gem_home}#{File::PATH_SEPARATOR}#{Gem.paths.home}", + "RUBYOPT" => nil, "RUBYLIB" => nil, + "BUNDLE_GEMFILE" => nil, "BUNDLE_BIN_PATH" => nil, "BUNDLER_SETUP" => nil +} +out, err, status = Open3.capture3(clean_env, RbConfig.ruby, "-e", probe, chdir: stage) +if status.success? && out.include?("the package works") + puts " act 4 - boot from the package: \"#{out}\" (repo lib/ never on the path)" +else + failures << "packaged gem failed to boot" + puts " act 4 - boot FAILED: #{err.lines.grep_v(/warning/).last&.strip}" +end + +puts +if failures.empty? + puts " the rehearsal passed all four acts, which certifies the thing" + puts " releases actually ship: not your repo, THE PACKAGE. the classic" + puts " release-day wounds are all rehearsable - a file added without" + puts " `git add` (invisible to git-ls-files manifests), a version.rb" + puts " bumped but gemspec pinned, an implicit load-order dependency" + puts " that only your spec_helper satisfied. rubygems maintenance is" + puts " mostly this lesson at scale: every gem that breaks on install" + puts " worked perfectly in its own repo. rehearse the ceremony in CI" + puts " and release day becomes a tag, not an event." +else + puts " REHEARSAL FAILED: #{failures.join("; ")} - fix before tagging." +end +exit(failures.empty? ? 0 : 1) From f184add0ec8f998ce2e88f807e6a389413f86aca Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 00:10:49 +0000 Subject: [PATCH 11/24] fix: force UTF-8 on git log output in two examples The stacked-PR merge commits carry em dashes in their subjects; in a container with no locale set, backtick output arrives tagged US-ASCII and strip/regex raise Encoding::CompatibilityError. Caught by the round-16 smoke run. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FeZLdZ3M4q4cho4DZPfSkF --- examples/changelog_scout.rb | 3 ++- examples/standup_digest.rb | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/examples/changelog_scout.rb b/examples/changelog_scout.rb index afd2f4a..bed571f 100644 --- a/examples/changelog_scout.rb +++ b/examples/changelog_scout.rb @@ -41,7 +41,8 @@ scribe.add_capability("classify_commit") # --- the plan: classify commits in parallel, then one writer fans in -------- -subjects = `git -C #{ROOT} log -#{count} --pretty=format:%s`.lines.map(&:strip) +subjects = `git -C #{ROOT} log -#{count} --pretty=format:%s` + .force_encoding(Encoding::UTF_8).lines.map(&:strip) orchestrator = Agentic::PlanOrchestrator.new(concurrency_limit: 8) classifications = subjects.map.with_index do |subject, i| diff --git a/examples/standup_digest.rb b/examples/standup_digest.rb index 51d145d..6f40db7 100644 --- a/examples/standup_digest.rb +++ b/examples/standup_digest.rb @@ -25,7 +25,8 @@ def repo_task(description, payload = nil) commits = repo_task("recent commits") orchestrator.add_task(commits, agent: ->(_t) { - log = `git -C #{ROOT} log --oneline -12 --pretty=format:"%s"`.lines.map(&:strip) + log = `git -C #{ROOT} log --oneline -12 --pretty=format:"%s"` + .force_encoding(Encoding::UTF_8).lines.map(&:strip) themes = log.group_by { |line| line[/\A(\w+)(?:\(|:)/, 1] || "misc" } {count: log.size, themes: themes.transform_values(&:size), latest: log.first} }) From fd74f9ddf2fb4b1563f54b8f050545ceb6bf3188 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 00:11:11 +0000 Subject: [PATCH 12/24] docs: round 16 index and findings (the lottery round) Ten personas drawn at random from all fifty prior casts; catalog at 141 examples. Findings: returning personas attacked new seams, the tools-correct-authors streak reached nine rounds (smuggler ledger, RUBYOPT contamination, plus the smoke run's em-dash encoding catch), and three soft asks are recorded for whoever reopens the shop. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FeZLdZ3M4q4cho4DZPfSkF --- docs/perspectives/README.md | 56 +++++++++++++++++++++++++++++++++++++ examples/README.md | 10 +++++++ 2 files changed, 66 insertions(+) diff --git a/docs/perspectives/README.md b/docs/perspectives/README.md index bccb3b6..6dceae8 100644 --- a/docs/perspectives/README.md +++ b/docs/perspectives/README.md @@ -590,6 +590,62 @@ directories, and a framework whose contracts, journals, limiters, and plans all testify about themselves. The bench is cleared; the asks list is empty; everything the room asked for was shipped. +## Round 16 — the lottery round + +The close-out lasted one message. With no asks outstanding, the +bench was drawn by lottery — ten names sampled at random from all +fifty prior personas — so returning builders faced the framework a +second time with different obsessions: + +| # | Persona | Built with the gem | Run it | Field notes | +|---|---------|--------------------|--------|-------------| +| 1 | Yukihiro Matsumoto | Gentle deadline — optional tasks decline with regrets, essentials never starve | `examples/gentle_deadline.rb` | [round-16/01-matz.md](round-16/01-matz.md) | +| 2 | Piotr Solnica | Railway plan — a 14-line Result monad lifts plan outcomes into `.bind` chains | `examples/railway_plan.rb` | [round-16/02-solnic.md](round-16/02-solnic.md) | +| 3 | Benoit Daloze | Schedule equivalence — same plan at concurrency 1/2/8 must agree; a smuggler proves the prover | `examples/schedule_equivalence.rb` | [round-16/03-eregon.md](round-16/03-eregon.md) | +| 4 | Bozhidar Batsov | Configurable cops — a YAML layer over plan lints, with a pending-cop policy | `examples/configurable_cops.rb` | [round-16/04-bbatsov.md](round-16/04-bbatsov.md) | +| 5 | Noel Rappin | Spend ledger — integer cents, `afford!` before work, an invoice with a running balance | `examples/spend_ledger.rb` | [round-16/05-noelrap.md](round-16/05-noelrap.md) | +| 6 | Eileen Uchitelle | Shadow traffic — v2 answers every request, serves none; mismatches journaled | `examples/shadow_traffic.rb` | [round-16/06-eileencodes.md](round-16/06-eileencodes.md) | +| 7 | Justin Searls | Discovery testing — fakes discover interfaces, then reality replaces them shape-checked | `examples/discovery_testing.rb` | [round-16/07-searls.md](round-16/07-searls.md) | +| 8 | Vladimir Dementyev | Progress channel — every subscriber names its backpressure policy; publish never blocks | `examples/progress_channel.rb` | [round-16/08-palkan.md](round-16/08-palkan.md) | +| 9 | John Nunemaker | Kill switch — per-capability, use-time, non-retryable by decree, flips audited | `examples/kill_switch.rb` | [round-16/09-jnunemaker.md](round-16/09-jnunemaker.md) | +| 10 | Hiroshi Shibata | Release rehearsal — build, audit, clean-install, and boot THE PACKAGE, not the repo | `examples/release_rehearsal.rb` | [round-16/10-hsbt.md](round-16/10-hsbt.md) | + +### What round 16 surfaced + +1. **Second appearances built different organs.** Every returning + persona attacked a seam their first visit never touched: Matz went + from three-lines-that-smile to time-budget courtesy, solnic from + contract boundaries to railway composition, eregon from behavior + specs to schedule-equivalence proofs, Nunemaker from feature flags + ("who gets this?") to kill switches ("how fast can it stop?"). + The framework held; the angles were new. +2. **Ninth consecutive round of tools correcting authors.** The + equivalence prover's smuggler never diverged because the shared + ledger was created once *outside* the per-run builder — the race + detector had its own race removed; and the release rehearsal's + first boot probe praised a package it never loaded, because under + `bundle exec` RUBYOPT smuggles `bundler/setup` into every child + and puts the repo back on the load path. Both catches came from + tripwires the examples had written for themselves. The smoke run + then added a third: the stacked-PR merge subjects carry em dashes, + and in a locale-less container two older git-reading examples + (changelog scout, standup digest) choked on US-ASCII-tagged + backtick output — fixed with an explicit UTF-8 force. +3. **The operational trilogy completed itself**: budgets that veto + before work (spend ledger), switches that stop mid-incident + (kill switch), and channels that shed or disconnect by declared + policy (progress channel) — all built on the same two seams, + the duck-typed `agent:` wrapper and the lifecycle hooks. Sixteen + rounds in, cross-cutting concerns still cost exactly one lambda. +4. **Soft asks, gently held** (no release planned; recorded for + whoever reopens the shop): an `optional:` task marking the + scheduler understands, so deadline courtesy is a property instead + of a convention (Matz); a scheduling-veto hook so budget/spend + gates run before a task is dispatched rather than inside its agent + (Noel); and a deterministic seeded-schedule mode so equivalence + provers can enumerate interleavings instead of sampling them + (eregon). + ### What round 6 surfaced 1. **Plans became artifacts**: narratable (tour), serializable with an diff --git a/examples/README.md b/examples/README.md index 32ef18d..a5ded38 100644 --- a/examples/README.md +++ b/examples/README.md @@ -29,6 +29,7 @@ not this file. | `composed_limits.rb` | Composed Limits: a real provider enforces BOTH a billed quota and a connection ceiling. quota.and(pool) - new this round... | | `concurrency_key.rb` | The Concurrency Key: "at most one sync per TENANT, any number of tenants at once" is the concurrency control every multi... | | `confident_pipeline.rb` | The Confident Pipeline: timid code checks nil at every step because it trusts nothing, including itself. Confident code ... | +| `configurable_cops.rb` | Configurable Cops: a style guide nobody can configure is a style FIGHT on a delay timer. RuboCop's deepest lesson isn't ... | | `contract_cop.rb` | The Contract Cop: RuboCop for capability specs. Contracts are the most-read documents in this framework - six tools cons... | | `contract_fixtures.rb` | Contract Fixtures: example payloads in docs rot the day the contract changes. So don't write them - DERIVE them. This ge... | | `contract_fuzzer.rb` | The Contract Fuzzer: for every registered capability, generate inputs that SHOULD pass its declared contract and mutatio... | @@ -40,6 +41,7 @@ not this file. | `dead_letter_office.rb` | The Dead Letter Office: three days of journaled runs, every failure collected and triaged by what the errors said about ... | | `deploy_train.rb` | The Deploy Train: lint -> test -> build -> canary -> ship, where a red gate stops the train and everything behind it rep... | | `did_you_mean.rb` | Did You Mean, for plans: the kindest thing an error can do is finish your sentence. In round 14 this example retrofitted... | +| `discovery_testing.rb` | Discovery Testing: most people use test doubles to ISOLATE code that already exists. The better trick is using them to D... | | `doc_coverage.rb` | The Documentation Surveyor: measures YARD comment coverage for every public method in a lib/ tree. One survey task per f... | | `doctest_runner.rb` | The Doctest Runner: Rust taught the industry one enormous docs lesson - EXAMPLES IN DOCS SHOULD EXECUTE. This harvests e... | | `duck_agents.rb` | Duck Agents: the agent: seam asks one question - "can you be called with a task?" - and five differently-shaped objects ... | @@ -56,6 +58,7 @@ not this file. | `form_errors.rb` | The 422 Generator: turn a ValidationError into the API error document your frontend actually wants - message, allowed va... | | `freight_rules.rb` | The Freight Desk: a quoting capability whose tariff book is written as cross-field contract rules (new this round). Per-... | | `gem_scout.rb` | Gem Scout: describe what you need, get a ranked shortlist of gems. Search and scoring are separate capabilities; the sea... | +| `gentle_deadline.rb` | The Gentle Deadline: most deadline code is violent - a timeout fires, everything dies, the user gets an error page at 30... | | `gentle_deprecations.rb` | Gentle Deprecations: the hard part of maintaining a framework isn't adding the better name - it's the two years of not b... | | `graph_critic.rb` | The Graph Critic: reviews a plan's dependency structure BEFORE it runs, the way you'd review a class diagram. God tasks,... | | `graph_invariants.rb` | The Graph Invariants Prover: the reflection API makes promises - order respects edges, roots have no dependencies, depth... | @@ -74,6 +77,7 @@ not this file. | `journal_tail.rb` | The Journal Tail Pager: production journals grow like production tables, and the question asked of both is always the sa... | | `json_schema_export.rb` | The Schema Export: a capability contract emitted as draft-07 JSON Schema (new this round), then PROVEN faithful - the sa... | | `kanban_board.rb` | The Kanban Board: a plan rendered as the three columns everyone actually understands - To Do, Doing, Done - reprinted at... | +| `kill_switch.rb` | The Kill Switch: feature flags answer "who should get this?"; kill switches answer a grimmer question - "how fast can a ... | | `knee_finder.rb` | The Knee Finder: runs the same plan at increasing concurrency limits, measures wall time and total queue-wait via the ta... | | `latency_lab.rb` | The Latency Lab: 20 simulated LLM calls (200ms of IO each) executed through the orchestrator at different concurrency li... | | `live_dashboard.rb` | The Live Dashboard: lifecycle hooks publish events onto an Async::Queue; a consumer task IN THE SAME REACTOR renders the... | @@ -100,25 +104,31 @@ not this file. | `polite_form.rb` | The Polite Form: a contract usually speaks AFTER you fail - a 422, a stack of violations. This assistant makes it speak ... | | `ports_and_adapters.rb` | Ports and Adapters: the domain is the part of your app that would survive a framework migration - IF you kept it clean. ... | | `process_drill.rb` | The Process Drill: threads share a Mutex; PROCESSES share nothing but the file. The journal claims flock+fsync, which is... | +| `progress_channel.rb` | The Progress Channel: broadcasting plan progress to N subscribers is easy until one subscriber is slow - then your "real... | | `projection_agreement.rb` | The Projection Agreement Prover: relation rules now render twice - the validator enforces them in Ruby, and to_json_sche... | | `quota_keeper.rb` | The Quota Keeper: the same 20 requests through two different laws. A concurrency ceiling ("3 in flight") models connecti... | | `ractor_shareability.rb` | The Ractor Shareability Audit: `freeze` is a promise about one object; Ractor.shareable? is a promise about everything i... | +| `railway_plan.rb` | The Railway Plan: dry-monads taught Ruby that failure handling is COMPOSITION, not rescue blocks - a pipeline of steps w... | | `rbs_export.rb` | The RBS Export: a capability contract already knows its types - it validates them at runtime on every call. RBS is the s... | | `readme_verifier.rb` | The README Verifier: every ruby code fence in the README is a promise. This extracts them all, syntax-checks each with P... | | `refactor_receipts.rb` | Refactor Receipts: the god-join plan from the graph critic, improved in two small steps - with a receipt after each one.... | | `refactoring_dojo.rb` | The Refactoring Dojo: a student submits a method, three critic agents review it from three distinct perspectives, and th... | | `relation_diff.rb` | The Relation Diff: round 8's semver advisor classified declaration changes but had to shrug at rules - lambdas can't be ... | | `relation_prober.rb` | The Relation Prober: relation-typed rules are new, and new predicates deserve hostility. Each relation is probed with ed... | +| `release_rehearsal.rb` | The Release Rehearsal: your repo is not your gem. The gem is whatever the gemspec PACKAGES, installed into a clean GEM_H... | | `renga_circle.rb` | A renga circle: three poet agents compose a linked-verse poem, each verse responding to the one before it. The dependenc... | | `require_cost.rb` | The Require Cost Report: `require` is a purchase - memory, objects, and boot time, paid again by every process you fork ... | | `resize_torture.rb` | The Resize Torture Test: a feature that changes a limiter's ceiling while fibers are waiting on it had better say exactl... | | `retry_budget.rb` | The Retry Budget: a retry storm is a self-inflicted DDoS - every job politely retrying 3x turns one outage into four. Re... | | `rule_prober.rb` | The Rule Prober: structured rules declare which fields they read - so now that claim can be AUDITED. For each rule, pert... | | `rule_shapes.rb` | Rule Shapes: the same policy - "express shipments need a customs code" - written three ways: a lambda, a structured chec... | +| `schedule_equivalence.rb` | Schedule Equivalence: a plan's declared meaning is its dependency graph - which implies a PROMISE nobody usually tests: ... | | `schema_advisor.rb` | The Schema Advisor: give it a schema and a query log, get back the advisories a careful DBA would write - each rule its ... | | `self_correcting_output.rb` | Self-Correcting Output: the pattern that makes LLM components shippable. The model's output is validated against the cap... | | `setup_doctor.rb` | The Setup Doctor: every onboarding wiki page is a bug. This runs the checks a README asks a new hire to do by hand - rub... | +| `shadow_traffic.rb` | Shadow Traffic: the safest way to replace a component at scale is to never let the replacement answer. The OLD implement... | | `shared_rate_limit.rb` | The Shared Rate Limit: two plans run concurrently in one reactor, but the API key they share allows only 3 requests in f... | +| `spend_ledger.rb` | The Spend Ledger: LLM plans spend real money, and money has rules older than software - integer cents (floats round YOUR... | | `stampede_sim.rb` | The Stampede Simulator: twenty workers hit a hiccuping upstream, all fail at once, all retry. With jitter OFF they come ... | | `standup_digest.rb` | The Standup Digest: three collectors gather from the repo in parallel - recent commits, TODO debt, test suite shape - an... | | `state_machine.rb` | The Contract State Machine: each transition is a capability whose guard is not an if-statement but an enum predicate on ... | From 7e8149ea45166d7261b11e5d68c1c23e3e1c12e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 01:25:18 +0000 Subject: [PATCH 13/24] docs: plan heckler example (zenspider round 17) Mutation testing for workflows: five sabotages, three spec generations, and a fixture audit - a surviving mutant can mean a branch your inputs never visit, not just a missing assertion. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FeZLdZ3M4q4cho4DZPfSkF --- docs/perspectives/round-17/01-zenspider.md | 61 +++++++++++ examples/plan_heckler.rb | 120 +++++++++++++++++++++ 2 files changed, 181 insertions(+) create mode 100644 docs/perspectives/round-17/01-zenspider.md create mode 100644 examples/plan_heckler.rb diff --git a/docs/perspectives/round-17/01-zenspider.md b/docs/perspectives/round-17/01-zenspider.md new file mode 100644 index 0000000..e33af21 --- /dev/null +++ b/docs/perspectives/round-17/01-zenspider.md @@ -0,0 +1,61 @@ +# Round 17 field notes — Ryan Davis heckles the plan + +*Built: `examples/plan_heckler.rb` — mutation testing for workflows. +Five sabotages applied one at a time to a pricing pipeline; the +plan's spec is graded not on whether it passes but on whether it can +FAIL. Exit 1 while any mutant survives.* + +## What I built and why + +Round 11 I flogged plans — a pain score for structure. The lottery +sent me back and the brief said build a *tool*, so I built the other +half of heckle's old job: your plan has tests, cute, but **do the +tests fail when the plan is wrong?** The heckler answers empirically. +Break the plan on purpose — no-op the pricer, kill the discount +branch, divide the tax rate by ten, bypass the tax stage entirely, +truncate the receipt — and run the spec against each mutant: + +``` +SPEC v1 - the tests the team wrote (3 assertions): 1/5 killed +SPEC v2 - plus a golden total and a roll-call: 4/5 killed +SPEC v3 - plus a golden that CROSSES the bar: 5/5 killed +``` + +v1 is every spec I've ever been mad at: "completes, says TOTAL, +number is positive." It waved four of five saboteurs through, +including a **bypassed tax stage**. The fix isn't clever — pin one +golden end-to-end number you priced by hand, and roll-call the +stages (`ran == %w[price discount tax receipt]`). + +## The heckler heckled its author + +v2 still let `discount_never_fires` walk, and the reason is the +round's best finding: my fixture order was under the discount bar, +so the discount branch never executed, so no assertion on Earth +could see it die. **A mutant that survives isn't always a missing +assertion — sometimes it's a fixture that never visits the branch.** +The heckler audits your inputs as much as your expectations; every +branch your fixtures never reach is a mutant sanctuary. One bulk +order later: 5/5. (Yes: the mutation tool's author got caught by the +mutation tool, on its first run. Ninth… tenth? I've lost count. +The streak continues.) + +## Notes + +- The framework made mutation cheap: plans assemble from data, so a + mutant is a one-symbol argument to the builder — drop an edge, + swap a lambda. No AST surgery, no reloading. Heckle needed to + rewrite methods at runtime; here the graph IS the program. +- The baseline gate matters: heckling a red plan proves nothing, so + the heckler aborts unless the unmutated pipeline passes everything + first. Same rule as heckle had. Same rule as always. +- Extensions that stay honest: auto-generate edge mutants from + `graph[:edges]` (drop each in turn), and score fixtures by branch + visitation before wasting runs. + +## Verdict + +Three specs, five mutants, one embarrassed author. Mutation testing +doesn't ask whether your tests pass; it asks whether they can fail — +the only thing a test is for. The plan graph being data makes plans +*more* heckleable than Ruby ever was, and I say that with love. diff --git a/examples/plan_heckler.rb b/examples/plan_heckler.rb new file mode 100644 index 0000000..f17944b --- /dev/null +++ b/examples/plan_heckler.rb @@ -0,0 +1,120 @@ +# frozen_string_literal: true + +# The Plan Heckler: mutation testing for workflows. Your plan has +# tests. Cute. Do the tests actually FAIL when the plan is wrong, or +# are they decoration? The heckler finds out the honest way: it +# breaks the plan on purpose - five sabotages, one at a time - and +# runs your spec against each mutant. A mutant your spec kills is +# coverage. A mutant that SURVIVES is a bug your tests would wave +# through the door. Tests that can't fail aren't tests. +# +# bundle exec ruby examples/plan_heckler.rb +# +# Runs offline; exits 1 if any mutant survives the final spec. + +require_relative "../lib/agentic" + +Agentic.logger.level = :fatal + +SMALL_ORDER = [{sku: "flog", cents: 4200}, {sku: "flay", cents: 3100}].freeze # 7300, under the discount bar +BULK_ORDER = [{sku: "flog", cents: 4200}] * 3 # 12600, discount fires +SMALL_GOLDEN = 7884 # 7300 * 1.08, hand-priced +BULK_GOLDEN = 12_247 # (12600 * 0.9) * 1.08 = 12247.2, rounded + +# --- the plan under test: a pricing pipeline, optionally sabotaged ---------------- +def build_pipeline(order, mutation = nil) + orchestrator = Agentic::PlanOrchestrator.new(concurrency_limit: 1) + ran = [] + stage = ->(name, deps, fn) { + task = Agentic::Task.new(description: name, agent_spec: {"name" => name, "instructions" => "price"}) + orchestrator.add_task(task, deps, agent: ->(t) { + ran << name + fn.call(t.previous_output) + }) + task + } + + price_fn = ->(_) { {cents: order.sum { |i| i[:cents] }} } + price_fn = ->(_) { {cents: 0} } if mutation == :price_returns_zero + + discount_fn = ->(o) { {cents: (o[:cents] >= 10_000) ? (o[:cents] * 0.9).round : o[:cents]} } + discount_fn = ->(o) { o } if mutation == :discount_never_fires + + tax_rate = (mutation == :tax_off_by_10x) ? 1.008 : 1.08 + tax_fn = ->(o) { {cents: (o[:cents] * tax_rate).round} } + + receipt_fn = ->(o) { "TOTAL: #{o[:cents]} cents" } + receipt_fn = ->(o) { "TOTAL: #{o[:cents] / 100 * 100} cents" } if mutation == :receipt_truncates + + price = stage.call("price", [], price_fn) + discount = stage.call("discount", [price], discount_fn) + tax = stage.call("tax", [discount], tax_fn) + receipt_dep = (mutation == :tax_stage_bypassed) ? discount : tax + receipt = stage.call("receipt", [receipt_dep], receipt_fn) + + [orchestrator, receipt, ran] +end + +MUTANTS = [:price_returns_zero, :discount_never_fires, :tax_off_by_10x, :tax_stage_bypassed, :receipt_truncates].freeze + +# --- three specs: what the team wrote, then what the heckler extorts, twice ------- +SPEC_V1 = { + "plan completes" => ->(runs) { runs.all? { |r| r[:result].status == :completed } }, + "receipt says TOTAL" => ->(runs) { runs.all? { |r| r[:out].to_s.include?("TOTAL") } }, + "total is positive" => ->(runs) { runs.all? { |r| r[:out].to_s[/\d+/].to_i.positive? } } +}.freeze + +SPEC_V2 = SPEC_V1.merge( + "small order prices to the golden 7884" => ->(runs) { runs.first[:out].to_s[/\d+/].to_i == SMALL_GOLDEN }, + "all four stages ran, in order" => ->(runs) { runs.all? { |r| r[:ran] == %w[price discount tax receipt] } } +).freeze + +SPEC_V3 = SPEC_V2.merge( + "BULK order prices to the golden 12247" => ->(runs) { runs.last[:out].to_s[/\d+/].to_i == BULK_GOLDEN } +).freeze + +def run_spec(spec, mutation = nil) + runs = [SMALL_ORDER, BULK_ORDER].map do |order| + orchestrator, receipt, ran = build_pipeline(order, mutation) + result = orchestrator.execute_plan + {result: result, out: result.task_result(receipt.id)&.output, ran: ran} + end + spec.reject { |_name, check| check.call(runs) }.keys +end + +def heckle(spec_name, spec) + puts " heckling with #{spec_name} (#{spec.size} assertions):" + survivors = MUTANTS.reject do |mutation| + failed = run_spec(spec, mutation) + puts format(" %-22s %s", mutation, failed.any? ? "KILLED by #{failed.first.inspect}" : "SURVIVED - your tests shrug") + failed.any? + end + puts format(" score: %d/%d mutants killed", MUTANTS.size - survivors.size, MUTANTS.size) + puts + survivors +end + +puts "THE PLAN HECKLER (tests that can't fail aren't tests)" +puts + +baseline = run_spec(SPEC_V3) +abort(" baseline is red; heckling a broken plan proves nothing") unless baseline.empty? +puts " baseline: unmutated plan passes every assertion (heckling requires green)" +puts + +heckle("SPEC v1 - the tests the team wrote", SPEC_V1) +survivors_v2 = heckle("SPEC v2 - plus one golden total and a stage roll-call", SPEC_V2) +survivors_v3 = heckle("SPEC v3 - plus a golden total that CROSSES the discount bar", SPEC_V3) + +puts " v1 waved four saboteurs through - \"completes, says TOTAL, positive\"" +puts " can't see a bypassed tax stage or a 10x rate error. v2's golden" +puts " number killed three more, but #{survivors_v2.first} still walked:" +puts " the small order never crosses the discount bar, so a dead discount" +puts " branch is INVISIBLE to any assertion about it. that's the heckler's" +puts " real product - it doesn't just grade your assertions, it audits your" +puts " FIXTURES: every branch your inputs never reach is a mutant sanctuary." +puts " one bulk order later, 5/5. pin golden numbers you priced by hand," +puts " roll-call the stages, and make your inputs visit every branch." +puts " mutation testing doesn't ask whether tests pass; it asks whether" +puts " they can FAIL - the only thing a test is for." +exit(survivors_v3.empty? ? 0 : 1) From e5b4523afd45ba554bccb5a6046e55394e446461 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 01:27:52 +0000 Subject: [PATCH 14/24] docs: omakase scaffold example (dhh round 17) rails-new for plans: a six-line recipe generates a complete runnable program with journal, retries, and concurrency conventions poured in. The generated output is executed as the acceptance test - which caught the template's own missing require on first run. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FeZLdZ3M4q4cho4DZPfSkF --- docs/perspectives/round-17/02-dhh.md | 61 ++++++++++++++ examples/omakase_scaffold.rb | 114 +++++++++++++++++++++++++++ 2 files changed, 175 insertions(+) create mode 100644 docs/perspectives/round-17/02-dhh.md create mode 100644 examples/omakase_scaffold.rb diff --git a/docs/perspectives/round-17/02-dhh.md b/docs/perspectives/round-17/02-dhh.md new file mode 100644 index 0000000..8d4d23f --- /dev/null +++ b/docs/perspectives/round-17/02-dhh.md @@ -0,0 +1,61 @@ +# Round 17 field notes — DHH scaffolds the omakase plan + +*Built: `examples/omakase_scaffold.rb` — `rails new` for plans. A +six-line recipe in, a complete runnable program out: journal wired, +retries configured, concurrency chosen, TODOs where your work goes. +The generated files are actually executed — the proof of a generator +is its output booting.* + +## What I built and why + +Seven rounds of building products on this framework and the brief +finally asked the Rails question directly: where's the *on-ramp*? +The gem's primitives are good — I've said so, grudgingly, since +round 2 — but every program in this catalog starts with the same +fifteen lines of ceremony that someone had to already know: the +journal, the retry policy with jitter, the concurrency ceiling. +Conventions that live only in examples are folklore. **Generators +are how conventions travel.** + +``` +recipe "newsletter_digest" (4 steps declared) + generated: newsletter_digest.rb (46 lines you didn't write) + ran it: "newsletter_digest: completed, 4/4 steps, journal at ..." +``` + +The recipe is the entire interface: a name, steps, `after:` for +order. The scaffold pours the omakase around it — every run +journaled and replayable, transient errors retried three times with +full jitter, a sane parallelism ceiling. Disagree with the chef? +**The file is yours.** Plain Ruby, your name on it, no framework +umbilical, TODOs marking exactly where your real work replaces the +stubs. That's the part people miss about omakase: it's not that you +can't choose, it's that you don't have to choose *first*. + +## The scaffold's first output didn't boot + +And here is why the example *runs* its generated programs instead of +admiring them: the first template used `Dir.tmpdir` without +`require "tmpdir"` — the exact missing-require sin this repo's +census (round 11) and learning-corner autopsy (round 14) have +documented six times in handwritten code. Now a *generator* committed +it, which is worse, because a generator ships a bug at scale. The +smoke assertion caught it on first run. A generator that doesn't +execute its own output in CI is a template with delusions. + +## Notes + +- Generation is string assembly, ~40 lines, no ERB, no engine. The + plan graph being data means the template barely has logic: steps + become tasks, `after:` becomes the dependency array. Compression + where it counts. +- The right home for this is `agentic new ` in the CLI, with + the recipe format exactly as minimal as this one. If the recipe + ever needs a manual, the scaffold has failed. + +## Verdict + +Six lines of intent became a 46-line running program with the 2am +concerns already handled. The framework's ceremony was never the +problem — unshipped conventions were. A generator is a gem's opinion +made portable; this gem has opinions worth shipping. diff --git a/examples/omakase_scaffold.rb b/examples/omakase_scaffold.rb new file mode 100644 index 0000000..a8ae1dc --- /dev/null +++ b/examples/omakase_scaffold.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +# The Omakase Scaffold: `rails new` for plans. You bring six lines +# of intent - a name and some steps. The generator brings the menu: +# journaling on, retries configured, concurrency chosen, a runnable +# file with your name on it. Convention over configuration isn't +# about taking choices away; it's about not making you answer forty +# questions before hello-world. And the output is JUST RUBY - a file +# you own, edit, and outgrow. The scaffold is a starting line, not +# a cage. +# +# bundle exec ruby examples/omakase_scaffold.rb +# +# Runs offline; two recipes are scaffolded, and the generated +# programs are actually RUN - the proof of a generator is its output +# booting, not its template compiling. + +require_relative "../lib/agentic" +require "open3" +require "rbconfig" +require "tmpdir" + +ROOT = File.expand_path("..", __dir__) + +# --- the whole interface: what a recipe author writes ------------------------------ +RECIPES = [ + {name: "newsletter_digest", + steps: [ + {step: "collect articles"}, + {step: "rank by relevance", after: "collect articles"}, + {step: "draft summary", after: "rank by relevance"}, + {step: "format email", after: "draft summary"} + ]}, + {name: "release_notes", + steps: [ + {step: "gather merged prs"}, + {step: "gather closed issues"}, + {step: "write highlights", after: ["gather merged prs", "gather closed issues"]} + ]} +].freeze + +# --- the generator: conventions poured around the recipe --------------------------- +def scaffold(recipe, dir) + vars = recipe[:steps].each_with_index.to_h { |s, i| [s[:step], "task_#{i}"] } + task_lines = recipe[:steps].map do |s| + deps = Array(s[:after]).map { |d| vars.fetch(d) } + <<~RUBY.chomp + #{vars[s[:step]]} = Agentic::Task.new(description: #{s[:step].inspect}, agent_spec: {"name" => #{s[:step].inspect}, "instructions" => "do it"}) + orchestrator.add_task(#{vars[s[:step]]}, [#{deps.join(", ")}], agent: ->(t) { + # TODO: replace this stub with your real work for #{s[:step].inspect} + "#{s[:step]}: done#{" (given: \#{t.previous_output})" if deps.any?}" + }) + RUBY + end + + source = <<~RUBY + # frozen_string_literal: true + # Generated by omakase_scaffold - and immediately YOURS. Edit freely. + require "agentic" + require "tmpdir" + + Agentic.logger.level = :fatal + + # Omakase defaults: a journal (so every run is replayable), a retry + # policy (transient errors get three chances), a concurrency ceiling + # (parallel where the graph allows, never a stampede). Disagree with + # any of it? It's your file now - change it. + journal = Agentic::ExecutionJournal.new(path: File.join(Dir.tmpdir, "#{recipe[:name]}.jsonl")) + orchestrator = Agentic::PlanOrchestrator.new( + concurrency_limit: 4, + lifecycle_hooks: journal.lifecycle_hooks, + retry_policy: {max_retries: 3, backoff_base: 0.05, backoff_jitter: :full} + ) + + #{task_lines.join("\n\n").gsub("\n", "\n ").strip} + + result = orchestrator.execute_plan + done = result.results.values.count(&:successful?) + puts "#{recipe[:name]}: \#{result.status}, \#{done}/#{recipe[:steps].size} steps, journal at \#{journal.path}" + exit(result.status == :completed ? 0 : 1) + RUBY + + path = File.join(dir, "#{recipe[:name]}.rb") + File.write(path, source) + path +end + +puts "THE OMAKASE SCAFFOLD (six lines of intent, a running plan back)" +puts + +failures = 0 +Dir.mktmpdir("omakase") do |dir| + RECIPES.each do |recipe| + path = scaffold(recipe, dir) + lines = File.read(path).lines.size + out, err, status = Open3.capture3(RbConfig.ruby, "-I", File.join(ROOT, "lib"), path) + ok = status.success? && out.include?("#{recipe[:name]}: completed") + failures += 1 unless ok + puts " recipe #{recipe[:name].inspect} (#{recipe[:steps].size} steps declared)" + puts " generated: #{File.basename(path)} (#{lines} lines you didn't write)" + puts " ran it: #{ok ? out.strip.inspect : "FAILED: #{err.lines.last&.strip}"}" + puts + end +end + +puts " the recipe says WHAT (steps and order); the scaffold supplies the" +puts " HOW everyone forgets until 2am - journaling, retries with jittered" +puts " backoff, a sane concurrency ceiling. that's omakase: the chef" +puts " chooses, you eat well, and if you hate the fish you own the" +puts " kitchen - the generated file is plain Ruby with your name on it," +puts " TODOs where your work goes, no framework umbilical. generators" +puts " are how conventions travel; a convention nobody scaffolds is a" +puts " convention nobody follows." +exit(failures.zero? ? 0 : 1) From 01ca92a3a8349e295a56105c9d1d718954fcf706 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 01:31:12 +0000 Subject: [PATCH 15/24] docs: document refinery example (flavorjones round 17) Hostile-HTML ETL as a plan: decode, sanitize, extract, resolve per feed in parallel with a referee grepping the refined product. The first draft ran encoding repair last and the plan itself refused. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FeZLdZ3M4q4cho4DZPfSkF --- docs/perspectives/round-17/03-flavorjones.md | 68 +++++++++++ examples/document_refinery.rb | 118 +++++++++++++++++++ 2 files changed, 186 insertions(+) create mode 100644 docs/perspectives/round-17/03-flavorjones.md create mode 100644 examples/document_refinery.rb diff --git a/docs/perspectives/round-17/03-flavorjones.md b/docs/perspectives/round-17/03-flavorjones.md new file mode 100644 index 0000000..7673bf3 --- /dev/null +++ b/docs/perspectives/round-17/03-flavorjones.md @@ -0,0 +1,68 @@ +# Round 17 field notes — Mike Dalessio builds the document refinery + +*Built: `examples/document_refinery.rb` — an HTML-to-digest ETL +pipeline as a plan: decode → sanitize → extract → resolve per +document, documents in parallel, one digest fan-in, and a referee +that greps the refined product for everything the fixtures smuggled +in. Exit 1 if any hostility survives refinement.* + +## What I built and why + +Round 12 I tortured the journal with hostile inputs. This time the +brief asked for a *product*, so I built the workflow I've spent +fifteen years supplying parts for: taking markup you didn't write — +script injections, `onload=` handlers, `javascript:` hrefs, tracking +pixels, encoding damage — and refining it into something you'd let +near a user. The framework's contribution is the shape: each stage +is a task, each document is an independent chain, the chains run in +parallel under one ceiling, and the digest is an honest fan-in that +reads its dependencies by name. + +``` +refined 3 feeds in parallel: + - Changelog Weekly — Issue 12 (1 links) - ... + - Ruby News (1 links) - ... [pwn(), steal(), tracker gone] + - Café Ruby, la gazette (1 links) - ... [latin-1 bytes, repaired] +referee: no script bodies, no javascript: hrefs, no event handlers, + all output valid UTF-8, relative links resolved, 3/3 present +``` + +## The pipeline corrected its author, structurally + +My first draft ran encoding repair *last* — "normalize" as a +finishing touch, the way it reads in a design doc. The plan refused: +the sanitize stage crashed on the gazette, because **you cannot run +a regex over invalid UTF-8** — every downstream stage assumes valid +encoding as a precondition. The fix is the ordering Nokogiri has +always embodied (encoding detection happens at *parse*, before +anything touches the tree): **decode is the price of admission, not +a garnish.** The pipeline-as-plan made the wrong order fail loudly +at the right stage name, which is more than my design doc did. + +Second fixture lesson, smaller but real: my "latin-1" gazette +originally contained a UTF-8 em dash *and* latin-1 bytes — mixed +encoding in one string — and the repair correctly turned the em dash +into `â€"` porridge. Single strings with two encodings are beyond +transcoding; that one you fix at the source, which is exactly what +real feed triage looks like. + +## Notes + +- Stdlib-only parsing keeps the example offline; the comment says + plainly to put Nokogiri at stage 2 in production. Regexes over + HTML are a demo dialect, not a recommendation — I wrote the + library that exists so you don't do this. +- Sanitize-before-extract is the other load-bearing order: extract + from unsanitized markup and your extractor can be steered by what + the attacker left for it to find. +- The referee greps *outputs* for the fixtures' specific payloads + (`track(`, `pwn(`, `steal(`, `javascript:`) — the assertion aims + at what was smuggled in, not at generic cleanliness. + +## Verdict + +Three hostile feeds in, one clean digest out, and the plan's own +stage boundaries taught the author his ordering was wrong before +any user could learn it the harder way. Parse hostile things with +real parsers, repair encodings at the door, and let the pipeline +shape *be* the security model. diff --git a/examples/document_refinery.rb b/examples/document_refinery.rb new file mode 100644 index 0000000..9b6731b --- /dev/null +++ b/examples/document_refinery.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +# The Document Refinery: an HTML-to-digest pipeline where every +# stage assumes the input is hostile until proven boring - because +# it is. Real-world markup arrives with script injections, event +# handlers, javascript: hrefs, unclosed tags, and encoding damage, +# and "parse then use" is how that hostility reaches your users. +# The refinery runs sanitize -> extract -> normalize as a plan per +# document (documents in parallel), fans into one digest, and a +# referee proves nothing dangerous survived refinement. +# +# bundle exec ruby examples/document_refinery.rb +# +# Runs offline against embedded fixtures; exits 1 if anything +# hostile leaks through. (Stdlib-only parsing here for offline +# honesty - in production you'd put Nokogiri at stage 2 and I'd +# thank you for it.) + +require_relative "../lib/agentic" +require "tmpdir" + +Agentic.logger.level = :fatal + +FEEDS = { + "changelog weekly" => "Changelog Weekly — Issue 12" \ + "

Changelog Weekly

" \ + "Read issue" \ + "totally safe link

Gems shipped this week: 41

", + "ruby news" => "Ruby News" \ + "

SEO garbage

Release 3.4" \ + "

Patch tuesday: 3 CVEs fixed

", + "mojibake gazette" => "Caf\xE9 Ruby, la gazette" \ + "

Nouveaut\xE9s: 7 gems

Lire" +}.freeze + +# --- the refinery stages, each one paranoid on purpose ------------------------------ +# DECODE runs FIRST, not last: every regex downstream assumes valid +# UTF-8 and raises on damaged bytes, so encoding repair is the price +# of admission, not a finishing touch. Damage is data, not a crash - +# transcode the legacy bytes instead of moving the outage downstream. +DECODE = ->(raw) { + utf8 = raw.dup.force_encoding(Encoding::UTF_8) + utf8.valid_encoding? ? utf8 : raw.dup.force_encoding(Encoding::ISO_8859_1).encode(Encoding::UTF_8) +} + +SANITIZE = ->(html) { + html.gsub(%r{}mi, "") # no executable content + .gsub(/\son\w+\s*=\s*(['"]).*?\1/i, "") # no event handlers + .gsub(/href\s*=\s*(['"])\s*javascript:.*?\1/i, "href='#neutralized'") +} + +EXTRACT = ->(html) { + {title: html[%r{(.*?)}mi, 1].to_s.strip, + links: html.scan(/href\s*=\s*['"]([^'"]+)['"]/i).flatten, + text: html.gsub(%r{<[^>]*>}, " ").squeeze(" ").strip} +} + +RESOLVE = ->(doc, base_url) { + {title: doc[:title], + links: doc[:links].map { |l| l.start_with?("/") ? base_url + l : l }.reject { |l| l == "#neutralized" }, + text: doc[:text]} +} + +journal = Agentic::ExecutionJournal.new(path: File.join(Dir.tmpdir, "agentic_refinery.jsonl")) +File.delete(journal.path) if File.exist?(journal.path) +orchestrator = Agentic::PlanOrchestrator.new(concurrency_limit: 3, lifecycle_hooks: journal.lifecycle_hooks) + +refined_tasks = FEEDS.map do |name, raw| + decode = Agentic::Task.new(description: "decode: #{name}", agent_spec: {"name" => "d", "instructions" => "w"}, payload: raw) + sanitize = Agentic::Task.new(description: "sanitize: #{name}", agent_spec: {"name" => "s", "instructions" => "w"}) + extract = Agentic::Task.new(description: "extract: #{name}", agent_spec: {"name" => "e", "instructions" => "w"}) + resolve = Agentic::Task.new(description: "resolve: #{name}", agent_spec: {"name" => "r", "instructions" => "w"}) + orchestrator.add_task(decode, agent: ->(t) { DECODE.call(t.payload) }) + orchestrator.add_task(sanitize, [decode], agent: ->(t) { SANITIZE.call(t.previous_output) }) + orchestrator.add_task(extract, [sanitize], agent: ->(t) { EXTRACT.call(t.previous_output) }) + orchestrator.add_task(resolve, [extract], agent: ->(t) { RESOLVE.call(t.previous_output, "https://#{name.delete(" ")}.example") }) + resolve +end + +digest = Agentic::Task.new(description: "digest", agent_spec: {"name" => "d", "instructions" => "w"}) +orchestrator.add_task(digest, refined_tasks, agent: ->(t) { + refined_tasks.map { |rt| t.output_of(rt) }.map { |d| "#{d[:title]} (#{d[:links].size} links) - #{d[:text][0, 40]}" } +}) + +result = orchestrator.execute_plan +entries = result.task_result(digest.id).output + +puts "THE DOCUMENT REFINERY (all input is hostile until proven boring)" +puts +puts " refined #{FEEDS.size} feeds in parallel:" +entries.each { |e| puts " - #{e}" } +puts + +# --- the referee: prove the hostility died in the refinery -------------------------- +outputs = refined_tasks.map { |rt| result.task_result(rt.id).output } +violations = [] +outputs.each do |doc| + blob = [doc[:title], doc[:text], *doc[:links]].join(" ") + violations << "script content survived" if blob.match?(/track\(|pwn\(|steal\(|

Changelog Weekly

" \ - "Read issue" \ - "totally safe link

Gems shipped this week: 41

", - "ruby news" => "Ruby News" \ - "

SEO garbage

Release 3.4" \ - "

Patch tuesday: 3 CVEs fixed

", - "mojibake gazette" => "Caf\xE9 Ruby, la gazette" \ - "

Nouveaut\xE9s: 7 gems

Lire" -}.freeze - -# --- the refinery stages, each one paranoid on purpose ------------------------------ -# DECODE runs FIRST, not last: every regex downstream assumes valid -# UTF-8 and raises on damaged bytes, so encoding repair is the price -# of admission, not a finishing touch. Damage is data, not a crash - -# transcode the legacy bytes instead of moving the outage downstream. -DECODE = ->(raw) { - utf8 = raw.dup.force_encoding(Encoding::UTF_8) - utf8.valid_encoding? ? utf8 : raw.dup.force_encoding(Encoding::ISO_8859_1).encode(Encoding::UTF_8) -} - -SANITIZE = ->(html) { - html.gsub(%r{}mi, "") # no executable content - .gsub(/\son\w+\s*=\s*(['"]).*?\1/i, "") # no event handlers - .gsub(/href\s*=\s*(['"])\s*javascript:.*?\1/i, "href='#neutralized'") -} - -EXTRACT = ->(html) { - {title: html[%r{(.*?)}mi, 1].to_s.strip, - links: html.scan(/href\s*=\s*['"]([^'"]+)['"]/i).flatten, - text: html.gsub(%r{<[^>]*>}, " ").squeeze(" ").strip} -} - -RESOLVE = ->(doc, base_url) { - {title: doc[:title], - links: doc[:links].map { |l| l.start_with?("/") ? base_url + l : l }.reject { |l| l == "#neutralized" }, - text: doc[:text]} -} - -journal = Agentic::ExecutionJournal.new(path: File.join(Dir.tmpdir, "agentic_refinery.jsonl")) -File.delete(journal.path) if File.exist?(journal.path) -orchestrator = Agentic::PlanOrchestrator.new(concurrency_limit: 3, lifecycle_hooks: journal.lifecycle_hooks) - -refined_tasks = FEEDS.map do |name, raw| - decode = Agentic::Task.new(description: "decode: #{name}", agent_spec: {"name" => "d", "instructions" => "w"}, payload: raw) - sanitize = Agentic::Task.new(description: "sanitize: #{name}", agent_spec: {"name" => "s", "instructions" => "w"}) - extract = Agentic::Task.new(description: "extract: #{name}", agent_spec: {"name" => "e", "instructions" => "w"}) - resolve = Agentic::Task.new(description: "resolve: #{name}", agent_spec: {"name" => "r", "instructions" => "w"}) - orchestrator.add_task(decode, agent: ->(t) { DECODE.call(t.payload) }) - orchestrator.add_task(sanitize, [decode], agent: ->(t) { SANITIZE.call(t.previous_output) }) - orchestrator.add_task(extract, [sanitize], agent: ->(t) { EXTRACT.call(t.previous_output) }) - orchestrator.add_task(resolve, [extract], agent: ->(t) { RESOLVE.call(t.previous_output, "https://#{name.delete(" ")}.example") }) - resolve -end - -digest = Agentic::Task.new(description: "digest", agent_spec: {"name" => "d", "instructions" => "w"}) -orchestrator.add_task(digest, refined_tasks, agent: ->(t) { - refined_tasks.map { |rt| t.output_of(rt) }.map { |d| "#{d[:title]} (#{d[:links].size} links) - #{d[:text][0, 40]}" } -}) - -result = orchestrator.execute_plan -entries = result.task_result(digest.id).output - -puts "THE DOCUMENT REFINERY (all input is hostile until proven boring)" -puts -puts " refined #{FEEDS.size} feeds in parallel:" -entries.each { |e| puts " - #{e}" } -puts - -# --- the referee: prove the hostility died in the refinery -------------------------- -outputs = refined_tasks.map { |rt| result.task_result(rt.id).output } -violations = [] -outputs.each do |doc| - blob = [doc[:title], doc[:text], *doc[:links]].join(" ") - violations << "script content survived" if blob.match?(/track\(|pwn\(|steal\(|