Skip to content

feat(reporting): buyer runtime and durable storage for reporting subscriptions (#3504, 3/3) - #3509

Merged
bokelley merged 55 commits into
mainfrom
bokelley/reporting-subscription-consumer
Oct 31, 2026
Merged

bokelley merged 55 commits into
mainfrom
bokelley/reporting-subscription-consumer

Conversation

@bokelley

@bokelley bokelley commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

Third of the series for #3504. It contains #3505's and #3508's commits, so merge those first and review only the commits from feat(reporting): buyer runtime and durable storage for reporting subscriptions on. It was rebased onto #3508's review fixes. It targets main because the CI workflow runs only for PRs into main. This slice adds the buyer runtime (issue item 8) and durable engine storage (item 6, partly).

What ships

  • syncReportingSubscriptionsV1(client, subscriptions, options) (client helper). It writes the complete reporting_subscriptions set through the existing guarded sync_principal lifecycle (expected_configuration_version, a fresh idempotency key on conflict). Before writing, it refuses to deactivate an active subscription by omission unless allowDeactivation: true. The check runs against every configuration read the write replaces, including the fresh read after a conflict. After the write it fails closed unless every submitted subscription reads back at its version and activation state, with every submitted configuration field stored unchanged.
  • reportingSubscriptionVersionRecordsV1({ state, configurations }) rebuilds the coverage function's version records from the readback (accepted_at, prior_versions[].superseded_at, deactivated_at) Each version's configuration comes from the buyer's records, or else from the readback: the current configuration, and prior_versions[].configuration from beta.2 sellers (adcp#8588). If neither has a version's configuration, the helper refuses rather than guessing.
  • subscriptionExpectedReportingPeriodsV1(obligations, template) maps owed obligations (from groupExpectedSubscriptionPeriodsV1) onto the consumer runtime's ExpectedReportingPeriod:
    • deliveryConfigId is the subscription_id, and deliveryConfigVersion is the subscription_version
    • the resolved report_definition_id and the frozen media_buy_ids
    • the coverage the obligation must report, which the template must supply (there is no default)
    • per-offering pins from the template
  • Consumer runtime:
    • Subscription expectations. A per-account subscriptionExpectations hook reads the summary view's subscription_coverage[] before each reconciliation and reconciles the returned periods alongside configuration-derived ones. The hook's now is the summary's ledger_as_of minus subscriptionExpectationGraceMs. A subscription period with no obligation becomes a counted missingExpectedPeriods entry, and the result is not definitive. Use it only against a seller that materializes subscription obligations in its ledger. This SDK's seller doesn't yet (see Remaining).
    • principal.changed refresh. handleAuthenticatedPrincipalChanged(payload, { consumerScope }) calls onPrincipalChanged (re-read get_principal, record accounts_unauthorized, replace the roster), then reconciles every account of that scope. A redelivery with an already seen idempotency_key is dropped; a failed delivery stays retryable. Notifications arriving during a run share one trailing run.
    • Run reason. ReliableReportingConsumerRunReasonV1 gains 'principal.changed'.
  • createReportingSubscriptionEngineStateStoreV1({ store }) persists the engine's sharded state on any AdcpStateStore: one CAS document per principal (max_accounts slots) and one per (principal, account) (grants, exceptions, gaps, cutovers, frozen owed periods). listKeys is per principal and listPrincipals(tenantId) is per tenant; a list walk that does not finish fails closed. On PostgresStateStore it is durable and needs nothing beyond ADCP_STATE_MIGRATION, the same table the principal store already uses.

Subscription version history stays in the principal row and is never pruned, so prior_versions stay answerable for as long as any obligation is retained (the maintenance requirement in item 6). Closed exceptions, gaps and timezone cutovers are pruned status_retention_days after until, as the spec allows.

Tests

  • reporting-subscription-consumer.test.js:
    • store CAS per key, key listing per principal, tenant-scoped principal listing, and the durability guard
    • a fresh engine reading the same state after a restart
    • the client helper writing v1 and v2
    • version records rebuilt from readback, producing exactly the seller's owed set
    • a lying readback that is refused
    • the expected-period mapping, and the refusal of a template without coverage
    • version records from the readback's prior_versions[].configuration
    • a refused deactivation by omission (nothing written), an explicit active: false, and a readback that rewrites a stored field
    • the consumer runtime reading subscription_coverage before reconciling and reporting three missing owed periods
    • expectations computed at ledger_as_of minus the grace
    • the principal.changed refresh, including redelivery dedupe, a coalesced burst, and ignored and malformed payloads
  • reporting-subscription-postgres.test.js: the principal row and engine state survive a restart on PostgreSQL (versions, accounts_unauthorized, owed periods, and the sharded key and tenant listings). It is added to the CI "PostgreSQL decisioning backend contracts" step and skipped without DATABASE_URL. It can't run in my sandbox (no database); CI ran it and it passed (see the review round).

Conformance to adcontextprotocol/adcp#8585 (commit bc780de0)

reportingSubscriptionVersionRecordsV1 no longer takes buyer-recorded inactive intervals: pass the readback's inactive_intervals to expectedSubscriptionPeriods unchanged. Test offerings publish standard_dimensions.

Expert review round (2026-10-29), commit 800adfc5

  • 1. Default expected coverage: fixed by making coverage required in the template, the reviewer's second option. Deriving package sets would mean guessing how a given seller reports package coverage.
  • 2. Wall-clock now: fixed. The hook gets ledger_as_of minus the optional subscriptionExpectationGraceMs.
  • 3. Unbounded CAS document: fixed. The store is sharded per principal and per (principal, account), matching feat(reporting): serve reporting subscriptions from the principal runtime (#3504, 2/3) #3508's engine.
  • 4. listPrincipals spanning tenants: fixed. listPrincipals(tenantId) filters on the document's tenantId field, and rows are re-checked after the backend filter.
  • Low: hook needs a materializing seller. Documented on the hook, and in this body.
  • Low: principal.changed dedupe: fixed. Dedupe by idempotency_key over a bounded window per scope, and coalescing into one trailing run.
  • Low: complete-set writes: fixed. allowDeactivation defaults to false, and the readback check compares configuration field by field.
  • Low: version records depend on retention: fixed. The helper falls back to the readback's configurations; adcp#8588 added prior_versions[].configuration.
  • Low: PostgreSQL test unconfirmed: confirmed. In CI run 40951039777, job "Webhook Delivery Backends", step "Run PostgreSQL decisioning backend contracts", reporting subscriptions on PostgreSQL ran without a skip directive and passed (ok 1 - versions, lost authority and owed periods survive a restart).

Deviations from the issue

  • No new tables in @adcp/reporting-storage. Engine state uses the SDK's existing PostgresStateStore document table, which the principal row already lives in, instead of new tables. It is one CAS document per principal plus one per (principal, account). Separate relational tables for versions, resolutions, exceptions and authority intervals become worthwhile with ledger materialization (below), when obligations must reference them.
  • No reference-seller storyboard yet. Conformance runs as the buyer-vs-seller period-by-period suite in feat(reporting): serve reporting subscriptions from the principal runtime (#3504, 2/3) #3508 (reporting-subscription-engine.test.js), not a compliance storyboard against the reference seller. A storyboard needs the seller to emit subscription obligations through the ledger, which hasn't landed (below).

Remaining after this series

  1. Ledger materialization of owedObligations. The producer must plan and execute subscription obligations. This conflicts with three ledger invariants:

    • one configuration per (account, delivery_config_id, delivery_config_version) (the configurationId digest), while one subscription version can resolve several offerings (match: all) and change resolution mid-version
    • contiguous ownership in evaluateReportingLedgerCoverageV1, while subscription periods are sparse (flights, exceptions)
    • constituents frozen per generation, while media_buy_ids must freeze per period

    A design that fits is to key subscription generations by (account, subscription_id, version, offering_id) and plan from owedObligations instead of contiguous ordinals. That changes the PostgreSQL ledger schema, so it should be its own PR.

  2. Managed delivery wiring (item 4): resolve the subscription's destination generation, expand path_template with expandReportingSubscriptionPathTemplate (ships in feat(reporting): serve reporting subscriptions from the principal runtime (#3504, 2/3) #3508), put account_id on every delivered row, and re-check authority per delivery and retry. This depends on 1.

  3. Receipts after loss: superseded. feat(reporting): revocation means no access; wind down with a reduced grant adcp#8607 removed the post-loss window, and engine.acceptsReceipt is gone. feat(reporting): owner-scoped subscription receipts and statuses; coverage follows the grant #3528 scopes receipts and statuses to the owner, and a reduced grant keeps reconciliation working.

  4. A reference-seller conformance storyboard.

Local checks

  • npm run typecheck, build:lib, lint:boundaries and check:package-size all pass. The file budget is +12, for three dual-format module sets.
  • Prettier passes on the changed files.
  • 311 tests pass across these suites; the PostgreSQL suite is skipped locally:
    • reporting-subscription-consumer.test.js
    • reporting-consumer-runtime.test.js
    • principal-lifecycle.test.js
    • reporting-subscription-engine.test.js
    • principal-reporting-subscriptions.test.js
    • reliable-reporting-service.test.js
    • reporting-subscription-coverage.test.js

Refs #3504

🤖 Generated with Claude Code

bokelley and others added 2 commits October 29, 2026 09:30
Add the pure expectedSubscriptionPeriods rule for AdCP 3.3 principal-level
reporting subscriptions (experimental media_buy.reporting_subscriptions),
the normative period_generation calendar it uses, selector resolution
(exact, finest, all with byte-order ties), scope membership, and a builder
for the 3.3 registration capabilities (configuration_tasks, the deprecated
configuration_task, and the subscriptions block).

createAdcpServerFromPlatform adds media_buy.reporting_subscriptions to
experimental_features only beside a subscriptions block.

installConfiguration now throws a typed ReportingConfigurationImmutableError
with the unchanged message.

The cross-SDK parity table is
test/fixtures/reporting-subscriptions/coverage-cases.json.

Refs #3504

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…al ships

Advertising media_buy.reporting_subscriptions also requires
adcp.principal.supported_sections to list reporting_subscriptions, which the
SDK does not emit yet. Keep the capability builder pure in this slice and add
the feature id together with the principal block in the runtime slice, so an
adopter can never produce a 3.3-invalid capabilities response.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Comment thread src/lib/server/create-adcp-server.ts Resolved
aao-secretariat[bot]
aao-secretariat Bot previously approved these changes Oct 29, 2026

@aao-secretariat aao-secretariat Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ladon verdict: Approve

Approve — additive AdCP 3.3 reporting-subscriptions feature (3/3), +11942/-24 across 40 files.

What I checked:

  • Changesets: three minor changesets present, correct for an additive experimental surface (DR-0033 non-breaking minor). Changeset-vs-wire-impact satisfied.
  • Load-bearing logic: coverage.ts expectedSubscriptionPeriods cutover/flight/authority math and the L1267-1298 ordinal-skip optimization are sound; engine.ts reconcile/CAS mutate (L2212-2224) is idempotent across retries with per-account error isolation in consumer-runtime.
  • Pathway-back: version pathway handled via droppable capability projection in create-adcp-server; I/O paths carry AbortSignals.
  • Witness-not-translator: server principal validation percent-encodes / and ./.. (SSRF-safe encodePathSegment); no fabrication/re-shaping at seams.

Medium findings (1):

  • src/lib/server/create-adcp-server.ts:9844 — protocol.principal left in experimental_features for sub-3.2 callers after its capability block is stripped.

One medium finding, no critical/high, no escalation trigger (high_risk false, gated_paths false, no team gate, no prior decision). review_decision is REVIEW_REQUIRED but gated_paths is false, so row 2 does not fire. Rows 1-8 do not match; falls through to row 9 → approve. The single medium is a residual-field cleanup worth a follow-up but does not block.

Medium findings

  • src/lib/server/create-adcp-server.ts:9844 — protocol.principal left in experimental_features for sub-3.2 callers after block is stripped

aao-secretariat[bot]
aao-secretariat Bot previously approved these changes Oct 29, 2026

@aao-secretariat aao-secretariat Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ladon verdict: Approve

Approve — subsequent pass is clean, prior Medium resolved.

This is a subsequent review following a prior approve. The single prior Medium finding (src/lib/server/create-adcp-server.ts:9844 — protocol.principal left in experimental_features for sub-3.2 callers after the block is stripped) is now resolved: create-adcp-server.ts filters the feature id when stripping the adcp.principal block, with a pinned-3.1 assertion in reliable-reporting-service.test.js.

New delta verified clean:

  • engine.ts readFacts fails closed on an unfinished roster (throws 'refusing a partial roster') with test coverage.
  • templatePlaceholders single-pass brace validator in server/principal/reporting-subscriptions.ts is correct across the three new unbalanced cases.
  • engine-state-store.ts guards cross-principal document reads and CAS-roundtrips revisions.
  • Consumer runtime subscription summary pre-read and principal.changed handler are abort-signal wrapped with per-account error isolation.
  • Changeset is minor, matching the additive new exports.

No Critical/High/Medium findings. high_risk=false, gated_paths=false, no author-team gate. No escalation triggers. Falls through to row 9 → approve.

aao-secretariat[bot]
aao-secretariat Bot previously approved these changes Oct 29, 2026

@aao-secretariat aao-secretariat Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ladon verdict: Approve

Approve — subsequent pass on PR #3509, net clean.

This pass covers the 13-file delta adding the buyer-side runtime for experimental 3.3 reporting subscriptions (syncReportingSubscriptionsV1 readback witness check, engine-state-store CAS document, expectations bridge, principal.changed consumer handler). Changeset is present and correctly typed minor (additive, experimental) — satisfies changeset-vs-wire-impact. Witness-not-translator checks pass: the sync refuses a seller that drops/misreports rather than fabricating. Lease-window cancellation, per-account degraded-completion, and onError reporting are all handled; InMemoryStateStore refuses a durable declaration.

Medium findings (1)

  • src/lib/reporting/subscriptions/expectations.ts:25 — Multi-version ordering in reportingSubscriptionVersionRecordsV1 is untested (the .reverse() branch is only exercised with a single prior version, so a coverage-recompute regression could pass silently)

Decision trace: no critical/high (row 1 n/a); gated_paths false (row 2 n/a); high_risk false (rows 3/5 n/a); the single medium is not data-loss/schema/infra category (row 4 n/a); prior decision was approve, not escalate (row 6 n/a); no no-auto-approve team match (row 7 n/a); only 1 medium finding, well under 3 (row 8 n/a). Falls through to row 9 → approve. The noted dedupe gap is Low (idempotent, lease-guarded, coalesced) and non-blocking.

Note: review_decision is REVIEW_REQUIRED, but gated_paths is false, so row 2 does not fire and this is a normal clean-diff approve.

Medium findings

  • src/lib/reporting/subscriptions/expectations.ts:25 — Multi-version ordering in reportingSubscriptionVersionRecordsV1 is untested

aao-secretariat[bot]
aao-secretariat Bot previously approved these changes Oct 29, 2026

@aao-secretariat aao-secretariat Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ladon verdict: Approve

Approve — test-only delta closes the prior Medium finding.

What I checked:

  • The delta since the prior approve is test-only (59 lines, one file: reporting-subscription-consumer.test.js). No new production code.
  • The new test exercises reportingSubscriptionVersionRecordsV1's reverse-then-append ordering (expectations.ts:2894-2906): newest-first prior_versions [v2,v1] produce oldest-first output [1,2,3], versions[1] carries version 2's exclude_account_ids ['x'], and records drive correct per-period cutovers via expectedSubscriptionPeriods. This directly closes the prior Medium finding (untested multi-version ordering).
  • No new findings this pass.

Gate checks:

  • No no-auto-approve team match.
  • gated_paths: false — row 2 does not apply (the REVIEW_REQUIRED status only gates when gated_paths is true).
  • high_risk: false — no deletion/modification escalation triggers.
  • Prior decision was approve (not escalate), so sticky-escalation row 6 does not apply.

No critical/high/medium findings remain. Rows 1–8 do not fire; outcome falls through to row 9 (approve). This is a net improvement — the prior Medium is resolved with added coverage.

Adopt the rules adcontextprotocol/adcp#8585 settled:

- Selection reads each offering's published standard_dimensions and
  unmapped_dimensions, and fails closed on an offering without them.
- period_timezone: a utc offering matches only UTC or Etc/UTC; a
  source_timezone offering only its own published zone (never an
  account-resolved one).
- The coverage function takes the readback's inactive_intervals in wire
  shape, per-offering timezone_cutovers with previous_timezone
  (account.timezone is now the current zone), and per-offering gaps.
- The parity table fixes the billing-cycle case's scope (both keys, which
  maxProperties 1 forbids), adds cases for the settled rules, and its
  subscriptions are now schema-validated.

The new wire fields are hand-modeled in subscriptions/wire.ts until
3.3.0-beta.2 ships.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
aao-secretariat[bot]
aao-secretariat Bot previously approved these changes Oct 29, 2026

@aao-secretariat aao-secretariat Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ladon verdict: Approve

Approve — AdCP 3.3 reporting-subscriptions delta, subsequent pass (prior decision: approve, clean).

Decision path: No critical/high findings (row 1 n/a). gated_paths: false so row 2 n/a despite review_decision: REVIEW_REQUIRED. high_risk: false (rows 3/5 n/a). The single medium finding is category billing-path (not data-loss/schema/infra), so row 4 n/a. Prior decision was approve, not escalate (row 6 n/a). No no-auto-approve team match (row 7 n/a). Only 1 medium finding, so row 8 (≥3 medium) does not fire. Falls through to row 9 → approve.

What was checked: engine.ts fail-closed readFacts pagination (L492-510) correct; CAS mutate/notify sound; server/principal path-template placeholder/traversal validation and encodePathSegment dot-encoding right-shaped; version projection strips 3.3 fields for pre-3.3.0-beta.2 callers (droppable backward-compat pathway per DR-0033); changesets present and correctly minor/experimental; witness-not-translator and official-client rules hold.

Medium findings (non-blocking):

  • src/lib/reporting/subscriptions/engine-state-store.ts:96 — listPrincipals returns a partial roster at the page cap and lacks a repeating-cursor guard (unlike fail-closed sibling readFacts), affecting receiptRoster on the billing path. Worth a follow-up to make it fail-closed consistent with readFacts, but a single medium is below the comment threshold.

Medium findings

  • src/lib/reporting/subscriptions/engine-state-store.ts:96 — engine-state-store listPrincipals returns partial roster instead of failing closed

aao-secretariat[bot]
aao-secretariat Bot previously approved these changes Oct 29, 2026

@aao-secretariat aao-secretariat Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ladon verdict: Approve

Approve — subsequent review, prior blocking concern resolved, no current findings.

This push resolves the sole prior finding (engine-state-store listPrincipals returning a partial roster at src/lib/reporting/subscriptions/engine-state-store.ts:96). listPrincipals now fails closed: returns only when nextCursor === undefined, throws on a repeating cursor (nextCursor === cursor), and throws after MAX_PRINCIPAL_PAGES (10,000) rather than returning a truncated list. New tests cover both the endless-cursor and repeating-cursor paths. Fail-closed is the correct fix.

Decision-table walk:

  • Row 1 (critical/high): no findings.
  • Row 2 (gated_paths + not APPROVED): gated_paths is false — does not fire.
  • Row 3 (deleted high-risk): high_risk is false.
  • Rows 4–5 (medium + sensitive/modified high-risk): no medium findings.
  • Row 6 (sticky escalate): prior decision was approve, not escalate.
  • Row 7 (no-auto-approve team): no team match.
  • Row 8 (≥3 medium): zero medium findings.
  • Row 9: approve.

Note: review_decision is REVIEW_REQUIRED, but gated_paths is false, so the hard approval gate does not apply. Clean delta, no new findings, prior issue addressed.

@bokelley bokelley left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: buyer runtime and durable engine storage (#3509, commits 3c287d3..ecb7b31)

This review covers only this PR's own commits. Engine and coverage findings are on #3505 and #3508. Several of them affect what this PR builds on: the retroactive offering_unsupported exception, pruning that changes history, and cutover instants stamped at reconcile time. The buyer helpers here faithfully reproduce whatever the seller publishes, so the buyer and seller will agree on those wrong answers.

Confirmed issues

1. Medium: the default expected coverage never matches a real obligation. At expectations.ts:150-163, when the template omits coverage, the expected period gets status: 'full' with package_ids: [] and covered_package_ids: []. coverageMatchesExpected (reconciliation.ts:912-929) compares every field exactly. Failure scenarios:

  • Any obligation that reports its packages (package_ids: ['pkg_1']) yields EXPECTED_COVERAGE_MISMATCH.
  • A buy with a package-level offering_unsupported exception, where the seller's obligation is partial with unsupported_package_ids, is always a mismatch, and with coverageRequirement: 'full' also COVERAGE_REQUIREMENT_NOT_MET.

Fix: derive the expected package sets from the buy's packages minus the package_ids named in offering_unsupported exceptions, and mark that buy partial. Alternatively, make coverage required in the template so the default can't silently mismatch.

2. Medium: expectations use wall-clock now, not the snapshot's ledger_as_of. At consumer-runtime.ts:686, subscriptionExpectations gets now: new Date(), while the reconciliation runs against the ledger_as_of snapshot. A period that ends between ledger_as_of and the hook call, or one whose obligation the seller creates at period end with any lag, is reported as a counted missingExpectedPeriods entry. That makes routine runs non-definitive. Fix: pass the summary response's ledger_as_of, optionally minus a configurable grace, as now.

3. Medium: the engine store is one unbounded CAS document per principal. At engine-state-store.ts:25-30,67-80:

  • Every account's grants (never pruned), exceptions, gaps and cutovers live in one JSON document per principal, rewritten on every reconcile and every authorizationChanged.
  • An agency principal with thousands of accounts gets a multi-megabyte row.
  • Concurrent authorizationChanged calls and reconciles contend on one CAS. With maxCasAttempts: 8, that surfaces as Reporting subscription engine state conflicted repeatedly.

Fix: shard per (principal, account), or at least per account inside the principal, before the format is persisted in production. Changing a durable format later needs a migration.

4. Medium: listPrincipals spans tenants, which feeds the cross-tenant receiptRoster issue noted on #3508. At engine-state-store.ts:81-104, every engine document in the collection is returned regardless of tenant. Combined with receiptRoster(accountId), which takes no tenant, receipt rosters mix tenants whenever account IDs are not globally unique. Fix: add a tenant filter, for example listPrincipals(tenantId) with a tenant-prefixed document ID or an indexed field.

Low

  • The consumer reconciles against a seller that cannot produce subscription obligations yet. The #3508 body says obligations aren't materialized into the ledger yet. Against this SDK's own seller, subscriptionExpectations therefore reports every owed subscription period as missing. Consider marking the hook as requiring a seller that materializes subscription obligations, or holding it until ledger materialization lands.
  • No dedupe on principal.changed. handleAuthenticatedPrincipalChanged (consumer-runtime.ts:744-764) reconciles every account in the scope serially on each notification and never checks idempotency_key. The seller fires principal.changed on every exception change, so a burst of changes produces N full reconciliations per notification. Dedupe by idempotency_key and coalesce runs that are already in flight.
  • syncReportingSubscriptionsV1 writes a complete set. Passing a filtered list deactivates every omitted subscription; deactivation is a cutover and cannot be undone retroactively. A guard such as allowDeactivation: false by default, which rejects any omission of a currently active subscription, would prevent an expensive mistake. The readback check also verifies only the version and active flag, not that the stored configuration equals what was sent.
  • The version records helper depends on retention. reportingSubscriptionVersionRecordsV1 throws when the buyer lacks a version's configuration, which is correct. A buyer that lost local state still cannot recover a prior version's selector from the wire, because the readback carries only the current configuration. That is a spec gap worth raising: prior_versions could carry the configuration fingerprint or the configuration itself.
  • The PostgreSQL test has not run anywhere yet. reporting-subscription-postgres.test.js runs for the first time in CI, as the body notes. Please confirm it actually executed rather than skipped, since it is skipped when DATABASE_URL is unset.

bokelley and others added 7 commits October 29, 2026 18:48
- An empty flight (stop at or before start) overlaps nothing.
- A cutover carries the outgoing stretch's own owing start into E, so two
  cutovers inside one period never owe a period twice.
- Sub-daily periods under a local alignment restart at each local midnight
  on wall-clock multiples (adcp#8588); calendars gain next() and the walk
  iterates by instant with an iteration budget and skip-ahead.
- A billing anchor in a DST fold is interval zero itself.
- A wire authorized_at equal to the chain start continues the chain.
- The start rule lives in one function for the pending owner decision.
- Schema validation accepts unknown reporting-dimension values.
- The parity table grows to 79 cases with obligations on every case and
  non-wire inputs labelled.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The start bound is the first boundary at or after the later of the
version's accepted_at and the account's authorized_at; a buy is owed every
period that ends after its confirmed_at and overlaps its flight, so the
period in which the buy is confirmed is owed in full. The rule stays in
firstOwedBoundary. Parity table: the mid-period confirmed_at and hourly
cases change, plus every case whose first period contains confirmed_at;
new cases for an ASAP buy confirmed mid-day and a monthly billing buy
confirmed on the 12th.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…time

Opt-in support for the AdCP 3.3 sync_principal reporting_subscriptions
section (experimental media_buy.reporting_subscriptions):

- createPrincipalLifecycle({ reportingSubscriptions }) validates, versions
  and reads back subscriptions (core/reporting-subscription-state.json),
  honours dry_run and expected_configuration_version, applies
  replace_account_configs once per version through a host hook, derives
  action_required from the destination, and emits principal.changed with
  reason reporting_subscription_changed. It also exposes the adcp.principal
  capability block.
- createReportingSubscriptionEngineV1 turns three host hooks into grant
  history, max_accounts slots, precedence, exceptions at cutover instants,
  timezone cutovers, re-grant gaps, accounts_unauthorized, the receipt
  roster and the post-loss receipt window, and computes owed periods with
  the same expectedSubscriptionPeriods rule buyers run.
- createReliableReportingService({ reportingSubscriptions }) advertises
  configuration_tasks and the subscriptions block and adds
  subscription_coverage to summary and periods views.
- createAdcpServerFromPlatform passes capabilities.principal and
  capability_changes through, adds protocol.principal and
  media_buy.reporting_subscriptions to experimental_features, refuses
  subscriptions without the reporting_subscriptions section, and strips
  3.3-only fields for callers pinned below 3.3.

Without the options every response is byte-for-byte unchanged; a golden
transcript captured from the pre-change runtime proves it.

Refs #3504

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… by release

- readFacts refuses a roster that does not finish within the page cap, as it
  already did for a repeating cursor: a partial roster would read as
  revocations and fabricate accounts_unauthorized entries.
- Callers pinned below 3.2 no longer see protocol.principal once the
  adcp.principal block is removed for them.
- subscription_coverage is added only for callers served 3.3 or later;
  earlier get_reporting_status response shapes are closed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The placeholder regex backtracked polynomially on runs of '{' (CodeQL).
Tokenize braces in a single pass instead, which also rejects nested and
unbalanced braces explicitly.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… test

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- The principal readback lists inactive_intervals; the engine feeds them
  to the coverage function in wire shape.
- Timezone cutovers carry previous_timezone and gaps are recorded per
  offering_id when covered offerings' boundaries differ.
- authorized_at is the start of the authorization chain: a re-grant inside
  retention keeps it, so the engine stamps the re-grant when authority is
  observed again.
- The service requires standard_dimensions on every offering when
  subscriptions are advertised.
- Responses served at 3.3.0-beta.1 omit the fields its closed schemas do
  not define; they reach callers served 3.3.0-beta.2 or later.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

@aao-secretariat aao-secretariat Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ladon verdict: Approve

Approve — subsequent pass remains clean, no blocking or medium findings.

What I checked:

  • Core change: shared/factored MCP output-schema validators (mcp-schema-validator.ts content-keyed LRU, 256 entries/64 MiB, per-entry Ajv to avoid cross-$id reuse) and compact-schema.ts's compactMcpSchemaForValidation (~180 added lines). Factoring is conservative — falls back to the original document on $async, dynamic/recursive scopes, relative/noncanonical $id, duplicate resource bases, and unresolved rebased refs (compact-schema.ts:2004-2028), so validation semantics are preserved or deferred to the official engine.
  • Removal of experimental/unreleased ReportingSubscriptionEngineV1.acceptsReceipt has no stray callers.
  • Changesets present for every src/lib and bin change; COMPATIBLE_ADCP_VERSIONS 3.1.30/3.1.31 additions and compliance refresh 3.1.29→3.1.31 are changeset-managed and doc/pin-consistent — satisfies changeset-vs-wire-impact.
  • No witness-not-translator violations; no reimplemented transport.

Decision trace: No critical/high findings (row 1 n/a). gated_paths false (row 2 n/a). high_risk false (rows 3,5 n/a). No medium findings in data-loss/schema/infra (row 4 n/a). Prior decision was approve, not escalate (row 6 n/a). No no-auto-approve team match (row 7 n/a). Zero medium findings, fewer than three (row 8 n/a). Falls through to row 9 → approve. Note: review_decision is REVIEW_REQUIRED, but with gated_paths: false row 2 does not apply.

aao-secretariat[bot]
aao-secretariat Bot previously approved these changes Oct 30, 2026

@aao-secretariat aao-secretariat Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ladon verdict: Approve

Approve.

Subsequent pass on a prior clean approve; nothing from the prior run resurfaces and no new findings were raised. Reviewer confirms: version bumps applied consistently across package.json/lock/client-shim/version.ts/docs; new performance-regression test harness with out-of-process SIGKILL watchdog (isolate.mjs:617); content-keyed MCP output-schema validator LRU with per-entry isolated Ajv and byte+entry eviction; and remote-schema factoring in compact-schema.ts. The largest delta (compactMcpSchemaForValidation) fails closed to the original schema on every ambiguous URI/scope/duplicate-$id/unvisited-ref case, rewrites refs to absolute before relocation, and preserves literal proto via Object.create(null)+spread — i.e. conservatively correct, no wire re-shaping. acceptsReceipt removal has no remaining callers and is covered by tests asserting absence. Changeset coverage present for wire/CLI-touching changes.

Decision table: no critical/high/medium findings (rows 1, 4–8 do not fire); gated_paths is false (row 2 n/a) even though review_decision is REVIEW_REQUIRED; high_risk is false (rows 3, 5 n/a); prior decision was approve (row 6 n/a); no no-auto-approve team match (row 7 n/a). Falls through to row 9 → approve.

bokelley and others added 6 commits October 30, 2026 18:56
…ndle

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…cription tests

Main adopted 3.3.0-beta.2, which no longer ships the beta.1 bundle and
defines the fields settled after beta.1 (standard_dimensions,
inactive_intervals, prior_versions[].configuration, previous_timezone,
per-offering entries), so the release gate now emits them to beta.2
callers. inactive in coverage stays gated until beta.3.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…'s beta.2 adoption

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
aao-secretariat[bot]
aao-secretariat Bot previously approved these changes Oct 30, 2026

@aao-secretariat aao-secretariat Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ladon verdict: Approve

Approve — clean pass, no blocking or medium findings.

Checked: this PR adds the reporting-subscription consumer/runtime/coverage system (new files under src/lib/reporting/subscriptions/*, consumer-runtime.ts, ledger, principal reporting-subscription wiring) plus three correctly-typed changesets (minor/patch). The reviewer verified changeset-vs-wire-impact alignment, witness-not-translator fidelity (no fabricated/normalized wire shapes introduced), and reviewed the largest non-generated delta in full. The one non-blocking note (packageSizeAcceptor skipping a declaration on a single-axis legacy ref) was assessed as speculative — such refs don't occur in the legacy/VAST corpus — so it does not rise to a medium finding.

Decision table: no critical/high findings (row 1 n/a); gated_paths is false (row 2 n/a) — note review_decision is REVIEW_REQUIRED but the gated-paths gate only applies when gated_paths is true; high_risk is false (rows 3/5 n/a); no medium findings at all (rows 4/8 n/a); no no-auto-approve team match (row 7 n/a); prior decision was approve (row 6 n/a). Falls through to row 9 → approve.

bokelley and others added 3 commits October 30, 2026 19:50
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@aao-secretariat

Copy link
Copy Markdown

Ladon cannot review this PR until merge conflicts are resolved.

bokelley and others added 3 commits October 30, 2026 20:20
…bscription-coverage

Brings in #3548. The packed-byte cap keeps the higher of main's and this branch's.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…/reporting-subscription-runtime

# Conflicts:
#	scripts/check-package-size.mjs
…reporting-subscription-consumer

# Conflicts:
#	scripts/check-package-size.mjs
aao-secretariat[bot]
aao-secretariat Bot previously approved these changes Oct 30, 2026

@aao-secretariat aao-secretariat Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ladon verdict: Approve

Approve — delta pass over CI/test-only changes, no blocking findings.

This subsequent review follows a prior approve at an earlier head. The current delta touches 4 CI/test-only files (67 lines): check-package-size.mjs adds comment-only measurements (all under the unchanged 26,510,000 cap), a CI-only empty-frontmatter changeset, a pre-dispatch test swapping the retired 3.3.0-beta.1 pin for the live ADCP_VERSION constant (3.3-only pre-dispatch refusal still holds), and a pure-formatting coverage test. No src/lib/** or wire-shape changes in this delta.

Checked: no witness-not-translator violations, no minor-release wire-pathway gaps, no official-transport-client reimplementation, changeset-vs-wire-impact N/A (no src/lib/** behavioral change in delta). No critical/high/medium findings.

Decision table: rows 1–8 do not fire (no findings; gated_paths false; high_risk false; no team gate; prior decision was approve, not escalate). Falls through to row 9 → approve.

bokelley and others added 5 commits October 30, 2026 22:24
A sub-daily billing_cycle was routed to the local sub-daily calendar,
which aligns to local midnight and ignores period_anchor, so every
boundary was off by the anchor's time of day. adcontextprotocol/adcp#8622
makes a sub-daily billing_cycle invalid; refuse it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…nt_configs

A later write that merely omitted replace_account_configs cleared both the
applied marker and a committed-but-unconfirmed replacement, so a failed host
hook was silently abandoned. An omitted flag now leaves both untouched; an
explicit false clears them, so a later true applies again.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…t owed period end

- An open action_required exception now takes the host's current
  pending_action (url, message, reason) when it changes, keeps its from, and
  notifies principal.changed. It used to keep the first action forever.
- accounts_unauthorized last_owed_period_end takes the latest frozen
  period_end. Frozen periods are not stored in end order, so the last entry
  could under-state the window.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
aao-secretariat[bot]
aao-secretariat Bot previously approved these changes Oct 30, 2026

@aao-secretariat aao-secretariat Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ladon verdict: Approve

Approve.

Subsequent pass over 6 delta files (229 lines) in response to a prior clean approve. The reviewer found no critical, high, or medium findings.

Checked:

  • No findings of any severity (clean pass).
  • Author is not on any no-auto-approve team.
  • gated_paths is false, so the hard approval gate (row 2) does not apply despite review_decision: REVIEW_REQUIRED.
  • high_risk is false; no deletions or modified-high-risk triggers.
  • Three source refinements, each tested: calendar.ts fails closed on sub-daily billing_cycle alignment (adcp#8622); engine.ts fixes a frozen-period ordering bug via latestPeriodEnd() and refreshes the open action_required entry on pending_action change; server reporting-subscriptions.ts narrows !== true to === false so an omitted one-shot flag no longer discards a committed replacePending retry.
  • Changesets present for the reporting-subscription work.

No rows 1–8 fired → row 9 approve.

@bokelley
bokelley merged commit 48c93e9 into main Oct 31, 2026
45 checks passed
@bokelley
bokelley dismissed aao-secretariat[bot]’s stale review October 31, 2026 14:17

The merge-base changed after approval.

@bokelley
bokelley deleted the bokelley/reporting-subscription-consumer branch October 31, 2026 14:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants