Repository navigation
feat(reporting): check reporting tasks against the current subscription grant - #1728
Conversation
…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>
There was a problem hiding this comment.
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).
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>
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>
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>
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>
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>
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>
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_receiptsandsync_reporting_statusincluded. A seller that wants the buyer to finish reconciliation keeps a reduced grant instead of revoking, for exampleallowed_tasksofget_reporting_status,sync_reporting_receiptsandsync_reporting_status. Coverage continues while that grant listsget_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 forstatus_retention_daysafterunauthorized_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_taskscarries the grant'sauthorization.allowed_tasks;Nonemeans the host does not scope tasks.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;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.pycovers:get_reporting_status: such a grant ends coverage and refuses receipts.allowed_tasksround-trips, and unscoped documents are unchanged.This does not adopt beta.2.
🤖 Generated with Claude Code