The Deprecation Shepherd
The Deprecation Shepherd: removing an API is easy; removing it WITHOUT breaking anyone is a data problem wearing an etiquette costume. The failure mode is universal - a warning gets printed for two years, nobody reads warnings, the removal ships, pagers sing. The cure is to treat deprecation as data collection: the shim counts every call WITH its call site, the removal is gated on observed evidence (zero uses in the window, not zero complaints), and while usage exists the …
Observability & Ops
Round 20
Rafael França
exit 0
bundle exec ruby examples/deprecation_shepherd.rb
a real captured run
THE DEPRECATION SHEPHERD (remove when the telemetry says nobody's standing there)
release 2.1 - shim in place, nobody migrated yet:
removal REFUSED - 8 call(s) observed from 3 site(s):
site_checkout_flow: 5 call(s) this window
site_nightly_reconcile: 2 call(s) this window
site_admin_refunds: 1 call(s) this window
release 2.2 - checkout and reconcile migrated:
removal REFUSED - 1 call(s) observed from 1 site(s):
site_admin_refunds: 1 call(s) this window
release 2.3 - the refunds admin finally migrates:
removal APPROVED - zero uses observed. the door can close.
the shepherd's three rules, all data: the shim DELEGATES (users
keep working - a deprecation that breaks people is just a
removal with extra steps) while counting every call with its
SITE, because 'someone still uses this' is useless and
'admin_refunds calls it once nightly' is a pull request you can
write; the gate consumes an observation WINDOW, not a release
count - time passing is not evidence, traffic passing is; and
the approval is falsifiable: zero observed uses in a
representative window, or no removal. two releases of warnings
convinced nobody in the history of software. one report naming
the last holdout has ended every deprecation I've ever shipped.
source
# frozen_string_literal: true # The Deprecation Shepherd: removing an API is easy; removing it # WITHOUT breaking anyone is a data problem wearing an etiquette # costume. The failure mode is universal - a warning gets printed # for two years, nobody reads warnings, the removal ships, pagers # sing. The cure is to treat deprecation as data collection: the # shim counts every call WITH its call site, the removal is gated # on observed evidence (zero uses in the window, not zero # complaints), and while usage exists the gate names the holdouts # instead of shaming the void. You don't remove an API when the # changelog says you may; you remove it when the telemetry says # nobody's standing there. # # bundle exec ruby examples/deprecation_shepherd.rb # # Runs offline; exits 1 unless the gate refuses while evidence # exists and approves only at observed zero. require class="s">"bundler/setup" require class="s">"agentic" Agentic.logger.level = class="y">:fatal # The library: one deprecated door, one replacement USAGE = Hash.new(0) module Billing def self.legacy_total(items) # deprecated since 2.1 site = caller_locations(1, 1).first.label USAGE[site] += 1 total(items) # the shim delegates; behavior identical, data collected end def self.total(items) = items.sum { |i| i[class="y">:cents] } end # The app: three call sites, migrated one phase at a time def app_workload(migrated:) orchestrator = Agentic:class="y">:PlanOrchestrator.new(concurrency_limit: 3) jobs = { class="s">"checkout_flow" => 5, # calls per run class="s">"nightly_reconcile" => 2, class="s">"admin_refunds" => 1 } jobs.each do |site, calls| task = Agentic:class="y">:Task.new(description: site, agent_spec: {class="s">"name" => site, class="s">"instructions" => class="s">"bill"}) orchestrator.add_task(task, agent: ->(_t) { calls.times do items = [{cents: 1200}, {cents: 300}] migrated.include?(site) ? Billing.total(items) : send(class="y">:call_from, site, items) end class="y">:ran }) end orchestrator.execute_plan end # Distinct call sites for honest attribution (the label is the method name) def call_from(site, items) = method(class="s">"site_#{site}").call(items) def site_checkout_flow(items) = Billing.legacy_total(items) def site_nightly_reconcile(items) = Billing.legacy_total(items) def site_admin_refunds(items) = Billing.legacy_total(items) # The gate: evidence in, verdict out def removal_gate USAGE.clear # open a fresh observation window yield # let a representative workload run through it if USAGE.empty? {verdict: class="y">:approved, holdouts: {}} else {verdict: class="y">:refused, holdouts: USAGE.dup} end end puts class="s">"THE DEPRECATION SHEPHERD (remove when the telemetry says nobody's standing there)" puts phases = [ {label: class="s">"release 2.1 - shim in place, nobody migrated yet", migrated: []}, {label: class="s">"release 2.2 - checkout and reconcile migrated", migrated: [class="s">"checkout_flow", class="s">"nightly_reconcile"]}, {label: class="s">"release 2.3 - the refunds admin finally migrates", migrated: [class="s">"checkout_flow", class="s">"nightly_reconcile", class="s">"admin_refunds"]} ] verdicts = phases.map do |phase| gate = removal_gate { app_workload(migrated: phase[class="y">:migrated]) } puts class="s">" #{phase[class="y">:label]}:" if gate[class="y">:verdict] == class="y">:refused puts class="s">" removal REFUSED - #{gate[class="y">:holdouts].values.sum} call(s) observed from #{gate[class="y">:holdouts].size} site(s):" gate[class="y">:holdouts].each { |site, count| puts class="s">" #{site}: #{count} call(s) this window" } else puts class="s">" removal APPROVED - zero uses observed. the door can close." end puts gate end failures = [] failures << class="s">"gate approved with 3 live sites" unless verdicts[0][class="y">:verdict] == class="y">:refused && verdicts[0][class="y">:holdouts].size == 3 failures << class="s">"gate lost track of the holdout" unless verdicts[1][class="y">:verdict] == class="y">:refused && verdicts[1][class="y">:holdouts].keys == [class="s">"site_admin_refunds"] failures << class="s">"gate never approved" unless verdicts[2][class="y">:verdict] == class="y">:approved failures << class="s">"call counts wrong" unless verdicts[0][class="y">:holdouts].values.sum == 8 puts class="s">" the shepherd's three rules, all data: the shim DELEGATES (users" puts class="s">" keep working - a deprecation that breaks people is just a" puts class="s">" removal with extra steps) while counting every call with its" puts class="s">" SITE, because 'someone still uses this' is useless and" puts class="s">" 'admin_refunds calls it once nightly' is a pull request you can" puts class="s">" write; the gate consumes an observation WINDOW, not a release" puts class="s">" count - time passing is not evidence, traffic passing is; and" puts class="s">" the approval is falsifiable: zero observed uses in a" puts class="s">" representative window, or no removal. two releases of warnings" puts class="s">" convinced nobody in the history of software. one report naming" puts class="s">" the last holdout has ended every deprecation I've ever shipped." exit(failures.empty? ? 0 : 1)