test/openagents/compensation_test.exs

58e6347eeb72 · 11 KB

defmodule OpenAgents.CompensationTest do
  use OpenAgents.DataCase, async: false
  alias OpenAgents.{Compensation, Context.Composer, Conversations}
  alias OpenAgents.Compensation.{Event, Statement}
  alias OpenAgents.Providers.Request

  test "accepted attributable usage reconciles once and adjustments never imply payout" do
    policy = policy!()
    artifact = artifact()
    register!(policy, artifact)
    step = completed_step("eligible", 100, "private customer result")

    assert {:error, :outcome_decision_missing} = Compensation.account(step.id, policy)

    assert {:ok, decision} =
             Compensation.decide_outcome(
               step.id,
               reviewer("accept"),
               "accepted",
               "verified_outcome"
             )

    assert decision.outcome_receipt_ref == step.outcome_receipt_ref

    assert {:error, %Ecto.Changeset{}} =
             Compensation.decide_outcome(
               step.id,
               reviewer("duplicate"),
               "accepted",
               "verified_outcome"
             )

    assert {:ok, %{event: event, shares: [share]}} = Compensation.account(step.id, policy)
    assert event.classification == "eligible"
    assert event.technical_units == 100
    assert event.eligible_units == 100
    assert share.contribution_ref == "OpenAgentsInc/openagents.com"
    assert share.allocated_units == 100

    assert {:ok, %{event: same, shares: [same_share]}} = Compensation.account(step.id, policy)
    assert same.id == event.id
    assert same_share.id == share.id
    assert Repo.aggregate(Event, :count) == 1

    assert {:ok, adjustment} =
             Compensation.adjust(
               event,
               share.contribution_ref,
               operator("refund"),
               "refund",
               -25,
               "customer_refund"
             )

    assert adjustment.delta_units == -25

    assert {:ok, statement} =
             Compensation.reconcile(share.contribution_ref, policy, operator("statement"))

    assert statement.gross_units == 100
    assert statement.adjustment_units == -25
    assert statement.net_units == 75
    assert statement.state == "reconciled"

    projection = Compensation.statement_projection(statement)
    refute Jason.encode!(projection) =~ "private customer result"
    assert projection["payout_authority"] == false
    refute function_exported?(Compensation, :payout, 2)
  end

  test "an invocation or rejected outcome alone cannot become payable" do
    policy = policy!()
    register!(policy, artifact())
    step = completed_step("rejected", 80, "not accepted")

    assert {:ok, _decision} =
             Compensation.decide_outcome(
               step.id,
               reviewer("reject"),
               "rejected",
               "utility_failed"
             )

    assert {:ok, %{event: event, shares: [share]}} = Compensation.account(step.id, policy)
    assert event.classification == "ineligible"
    assert event.reason_code == "outcome_rejected"
    assert event.technical_units == 80
    assert event.eligible_units == 0
    assert share.allocated_units == 0
  end

  test "revocation blocks future eligibility but preserves historical accounting" do
    policy = policy!()
    artifact = artifact()
    register!(policy, artifact)

    before = completed_step("before-revoke", 40, "accepted")

    assert {:ok, _} =
             Compensation.decide_outcome(
               before.id,
               reviewer("before"),
               "accepted",
               "verified_outcome"
             )

    assert {:ok, %{event: historical}} = Compensation.account(before.id, policy)
    assert historical.classification == "eligible"

    assert {:ok, _receipt, _snapshot} =
             OpenAgents.Modules.Lifecycle.transition(
               OpenAgents.Tools.Registry.current!(),
               artifact.module_id,
               artifact.version,
               "revoke",
               operator("module-revoke"),
               %{"reason" => "Revoke before later accounting classification."}
             )

    after_revocation = completed_step("after-revoke", 40, "accepted later")

    assert {:ok, _} =
             Compensation.decide_outcome(
               after_revocation.id,
               reviewer("after"),
               "accepted",
               "verified_outcome"
             )

    assert {:ok, %{event: blocked}} = Compensation.account(after_revocation.id, policy)
    assert blocked.classification == "ineligible"
    assert blocked.reason_code == "module_revoked"
    assert Repo.get!(Event, historical.id).eligible_units == 40
  end

  test "shared allocations are deterministic, exact, and order independent" do
    allocations = [
      %{contribution_ref: "contribution:b", allocation_ppm: 666_667},
      %{contribution_ref: "contribution:a", allocation_ppm: 333_333}
    ]

    first = Compensation.allocate_units(101, allocations)
    second = Compensation.allocate_units(101, Enum.reverse(allocations))
    assert first == second
    assert Enum.sum(Enum.map(first, & &1.allocated_units)) == 101
    assert Enum.map(first, & &1.contribution_ref) == ["contribution:a", "contribution:b"]
  end

  test "fraud holds and dispute resolution reconcile through later receipts" do
    policy = policy!()
    register!(policy, artifact())
    step = completed_step("dispute", 50, "private disputed result")

    assert {:ok, _} =
             Compensation.decide_outcome(
               step.id,
               reviewer("dispute"),
               "accepted",
               "verified_outcome"
             )

    assert {:ok, %{event: event, shares: [share]}} = Compensation.account(step.id, policy)

    assert {:ok, _} =
             Compensation.adjust(
               event,
               share.contribution_ref,
               operator("hold"),
               "fraud_hold",
               -10,
               "fraud_review"
             )

    assert {:ok, disputed} =
             Compensation.reconcile(
               share.contribution_ref,
               policy,
               operator("disputed-statement")
             )

    assert disputed.state == "disputed"
    assert disputed.net_units == 40

    assert {:ok, _} =
             Compensation.adjust(
               event,
               share.contribution_ref,
               operator("resolve"),
               "dispute_resolution",
               10,
               "fraud_cleared"
             )

    assert {:ok, resolved} =
             Compensation.reconcile(
               share.contribution_ref,
               policy,
               operator("resolved-statement")
             )

    assert resolved.state == "reconciled"
    assert resolved.net_units == 50
  end

  test "immutable reconciliation receipts reject rewriting" do
    policy = policy!()
    register!(policy, artifact())

    assert {:ok, statement} =
             Compensation.reconcile(
               "OpenAgentsInc/openagents.com",
               policy,
               operator("empty-statement")
             )

    assert_raise Postgrex.Error, fn ->
      statement |> Ecto.Changeset.change(net_units: 999) |> Repo.update!()
    end

    assert Repo.aggregate(Statement, :count) == 1
  end

  defp policy! do
    assert {:ok, policy} = Compensation.admit_policy(operator("policy"))
    policy
  end

  defp register!(policy, artifact) do
    assert {:ok, [_allocation]} =
             Compensation.register_module(
               policy,
               artifact,
               [
                 %{
                   "contribution_ref" => "OpenAgentsInc/openagents.com",
                   "allocation_ppm" => 1_000_000
                 }
               ],
               operator("module-allocation")
             )
  end

  defp completed_step(suffix, cost_units, private_result) do
    %{turn: turn, receipt: receipt} = begin_turn("compensation-#{suffix}")
    artifact = artifact()
    route = route!(receipt, "call-#{suffix}", artifact)
    policy = artifact.attribution_policy

    assert {:ok, step, :created} =
             Conversations.request_tool_step(turn, receipt, %{
               provider_call_id: "call-#{suffix}",
               provider_item_id: "item-#{suffix}",
               provider_response_id: "response-#{suffix}",
               tool_name: "recall_messages",
               tool_version: artifact.version,
               module_id: artifact.module_id,
               module_artifact_digest: artifact.artifact_digest,
               executor_implementation_digest: artifact.implementation_digest,
               routing_receipt_id: route.id,
               side_effect_class: artifact.side_effect_class,
               attribution_policy_id: policy["id"],
               attribution_policy_version: policy["version"],
               attribution_policy_digest: policy["digest"],
               cost_units: cost_units,
               raw_arguments: "{}"
             })

    outcome = %{
      "schema" => "sarah.tool_outcome.v1",
      "call_id" => step.provider_call_id,
      "module_ref" => %{
        "module_id" => step.module_id,
        "tool_name" => step.tool_name,
        "version" => step.tool_version,
        "artifact_digest" => step.module_artifact_digest
      },
      "executor_ref" => %{
        "id" => "sarah.local",
        "disclosure" => "Sarah local recall",
        "implementation_digest" => step.executor_implementation_digest
      },
      "status" => "succeeded",
      "result" => %{"private" => private_result},
      "error" => nil,
      "target_receipt_refs" => ["message:opaque"],
      "attribution_refs" => ["OpenAgentsInc/openagents.com"],
      "started_at" => "2026-08-16T20:00:00Z",
      "completed_at" => "2026-08-16T20:00:01Z"
    }

    assert {:ok, completed} = Conversations.complete_tool_step(step, outcome)
    completed
  end

  defp begin_turn(browser_key) do
    assert {:ok, conversation} = Conversations.ensure_conversation(browser_key)
    assert {:ok, records} = Conversations.create_turn(conversation, "Use a module.")
    context = Composer.compose!()

    request = %Request{
      model_id: "accounting-test-model",
      instructions: context.instructions,
      input: Conversations.provider_messages(conversation.id)
    }

    assert {:ok, inference} =
             Conversations.begin_inference(records.turn, context, request, "test.provider",
               tool_catalog_digest: OpenAgents.Tools.Registry.current!().digest
             )

    inference
  end

  defp artifact,
    do:
      Map.fetch!(
        OpenAgents.Tools.Registry.current!().modules,
        {"sarah.tool.recall_messages", 1}
      )

  defp route!(receipt, call_id, artifact) do
    snapshot = OpenAgents.Tools.Registry.current!()

    proposal = %{
      "module_id" => artifact.module_id,
      "version" => artifact.version,
      "artifact_digest" => artifact.artifact_digest,
      "registry_digest" => snapshot.digest
    }

    assert {:ok, decision} =
             OpenAgents.Modules.Router.route(
               snapshot,
               OpenAgents.Modules.RoutingPolicy.default(),
               %{
                 intent_digest: receipt.input_digest,
                 required_capability: "conversation.read",
                 required_side_effect: "read_only",
                 surface: "text",
                 data_scope: "browser_conversation",
                 authorities: MapSet.new(["conversation.read"]),
                 proposal: proposal,
                 exact_proposal: true
               }
             )

    assert {:ok, route} =
             OpenAgents.Modules.RoutingReceipts.persist(receipt.id, call_id, decision)

    route
  end

  defp operator(suffix),
    do: %{
      authenticated: true,
      role: "operator",
      actor_id: "operator:test",
      auth_method: "test_session",
      approval_receipt_ref: "accounting-operator:#{suffix}:#{System.unique_integer([:positive])}"
    }

  defp reviewer(suffix),
    do: %{
      authenticated: true,
      role: "outcome_reviewer",
      actor_id: "outcome-reviewer:test",
      auth_method: "test_session",
      decision_receipt_ref: "outcome-decision:#{suffix}:#{System.unique_integer([:positive])}"
    }
end