id: pagination_integrity
version: "1.0.0"
title: "Pagination cursor integrity"
category: schema_validation
summary: "Validates the cursor↔has_more invariant by walking a paginated list_creatives response from a continuation page to a terminal page."
track: core
required_tools:
  - list_creatives

requires:
  - controller

narrative: |
  Pagination is cursor-based across AdCP. Every response carrying a
  `pagination` block satisfies a single invariant: when `has_more` is true the
  `cursor` MUST be present (callers need it to fetch the next page); when
  `has_more` is false the `cursor` MUST be absent (a stale token on a terminal
  page invites callers to follow it into undefined behavior).

  The invariant is documented in prose on
  `static/schemas/source/core/pagination-response.json` but JSON Schema does
  not gate the two fields against each other. This storyboard exercises both
  sides of the invariant on the same agent in a single run.

  The runner seeds three creatives into the agent's library and lists them
  back with `max_results=2`. The first page MUST cap at two creatives and
  signal continuation with `has_more=true` plus a cursor. The runner then
  follows that cursor; the next page returns the remaining creative(s) and
  MUST signal `has_more=false` with no cursor. An agent that hides the third
  fixture by reporting `has_more=false` on the first page (the
  dishonest-pagination case) fails the first-page assertion. An agent that
  carries a stale cursor onto the terminal page fails the second-page
  assertion.

  The `total_count` field is allowed to be absent (some backends cannot
  compute it cheaply per the pagination-response schema) but when an agent
  volunteers it, the value MUST match the seeded fixture count of three.
  This is the count-honesty half of the invariant: an agent that returns
  `total_count: 2` while three creatives are seeded is hiding inventory
  the way `has_more: false` would, just on a different field.
  `query_summary.total_matching` is required by the response schema and
  asserted unconditionally; `query_summary.returned` MUST equal the size
  of each page's slice (2 on the continuation page, 1 on the terminal
  page) — drift here flags an agent whose summary numbers don't match
  what it actually emitted.

agent:
  interaction_model: stateful_preloaded
  capabilities:
    - has_creative_library
  examples:
    - "Any AdCP agent that exposes a paginated list_creatives"

caller:
  role: buyer_agent
  example: "Compliance test harness"

prerequisites:
  description: |
    The runner seeds three creatives ahead of time so a small page-size
    request reliably triggers a continuation page on the first call. No
    assets are required — the seeded creatives only need an id, status, and
    canonical format_kind for list_creatives to surface them.
  test_kit: "test-kits/acme-outdoor.yaml"
  controller_seeding: true

fixtures:
  creatives:
    - creative_id: "pagination_integrity_creative_1"
      status: "approved"
      format_kind: "image"
    - creative_id: "pagination_integrity_creative_2"
      status: "approved"
      format_kind: "image"
    - creative_id: "pagination_integrity_creative_3"
      status: "approved"
      format_kind: "image"

