Igniter Embed

igniter-embed is the host-local layer for applications that want to register, cache, and execute Igniter contracts without adopting the full application runtime.

contracts = Igniter::Embed.configure(:sparkcrm) do |config|
  config.cache = true
  config.pack Igniter::Contracts::ProjectPack
end

contracts.register(:tax_quote) do
  input :amount
  compute :tax, depends_on: [:amount] do |amount:|
    amount * 0.2
  end
  output :tax
end

result = contracts.call(:tax_quote, amount: 100)
result.success?
result.output(:tax)

For human-facing app initializers, host is sugar over the same host-local configuration:

contracts = Igniter::Embed.host(:shop) do
  owner Shop
  path "app/contracts"
  cache !Rails.env.development?

  contracts do
    add :price_quote, PriceContract
  end
end

For app-local contract classes, prefer host-level registration:

class PriceContract < Igniter::Contract
  define do
    input :amount
    compute :total, depends_on: [:amount] do |amount:|
      amount * 1.2
    end
    output :total
  end
end

contracts = Igniter::Embed.configure(:shop) do |config|
  config.root "app/contracts"
  config.contract PriceContract, as: :price_quote
end

contracts.call(:price_quote, amount: 100).output(:total)

Named contract classes can also be registered directly:

contracts.register(PriceContract)
contracts.call(:price, amount: 100)

config.root is the host-local directory where contract files live. It is metadata for explicit registration unless discovery is enabled.

Discovery is opt-in:

contracts = Igniter::Embed.configure(:shop) do |config|
  config.root "app/contracts"
  config.discover!
end

