@@ -15,11 +15,19 @@ replay. The coordinator input must never be sent to an AdCP seller.
1515``` python
1616from adcp.compat import (
1717 LegacyPurchaseCoordinator,
18+ PendingTaskResolution,
1819 ReconciliationResult,
1920 SqliteCompatibilityContinuationStore,
2021)
2122
22- store = SqliteCompatibilityContinuationStore(" state/adcp-continuations.sqlite3" )
23+ store = SqliteCompatibilityContinuationStore(
24+ " state/adcp-continuations.sqlite3" ,
25+ max_records = 20_000 ,
26+ max_bytes = 64 * 1024 * 1024 ,
27+ max_payload_bytes = 1024 * 1024 ,
28+ max_records_per_principal = 2_000 ,
29+ max_bytes_per_principal = 8 * 1024 * 1024 ,
30+ )
2331
2432async def execute_legacy_purchase (execution ):
2533 # Route using execution.target_binding to the same authenticated seller
@@ -39,10 +47,21 @@ async def reconcile_legacy_purchase(execution, operation):
3947 return ReconciliationResult.not_applied()
4048 return ReconciliationResult.ambiguous()
4149
50+ async def poll_legacy_purchase (execution , operation ):
51+ # Read-only, idempotent polling only. Submit approval/input separately,
52+ # then let this callback observe the original seller task's new state.
53+ task_id = operation.result[" task_id" ]
54+ return PendingTaskResolution(task_id, await legacy_client.get_task(task_id))
55+
4256coordinator = LegacyPurchaseCoordinator(
4357 store = store,
4458 executor = execute_legacy_purchase,
4559 reconciler = reconcile_legacy_purchase,
60+ pending_poller = poll_legacy_purchase,
61+ # Load from an application secret manager. Keep the same key across every
62+ # process/restart; generate at least 256 secret bits and never store it in
63+ # the continuation ledger.
64+ token_derivation_key = continuation_token_key,
4665)
4766```
4867
@@ -52,6 +71,9 @@ token in `purchase_continuation`:
5271``` python
5372token = await coordinator.issue_legacy_create_continuation(
5473 principal_id = authenticated_principal,
74+ # Stable identity of this discovery/projection transaction. Exact retries
75+ # must reuse it; a genuinely new discovery must use a new value.
76+ issuance_idempotency_key = discovery_transaction_id,
5577 account = account,
5678 source_adcp_version = " 3.1.15" , # exact negotiated patch release
5779 expires_at = expires_at,
@@ -87,12 +109,34 @@ The SQLite ledger is created with mode `0600`; its direct parent must be owned
87109by the current user and cannot be group/world writable. Existing database and
88110sidecar files with group or other access are rejected before every pathname
89111open. This is access control, not encryption; use an encrypted volume or an
90- application-owned encrypted store when payloads require encryption at rest.
112+ application-owned encrypted store when non-secret payloads require encryption
113+ at rest. The built-in stores reject credential-bearing fields,
114+ ` push_notification_config ` , webhook/callback URLs, URL user information, and
115+ presigned/signed query parameters rather than writing them to the ledger. Move
116+ such values to an application secret store and resolve them only inside the
117+ executor or pending poller.
118+
91119` purge_resolved_before(cutoff) ` removes only old succeeded/failed and
92120never-claimed continuations. It deliberately retains claimed, ` in_flight ` ,
93- ` pending ` , and ` ambiguous ` operations regardless of age.
121+ ` pending ` , and ` ambiguous ` operations regardless of age. Configure global and
122+ per-principal record/logical-byte limits plus a per-payload limit for the
123+ deployment; quota exhaustion fails closed and rolls back the attempted state
124+ change. Before entering ` in_flight ` or ` ambiguous ` , the SQLite store durably
125+ records a full result-payload reservation against both byte quotas. Every
126+ worker uses that stored amount even if its local quota configuration differs.
127+ Pending and terminal writes consume the immutable reservation and therefore
128+ cannot be starved by another principal's later ledger use—or by a lower runtime
129+ payload setting—after the seller mutation starts. Logical quotas do not include
130+ SQLite indexes, free pages, or transient WAL growth, so production must also
131+ impose a filesystem/container volume quota and caller-level issuance/polling
132+ rate limits.
94133
95134Ledgers created by the initial pre-release coordinator are migrated in place.
135+ The migration audits existing payloads against the credential policy and fails
136+ startup if operator remediation is required. Pre-fingerprint authorizations
137+ also block equivalent new issuance until they are resolved or quarantined, so
138+ an upgrade cannot silently create a second redeemable token.
139+
96140The old ledger did not retain the buyer-visible pricing subset, so unresolved
97141old rows are explicitly non-executable instead of exposing seller-only options.
98142The first exact retry may atomically adopt the sanitized execution snapshot
@@ -115,19 +159,25 @@ The SDK cannot infer security or commercial identity. The application must:
115159 and make it globally unambiguous by binding issuer, tenant, and subject;
116160- preserve the original account and seller target/session, especially for 2.5,
117161 whose wire request has no account field;
118- - encrypt sensitive stored discovery payloads at rest, set a state-aware
119- retention policy, and restrict ledger access. Never purge unresolved
120- ` in_flight ` or ` ambiguous ` operations automatically;
162+ - encrypt confidential non-secret discovery payloads at rest, set a state-aware
163+ retention policy, and restrict ledger access. Secret-bearing payloads must
164+ not enter this ledger at all. Never purge unresolved ` in_flight ` , ` pending ` ,
165+ or ` ambiguous ` operations automatically;
121166- authorize the actual ` create_media_buy ` call and select its credentials;
122167- implement authoritative reconciliation using a seller transaction identity;
123168- keep the exact negotiated patch version and full observed product/pricing
124169 payload until expiry and reconciliation retention have elapsed.
125170
126- The opaque token is generated with at least 128 bits of randomness and only its
127- SHA-256 hash is stored. A principal mismatch is reported as not found to avoid
128- cross-tenant token enumeration. Natural account comparison excludes mutable
129- display metadata such as ` operator_unit.name ` but includes the account's actual
130- natural key.
171+ The opaque token is derived with HMAC-SHA-256 from the application-held key,
172+ the principal-scoped issuance identity, and a canonical hash of every issuance
173+ binding; only its SHA-256 hash, key fingerprint, and binding hash are stored.
174+ The unique fingerprint makes exact projection retries return the same token,
175+ while changed bindings produce a different token even after an old terminal
176+ row has been purged. Reusing an unpurged issuance key with changed inputs fails
177+ closed. A principal mismatch is reported as not found to avoid cross-tenant
178+ token enumeration. Natural account comparison excludes mutable display
179+ metadata such as ` operator_unit.name ` but includes the account's actual natural
180+ key.
131181
132182## Claim and crash behavior
133183
@@ -136,6 +186,7 @@ The durable operation ledger moves through:
136186``` text
137187claimed -> in_flight -> succeeded | failed | pending
138188 \-> ambiguous -> claimed (only after authoritative absence)
189+ pending -> pending | succeeded | failed
139190```
140191
141192The token is consumed when the first seller mutation is reserved. Exact
@@ -149,8 +200,8 @@ An exception, timeout, or cancellation observed by the coordinator after
149200executor. Look up a revision-bearing snapshot and use the fenced recovery API:
150201
151202``` python
152- operation = await coordinator.get_legacy_purchase_operation (
153- operation_id ,
203+ operation = await coordinator.get_legacy_purchase_operation_by_idempotency_key (
204+ compatibility_input.idempotency_key ,
154205 principal_id = authenticated_principal,
155206)
156207
@@ -163,6 +214,29 @@ result = await coordinator.recover_legacy_purchase(
163214)
164215```
165216
217+ A submitted, working, or input-required response is durable but not terminal.
218+ Look up its latest revision and poll the original task through the configured
219+ poller:
220+
221+ ``` python
222+ operation = await coordinator.get_legacy_purchase_operation_by_idempotency_key(
223+ compatibility_input.idempotency_key,
224+ principal_id = authenticated_principal,
225+ )
226+ result = await coordinator.refresh_pending_legacy_purchase(
227+ operation,
228+ principal_id = authenticated_principal,
229+ target_binding = stable_seller_session_id,
230+ )
231+ ```
232+
233+ Every refresh requires ` PendingTaskResolution ` , binds both pending and terminal
234+ results to the original ` task_id ` , validates the later envelope against the
235+ exact legacy schema, and commits by revision CAS. A stale concurrent poll
236+ cannot overwrite a newer task state. The poller must be idempotent and
237+ read-only because concurrent workers can both perform the lookup before one
238+ wins the durable CAS.
239+
166240Recovery atomically fences ` in_flight ` to ` ambiguous ` with a revision CAS. A
167241stale snapshot cannot recover or complete the operation. The SDK never reopens
168242the token by elapsed time and never blindly resends the legacy request. A
0 commit comments