Skip to content

feat(reporting): check reporting tasks against the current subscription grant - #1728

Merged
bokelley merged 5 commits into
nextfrom
claude/issue-1695-subscription-grants
Oct 31, 2026
Merged

bokelley merged 5 commits into
nextfrom
claude/issue-1695-subscription-grants

Conversation

@bokelley

Copy link
Copy Markdown
Contributor

Part of #1695, slice 5 (receipts after loss), reworked per the owner's decision. It stacks on #1725 and #1723, so it contains their commits. Review only feat(reporting): check reporting tasks against the current subscription grant.

The rule: access means access

There is no post-revocation window. Once authority is lost, the account is unknown to every task, sync_reporting_receipts and sync_reporting_status included. A seller that wants the buyer to finish reconciliation keeps a reduced grant instead of revoking, for example allowed_tasks of get_reporting_status, sync_reporting_receipts and sync_reporting_status. Coverage continues while that grant lists get_reporting_status.

Spec note: the beta.2 text (sync_reporting_receipts.mdx, sync_reporting_status.mdx, get_reporting_status.mdx "When authorization ends") still describes accepting receipts and statuses for status_retention_days after unauthorized_at. Per the owner, a beta.3 spec change replaces it. This PR implements the owner's rule and deliberately diverges from the beta.2 text.

What ships

  • The grant on the account:

    • AuthorizedAccount.allowed_tasks carries the grant's authorization.allowed_tasks; None means the host does not scope tasks.
    • It is persisted in the engine state document only when set, so documents written before this change stay byte-identical (tested).
  • Coverage: it continues only while the grant lists get_reporting_status. A grant reduced below that ends coverage exactly as a revocation does.

  • ReportingSubscriptionEngine.grants_task(principal, account_id, task) answers from the engine's last reconcile of the host's grants.

  • Per-task checks in ReliableReportingService: for a caller holding subscription generations on the account, the service checks each task against the current grant before handling it:

    • get_reporting_status;
    • exact revision reads (get_media_buy_delivery);
    • sync_reporting_status;
    • sync_reporting_receipts.

    A task the grant does not list fails with ACCOUNT_NOT_FOUND, the same as an unknown account. There is no special post-loss path.

Tests

tests/test_reporting_subscription_grants.py covers:

  • Reduced grant: a reconciliation-only grant keeps coverage, receipts and status ingest working, and refuses exact reads, which it does not list.
  • Full revocation: receipts, statuses and status reads are refused, and the receipt handler is never reached.
  • No get_reporting_status: such a grant ends coverage and refuses receipts.
  • Persistence: allowed_tasks round-trips, and unscoped documents are unchanged.

This does not adopt beta.2.

🤖 Generated with Claude Code

bokelley and others added 5 commits October 30, 2026 03:20
…ng ledger

A principal's reporting subscription is now executable by the ledger. The
engine's frozen owed sets become ordinary obligations of one configuration
generation per account, owning principal, subscription_id, version and
resolved offering_id, aligned with the TypeScript SDK.

- ReportingConfigurationGenerationKey gains an optional offering_id, set
  only for subscription generations. ledger_asdict omits it when unset, so
  every digest, work key and persisted scope of an account generation is
  byte-identical to before; a guard test forbids bare asdict in reporting.
- SubscriptionObligationPlanner writes sparse, immutable obligations for
  periods ending by the engine's horizon, then advances the owed set's
  completeness horizon. Stores opt in through ReportingSubscriptionLedger;
  the in-memory store implements it, and the planner refuses other stores.
- The producer never grid-fills an owed set and reads an owed obligation
  only once its period has ended. Owed sets are not leased; the service
  runs them through run_configuration.
- get_reporting_status reports offering_id per period, keeps one wire
  generation per id, version and purpose, and leaves scope_closed false
  while the scope ends after the owed set's horizon.
- sync_reporting_status resolves a statement through the caller's owed
  obligation for its period and report definition; a period the owed set
  does not hold fails with INVALID_STATUS_PERIOD.

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

The closed-payload allowlist in reporting_reconciliation_validate now names
offering_id, so the required-schema manifest carries the function's new
fingerprint, computed from a clean migration chain. The public API snapshot
records ReportingSubscriptionGeneration and ReportingSubscriptionLedger.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The engine now persists its state through a compare-and-swap store, so
frozen_owed_sets reads one snapshot of the principal's state, as
owed_periods and subscription_coverage do, instead of the removed
process-local lock.

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

