Repository navigation
feat(reporting): buyer runtime and durable storage for reporting subscriptions (#3504, 3/3) - #3509
Conversation
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>
There was a problem hiding this comment.
Ladon verdict: Approve
Approve — additive AdCP 3.3 reporting-subscriptions feature (3/3), +11942/-24 across 40 files.
What I checked:
- Changesets: three
minorchangesets present, correct for an additive experimental surface (DR-0033 non-breaking minor). Changeset-vs-wire-impact satisfied. - Load-bearing logic: coverage.ts
expectedSubscriptionPeriodscutover/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-safeencodePathSegment); no fabrication/re-shaping at seams.
Medium findings (1):
- src/lib/server/create-adcp-server.ts:9844 —
protocol.principalleft inexperimental_featuresfor 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
252c477 to
c3f0b1c
Compare
There was a problem hiding this comment.
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
readFactsfails closed on an unfinished roster (throws 'refusing a partial roster') with test coverage. templatePlaceholderssingle-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.
c3f0b1c to
facaca4
Compare
There was a problem hiding this comment.
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
reportingSubscriptionVersionRecordsV1is 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
There was a problem hiding this comment.
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'sexclude_account_ids ['x'], and records drive correct per-period cutovers viaexpectedSubscriptionPeriods. 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 (theREVIEW_REQUIREDstatus 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>
d195aff to
bc780de
Compare
There was a problem hiding this comment.
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 —
listPrincipalsreturns a partial roster at the page cap and lacks a repeating-cursor guard (unlike fail-closed siblingreadFacts), affectingreceiptRosteron the billing path. Worth a follow-up to make it fail-closed consistent withreadFacts, 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
There was a problem hiding this comment.
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, notescalate. - 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
left a comment
There was a problem hiding this comment.
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']) yieldsEXPECTED_COVERAGE_MISMATCH. - A buy with a package-level
offering_unsupportedexception, where the seller's obligation ispartialwithunsupported_package_ids, is always a mismatch, and withcoverageRequirement: 'full'alsoCOVERAGE_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
authorizationChangedcalls and reconciles contend on one CAS. WithmaxCasAttempts: 8, that surfaces asReporting 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,
subscriptionExpectationstherefore 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 checksidempotency_key. The seller firesprincipal.changedon every exception change, so a burst of changes produces N full reconciliations per notification. Dedupe byidempotency_keyand coalesce runs that are already in flight. syncReportingSubscriptionsV1writes a complete set. Passing a filtered list deactivates every omitted subscription; deactivation is a cutover and cannot be undone retroactively. A guard such asallowDeactivation: falseby 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 storedconfigurationequals what was sent.- The version records helper depends on retention.
reportingSubscriptionVersionRecordsV1throws 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 currentconfiguration. That is a spec gap worth raising:prior_versionscould carry the configuration fingerprint or the configuration itself. - The PostgreSQL test has not run anywhere yet.
reporting-subscription-postgres.test.jsruns for the first time in CI, as the body notes. Please confirm it actually executed rather than skipped, since it is skipped whenDATABASE_URLis unset.
- 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>
There was a problem hiding this comment.
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.tscontent-keyed LRU, 256 entries/64 MiB, per-entry Ajv to avoid cross-$idreuse) andcompact-schema.ts'scompactMcpSchemaForValidation(~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.acceptsReceipthas no stray callers. - Changesets present for every
src/liband bin change;COMPATIBLE_ADCP_VERSIONS3.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.
There was a problem hiding this comment.
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.
…bscription-coverage
…ndle Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…/reporting-subscription-runtime
…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>
…reporting-subscription-consumer
…'s beta.2 adoption Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
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.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…/reporting-subscription-runtime
…reporting-subscription-consumer
|
Ladon cannot review this PR until merge conflicts are resolved. |
…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
There was a problem hiding this comment.
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.
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>
…/reporting-subscription-runtime
…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>
…reporting-subscription-consumer
There was a problem hiding this comment.
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_pathsis false, so the hard approval gate (row 2) does not apply despitereview_decision: REVIEW_REQUIRED.high_riskis 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
!== trueto=== falseso 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.
The merge-base changed after approval.
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 subscriptionson. It was rebased onto #3508's review fixes. It targetsmainbecause the CI workflow runs only for PRs intomain. 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 completereporting_subscriptionsset through the existing guardedsync_principallifecycle (expected_configuration_version, a fresh idempotency key on conflict). Before writing, it refuses to deactivate an active subscription by omission unlessallowDeactivation: 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 currentconfiguration, andprior_versions[].configurationfrom beta.2 sellers (adcp#8588). If neither has a version's configuration, the helper refuses rather than guessing.subscriptionExpectedReportingPeriodsV1(obligations, template)maps owed obligations (fromgroupExpectedSubscriptionPeriodsV1) onto the consumer runtime'sExpectedReportingPeriod:deliveryConfigIdis thesubscription_id, anddeliveryConfigVersionis thesubscription_versionreport_definition_idand the frozenmedia_buy_idssubscriptionExpectationshook reads the summary view'ssubscription_coverage[]before each reconciliation and reconciles the returned periods alongside configuration-derived ones. The hook'snowis the summary'sledger_as_ofminussubscriptionExpectationGraceMs. A subscription period with no obligation becomes a countedmissingExpectedPeriodsentry, 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.changedrefresh.handleAuthenticatedPrincipalChanged(payload, { consumerScope })callsonPrincipalChanged(re-readget_principal, recordaccounts_unauthorized, replace the roster), then reconciles every account of that scope. A redelivery with an already seenidempotency_keyis dropped; a failed delivery stays retryable. Notifications arriving during a run share one trailing run.ReliableReportingConsumerRunReasonV1gains'principal.changed'.createReportingSubscriptionEngineStateStoreV1({ store })persists the engine's sharded state on anyAdcpStateStore: one CAS document per principal (max_accountsslots) and one per (principal, account) (grants, exceptions, gaps, cutovers, frozen owed periods).listKeysis per principal andlistPrincipals(tenantId)is per tenant; a list walk that does not finish fails closed. OnPostgresStateStoreit is durable and needs nothing beyondADCP_STATE_MIGRATION, the same table the principal store already uses.Subscription version history stays in the principal row and is never pruned, so
prior_versionsstay answerable for as long as any obligation is retained (the maintenance requirement in item 6). Closed exceptions, gaps and timezone cutovers are prunedstatus_retention_daysafteruntil, as the spec allows.Tests
reporting-subscription-consumer.test.js:prior_versions[].configurationactive: false, and a readback that rewrites a stored fieldsubscription_coveragebefore reconciling and reporting three missing owed periodsledger_as_ofminus the graceprincipal.changedrefresh, including redelivery dedupe, a coalesced burst, and ignored and malformed payloadsreporting-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 withoutDATABASE_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)reportingSubscriptionVersionRecordsV1no longer takes buyer-recorded inactive intervals: pass the readback'sinactive_intervalstoexpectedSubscriptionPeriodsunchanged. Test offerings publishstandard_dimensions.Expert review round (2026-10-29), commit
800adfc5coveragerequired in the template, the reviewer's second option. Deriving package sets would mean guessing how a given seller reports package coverage.now: fixed. The hook getsledger_as_ofminus the optionalsubscriptionExpectationGraceMs.listPrincipalsspanning tenants: fixed.listPrincipals(tenantId)filters on the document'stenantIdfield, and rows are re-checked after the backend filter.principal.changeddedupe: fixed. Dedupe byidempotency_keyover a bounded window per scope, and coalescing into one trailing run.allowDeactivationdefaults to false, and the readback check compares configuration field by field.prior_versions[].configuration.reporting subscriptions on PostgreSQLran without a skip directive and passed (ok 1 - versions, lost authority and owed periods survive a restart).Deviations from the issue
@adcp/reporting-storage. Engine state uses the SDK's existingPostgresStateStoredocument 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.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
Ledger materialization of
owedObligations. The producer must plan and execute subscription obligations. This conflicts with three ledger invariants:(account, delivery_config_id, delivery_config_version)(theconfigurationIddigest), while one subscription version can resolve several offerings (match: all) and change resolution mid-versionevaluateReportingLedgerCoverageV1, while subscription periods are sparse (flights, exceptions)media_buy_idsmust freeze per periodA design that fits is to key subscription generations by
(account, subscription_id, version, offering_id)and plan fromowedObligationsinstead of contiguous ordinals. That changes the PostgreSQL ledger schema, so it should be its own PR.Managed delivery wiring (item 4): resolve the subscription's destination generation, expand
path_templatewithexpandReportingSubscriptionPathTemplate(ships in feat(reporting): serve reporting subscriptions from the principal runtime (#3504, 2/3) #3508), putaccount_idon every delivered row, and re-check authority per delivery and retry. This depends on 1.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.acceptsReceiptis 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.A reference-seller conformance storyboard.
Local checks
npm run typecheck,build:lib,lint:boundariesandcheck:package-sizeall pass. The file budget is +12, for three dual-format module sets.reporting-subscription-consumer.test.jsreporting-consumer-runtime.test.jsprincipal-lifecycle.test.jsreporting-subscription-engine.test.jsprincipal-reporting-subscriptions.test.jsreliable-reporting-service.test.jsreporting-subscription-coverage.test.jsRefs #3504
🤖 Generated with Claude Code