agentic examples

The Contract Semver Advisor

The Contract Semver Advisor: two versions of a capability's contract, every change classified as breaking or compatible - FROM THE CALLER'S and THE CONSUMER'S seats, which disagree about what "breaking" means. The verdict is the version bump you owe your users.

Developer Experience Round 8 Piotr Solnica exit 0

source on github

bundle exec ruby examples/contract_semver.rb

a real captured run

CONTRACT SEMVER ADVISOR: quote_shipping v1.4.0 -> v?

  BREAKING   input :weight max tightened 10000 -> 5000 - previously legal calls now rejected
  BREAKING   input :customs_code added as REQUIRED - existing callers don't send it
  BREAKING   output :carrier removed - consumers reading it get nil
  COMPATIBLE input :mode enum widened (road)
  COMPATIBLE output :eta_days added (consumers ignore unknown keys)

  verdict: 3 breaking change(s) -> ship as v2.0.0

  note the asymmetry: INPUTS break when tightened (callers rejected),
  OUTPUTS break when narrowed (consumers starved). the same edit is
  breaking on one side and compatible on the other - semver for
  contracts is a two-seat calculation, and both seats are customers.

source

# frozen_string_literal: true

# The Contract Semver Advisor: two versions of a capability's contract,
# every change classified as breaking or compatible - FROM THE CALLER'S
# and THE CONSUMER'S seats, which disagree about what "breaking" means.
# The verdict is the version bump you owe your users.
#
#   bundle exec ruby examples/contract_semver.rb
#
# Runs offline; v2 contains one of every interesting change.

require class="s">"bundler/setup"
require class="s">"agentic"

V1 = Agentic:class="y">:CapabilitySpecification.new(
  name: class="s">"quote_shipping", description: class="s">"Quote a shipment", version: class="s">"1.4.0",
  inputs: {
    mode: {type: class="s">"string", required: true, enum: %w[air sea]},
    weight: {type: class="s">"number", required: true, min: 1, max: 10_000},
    notes: {type: class="s">"string"}
  },
  outputs: {
    price_cents: {type: class="s">"number", required: true},
    carrier: {type: class="s">"string", required: true}
  }
)

V2 = Agentic:class="y">:CapabilitySpecification.new(
  name: class="s">"quote_shipping", description: class="s">"Quote a shipment", version: class="s">"?",
  inputs: {
    mode: {type: class="s">"string", required: true, enum: %w[air sea road]}, # enum widened
    weight: {type: class="s">"number", required: true, min: 1, max: 5_000},   # max tightened
    customs_code: {type: class="s">"string", required: true},                 # new required input
    notes: {type: class="s">"string"}
  },
  outputs: {
    price_cents: {type: class="s">"number", required: true},
    eta_days: {type: class="s">"number", required: true}                      # new output
    # carrier: removed
  }
)

def classify_inputs(v1, v2)
  changes = []
  v2.inputs.each do |key, decl|
    old = v1.inputs[key]
    if old.nil?
      changes << [decl[class="y">:required] ? class="y">:breaking : class="y">:compatible,
        class="s">"input :#{key} added#{decl[class="y">:required] ? " as REQUIRED - existing callers don't send itclass="s">" : " (optional)class="s">"}"]
      next
    end
    changes << [class="y">:breaking, class="s">"input :#{key} type changed #{old[class="y">:type]} -> #{decl[class="y">:type]}"] if old[class="y">:type] != decl[class="y">:type]
    changes << [class="y">:breaking, class="s">"input :#{key} became required"] if decl[class="y">:required] && !old[class="y">:required]
    if old[class="y">:enum] && decl[class="y">:enum]
      changes << [class="y">:compatible, class="s">"input :#{key} enum widened (#{(decl[class="y">:enum] - old[class="y">:enum]).join(", class="s">")})"] if (old[class="y">:enum] - decl[class="y">:enum]).empty? && decl[class="y">:enum] != old[class="y">:enum]
      changes << [class="y">:breaking, class="s">"input :#{key} enum narrowed (removed #{(old[class="y">:enum] - decl[class="y">:enum]).join(", class="s">")})"] unless (old[class="y">:enum] - decl[class="y">:enum]).empty?
    end
    changes << [class="y">:breaking, class="s">"input :#{key} max tightened #{old[class="y">:max]} -> #{decl[class="y">:max]} - previously legal calls now rejected"] if old[class="y">:max] && decl[class="y">:max] && decl[class="y">:max] < old[class="y">:max]
    changes << [class="y">:breaking, class="s">"input :#{key} min tightened #{old[class="y">:min]} -> #{decl[class="y">:min]}"] if old[class="y">:min] && decl[class="y">:min] && decl[class="y">:min] > old[class="y">:min]
  end
  (v1.inputs.keys - v2.inputs.keys).each do |key|
    changes << [class="y">:compatible, class="s">"input :#{key} no longer declared (extra keys were always permitted)"]
  end
  changes
end

def classify_outputs(v1, v2)
  changes = []
  (v1.outputs.keys - v2.outputs.keys).each do |key|
    changes << [class="y">:breaking, class="s">"output :#{key} removed - consumers reading it get nil"]
  end
  (v2.outputs.keys - v1.outputs.keys).each do |key|
    changes << [class="y">:compatible, class="s">"output :#{key} added (consumers ignore unknown keys)"]
  end
  changes
end

changes = classify_inputs(V1, V2) + classify_outputs(V1, V2)
breaking = changes.count { |kind, _| kind == class="y">:breaking }

puts class="s">"CONTRACT SEMVER ADVISOR: #{V1.name} v#{V1.version} -> v?"
puts
changes.sort_by { |kind, _| (kind == class="y">:breaking) ? 0 : 1 }.each do |kind, message|
  puts format(class="s">"  %-10s %s", kind.to_s.upcase, message)
end

major, minor, = V1.version.split(class="s">".").map(&class="y">:to_i)
suggested = breaking.positive? ? class="s">"#{major + 1}.0.0" : class="s">"#{major}.#{minor + 1}.0"
puts
puts class="s">"  verdict: #{breaking} breaking change(s) -> ship as v#{suggested}"
puts
puts class="s">"  note the asymmetry: INPUTS break when tightened (callers rejected),"
puts class="s">"  OUTPUTS break when narrowed (consumers starved). the same edit is"
puts class="s">"  breaking on one side and compatible on the other - semver for"
puts class="s">"  contracts is a two-seat calculation, and both seats are customers."