phases:
  - id: capability_discovery
    title: "Capability discovery"
    narrative: |
      Confirm the agent supports the creative protocol before exercising
      list_creatives.
    steps:
      - id: get_capabilities
        title: "Check agent capabilities"
        narrative: |
          Verify that the agent declares creative in supported_protocols
          before driving paginated list_creatives.
        task: get_adcp_capabilities
        schema_ref: "protocol/get-adcp-capabilities-request.json"
        response_schema_ref: "protocol/get-adcp-capabilities-response.json"
        doc_ref: "/protocol/get_adcp_capabilities"
        comply_scenario: capability_discovery
        stateful: false
        expected: |
          Return capabilities declaring creative in supported_protocols.

        sample_request:
          context:
            correlation_id: "pagination_integrity--get_capabilities"
        validations:
          - check: response_schema
            description: "Response matches get-adcp-capabilities-response.json schema"
          - check: field_present
            path: "supported_protocols"
            description: "Agent declares supported protocols"

          - check: field_present
            path: "context"
            description: "Response echoes back the context object"
          - check: field_value
            path: "context.correlation_id"
            value: "pagination_integrity--get_capabilities"
            description: "Context correlation_id returned unchanged"

  - id: pagination_walk
    title: "Walk pages with a small max_results"
    narrative: |
      With three creatives seeded and `max_results: 2`, the first call MUST
      return a continuation page (has_more=true with a cursor). The runner
      then follows the cursor; the second call MUST return the terminal
      page (has_more=false with no cursor).

    steps:
      - id: first_page
        title: "Request the first page with max_results=2"
        narrative: |
          The buyer asks for up to two creatives. The agent has three matching
          fixtures, so the request is non-terminal: callers expect
          `has_more=true` and a `cursor` they can follow to retrieve the
          remaining creative.

          The runner captures `pagination.cursor` into `$context.next_cursor`
          for the follow-up step. If the agent reports `has_more=false` on
          this page (hiding the seeded third creative behind a dishonest
          terminal signal), the field_value check on `pagination.has_more`
          fails — surfacing exactly the bug class this storyboard exists to
          catch.
        task: list_creatives
        schema_ref: "creative/list-creatives-request.json"
        response_schema_ref: "creative/list-creatives-response.json"
        doc_ref: "/creative/task-reference/list_creatives"
        comply_scenario: pagination_integrity
        stateful: true
        context_outputs:
          - name: next_cursor
            path: "pagination.cursor"
        expected: |
          Return up to two creatives with:
          - pagination.has_more = true
          - pagination.cursor present (an opaque continuation token)

        sample_request:
          account:
            account_id: "acct_pagination_integrity"
          filters:
            creative_ids:
              - "pagination_integrity_creative_1"
              - "pagination_integrity_creative_2"
              - "pagination_integrity_creative_3"
          pagination:
            max_results: 2

          context:
            correlation_id: "pagination_integrity--first_page"
        validations:
          - check: response_schema
            description: "Response matches list-creatives-response.json schema"
          - check: field_value
            path: "pagination.has_more"
            value: true
            description: "Three fixtures seeded with max_results=2 → first page is non-terminal"
          - check: field_present
            path: "pagination.cursor"
            description: "has_more=true requires a cursor — without one the caller cannot continue"
          - check: field_value
            path: "query_summary.total_matching"
            value: 3
            description: "Three fixtures seeded — total_matching MUST reflect the full result set, not just this page"
          - check: field_value
            path: "query_summary.returned"
            value: 2
            description: "max_results=2 capped this page at 2 items — query_summary.returned MUST match the slice"
          - check: field_value_or_absent
            path: "pagination.total_count"
            allowed_values: [3]
            description: "If volunteered, total_count MUST equal the seeded set size — under-reporting hides inventory the same way a dishonest has_more does"

          - check: field_present
            path: "context"
            description: "Response echoes back the context object"
          - check: field_value
            path: "context.correlation_id"
            value: "pagination_integrity--first_page"
            description: "Context correlation_id returned unchanged"

      - id: terminal_page
        title: "Follow the cursor to the terminal page"
        narrative: |
          The buyer follows the captured cursor. With at most one remaining
          creative the agent MUST return a terminal page: `has_more=false`
          with no `cursor` field. A cursor on `has_more=false` is a stale
          token that invites callers to follow it past the end of results.

          The agent is free to sub-paginate — i.e. return a non-terminal
          second page if it uses page sizes smaller than the runner's
          `max_results=2`. This storyboard does not enforce exhaustion in
          two calls; it enforces the cursor↔has_more invariant on every
          page it observes. Sub-paginators land on a non-terminal second
          page and fail the `has_more=false` assertion below — which is the
          intended outcome for this storyboard's scope. Agents that
          legitimately stream many small pages should expose larger
          `max_results` ceilings or run a more elaborate pagination
          storyboard.
        task: list_creatives
        schema_ref: "creative/list-creatives-request.json"
        response_schema_ref: "creative/list-creatives-response.json"
        doc_ref: "/creative/task-reference/list_creatives"
        comply_scenario: pagination_integrity
        stateful: true
        expected: |
          Return the remaining creative(s) with:
          - pagination.has_more = false
          - pagination.cursor absent (a stale cursor on a terminal page is a
            dishonest pagination signal)

        sample_request:
          account:
            account_id: "acct_pagination_integrity"
          filters:
            creative_ids:
              - "pagination_integrity_creative_1"
              - "pagination_integrity_creative_2"
              - "pagination_integrity_creative_3"
          pagination:
            cursor: "$context.next_cursor"
            max_results: 2

          context:
            correlation_id: "pagination_integrity--terminal_page"
        validations:
          - check: response_schema
            description: "Response matches list-creatives-response.json schema"
          - check: field_value
            path: "pagination.has_more"
            value: false
            description: "After two pages of two items the seeded set is exhausted"
          - check: field_value_or_absent
            path: "pagination.cursor"
            allowed_values: [null]
            description: "Terminal page MUST omit cursor (null also accepted for clients that explicitly clear the field)"
          - check: field_value
            path: "query_summary.total_matching"
            value: 3
            description: "total_matching MUST stay stable across pages — drift between pages is a query-summary bug"
          - check: field_value
            path: "query_summary.returned"
            value: 1
            description: "One creative left after the first page returned two — query_summary.returned MUST match the slice"
          - check: field_value_or_absent
            path: "pagination.total_count"
            allowed_values: [3]
            description: "total_count (when volunteered) MUST stay stable across pages and equal the seeded set size"

          - check: field_present
            path: "context"
            description: "Response echoes back the context object"
          - check: field_value
            path: "context.correlation_id"
            value: "pagination_integrity--terminal_page"
            description: "Context correlation_id returned unchanged"