ReliableReportingService(reporting_subscriptions=...) now owes and serves a
principal's reporting subscriptions. Without the option, capabilities,
responses and worker turns are unchanged.

- Each worker turn plans every listed principal's frozen owed set, then
  executes it with the account configurations. A subscription generation is
  installed through the same trusted configure path: account_context
  receives it with configuration.subscription set, and the service validates
  the source route, currency and advertised offering as for an account
  configuration. The horizon is the earlier of the engine's and the turn
  clock.
- A subscription that cannot be planned is isolated, recorded in
  turn.subscription_errors and reported to planning_error_handler or the
  worker error handler; it never stops another subscription or an account
  configuration.
- Capabilities gain configuration_tasks, the subscriptions block,
  standard_dimensions, the feature id and the principal section at the
  served release; callers below 3.3 see the pre-3.3 block, and adcp#8585
  offering fields reach only 3.3.0-beta.2 callers.
- get_reporting_status summary and periods views gain
  subscription_coverage. Ledger reads stay scoped to the caller, and
  resolve_principal is checked against the caller resolver.
- The planner takes an install hook and resolves currency per generation;
  the engine exposes its offerings and status_retention_days.

Production options, managed or receipt-reconciled subscription offerings
and PostgreSQL owed sets are refused or not yet served.

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

Access means access. A loss of authority opens no window: the account is
unknown to every task, sync_reporting_receipts and sync_reporting_status
included. A seller that wants a buyer to finish reconciliation keeps a
reduced grant instead of revoking.

- AuthorizedAccount carries the grant's allowed_tasks, persisted in the
  engine state document only when set, so unscoped documents are unchanged.
- Coverage continues only while the grant lists get_reporting_status; a
  grant reduced below that ends coverage as a revocation does.
- ReportingSubscriptionEngine.grants_task answers from the last reconcile.
- For a caller holding subscription generations on the account,
  ReliableReportingService checks get_reporting_status, exact revision
  reads, sync_reporting_status and sync_reporting_receipts against the
  grant, and refuses an absent task with ACCOUNT_NOT_FOUND.

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 — subscription-grant gate on reporting tasks is well-formed, with no Critical/High/Medium findings.

The reviewer confirmed all four subscription-facing reporting tasks (get_reporting_status, sync_reporting_status, sync_reporting_receipts, and the exact-read get_media_buy_delivery at service.py:1253/1283/1301/1321) route through require_subscription_grant, which no-ops for non-subscription callers and fails closed with ACCOUNT_NOT_FOUND otherwise. grants_task returns False for unknown accounts (engine.py:1483). Backward-compat is preserved: allowed_tasks=None means "host does not scope" and is only persisted when set (byte-identical unscoped docs, tested at test_reporting_subscription_grants.py:140); dropping get_reporting_status de-authorizes exactly like a revocation (engine.py:756-766).

High-risk flag is true only because src/adcp/decisioning/handler.py is (modified), but the reviewer found no medium-or-higher concerns on it, so the modification is presumed safe — not an escalation trigger (row 5 does not fire without a medium finding).

Two non-blocking notes, both correctly classified as low/non-code: (1) the "access means access" rule intentionally diverges from beta.2 spec text, documented as an owner decision pending beta.3 — a governance/semantic call for humans, not a defect; (2) require_subscription_grant issues a redundant list_configurations per call — a minor efficiency follow-up.

No findings at any severity. gated_paths false, no author team gate, no deletions, no prior decision. Rows 1–8 do not fire; outcome is approve (row 9).

bokelley added a commit that referenced this pull request Oct 30, 2026
The reporting-subscription engine now journals every grant it learns of and is the only source of authorization.authorized_at (DX plan slice P6).