By default discovery requires **/*_contract.rb under config.root and registers newly loaded, named Class < Igniter::Contract definitions by inferred name. Anonymous contract classes are ignored by discovery and must be registered explicitly with as: if you want to call them through the host.

Prefer explicit config.contract for application boot paths where stable naming matters. If explicit registration and discovery produce the same name, the explicit registration wins. If two discovered classes infer the same name, discovery raises Igniter::Embed::DiscoveryError and asks you to register them explicitly.

Rails integration is optional:

require "igniter/embed/rails"

Igniter::Embed::Rails.install(
  contracts,
  reloader: Rails.application.reloader,
  cache: !Rails.env.development?
)

The Rails adapter only connects host reload callbacks to container.reload!. The base package remains Rails-free.

Contractable Shadowing

igniter-contracts owns the core Contractable service protocol used by compute using:. The igniter-embed contractable API below is a host wrapper for migration, shadowing, discovery, and production observation.

contractable wraps host services without changing their public API. The primary callable runs synchronously and its raw result is returned; an optional candidate can run through a shadow adapter, normalize outputs, compare through DifferentialPack, and record an observation through an app-supplied store. When async is true, the default adapter uses a local Ruby thread so candidate work does not block the primary response. It is not a durable production job queue; provide an app adapter for ActiveJob, Sidekiq, or another backend when durability matters.

When a primary or candidate is a core Igniter::Contracts::Contractable service, embed invokes it through the core protocol and adopts its declared role, stage, and metadata as wrapper defaults unless the wrapper explicitly overrides them.

QuoteShadow = Igniter::Embed.contractable(:quote) do |config|
  config.role :migration_candidate
  config.stage :shadowed
  config.primary LegacyQuote
  config.candidate ContractQuote
  config.normalize_primary QuoteNormalizer
  config.normalize_candidate QuoteNormalizer
  config.accept :shape, outputs: { total: Numeric, status: String }
  config.store QuoteObservationStore
end

result = QuoteShadow.call(amount: 100)

The same shape can be declared through host sugar. This keeps registration, shadow migration intent, adapters, and event hooks in one inspectable initializer:

contracts = Igniter::Embed.host(:billing) do
  contracts do
    add :price_quote, Billing::PriceContract do
      migrate Billing::LegacyQuote, to: Billing::ContractQuote
      shadow async: false, sample: 1.0

      use :normalizer, Billing::QuoteNormalizer
      use :redaction, only: %i[amount customer_id]
      use :acceptance, policy: :shape, outputs: { total: Numeric }
      use :store, Billing::ObservationStore

      on :divergence do |event|
        Billing.logger.warn(event)
      end
    end
  end
end

runner = contracts.contractable(:price_quote)
runner.call(amount: 100, customer_id: "cust_1", token: "secret")

Generated contractable runners are host-local:

contracts.contractable_names
contracts.fetch_contractable(:price_quote)
contracts.sugar_expansion.to_h

on :failure is an alias family for typed failure events: :primary_error, :candidate_error, :acceptance_failure, and :store_error. Divergence is intentionally separate and should be subscribed to with on :divergence.

Capability attachment sugar exists for host-owned targets. It does not install implicit built-ins:

contracts = Igniter::Embed.host(:billing) do
  contracts do
    add :price_quote, Billing::PriceContract do
      migrate Billing::LegacyQuote, to: Billing::ContractQuote
      use :normalizer, Billing::QuoteNormalizer

      use :logging, contract: Billing::LogObservationContract
      use :reporting, ->(event) { Billing.reporter.record(event) }
      use :metrics, target: Billing::MetricsSink
      use :validation, callable: Billing::ObservationValidator
    end
  end
end

Each explicit target appears in sugar_expansion as either kind: :contract or kind: :callable_adapter.

Primary-only observed services use the same surface:

ObservedQuote = Igniter::Embed.contractable(:quote) do |config|
  config.role :observed_service
  config.primary LegacyQuote
  config.normalize_primary QuoteNormalizer
  config.store QuoteObservationStore
end

For an observed service, the normalizer should return a redacted aggregate summary. The primary callable remains authoritative and its raw result is still returned to the host app.

AvailabilityObserver = Igniter::Embed.contractable(:availability) do |config|
  config.role :observed_service
  config.stage :captured
  config.primary AvailabilityService
  config.normalize_primary AvailabilitySummaryNormalizer
  config.redact_inputs ->(**inputs) { inputs.slice(:request_ref, :window_ref) }
  config.store AvailabilityObservationStore
end

class AvailabilitySummaryNormalizer
  def self.call(_result)
    {
      status: :ok,
      outputs: {
        status: "success",
        receipt_kind: "availability_slot_map_summary",
        redaction_policy: "availability_slot_map_summary_v1",
        availability_bucket: "available",
        dominant_unavailable_state: "day_off",
        available_ratio: 0.75,
        total_slots: 4,
        available_slots: 3,
        scheduled_slots: 0,
        off_schedule_slots: 0,
        day_off_slots: 1,
        past_slots: 0
      },
      metadata: { normalizer: :availability_summary_v1 }
    }
  end
end

The aggregate payload above is a sanitized normalizer example. It becomes part of receipt[:primary][:outputs]; it is not the top-level Embed receipt envelope. In particular, "availability_slot_map_summary" is fixture/example vocabulary for the aggregate output shape, not an igniter-embed receipt kind. The Embed observation receipt that contains it still uses receipt_kind: :contractable_observation, and event receipts still use receipt_kind: :contractable_event.

Keep this shape host-local:

  • choose the observed target, rollout flag, and sample rate in the app;
  • keep the redaction allow-list app-owned;
  • persist receipts through an app-owned store adapter;
  • treat Ledger sinks as optional adapters, not as the source of truth;
  • do not infer release readiness or a public schema from synthetic aggregate examples.

Observation Receipts

Each contractable call produces a canonical observation receipt. The receipt includes a stable observation_id, schema_version, receipt_kind, and a status that summarises the outcome:

:ok               — primary and candidate matched and were accepted
:diverged         — outputs diverged but acceptance policy passed
:candidate_error  — candidate raised an exception
:acceptance_failed — candidate succeeded but acceptance policy failed
:store_error      — store adapter raised after primary returned
:unsampled        — call was outside the configured sample rate

A Spark-style store adapter wires receipts into a durable sink:

class SparkObservationStore
  def record_observation(receipt)
    # receipt[:observation_id]  — stable id for linking to logs/admin
    # receipt[:status]          — :ok | :diverged | :candidate_error | …
    # receipt[:redaction]       — policy applied to inputs
    ObservationRecord.create!(receipt.slice(:observation_id, :status, :name, :role, :stage).merge(payload: receipt))
  end

  def record_event(receipt)
    # receipt[:receipt_kind]  == :contractable_event
    # receipt[:event_id]      — unique per event
    # receipt[:observation_id] — links back to the observation
    # receipt[:severity]      — :info | :warning | :error
    return unless receipt[:severity] == :error || receipt[:event] == :divergence

    ObservationEvent.create!(receipt.slice(:event_id, :observation_id, :event, :severity, :summary))
  end
end

Register the store in a host:

runner = Igniter::Embed.contractable(:marketing_executor) do
  migrate Api::Marketing::ExecutorService::Legacy,
          to: Api::Marketing::ExecutorService::Contract
  shadow async: true, sample: 0.1
  use :normalizer, Api::Marketing::ExecutorNormalizer
  use :redaction, only: %i[provider_payload technician_id customer_id]
  use :acceptance, policy: :shape, outputs: { status: String, result: Hash }
  use :store, SparkObservationStore.new

  on :divergence do |event|
    Rails.logger.warn("[igniter] divergence obs=#{event.dig(:receipt, :observation_id)}")
  end
end

A divergence event payload includes a compact receipt:

{
  event: :divergence,
  receipt: {
    schema_version: 1,
    receipt_kind: :contractable_event,
    event_id: "evt_...",
    observation_id: "obs_...",
    severity: :warning,
    summary: "outputs diverged from primary",
    observation_ref: { observation_id: "obs_...", match: false, accepted: false }
  }
}

Async adapters receive a handoff descriptor for durable job wiring:

class SidekiqObservationAdapter
  def enqueue(name:, inputs:, metadata:, handoff: nil, &block)
    if handoff
      ObservationJob.perform_later(
        observation_id: handoff[:observation_id],
        name: handoff[:name],
        queued_at: handoff[:queued_at]
      )
    else
      # fallback: run inline
      block.call
    end
  end
end