Gentle Deprecations
Gentle Deprecations: the hard part of maintaining a framework isn't adding the better name - it's the two years of not breaking anyone who used the old one. This shims a renamed contract field through three release phases: translate-and-warn (once per call site, with the caller named), count everything for the migration report, and a strict mode that turns warnings into CI failures ON YOUR schedule, not the users'.
Scheduling & Concurrency
Round 12
Rafael França
exit 0
bundle exec ruby examples/gentle_deprecations.rb
a real captured run
GENTLE DEPRECATIONS (rename shipped; nobody broken; everybody counted)
the migration report (deprecation is data about your users):
weight at legacy_billing_job (gentle_deprecations.rb:76) 3 call(s)
weight at legacy_admin_panel (gentle_deprecations.rb:78) 2 call(s)
migrated call sites warn nothing and appear nowhere.
strict mode (release N+2, or CI today): DEPRECATED input :weight (use :weight_kg) - strict mode refuses it
the choreography, straight from the Rails playbook: release N
adds the new name and the shim - old code runs, warns once per
call site (per-site, or your logs become the outage), and the
tally tells you exactly who still needs a PR. release N+1 you
chase the tally to zero. release N+2 flips strict and deletes
the shim on YOUR schedule - because the deadline was enforced
by CI failures in the laggards' builds, not by breaking their
production. renames are cheap; broken trust compounds.
source
# frozen_string_literal: true # Gentle Deprecations: the hard part of maintaining a framework isn't # adding the better name - it's the two years of not breaking anyone # who used the old one. This shims a renamed contract field through # three release phases: translate-and-warn (once per call site, with # the caller named), count everything for the migration report, and # a strict mode that turns warnings into CI failures ON YOUR schedule, # not the users'. # # bundle exec ruby examples/gentle_deprecations.rb # # Runs offline; three "apps" call the API from three code sites. require class="s">"bundler/setup" require class="s">"agentic" Agentic.logger.level = class="y">:fatal # v2 renamed weight: -> weight_kg:. The contract only knows the new world. CONTRACT = Agentic:class="y">:CapabilitySpecification.new( name: class="s">"quote_shipping", description: class="s">"Quote a shipment", version: class="s">"2.0.0", inputs: { mode: {type: class="s">"string", required: true, enum: %w[air sea]}, weight_kg: {type: class="s">"number", required: true, min: 1} }, outputs: {price_cents: {type: class="s">"number", required: true}} ) # The shim: old names translated at the door, warned once per call # site, tallied for the report. Deprecation is DATA about your users. class DeprecationShim RENAMES = {weight: class="y">:weight_kg}.freeze attr_reader class="y">:hits def initialize(strict: false) @strict = strict @warned = {} @hits = Hash.new(0) end def translate(inputs) RENAMES.each do |old_name, new_name| next unless inputs.key?(old_name) # The interesting frame is the USER's: skip the shim and the API # boundary, blame the first frame that belongs to neither site_location = caller_locations.find { |l| !l.label.include?(class="s">"translate") && !%w[each quote].include?(l.label) } site = class="s">"#{site_location.label} (#{site_location.to_s[/[^\/]+:\d+/]})" @hits[class="s">"#{old_name} at #{site}"] += 1 if @strict raise ArgumentError, class="s">"DEPRECATED input :#{old_name} (use :#{new_name}) - strict mode refuses it" end unless @warned[class="s">"#{old_name}-#{site}"] @warned[class="s">"#{old_name}-#{site}"] = true warn class="s">" DEPRECATION: :#{old_name} is now :#{new_name} (called from #{site}; this warning shows once per site)" end inputs = inputs.dup inputs[new_name] = inputs.delete(old_name) end inputs end end SHIM = DeprecationShim.new VALIDATOR = Agentic:class="y">:CapabilityValidator.new(CONTRACT) def quote(inputs) inputs = SHIM.translate(inputs) VALIDATOR.validate_inputs!(inputs) {price_cents: (inputs[class="y">:weight_kg] * ((inputs[class="y">:mode] == class="s">"air") ? 9 : 2) * 100).round} end # Three call sites: one migrated, two still on the old name def legacy_billing_job = quote(mode: class="s">"air", weight: 12) def legacy_admin_panel = quote(mode: class="s">"sea", weight: 400) def migrated_checkout = quote(mode: class="s">"air", weight_kg: 3) puts class="s">"GENTLE DEPRECATIONS (rename shipped; nobody broken; everybody counted)" puts 3.times { legacy_billing_job } 2.times { legacy_admin_panel } 4.times { migrated_checkout } puts puts class="s">" the migration report (deprecation is data about your users):" SHIM.hits.each { |site, count| puts format(class="s">" %-46s %d call(s)", site, count) } puts class="s">" migrated call sites warn nothing and appear nowhere." puts # Release N+2: strict mode - the same shim becomes the enforcement strict = DeprecationShim.new(strict: true) begin strict.translate(mode: class="s">"air", weight: 12) rescue ArgumentError => e puts class="s">" strict mode (release N+2, or CI today): #{e.message}" end puts puts class="s">" the choreography, straight from the Rails playbook: release N" puts class="s">" adds the new name and the shim - old code runs, warns once per" puts class="s">" call site (per-site, or your logs become the outage), and the" puts class="s">" tally tells you exactly who still needs a PR. release N+1 you" puts class="s">" chase the tally to zero. release N+2 flips strict and deletes" puts class="s">" the shim on YOUR schedule - because the deadline was enforced" puts class="s">" by CI failures in the laggards' builds, not by breaking their" puts class="s">" production. renames are cheap; broken trust compounds."