- The chain rule lives in one place, authorization_chain_start: a re-grant before unauthorized_at + status_retention_days continues the chain, so authorized_at does not move; a later re-grant starts a new chain. Coverage uses the same rule.
- On the managed path, served list_accounts and sync_accounts responses carry the journal's authorized_at, the value coverage uses. Each account they show is first observed by the journal: a new grant, a re-grant, or a reduction below get_reporting_status, which reads as a loss. A host value is adopted on first observation; one that later moves backwards is rejected, logged as authorized_at_regressed and counted. Absence from a response never revokes.
- reporting.grants.changed(principal, account_id, at=, authorized=, allowed_tasks=) pushes a grant change into the journal and re-evaluates without listing, so a lagging listing cannot undo it. engine.authorization_changed gains allowed_tasks and source.
- Grant checks read the journal, which these request-path observations and pushes keep current, so a reduction the caller was shown is enforced on its next reporting task (the staleness #1728 left).
- wind_down_grant(current=None) and REPORTING_WIND_DOWN_TASKS (adcp.server) return the reduced grant that ends a relationship without cutting reconciliation, keeping the current authorized_at, as in adcp-client#3543.

Journal entries record their source (listed, pushed); listed entries omit it, so existing state documents stay byte-identical. The shared grant-cases.json vectors from adcp-client#3543 run through the Python engine.

Refs #1695

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
bokelley added a commit that referenced this pull request Oct 30, 2026
The reporting-subscription engine now journals every grant it learns of and is the only source of authorization.authorized_at (DX plan slice P6).

- The chain rule lives in one place, authorization_chain_start: a re-grant before unauthorized_at + status_retention_days continues the chain, so authorized_at does not move; a later re-grant starts a new chain. Coverage uses the same rule.
- On the managed path, served list_accounts and sync_accounts responses carry the journal's authorized_at, the value coverage uses. Each account they show is first observed by the journal: a new grant, a re-grant, or a reduction below get_reporting_status, which reads as a loss. A host value is adopted on first observation; one that later moves backwards is rejected, logged as authorized_at_regressed and counted. Absence from a response never revokes.
- reporting.grants.changed(principal, account_id, at=, authorized=, allowed_tasks=) pushes a grant change into the journal and re-evaluates without listing, so a lagging listing cannot undo it. engine.authorization_changed gains allowed_tasks and source.
- Grant checks read the journal, which these request-path observations and pushes keep current, so a reduction the caller was shown is enforced on its next reporting task (the staleness #1728 left).
- wind_down_grant(current=None) and REPORTING_WIND_DOWN_TASKS (adcp.server) return the reduced grant that ends a relationship without cutting reconciliation, keeping the current authorized_at, as in adcp-client#3543.

Journal entries record their source (listed, pushed); listed entries omit it, so existing state documents stay byte-identical. The shared grant-cases.json vectors from adcp-client#3543 run through the Python engine.

Refs #1695

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
bokelley added a commit that referenced this pull request Oct 30, 2026
The reporting-subscription engine now journals every grant it learns of and is the only source of authorization.authorized_at (DX plan slice P6).

- The chain rule lives in one place, authorization_chain_start: a re-grant before unauthorized_at + status_retention_days continues the chain, so authorized_at does not move; a later re-grant starts a new chain. Coverage uses the same rule.
- On the managed path, served list_accounts and sync_accounts responses carry the journal's authorized_at, the value coverage uses. Each account they show is first observed by the journal: a new grant, a re-grant, or a reduction below get_reporting_status, which reads as a loss. A host value is adopted on first observation; one that later moves backwards is rejected, logged as authorized_at_regressed and counted. Absence from a response never revokes.
- reporting.grants.changed(principal, account_id, at=, authorized=, allowed_tasks=) pushes a grant change into the journal and re-evaluates without listing, so a lagging listing cannot undo it. engine.authorization_changed gains allowed_tasks and source.
- Grant checks read the journal, which these request-path observations and pushes keep current, so a reduction the caller was shown is enforced on its next reporting task (the staleness #1728 left).
- wind_down_grant(current=None) and REPORTING_WIND_DOWN_TASKS (adcp.server) return the reduced grant that ends a relationship without cutting reconciliation, keeping the current authorized_at, as in adcp-client#3543.

Journal entries record their source (listed, pushed); listed entries omit it, so existing state documents stay byte-identical. The shared grant-cases.json vectors from adcp-client#3543 run through the Python engine.

Refs #1695

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
bokelley added a commit that referenced this pull request Oct 30, 2026
The reporting-subscription engine now journals every grant it learns of and is the only source of authorization.authorized_at (DX plan slice P6).

- The chain rule lives in one place, authorization_chain_start: a re-grant before unauthorized_at + status_retention_days continues the chain, so authorized_at does not move; a later re-grant starts a new chain. Coverage uses the same rule.
- On the managed path, served list_accounts and sync_accounts responses carry the journal's authorized_at, the value coverage uses. Each account they show is first observed by the journal: a new grant, a re-grant, or a reduction below get_reporting_status, which reads as a loss. A host value is adopted on first observation; one that later moves backwards is rejected, logged as authorized_at_regressed and counted. Absence from a response never revokes.
- reporting.grants.changed(principal, account_id, at=, authorized=, allowed_tasks=) pushes a grant change into the journal and re-evaluates without listing, so a lagging listing cannot undo it. engine.authorization_changed gains allowed_tasks and source.
- Grant checks read the journal, which these request-path observations and pushes keep current, so a reduction the caller was shown is enforced on its next reporting task (the staleness #1728 left).
- wind_down_grant(current=None) and REPORTING_WIND_DOWN_TASKS (adcp.server) return the reduced grant that ends a relationship without cutting reconciliation, keeping the current authorized_at, as in adcp-client#3543.

Journal entries record their source (listed, pushed); listed entries omit it, so existing state documents stay byte-identical. The shared grant-cases.json vectors from adcp-client#3543 run through the Python engine.

Refs #1695

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
bokelley added a commit that referenced this pull request Oct 30, 2026
The reporting-subscription engine now journals every grant it learns of and is the only source of authorization.authorized_at (DX plan slice P6).

- The chain rule lives in one place, authorization_chain_start: a re-grant before unauthorized_at + status_retention_days continues the chain, so authorized_at does not move; a later re-grant starts a new chain. Coverage uses the same rule.
- On the managed path, served list_accounts and sync_accounts responses carry the journal's authorized_at, the value coverage uses. Each account they show is first observed by the journal: a new grant, a re-grant, or a reduction below get_reporting_status, which reads as a loss. A host value is adopted on first observation; one that later moves backwards is rejected, logged as authorized_at_regressed and counted. Absence from a response never revokes.
- reporting.grants.changed(principal, account_id, at=, authorized=, allowed_tasks=) pushes a grant change into the journal and re-evaluates without listing, so a lagging listing cannot undo it. engine.authorization_changed gains allowed_tasks and source.
- Grant checks read the journal, which these request-path observations and pushes keep current, so a reduction the caller was shown is enforced on its next reporting task (the staleness #1728 left).
- wind_down_grant(current=None) and REPORTING_WIND_DOWN_TASKS (adcp.server) return the reduced grant that ends a relationship without cutting reconciliation, keeping the current authorized_at, as in adcp-client#3543.

Journal entries record their source (listed, pushed); listed entries omit it, so existing state documents stay byte-identical. The shared grant-cases.json vectors from adcp-client#3543 run through the Python engine.

Refs #1695

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
bokelley added a commit that referenced this pull request Oct 30, 2026
The reporting-subscription engine now journals every grant it learns of and is the only source of authorization.authorized_at (DX plan slice P6).

- The chain rule lives in one place, authorization_chain_start: a re-grant before unauthorized_at + status_retention_days continues the chain, so authorized_at does not move; a later re-grant starts a new chain. Coverage uses the same rule.
- On the managed path, served list_accounts and sync_accounts responses carry the journal's authorized_at, the value coverage uses. Each account they show is first observed by the journal: a new grant, a re-grant, or a reduction below get_reporting_status, which reads as a loss. A host value is adopted on first observation; one that later moves backwards is rejected, logged as authorized_at_regressed and counted. Absence from a response never revokes.
- reporting.grants.changed(principal, account_id, at=, authorized=, allowed_tasks=) pushes a grant change into the journal and re-evaluates without listing, so a lagging listing cannot undo it. engine.authorization_changed gains allowed_tasks and source.
- Grant checks read the journal, which these request-path observations and pushes keep current, so a reduction the caller was shown is enforced on its next reporting task (the staleness #1728 left).
- wind_down_grant(current=None) and REPORTING_WIND_DOWN_TASKS (adcp.server) return the reduced grant that ends a relationship without cutting reconciliation, keeping the current authorized_at, as in adcp-client#3543.

Journal entries record their source (listed, pushed); listed entries omit it, so existing state documents stay byte-identical. The shared grant-cases.json vectors from adcp-client#3543 run through the Python engine.

Refs #1695

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@bokelley
bokelley merged commit d34633c into next Oct 31, 2026
41 checks passed
@bokelley
bokelley deleted the claude/issue-1695-subscription-grants branch October 31, 2026 14:23
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.

1 participant