lib/openagents/data_rights/account_export.ex

main at 58e6347eeb72 · 39 KB

defmodule OpenAgents.DataRights.AccountExport do
  @moduledoc """
  One account-scoped export of the forge-owned and forum-owned records an
  account authored, alongside `sarah.account_data_export.v1` rather than inside
  it.

  `OpenAgents.DataRights.export/3` is scoped to one conversation, because that
  is what Sarah owns. Everything else an account produces here — forum topics
  and posts, threads and their transcripts, push receipts, deployment requests
  and approvals, Box leases and runs, paired computers, agent links — keys on
  the account or on an identity the account has claimed, and before this
  document left through no export at all.

  ## What the account owns

  Two identities resolve to one account and no third does:

  * `user:<account-id>`, the `actor_ref` every topic and post written on this
    surface carries.
  * Every `actor_ref` the account holds a `linked` claim on in
    `forum_actor_links`.

  A migrated post written under a legacy identity nobody has claimed is *not*
  exported, even where a display name makes the authorship obvious. Only a
  `linked` claim resolves a legacy actor to an account
  (`OpenAgents.Forum.actor_user/1`), and an export that guessed wider would
  hand one account another account's writing. The claims themselves travel in
  the document, `pending` and `rejected` included, so an account can see which
  identities it asked for and what the operator decided.

  ## Bounds

  Every collection is capped and reports its own truncation, the way the
  conversation export does. A truncated collection says so in a sibling
  `*_truncated` flag; nothing is dropped silently. Box run output is capped per
  run as well, because a run's output stream has no schema ceiling.

  ## What a recipient can do with it

  The document is one JSON object. Post and topic bodies are the markdown
  source as written, so they re-publish on any surface that renders CommonMark
  without passing back through this forge. Push receipts carry the WAL sequence
  and ref map a `git` clone does not, so they re-attach a history to who pushed
  it and when. Identifiers are this forge's UUIDs, but every cross-reference
  inside the document — a post to its topic, a tip to its post, an event to its
  thread — resolves inside the document itself.

  ## Repository names

  A repository's current owner and name are the repository's, not the
  account's. They travel in this document only where
  `OpenAgents.Repositories.readable_by/2` admits the repository to this
  account, and every collection that renders `owner/name` applies that one
  rule: push receipts, deployment requests, deployment approvals, and the
  four families under `"repository_work"`. A private repository the account
  was removed from, or one renamed after they left, does not tell them what it
  is called now.

  What the rule does to the record differs by family, and the difference is
  stated rather than left to each query. An account-keyed record survives with
  `"repository": null`: a push receipt is the account's own evidence — the WAL
  sequence and ref map a clone does not carry — and withholding it would cost
  the account its own history to protect a name it no longer has. A
  repository-keyed record under `"repository_work"` is withheld entirely,
  because a pull request separated from its repository is not a record the
  account can use.

  ## Repository-keyed work

  Pull requests, stacks, and issue dependencies key on a repository rather than
  on an account, so `"repository_work"` is the one section that reads across
  every repository at once. Enumeration is the easy half. Authorization is the
  hard half, and it is answered by joining
  `OpenAgents.Repositories.readable_by/2` — the predicate every per-repository
  read composes — rather than by a second rule written here. An account that
  authored a pull request in a repository it was later removed from does not
  get that record back, and no widening of the query reaches a private
  repository the account never belonged to.

  What the document does not carry is named in `"not_included"` rather than
  left to inference.
  """

  import Ecto.Query

  alias OpenAgents.Accounts.User
  alias OpenAgents.Agents.{Agent, AgentUserLink}
  alias OpenAgents.Box.{ConversationBox, Run}
  alias OpenAgents.ComputerProjection
  alias OpenAgents.Conversations.{Conversation, Visitor}
  alias OpenAgents.Deployments.{Approval, Request}
  alias OpenAgents.Forge.PushReceipt
  alias OpenAgents.Forum.{ActorLink, Post, TipDestination, TipIntent, TipReceipt, Topic}
  alias OpenAgents.Forum.Forum, as: Board
  alias OpenAgents.Issues.{Issue, IssueDependency}
  alias OpenAgents.Machines
  alias OpenAgents.PullRequests.PullRequest
  alias OpenAgents.Repo
  alias OpenAgents.Repositories
  alias OpenAgents.Repositories.Membership
  alias OpenAgents.Repositories.Repository
  alias OpenAgents.Reputation
  alias OpenAgents.Reputation.Attestation
  alias OpenAgents.Stacks.{Stack, StackEntry}
  alias OpenAgents.Threads.{Event, Thread}

  @schema "openagents.account_export.v1"

  @maximum_forum_topics 2_000
  @maximum_forum_posts 10_000
  @maximum_tips 2_000
  @maximum_threads 1_000
  @maximum_thread_events 10_000
  @maximum_push_receipts 10_000
  @maximum_deployments 2_000
  @maximum_box_runs 2_000
  @maximum_box_output_bytes 65_536
  @maximum_pull_requests 5_000
  @maximum_stacks 2_000
  @maximum_stack_entries 10_000
  @maximum_issue_dependencies 5_000
  @maximum_attestations 5_000

  @doc """
  Builds the account's export document.

  Takes the account and nothing else: the scope is the account, so there is no
  root to disagree with and no argument that could widen it.
  """
  @spec build(User.t()) :: {:ok, map()} | {:error, term()}
  def build(%User{status: "active"} = user) do
    actor_links =
      Repo.all(
        from link in ActorLink, where: link.user_id == ^user.id, order_by: [asc: link.inserted_at]
      )

    refs = authored_actor_refs(user, actor_links)
    visitor_ids = visitor_ids(user)

    {:ok,
     %{
       "schema" => @schema,
       "exported_at" => DateTime.to_iso8601(DateTime.utc_now()),
       "scope" => "authenticated_account",
       "account" => %{
         "id" => user.id,
         "github_login" => user.github_login,
         "github_name" => user.github_name,
         # The one field on the account the person typed rather than the
         # provider supplied, so it is the one they are most likely to have
         # forgotten giving. Reported whether or not it was confirmed, because
         # the question a data export answers is what this deployment holds,
         # not what it would act on; the flag beside it says which.
         "notification_email" => user.notification_email,
         "notification_email_verified" =>
           not is_nil(OpenAgents.Notifications.EmailChannel.verified_address(user))
       },
       "identities" => identities_export(user, actor_links, refs),
       "bounds" => bounds(),
       "forum" => forum_export(user, actor_links, refs),
       "threads" => threads_export(visitor_ids),
       "push_receipts" => push_receipts_export(user),
       "deployments" => deployments_export(user),
       "boxes" => boxes_export(visitor_ids),
       "repository_work" => repository_work_export(user),
       "computers" => Enum.map(Machines.list_machines(user.id), &ComputerProjection.project/1),
       "agent_links" => agent_links_export(user),
       "not_included" => not_included()
     }}
  end

  def build(%User{}), do: {:error, :inactive_account}

  @doc "The account's own `actor_ref` on this surface."
  @spec account_actor_ref(User.t()) :: String.t()
  def account_actor_ref(%User{id: id}), do: "user:" <> id

  ## ── identity ───────────────────────────────────────────────────────────

  # Only a `linked` claim resolves a legacy identity, so only a `linked` claim
  # widens what this export reads.
  defp authored_actor_refs(user, actor_links) do
    linked = for link <- actor_links, link.status == "linked", do: link.actor_ref
    Enum.uniq([account_actor_ref(user) | linked])
  end

  defp identities_export(user, actor_links, refs) do
    %{
      "account_actor_ref" => account_actor_ref(user),
      "authored_actor_refs" => refs,
      "claims" => Enum.map(actor_links, &actor_link_export/1),
      "reputation_subject_claims" =>
        user
        |> Reputation.list_subject_claims()
        |> Enum.map(&Reputation.subject_claim_projection/1),
      "resolution_rule" =>
        "A legacy forum identity resolves to this account only through a claim with " <>
          "status \"linked\". Posts under a pending, rejected, or unclaimed actor_ref " <>
          "are not exported here, because nothing has established they are this account's."
    }
  end

  defp actor_link_export(link) do
    %{
      "id" => link.id,
      "actor_ref" => link.actor_ref,
      "status" => link.status,
      "proof_method" => link.proof_method,
      "requested_at" => iso8601(link.inserted_at),
      "linked_at" => iso8601(link.linked_at),
      "rejected_at" => iso8601(link.rejected_at),
      "authored_posts_exported" => link.status == "linked"
    }
  end

  defp visitor_ids(%User{id: user_id}) do
    Repo.all(from visitor in Visitor, where: visitor.user_id == ^user_id, select: visitor.id)
  end

  ## ── forum ──────────────────────────────────────────────────────────────

  defp forum_export(user, actor_links, refs) do
    topics =
      Repo.all(
        from topic in Topic,
          join: board in Board,
          on: board.id == topic.forum_id,
          where: topic.actor_ref in ^refs,
          order_by: [asc: topic.created_at, asc: topic.id],
          limit: ^(@maximum_forum_topics + 1),
          select: {topic, board.slug, board.title}
      )

    posts =
      Repo.all(
        from post in Post,
          join: topic in Topic,
          on: topic.id == post.topic_id,
          join: board in Board,
          on: board.id == topic.forum_id,
          where: post.actor_ref in ^refs,
          order_by: [asc: post.created_at, asc: post.id],
          limit: ^(@maximum_forum_posts + 1),
          select: {post, topic.slug, topic.title, board.slug}
      )

    sent =
      Repo.all(
        from intent in TipIntent,
          where: intent.payer_user_id == ^user.id or intent.payer_actor_ref in ^refs,
          order_by: [asc: intent.inserted_at, asc: intent.id],
          limit: ^(@maximum_tips + 1)
      )

    received =
      Repo.all(
        from intent in TipIntent,
          where: intent.recipient_user_id == ^user.id,
          order_by: [asc: intent.inserted_at, asc: intent.id],
          limit: ^(@maximum_tips + 1)
      )

    destinations =
      Repo.all(
        from destination in TipDestination,
          where: destination.user_id == ^user.id,
          order_by: [asc: destination.inserted_at]
      )

    %{
      "topics" => topics |> Enum.take(@maximum_forum_topics) |> Enum.map(&topic_export/1),
      "topics_truncated" => length(topics) > @maximum_forum_topics,
      "posts" => posts |> Enum.take(@maximum_forum_posts) |> Enum.map(&post_export/1),
      "posts_truncated" => length(posts) > @maximum_forum_posts,
      "claims" => Enum.map(actor_links, &actor_link_export/1),
      "tip_destinations" => Enum.map(destinations, &tip_destination_export/1),
      "tips_sent" => sent |> Enum.take(@maximum_tips) |> tips_export(),
      "tips_sent_truncated" => length(sent) > @maximum_tips,
      "tips_received" => received |> Enum.take(@maximum_tips) |> tips_export(),
      "tips_received_truncated" => length(received) > @maximum_tips
    }
  end

  defp topic_export({topic, board_slug, board_title}) do
    %{
      "id" => topic.id,
      "board_slug" => board_slug,
      "board_title" => board_title,
      "slug" => topic.slug,
      "title" => topic.title,
      "state" => topic.state,
      "pin_state" => topic.pin_state,
      "post_count" => topic.post_count,
      "actor_ref" => topic.actor_ref,
      "actor_display_name" => topic.actor_display_name,
      "tip_sats_total" => topic.tip_sats_total,
      "tip_count" => topic.tip_count,
      "created_at" => iso8601(topic.created_at),
      "archived_at" => iso8601(topic.archived_at)
    }
  end

  defp post_export({post, topic_slug, topic_title, board_slug}) do
    %{
      "id" => post.id,
      "topic_id" => post.topic_id,
      "topic_slug" => topic_slug,
      "topic_title" => topic_title,
      "board_slug" => board_slug,
      "post_number" => post.post_number,
      "body_text" => post.body_text,
      "content_kind" => post.content_kind,
      "actor_ref" => post.actor_ref,
      "actor_display_name" => post.actor_display_name,
      "parent_post_id" => post.parent_post_id,
      "quote_post_id" => post.quote_post_id,
      "state" => post.state,
      "tip_sats_total" => post.tip_sats_total,
      "tip_count" => post.tip_count,
      "created_at" => iso8601(post.created_at),
      "updated_at" => iso8601(post.updated_at),
      "archived_at" => iso8601(post.archived_at)
    }
  end

  # The destination string itself is the account's own address, so it travels;
  # the row carries no key, seed, or node credential to withhold.
  defp tip_destination_export(destination) do
    %{
      "id" => destination.id,
      "kind" => destination.kind,
      "destination" => destination.destination,
      "fingerprint" => destination.fingerprint,
      "label" => destination.label,
      "state" => destination.state,
      "accepting_tips" => destination.accepting_tips,
      "created_at" => iso8601(destination.inserted_at),
      "retired_at" => iso8601(destination.retired_at)
    }
  end

  defp tips_export(intents) do
    ids = Enum.map(intents, & &1.id)

    receipts =
      Repo.all(from receipt in TipReceipt, where: receipt.intent_id in ^ids)
      |> Enum.group_by(& &1.intent_id)

    Enum.map(intents, &tip_export(&1, Map.get(receipts, &1.id, [])))
  end

  defp tip_export(intent, receipts) do
    %{
      "id" => intent.id,
      "post_id" => intent.post_id,
      "topic_id" => intent.topic_id,
      "amount_sats" => intent.amount_sats,
      "counted_sats" => intent.counted_sats,
      "exclusion_reason" => intent.exclusion_reason,
      "state" => intent.state,
      "failure_code" => intent.failure_code,
      "created_at" => iso8601(intent.inserted_at),
      "settled_at" => iso8601(intent.settled_at),
      "failed_at" => iso8601(intent.failed_at),
      "refunded_at" => iso8601(intent.refunded_at),
      "receipts" => Enum.map(receipts, &tip_receipt_export/1)
    }
  end

  defp tip_receipt_export(%TipReceipt{} = receipt) do
    %{
      "id" => receipt.id,
      "kind" => receipt.kind,
      "amount_sats" => receipt.amount_sats,
      "fee_sats" => receipt.fee_sats,
      "payment_hash" => receipt.payment_hash,
      "failure_code" => receipt.failure_code,
      "occurred_at" => iso8601(receipt.occurred_at)
    }
  end

  ## ── threads ────────────────────────────────────────────────────────────

  defp threads_export([]),
    do: %{"records" => [], "records_truncated" => false, "events_truncated" => false}

  defp threads_export(visitor_ids) do
    threads =
      Repo.all(
        from thread in Thread,
          where: thread.owner_visitor_id in ^visitor_ids,
          order_by: [asc: thread.inserted_at, asc: thread.id],
          limit: ^(@maximum_threads + 1)
      )

    kept = Enum.take(threads, @maximum_threads)
    ids = Enum.map(kept, & &1.id)

    events =
      Repo.all(
        from event in Event,
          where: event.thread_id in ^ids,
          order_by: [asc: event.emitted_at, asc: event.id],
          limit: ^(@maximum_thread_events + 1)
      )

    by_thread = events |> Enum.take(@maximum_thread_events) |> Enum.group_by(& &1.thread_id)

    %{
      "records" => Enum.map(kept, &thread_export(&1, Map.get(by_thread, &1.id, []))),
      "records_truncated" => length(threads) > @maximum_threads,
      "events_truncated" => length(events) > @maximum_thread_events
    }
  end

  # The transparency tier travels with the thread. It is the consent record for
  # the transcript beneath it — what the account said may be read, and by whom
  # — so an export that carried the events without it would hand a recipient
  # the data and drop the terms (THREAD-002).
  defp thread_export(thread, events) do
    %{
      "id" => thread.id,
      "objective" => thread.objective,
      "repository" => thread.repository,
      "visibility" => thread.visibility,
      "status" => thread.status,
      "model" => thread.model,
      "reasoning_effort" => thread.reasoning_effort,
      "permission_profile" => thread.permission_profile,
      "generation" => thread.generation,
      "report" => thread.report,
      "report_digest" => thread.report_digest,
      "error_code" => thread.error_code,
      "event_count" => thread.event_count,
      "usage" => thread.usage,
      "started_at" => iso8601(thread.started_at),
      "completed_at" => iso8601(thread.completed_at),
      "events" => Enum.map(events, &thread_event_export/1),
      "events_exported" => length(events)
    }
  end

  defp thread_event_export(event) do
    %{
      "schema" => event.schema,
      "event_type" => event.event_type,
      "payload" => event.payload,
      "emitted_at" => iso8601(event.emitted_at)
    }
  end

  ## ── push receipts ──────────────────────────────────────────────────────

  # `forge_pushes.principal` is `user:<account-id>` for a person's push and an
  # operator or assignment string otherwise, so the account's own pushes are an
  # exact match rather than a guess.
  #
  # The receipt is the account's own and comes back whatever the repository
  # says now. The repository's current owner and name are not, so the join
  # goes through `readable_repositories/1` rather than straight at the table:
  # a receipt for a repository this account can no longer read arrives with
  # `"repository": null` and keeps its `storage_key`, `wal_seq`, and refs.
  defp push_receipts_export(user) do
    principal = account_actor_ref(user)
    readable = readable_repositories(user)

    receipts =
      Repo.all(
        from receipt in PushReceipt,
          left_join: repository in subquery(readable),
          on: repository.storage_key == receipt.repo,
          where: receipt.principal == ^principal,
          order_by: [asc: receipt.inserted_at, asc: receipt.id],
          limit: ^(@maximum_push_receipts + 1),
          select: {receipt, repository.name, repository.owner}
      )

    %{
      "records" =>
        receipts |> Enum.take(@maximum_push_receipts) |> Enum.map(&push_receipt_export/1),
      "records_truncated" => length(receipts) > @maximum_push_receipts
    }
  end

  defp push_receipt_export({receipt, name, owner_login}) do
    %{
      "id" => receipt.id,
      "storage_key" => receipt.repo,
      "repository" => repository_path(owner_login, name),
      "wal_seq" => receipt.wal_seq,
      "principal" => receipt.principal,
      "refs" => receipt.refs,
      "duration_ms" => receipt.duration_ms,
      "pushed_at" => iso8601(receipt.inserted_at)
    }
  end

  defp repository_path(nil, _name), do: nil
  defp repository_path(_owner, nil), do: nil
  defp repository_path(owner, name), do: owner <> "/" <> name

  ## ── deployments ────────────────────────────────────────────────────────

  # Both reads are keyed on the account — the requester of a deployment and the
  # approver of one — so both records survive a lost membership. Both join
  # `readable_repositories/1` for the same reason the push receipts do: the
  # request is the account's, the repository's current name is not.
  defp deployments_export(user) do
    readable = readable_repositories(user)

    requests =
      Repo.all(
        from request in Request,
          left_join: repository in subquery(readable),
          on: repository.id == request.repository_id,
          where: request.requested_by_user_id == ^user.id,
          order_by: [asc: request.requested_at, asc: request.id],
          limit: ^(@maximum_deployments + 1),
          select: {request, repository.owner, repository.name}
      )

    approvals =
      Repo.all(
        from approval in Approval,
          left_join: repository in subquery(readable),
          on: repository.id == approval.repository_id,
          where: approval.approver_user_id == ^user.id,
          order_by: [asc: approval.decided_at, asc: approval.id],
          limit: ^(@maximum_deployments + 1),
          select: {approval, repository.owner, repository.name}
      )

    %{
      "requests" =>
        requests |> Enum.take(@maximum_deployments) |> Enum.map(&deployment_request_export/1),
      "requests_truncated" => length(requests) > @maximum_deployments,
      "approvals" =>
        approvals |> Enum.take(@maximum_deployments) |> Enum.map(&deployment_approval_export/1),
      "approvals_truncated" => length(approvals) > @maximum_deployments
    }
  end

  defp deployment_request_export({request, owner_login, name}) do
    %{
      "id" => request.id,
      "repository" => repository_path(owner_login, name),
      "environment_id" => request.environment_id,
      "commit_sha" => request.commit_sha,
      "artifact_digest" => request.artifact_digest,
      "source_ref" => request.source_ref,
      "source_workflow" => request.source_workflow,
      "principal_type" => request.principal_type,
      "request_digest" => request.request_digest,
      "requested_at" => iso8601(request.requested_at)
    }
  end

  defp deployment_approval_export({approval, owner_login, name}) do
    %{
      "id" => approval.id,
      "repository" => repository_path(owner_login, name),
      "deployment_run_id" => approval.deployment_run_id,
      "decision" => approval.decision,
      "rule" => approval.rule,
      "comment" => approval.comment,
      "request_digest" => approval.request_digest,
      "decided_at" => iso8601(approval.decided_at)
    }
  end

  ## ── boxes ──────────────────────────────────────────────────────────────

  defp boxes_export([]), do: %{"leases" => [], "runs" => [], "runs_truncated" => false}

  defp boxes_export(visitor_ids) do
    conversation_ids =
      Repo.all(
        from conversation in Conversation,
          where: conversation.visitor_id in ^visitor_ids,
          select: conversation.id
      )

    leases =
      Repo.all(
        from box in ConversationBox,
          where: box.conversation_id in ^conversation_ids,
          order_by: [asc: box.inserted_at]
      )

    runs =
      Repo.all(
        from run in Run,
          where: run.conversation_id in ^conversation_ids,
          order_by: [asc: run.inserted_at, asc: run.id],
          limit: ^(@maximum_box_runs + 1)
      )

    %{
      "leases" => Enum.map(leases, &box_lease_export/1),
      "runs" => runs |> Enum.take(@maximum_box_runs) |> Enum.map(&box_run_export/1),
      "runs_truncated" => length(runs) > @maximum_box_runs
    }
  end

  defp box_lease_export(box) do
    %{
      "id" => box.id,
      "box_id" => box.box_id,
      "label" => box.label,
      "state" => box.state,
      "setup_status" => box.setup_status,
      "lifetime_seconds" => box.lifetime_seconds,
      "settled_cost_microusd" => box.settled_cost_microusd,
      "created_at" => iso8601(box.inserted_at),
      "stopped_at" => iso8601(box.stopped_at),
      "stop_reason" => box.stop_reason
    }
  end

  # A run's output is a stream with no schema ceiling, so it is capped here and
  # the cap is reported per run rather than applied silently.
  defp box_run_export(run) do
    output = run.output || ""
    kept = binary_part(output, 0, min(byte_size(output), @maximum_box_output_bytes))

    %{
      "id" => run.id,
      "conversation_box_id" => run.conversation_box_id,
      "command" => run.command,
      "state" => run.state,
      "exit_status" => run.exit_status,
      "timed_out" => run.timed_out,
      "output" => kept,
      "output_truncated" => byte_size(output) > @maximum_box_output_bytes,
      "output_byte_size" => byte_size(output),
      "failure_reason" => run.failure_reason,
      "admitted_at" => iso8601(run.admitted_at),
      "started_at" => iso8601(run.started_at),
      "finished_at" => iso8601(run.finished_at)
    }
  end

  ## ── repository-keyed work ──────────────────────────────────────────────

  # Pull requests, stacks, and issue dependencies key on a repository rather
  # than on an account, so reading them per account is a wider query than any
  # other read on this surface. Authorization is the whole problem: the
  # authoring column alone would return a record from a repository the account
  # was removed from, and a repository-shaped query written from memory would
  # reach a private repository the account never belonged to. Every query here
  # joins `OpenAgents.Repositories.readable_by/2` — the one predicate every
  # repository surface composes — so the widened read cannot be looser than the
  # per-repository reads it replaces.
  defp repository_work_export(user) do
    readable = readable_repositories(user)

    %{
      "authorization" =>
        "Every record here passes OpenAgents.Repositories.readable_by/2, the predicate the " <>
          "per-repository API reads compose. A record the account authored in a repository it " <>
          "can no longer read is not returned, and no record from a repository the account is " <>
          "not a member of reaches this document.",
      "pull_requests" => pull_requests_export(user, readable),
      "stacks" => stacks_export(user, readable),
      "issue_dependencies" => issue_dependencies_export(user, readable),
      "attestations" => attestations_export(user, readable)
    }
  end

  # A reputation attestation names its subject with a bare string, so the
  # filter is the subject binding rather than an authoring column: the strings
  # this account has a `linked` claim on in `reputation_subject_claims`. An
  # account with no linked claim gets nothing, and a subject another account
  # holds is unreachable, because the table's unique index on `subject_id`
  # means one string resolves to at most one account.
  #
  # Disclosure does not widen here. `readable_by/2` admits a public repository
  # to a non-member, and `OpenAgentsWeb.ReputationController` shows such a
  # reader `public` attestations only, so the same membership test gates the
  # `repository` and `private` tiers in this document. The account is active —
  # `build/1` refuses an inactive one — so a membership row is the whole of
  # `Repositories.member?/2` here.
  defp attestations_export(user, readable) do
    subject_ids = Reputation.linked_subject_ids(user)

    rows =
      Repo.all(
        from attestation in Attestation,
          join: repository in subquery(readable),
          on: repository.id == attestation.repository_id,
          left_join: membership in Membership,
          on: membership.repository_id == repository.id and membership.user_id == ^user.id,
          where:
            attestation.subject_id in ^subject_ids and
              (attestation.transparency_tier == "public" or not is_nil(membership.user_id)),
          order_by: [asc: attestation.attested_at, asc: attestation.id],
          limit: ^(@maximum_attestations + 1),
          select: {attestation, repository.owner, repository.name}
      )

    %{
      "subject_resolution_rule" =>
        "An attestation reaches this document only through a reputation subject claim with " <>
          "status \"linked\". The issuer supplies a bare subject_id, so nothing but an " <>
          "established claim says the subject is this account's, and one subject string " <>
          "resolves to at most one account.",
      "established_subjects" => subject_ids,
      "records" => rows |> Enum.take(@maximum_attestations) |> Enum.map(&attestation_export/1),
      "records_truncated" => length(rows) > @maximum_attestations
    }
  end

  # The signed claim travels verbatim beside its signature and the issuer key,
  # so a recipient checks the attestation offline the way
  # `OpenAgentsWeb.ReputationController` lets a stranger check it.
  defp attestation_export({attestation, owner_login, name}) do
    %{
      "id" => attestation.id,
      "repository" => repository_path(owner_login, name),
      "issue_number" => attestation.issue_number,
      "event_type" => attestation.event_type,
      "subject_id" => attestation.subject_id,
      "issuer_key_id" => attestation.issuer_key_id,
      "transparency_tier" => attestation.transparency_tier,
      "claim" => attestation.claim,
      "claim_digest" => attestation.claim_digest,
      "signature" => attestation.signature,
      "signature_algorithm" => attestation.signature_algorithm,
      "revoked" => not is_nil(attestation.revoked_at),
      "revocation_reason_code" => attestation.revocation_reason_code,
      "attested_at" => iso8601(attestation.attested_at)
    }
  end

  # A repository's identity comes back with the predicate rather than beside
  # it, so a caller cannot name a repository this query did not admit. Every
  # collection that renders `owner/name` joins this one query — the four
  # repository-keyed families here, and the account-keyed push receipts and
  # deployments above — so the module cannot state one disclosure rule and
  # apply two. `storage_key` rides along because a push receipt names its
  # repository by that key rather than by `repository_id`.
  defp readable_repositories(user) do
    from repository in Repositories.readable_by(Repository, user),
      select: %{
        id: repository.id,
        owner: repository.owner,
        name: repository.name,
        storage_key: repository.storage_key
      }
  end

  defp pull_requests_export(user, readable) do
    rows =
      Repo.all(
        from pull_request in PullRequest,
          join: repository in subquery(readable),
          on: repository.id == pull_request.repository_id,
          join: issue in Issue,
          on: issue.id == pull_request.issue_id,
          left_join: head in subquery(readable),
          on: head.id == pull_request.head_repository_id,
          where:
            pull_request.opened_by_user_id == ^user.id or
              pull_request.merged_by_user_id == ^user.id,
          order_by: [asc: pull_request.inserted_at, asc: pull_request.id],
          limit: ^(@maximum_pull_requests + 1),
          select:
            {pull_request, repository.owner, repository.name, issue.number, issue.title,
             head.owner, head.name}
      )

    %{
      "records" =>
        rows |> Enum.take(@maximum_pull_requests) |> Enum.map(&pull_request_export(&1, user)),
      "records_truncated" => length(rows) > @maximum_pull_requests
    }
  end

  defp pull_request_export(row, user) do
    {pull_request, owner_login, name, number, title, head_owner, head_name} = row

    %{
      "id" => pull_request.id,
      "repository" => repository_path(owner_login, name),
      "number" => number,
      "title" => title,
      "state" => pull_request.state,
      "draft" => pull_request.draft,
      "head_ref" => pull_request.head_ref,
      "head_sha" => pull_request.head_sha,
      "head_repository" => repository_path(head_owner, head_name),
      "base_ref" => pull_request.base_ref,
      "base_sha" => pull_request.base_sha,
      "opened_by_account" => pull_request.opened_by_user_id == user.id,
      "merged_by_account" => pull_request.merged_by_user_id == user.id,
      "merge_commit_sha" => pull_request.merge_commit_sha,
      "opened_at" => iso8601(pull_request.inserted_at),
      "merged_at" => iso8601(pull_request.merged_at)
    }
  end

  # A stack's boundary commits live under `refs/internal/`, which
  # `EXIT-004` records as the one namespace a clone does not advertise. The
  # object ids travel here, so the account keeps the shape of its own stack
  # even though the refs holding it are not fetchable.
  defp stacks_export(user, readable) do
    rows =
      Repo.all(
        from stack in Stack,
          join: repository in subquery(readable),
          on: repository.id == stack.repository_id,
          where: stack.created_by_user_id == ^user.id,
          order_by: [asc: stack.inserted_at, asc: stack.id],
          limit: ^(@maximum_stacks + 1),
          select: {stack, repository.owner, repository.name}
      )

    kept = Enum.take(rows, @maximum_stacks)
    stack_ids = Enum.map(kept, fn {stack, _owner, _name} -> stack.id end)
    entries = stack_entries(stack_ids)
    by_stack = entries |> Enum.take(@maximum_stack_entries) |> Enum.group_by(&elem(&1, 1))

    %{
      "records" =>
        Enum.map(kept, fn {stack, owner_login, name} ->
          stack_export(stack, owner_login, name, Map.get(by_stack, stack.id, []))
        end),
      "records_truncated" => length(rows) > @maximum_stacks,
      "entries_truncated" => length(entries) > @maximum_stack_entries
    }
  end

  defp stack_entries([]), do: []

  defp stack_entries(stack_ids) do
    Repo.all(
      from entry in StackEntry,
        join: pull_request in PullRequest,
        on: pull_request.id == entry.pull_request_id,
        join: issue in Issue,
        on: issue.id == pull_request.issue_id,
        where: entry.stack_id in ^stack_ids,
        order_by: [asc: entry.stack_id, asc: entry.position, asc: entry.id],
        limit: ^(@maximum_stack_entries + 1),
        select: {entry, entry.stack_id, issue.number, pull_request.head_ref}
    )
  end

  defp stack_export(stack, owner_login, name, entries) do
    %{
      "id" => stack.id,
      "repository" => repository_path(owner_login, name),
      "number" => stack.number,
      "trunk_ref" => stack.trunk_ref,
      "state" => stack.state,
      "health" => stack.health,
      "version" => stack.version,
      "created_at" => iso8601(stack.inserted_at),
      "entries" => Enum.map(entries, &stack_entry_export/1),
      "entries_exported" => length(entries)
    }
  end

  defp stack_entry_export({entry, _stack_id, number, head_ref}) do
    %{
      "position" => entry.position,
      "pull_request_number" => number,
      "head_ref" => head_ref,
      "boundary_oid" => entry.boundary_oid,
      "observed_head_oid" => entry.observed_head_oid,
      "removed_at" => iso8601(entry.removed_at)
    }
  end

  defp issue_dependencies_export(user, readable) do
    rows =
      Repo.all(
        from dependency in IssueDependency,
          join: repository in subquery(readable),
          on: repository.id == dependency.repository_id,
          join: issue in Issue,
          on: issue.id == dependency.issue_id,
          join: blocker in Issue,
          on: blocker.id == dependency.blocked_by_issue_id,
          where: dependency.created_by_user_id == ^user.id,
          order_by: [asc: dependency.inserted_at, asc: dependency.id],
          limit: ^(@maximum_issue_dependencies + 1),
          select:
            {dependency, repository.owner, repository.name, issue.number, issue.title,
             blocker.number, blocker.title, blocker.state}
      )

    %{
      "records" =>
        rows |> Enum.take(@maximum_issue_dependencies) |> Enum.map(&issue_dependency_export/1),
      "records_truncated" => length(rows) > @maximum_issue_dependencies
    }
  end

  defp issue_dependency_export(row) do
    {dependency, owner_login, name, number, title, blocker_number, blocker_title, blocker_state} =
      row

    %{
      "id" => dependency.id,
      "repository" => repository_path(owner_login, name),
      "issue_number" => number,
      "issue_title" => title,
      "blocked_by_issue_number" => blocker_number,
      "blocked_by_issue_title" => blocker_title,
      "blocked_by_issue_state" => blocker_state,
      "recorded_at" => iso8601(dependency.inserted_at)
    }
  end

  ## ── agents ─────────────────────────────────────────────────────────────

  defp agent_links_export(%User{id: user_id}) do
    Repo.all(
      from link in AgentUserLink,
        join: agent in Agent,
        on: agent.id == link.agent_id,
        where: link.user_id == ^user_id,
        order_by: [asc: link.inserted_at],
        select: {link, agent}
    )
    |> Enum.map(fn {link, agent} ->
      %{
        "id" => link.id,
        "status" => link.status,
        "proof_method" => link.proof_method,
        "requested_at" => iso8601(link.inserted_at),
        "linked_at" => iso8601(link.linked_at),
        "rejected_at" => iso8601(link.rejected_at),
        "agent" => %{
          "id" => agent.id,
          "handle" => agent.handle,
          "display_name" => agent.display_name,
          "status" => agent.status
        }
      }
    end)
  end

  ## ── bounds and gaps ────────────────────────────────────────────────────

  defp bounds do
    %{
      "forum_topics" => @maximum_forum_topics,
      "forum_posts" => @maximum_forum_posts,
      "forum_tips" => @maximum_tips,
      "threads" => @maximum_threads,
      "thread_events" => @maximum_thread_events,
      "push_receipts" => @maximum_push_receipts,
      "deployments" => @maximum_deployments,
      "box_runs" => @maximum_box_runs,
      "box_run_output_bytes" => @maximum_box_output_bytes,
      "pull_requests" => @maximum_pull_requests,
      "stacks" => @maximum_stacks,
      "stack_entries" => @maximum_stack_entries,
      "issue_dependencies" => @maximum_issue_dependencies,
      "attestations" => @maximum_attestations
    }
  end

  # Stated in the document, not only in the ledger: a recipient reading this
  # file offline should not have to infer what is missing.
  defp not_included do
    [
      %{
        "family" => "conversation",
        "reason" => "Sarah's conversation, memory, voice, and tool steps export separately.",
        "mechanism" => "GET /data/export and GET /data/export/atif",
        "issue" => nil
      },
      %{
        "family" => "repository_content",
        "reason" =>
          "Git history leaves through the authenticated Git transport, not this document.",
        "mechanism" => "git clone with an oa_pat_ token",
        "issue" => nil
      },
      %{
        "family" => "repository_identity",
        "reason" =>
          "A repository's current owner and name appear only where the account can still read " <>
            "the repository. A push receipt, deployment request, or approval for a repository " <>
            "the account was removed from arrives with a null repository and keeps every field " <>
            "that is the account's own; a record under repository_work is withheld instead, " <>
            "because it has no meaning apart from its repository.",
        "mechanism" => "OpenAgents.Repositories.readable_by/2",
        "issue" => nil
      },
      %{
        "family" => "forum",
        "reason" =>
          "Posts written under a legacy actor_ref this account has not claimed, or whose claim " <>
            "is still pending, are not exported. Only a linked claim resolves a legacy identity.",
        "mechanism" => "POST /api/v1/forum/claims",
        "issue" => nil
      }
    ]
  end

  defp iso8601(nil), do: nil
  defp iso8601(%DateTime{} = timestamp), do: DateTime.to_iso8601(timestamp)
  defp iso8601(%NaiveDateTime{} = timestamp), do: NaiveDateTime.to_iso8601(timestamp)
end