Repository navigation
Description
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.
- v1 (account_timezone) is accepted 09-01.
- v2 (utc) is accepted 09-04T12:00Z.
- The grant is revoked 09-08T06:00Z and re-granted 09-09T18:00Z, inside the 30-day retention.
gapswhile v2 is current:[{"from":"2026-09-08T00:00:00Z","until":"2026-09-10T00:00:00Z","reason":"unauthorized"}]. These are utc boundaries, which is correct.- v3 (account_timezone) is accepted 09-12T12:00Z.
gapsafter that:[{"from":"2026-09-08T03:00:00Z","until":"2026-09-10T03:00:00Z","reason":"unauthorized"}]. These are Santiago boundaries, with nooffering_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
bokelley commentedon Nov 1, 2026
Fixed on
nextby6a414582("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, peroffering_idwhere 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_removedcovers the issue's timeline and passes onnextat7edba16d.