Skip to content

Commit 930368e

Browse files
authored
feat(compat): harden continuation ledger lifecycle (#1061)
* feat(compat): harden continuation ledger lifecycle * fix(compat): reserve terminal ledger capacity
1 parent fb89aa0 commit 930368e

5 files changed

Lines changed: 1479 additions & 54 deletions

File tree

docs/legacy-purchase-continuations.md

Lines changed: 87 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,19 @@ replay. The coordinator input must never be sent to an AdCP seller.
1515
```python
1616
from 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

2432
async 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+
4256
coordinator = 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
5372
token = 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
87109
by the current user and cannot be group/world writable. Existing database and
88110
sidecar files with group or other access are rejected before every pathname
89111
open. 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
92120
never-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

95134
Ledgers 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+
96140
The old ledger did not retain the buyer-visible pricing subset, so unresolved
97141
old rows are explicitly non-executable instead of exposing seller-only options.
98142
The 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
137187
claimed -> in_flight -> succeeded | failed | pending
138188
\-> ambiguous -> claimed (only after authoritative absence)
189+
pending -> pending | succeeded | failed
139190
```
140191

141192
The 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
149200
executor. 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+
166240
Recovery atomically fences `in_flight` to `ambiguous` with a revision CAS. A
167241
stale snapshot cannot recover or complete the operation. The SDK never reopens
168242
the token by elapsed time and never blindly resends the legacy request. A

src/adcp/compat/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,10 @@
2121
LegacyPurchaseCoordinator,
2222
LegacyPurchaseExecution,
2323
LegacyPurchaseExecutor,
24+
LegacyPurchasePendingPoller,
2425
LegacyPurchaseReconciler,
2526
LegacyPurchaseResult,
27+
PendingTaskResolution,
2628
ReconciliationResult,
2729
ReconciliationStatus,
2830
canonical_account_identity,
@@ -40,8 +42,10 @@
4042
"LegacyPurchaseCoordinator",
4143
"LegacyPurchaseExecution",
4244
"LegacyPurchaseExecutor",
45+
"LegacyPurchasePendingPoller",
4346
"LegacyPurchaseReconciler",
4447
"LegacyPurchaseResult",
48+
"PendingTaskResolution",
4549
"ReconciliationResult",
4650
"ReconciliationStatus",
4751
"SqliteCompatibilityContinuationStore",

0 commit comments

Comments
 (0)