Feature Flags for Plans
Feature Flags for Plans: shipping a new pipeline step shouldn't be a deploy decision - it should be a FLAG decision. A tiny Flipper- shaped adapter (boolean, actor, percentage gates) decides per run whether the experimental step joins the plan, and rewire_task splices it in or routes around it. Same code in production for everyone; different plans per actor.
Data & Pipelines
Round 13
John Nunemaker
exit 0
bundle exec ruby examples/feature_flags.rb
a real captured run
FEATURE FLAGS FOR PLANS (one codebase, per-actor shapes)
phase 1 - flag off for everyone:
acme fetch -> summarize -> publish published: summary
globex fetch -> summarize -> publish published: summary
umbrella fetch -> summarize -> publish published: summary
phase 2 - enabled for actor acme (the design partner):
acme fetch -> summarize -> fact_check -> publish published: checked summary
globex fetch -> summarize -> publish published: summary
umbrella fetch -> summarize -> publish published: summary
phase 3 - 50% rollout (deterministic per-tenant bucketing):
acme fetch -> summarize -> fact_check -> publish published: checked summary
globex fetch -> summarize -> fact_check -> publish published: checked summary
umbrella fetch -> summarize -> publish published: summary
the shape of the trick: the experimental step isn't hidden
behind an if INSIDE a task - it's a different PLAN, built per
run, spliced in with rewire_task at exactly one seam. acme has
been running fact-checked for a phase before anyone else, the
50% bucket is deterministic (same tenant, same verdict, every
run - flapping flags are worse than no flags), and rollback is
disable, not deploy. flags decouple SHIPPING code from RUNNING
it; plans-as-data means they can decouple shipping a STEP from
running it, too.
source
# frozen_string_literal: true # Feature Flags for Plans: shipping a new pipeline step shouldn't be # a deploy decision - it should be a FLAG decision. A tiny Flipper- # shaped adapter (boolean, actor, percentage gates) decides per run # whether the experimental step joins the plan, and rewire_task # splices it in or routes around it. Same code in production for # everyone; different plans per actor. # # bundle exec ruby examples/feature_flags.rb # # Runs offline; three tenants, one flag, three rollout phases. require class="s">"bundler/setup" require class="s">"agentic" Agentic.logger.level = class="y">:fatal # Flipper's essential shape in 30 lines: gates checked in order class Flags def initialize @features = Hash.new { |h, k| h[k] = {boolean: false, actors: [], percentage: 0} } end def enable(name) = @features[name][class="y">:boolean] = true def enable_actor(name, actor) = @features[name][class="y">:actors] << actor def enable_percentage(name, pct) = @features[name][class="y">:percentage] = pct def enabled?(name, actor = nil) f = @features[name] return true if f[class="y">:boolean] return true if actor && f[class="y">:actors].include?(actor) return true if actor && f[class="y">:percentage].positive? && (actor.sum(&class="y">:ord) % 100) < f[class="y">:percentage] # deterministic bucketing false end end FLAGS = Flags.new def task_named(name) Agentic:class="y">:Task.new(description: name, agent_spec: {class="s">"name" => name, class="s">"instructions" => class="s">"w"}) end # One plan definition; the flag decides its SHAPE per actor def build_plan(tenant) o = Agentic:class="y">:PlanOrchestrator.new fetch = task_named(class="s">"fetch") summarize = task_named(class="s">"summarize") publish = task_named(class="s">"publish") o.add_task(fetch, agent: ->(_t) { class="s">"articles" }) o.add_task(summarize, [fetch], agent: ->(_t) { class="s">"summary" }) o.add_task(publish, [summarize], agent: ->(t) { class="s">"published: #{t.previous_output}" }) if FLAGS.enabled?(class="y">:fact_check, tenant) check = task_named(class="s">"fact_check") o.add_task(check, [summarize], agent: ->(_t) { class="s">"checked summary" }) o.rewire_task(publish, [check]) # splice the new step into the seam end [o, publish] end TENANTS = %w[acme globex umbrella].freeze def survey(phase) puts class="s">" #{phase}:" TENANTS.each do |tenant| orchestrator, publish = build_plan(tenant) shape = orchestrator.graph[class="y">:order].map { |id| orchestrator.graph[class="y">:tasks][id].description }.join(class="s">" -> ") result = orchestrator.execute_plan puts format(class="s">" %-8s %-46s %s", tenant, shape, result.task_result(publish.id).output) end puts end puts class="s">"FEATURE FLAGS FOR PLANS (one codebase, per-actor shapes)" puts survey(class="s">"phase 1 - flag off for everyone") FLAGS.enable_actor(class="y">:fact_check, class="s">"acme") survey(class="s">"phase 2 - enabled for actor acme (the design partner)") FLAGS.enable_percentage(class="y">:fact_check, 50) survey(class="s">"phase 3 - 50% rollout (deterministic per-tenant bucketing)") puts class="s">" the shape of the trick: the experimental step isn't hidden" puts class="s">" behind an if INSIDE a task - it's a different PLAN, built per" puts class="s">" run, spliced in with rewire_task at exactly one seam. acme has" puts class="s">" been running fact-checked for a phase before anyone else, the" puts class="s">" 50% bucket is deterministic (same tenant, same verdict, every" puts class="s">" run - flapping flags are worse than no flags), and rollback is" puts class="s">" disable, not deploy. flags decouple SHIPPING code from RUNNING" puts class="s">" it; plans-as-data means they can decouple shipping a STEP from" puts class="s">" running it, too."