Skip to content

Managed subscriptions: subscription_coverage gaps[] restated in the new version's boundaries after a cutover, disagreeing with the seller's own obligations #1770

Closed

Description

@bokelley

Summary

On the managed SubscriptionOptions path (adcp 10.0.0b4), the subscription_coverage[].gaps[] entry for a revoke and re-grant gets rewritten after a later version cutover. It is restated in the new version's period boundaries, with no offering_id. A buyer that applies gaps[] to the earlier version's periods, as the spec says ("Exception from and until values are such E instants"; one entry per offering_id when covered offerings have different boundaries), then computes a different owed set from the seller's own engine and ledger.

The adcontextprotocol/adcp interop matrix (adcontextprotocol/adcp#8679, tests/interop/matrix) found this. It shows up in the Python-seller control pair and in the TS-buyer -> Python-seller pair. The TS buyer and the Python buyer compute the same, matching result; only the seller's wire gaps[] is off.

Timeline

Account acct_1 is in America/Santiago. There are two P1D offerings, off_daily_local (account_timezone) and off_daily_utc (utc), and one buy covering both.

  1. v1 (account_timezone) is accepted 09-01.
  2. v2 (utc) is accepted 09-04T12:00Z.
  3. The grant is revoked 09-08T06:00Z and re-granted 09-09T18:00Z, inside the 30-day retention.
  4. gaps while v2 is current: [{"from":"2026-09-08T00:00:00Z","until":"2026-09-10T00:00:00Z","reason":"unauthorized"}]. These are utc boundaries, which is correct.
  5. v3 (account_timezone) is accepted 09-12T12:00Z.
  6. gaps after that: [{"from":"2026-09-08T03:00:00Z","until":"2026-09-10T03:00:00Z","reason":"unauthorized"}]. These are Santiago boundaries, with no offering_id.

The seller engine (engine.owed_periods) and the get_reporting_status obligations for v2 / off_daily_utc still include 09-10 and exclude 09-08, which is correct.

A buyer applying the served gap gets the opposite:

  • 09-08T00Z starts before 09-08T03Z, so the buyer owes it.
  • 09-10T00Z starts inside [09-08T03Z, 09-10T03Z), so the buyer drops it.

Expected

Keep the gap in the boundaries of the schedules it removed periods from: one entry per offering_id (here off_daily_utc [09-08T00Z, 09-10T00Z), plus off_daily_local for its own periods), or leave the entry as it was before the cutover.

Repro

This uses the minimal managed seller from the interop PR (tests/interop/matrix/py/seller.py, public surface only):

# PYTHONTZPATH= python repro.py   (with tests/interop/matrix/py on sys.path)
import asyncio, json, sys
import seller as S
from adcp.server.base import AccountAwareToolContext

def sub(alignment, version):
    return {"subscription_id": "analytics-daily", "subscription_version": version, "active": True,
            "scope": {"all_authorized_accounts": True}, "reconciliation_mode": "delivery_only",
            "selector": {"feed_purpose": "analytics", "period_duration": "P1D", "required_finality": "official",
                         "alignment": alignment, "dimensions": ["package"]}}

async def main():
    world, service, seller, platform = S.build(S.at("2026-09-01T00:00:00Z"))
    P = "buyer-x"; ctx = AccountAwareToolContext(caller_identity=P, account_id="acct_1")
    c = lambda body: S.control(world, service, seller, body)
    await c({"op": "grant", "principal": P, "at": "2026-09-01T00:00:00Z"})
    await c({"op": "put_buy", "buy": {"media_buy_id": "mb_1", "status": "active", "currency": "USD", "total_budget": 1,
             "revision": 1, "confirmed_at": "2026-09-01T00:00:00Z", "start_time": "2026-09-02T00:00:00Z",
             "end_time": "2026-09-20T00:00:00Z",
             "packages": [{"package_id": "p1", "reporting_delivery_offering_ids": ["off_daily_local", "off_daily_utc"]}]}})
    keys = iter(range(1, 99))
    async def write(alignment, version):
        r = await platform.sync_principal({"idempotency_key": f"repro-key-{next(keys):08d}",
            "configuration": {"reporting_subscriptions": [sub(alignment, version)]}}, ctx)
        assert r.result.kind == "applied"; await service.drain_subscription_triggers()
    async def gaps():
        st = await platform.get_reporting_status({"view": "periods", "account": {"account_id": "acct_1"}}, ctx)
        return [e.get("gaps") for e in st["subscription_coverage"]]
    await write("account_timezone", 1)
    await c({"op": "set_time", "at": "2026-09-04T12:00:00Z"}); await write("utc", 2)
    await c({"op": "set_time", "at": "2026-09-08T06:00:00Z"}); await c({"op": "revoke", "principal": P, "at": "2026-09-08T06:00:00Z"})
    await c({"op": "set_time", "at": "2026-09-09T18:00:00Z"}); await c({"op": "grant", "principal": P, "at": "2026-09-09T18:00:00Z"})
    await c({"op": "set_time", "at": "2026-09-12T12:00:00Z"}); await c({"op": "run_cycle"})
    print("v2 current:", json.dumps(await gaps()))
    await write("account_timezone", 3)
    await c({"op": "set_time", "at": "2026-09-16T00:00:00Z"}); await c({"op": "run_cycle"})
    print("after v3:", json.dumps(await gaps()))
    owed = (await c({"op": "obligations", "principal": P, "subscription_id": "analytics-daily"}))["rows"]
    print("engine, v2 off_daily_utc:", [r["period_start"] for r in owed if r["subscription_version"] == 2 and r["offering_id"] == "off_daily_utc"])

asyncio.run(main())

Output:

v2 current: [[{"from": "2026-09-08T00:00:00Z", "until": "2026-09-10T00:00:00Z", "reason": "unauthorized"}]]
after v3: [[{"from": "2026-09-08T03:00:00Z", "until": "2026-09-10T03:00:00Z", "reason": "unauthorized"}]]
engine, v2 off_daily_utc: ['2026-09-06T00:00:00Z', '2026-09-07T00:00:00Z', '2026-09-10T00:00:00Z', '2026-09-11T00:00:00Z', '2026-09-12T00:00:00Z']

🤖 Generated with Claude Code

Activity

  1. bokelley commented on Nov 1, 2026

    @bokelley
    ContributorAuthor

    Fixed on next by 6a414582 ("serve gaps in the boundaries of the schedule they removed"), which landed with #1774. It merged into a non-default branch, so the issue didn't auto-close.

    subscription_coverage[].gaps[] for a revoke and re-grant is now computed from the schedules in force at the loss: the version then current, in the zone then in effect. Each schedule gets its own entry, per offering_id where boundaries differ. A later version's cutover no longer restates the gap.

    tests/test_reporting_test_kit.py::test_issue_1770_gaps_keep_the_boundaries_of_the_schedule_they_removed covers the issue's timeline and passes on next at 7edba16d.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions