Module adcp.decisioning.proposal_dispatch
Dispatch-side wiring for the v1.5 proposal lifecycle.
This is the seam where the framework intercepts buyer calls and runs
the proposal-lifecycle work between the wire envelope and the adopter's
:class:ProposalManager / :class:DecisioningPlatform methods.
Three integration points, one per buyer-facing dispatch path:
-
:func:
maybe_intercept_finalize()— called from theget_productsshim. When the request carries arefine[i].action='finalize'for a finalize-capable manager, intercepts the call, callsmanager.finalize_proposal, commits the proposal, and projects the wire response. -
:func:
maybe_persist_draft_after_get_products()— called after the manager'sget_products/refine_productsreturns. Walks the response forproposals[]and calls :meth:ProposalStore.put_draftfor each one, validatingoverlap ⊆ wireper § D4 round-4. -
:func:
maybe_hydrate_recipes_for_create_media_buy()— called from thecreate_media_buyshim. When the request carriesproposal_id, hydrates the proposal record, validates expiry + capability overlap, populatesctx.recipes. After the platform method succeeds, the caller invokes :func:maybe_mark_consumedto record the single-write hand-off per § D3. -
:func:
maybe_hydrate_recipes_for_media_buy_id()— called from theupdate_media_buy/get_media_buy_deliveryshims. Looks up the proposal viaget_by_media_buy_idreverse-index; populatesctx.recipes. Re-runs capability validation per Resolutions §5.
All helpers are no-ops when the framework can't find a wired manager + store for the resolved tenant — strictly additive on top of v1.
The helpers' router-detection posture is duck-typed: they look for
proposal_manager_for_tenant / proposal_store_for_tenant on the
top-level platform. :class:PlatformRouter exposes these; non-router
platforms naturally don't, so they fall through to the v1 path with
zero behavioural change.
See docs/proposals/proposal-manager-v15-design.md § "Implementation
seams" — this module sits parallel to :mod:adcp.decisioning.refine
and :mod:adcp.decisioning.webhook_emit. Same architectural shape:
framework intercepts at a seam, does its work, dispatches.
Functions
async def mark_proposal_consumed(platform: Any,
proposal_record: ProposalRecord,
*,
media_buy_id: str,
ctx: RequestContext[Any]) ‑> None-
Expand source code
async def mark_proposal_consumed( platform: Any, proposal_record: ProposalRecord, *, media_buy_id: str, ctx: RequestContext[Any], ) -> None: """Finalize the two-phase consumption per § D3 + race fix. Called after the adapter's ``create_media_buy`` succeeds. Promotes ``CONSUMING → CONSUMED`` and records the ``media_buy_id`` back- reference. Idempotent on re-call with the same media_buy_id. """ _, store = _resolve_manager_and_store(platform, ctx) if store is None: return await _await_maybe( store.finalize_consumption( proposal_record.proposal_id, media_buy_id=media_buy_id, expected_account_id=proposal_record.account_id, ) ) consumed_log( proposal_id=proposal_record.proposal_id, account_id=proposal_record.account_id, media_buy_id=media_buy_id, )Finalize the two-phase consumption per § D3 + race fix.
Called after the adapter's
create_media_buysucceeds. PromotesCONSUMING → CONSUMEDand records themedia_buy_idback- reference. Idempotent on re-call with the same media_buy_id. async def maybe_hydrate_recipes_for_create_media_buy(platform: Any, params: Any, ctx: RequestContext[Any]) ‑> ProposalRecord | None-
Expand source code
async def maybe_hydrate_recipes_for_create_media_buy( platform: Any, params: Any, ctx: RequestContext[Any], ) -> ProposalRecord | None: """Reserve the proposal for consumption and hydrate ``ctx.recipes``. Atomic CAS via :meth:`ProposalStore.try_reserve_consumption` — the proposal transitions ``COMMITTED → CONSUMING`` before the adapter runs. Two parallel ``create_media_buy(proposal_id=X)`` calls cannot both reserve; the loser raises ``PROPOSAL_NOT_COMMITTED``. Solves the inventory double-spend race that a check-then-act sequence would expose. Returns the reserved :class:`ProposalRecord` (so the caller can :func:`mark_proposal_consumed` on success or :func:`release_proposal_reservation` on adapter failure); returns ``None`` when the request has no ``proposal_id`` OR no store is wired (v1 path). Validates per § D7 (expiry) + § D4 (capability overlap). Expiry runs BEFORE the reservation — an expired proposal stays ``COMMITTED`` (the framework rejects without consuming the reservation slot). """ proposal_id = _read(params, "proposal_id") if not proposal_id: return None proposal_id = str(proposal_id) manager, store = _resolve_manager_and_store(platform, ctx) if store is None: return None grace = 0 if manager is not None: caps = getattr(manager, "capabilities", None) grace = int(getattr(caps, "expires_at_grace_seconds", 0) or 0) # Expiry check BEFORE reserving. An expired proposal must surface # PROPOSAL_EXPIRED without flipping the state — the buyer can't # consume it, but we don't want to lock the slot while telling them. await enforce_proposal_expiry( proposal_id, proposal_store=store, expected_account_id=ctx.account.id, grace_seconds=grace, now=datetime.now(timezone.utc), ) # Atomic reservation. Two-phase commit: COMMITTED → CONSUMING. # Adapter runs against the reservation; finalize_consumption (on # success) or release_consumption (on failure) closes it out. raw = await _await_maybe( store.try_reserve_consumption(proposal_id, expected_account_id=ctx.account.id) ) record = cast("ProposalRecord", raw) # Anything that raises after the reservation must release it back to # COMMITTED before propagating — otherwise the proposal hangs in # CONSUMING until eviction. Wrap the derivation + overlap-gate block; # handler.py's adapter-failure hook covers the post-return path. try: # Framework-level package derivation per #727. Opt-in: either # the manager declares # ``ProposalCapabilities.derive_packages_from_allocations=True`` # (use the built-in even-percentage helper) OR implements # ``derive_packages(...)`` (custom math). Default behaviour # preserves pre-#727 semantics — framework leaves # ``req.packages`` untouched and the seller adapter handles it # from ``ctx.recipes``. Runs BEFORE the capability-overlap gate # so the gate validates the derived packages. packages = _read(params, "packages") or [] if ( not packages and _has_allocations(record.proposal_payload) and _derivation_enabled(manager) ): derived = _derive_packages( manager=manager, proposal_payload=record.proposal_payload, total_budget=_read(params, "total_budget"), recipes=record.recipes, ) if derived: # CreateMediaBuyRequest is a Pydantic model that # accepts attribute assignment for declared fields. # Setting ``params.packages`` here is what the seller # adapter sees in its create_media_buy method — it # never has to know the packages were derived from # the proposal. try: params.packages = derived except (AttributeError, ValueError, TypeError): if isinstance(params, dict): params["packages"] = derived else: raise packages = derived # Capability-overlap gate per D4. The buyer's packages may be # empty when proposal_id is provided AND derivation produced # nothing (legacy proposal without allocations); skip the gate # in that case since there's nothing to validate against. if packages: validate_capability_overlap( packages=list(packages), recipes=record.recipes, field_path_prefix="packages", ) except Exception: # Narrow to Exception (not BaseException): CancelledError / # SystemExit / KeyboardInterrupt skip the release path — under # cancellation the next worker will read the stale reservation # and eviction handles it; under shutdown we want fast exit. # release_consumption is idempotent on already-COMMITTED so a # release that races with a concurrent worker is harmless. try: await _await_maybe( store.release_consumption(proposal_id, expected_account_id=ctx.account.id) ) except Exception: logger.exception( "Failed to release proposal reservation %s after " "post-reserve validation error; record may be stuck in " "CONSUMING until eviction.", proposal_id, ) raise # Mutate ctx.recipes in place. RequestContext is a dataclass; the # field default is a dict, so direct assignment is safe. Keep the # mapping immutable for downstream code by wrapping. ctx.recipes = dict(record.recipes) return recordReserve the proposal for consumption and hydrate
ctx.recipes.Atomic CAS via :meth:
ProposalStore.try_reserve_consumption— the proposal transitionsCOMMITTED → CONSUMINGbefore the adapter runs. Two parallelcreate_media_buy(proposal_id=X)calls cannot both reserve; the loser raisesPROPOSAL_NOT_COMMITTED. Solves the inventory double-spend race that a check-then-act sequence would expose.Returns the reserved :class:
ProposalRecord(so the caller can :func:mark_proposal_consumed()on success or :func:release_proposal_reservation()on adapter failure); returnsNonewhen the request has noproposal_idOR no store is wired (v1 path).Validates per § D7 (expiry) + § D4 (capability overlap). Expiry runs BEFORE the reservation — an expired proposal stays
COMMITTED(the framework rejects without consuming the reservation slot). async def maybe_hydrate_recipes_for_media_buy_id(platform: Any,
media_buy_id: str,
ctx: RequestContext[Any],
*,
packages: list[Any] | None = None) ‑> ProposalRecord | None-
Expand source code
async def maybe_hydrate_recipes_for_media_buy_id( platform: Any, media_buy_id: str, ctx: RequestContext[Any], *, packages: list[Any] | None = None, ) -> ProposalRecord | None: """Hydrate ``ctx.recipes`` for post-acceptance buy operations. Looks up the consumed proposal via :meth:`ProposalStore.get_by_media_buy_id` reverse-index. Returns the record (so the caller can re-validate overlap if a packages-shaped patch is provided); returns ``None`` when no proposal backs this buy (legacy / non-proposal path). Per Resolutions §5: re-validates capability overlap on every update. Performance can be revisited when an adopter reports it as a bottleneck. """ if not media_buy_id: return None _, store = _resolve_manager_and_store(platform, ctx) if store is None: return None raw = await _await_maybe( store.get_by_media_buy_id(media_buy_id, expected_account_id=ctx.account.id) ) record = cast("ProposalRecord | None", raw) if record is None: return None if packages: validate_capability_overlap( packages=list(packages), recipes=record.recipes, field_path_prefix="packages", ) ctx.recipes = dict(record.recipes) return recordHydrate
ctx.recipesfor post-acceptance buy operations.Looks up the consumed proposal via :meth:
ProposalStore.get_by_media_buy_idreverse-index. Returns the record (so the caller can re-validate overlap if a packages-shaped patch is provided); returnsNonewhen no proposal backs this buy (legacy / non-proposal path).Per Resolutions §5: re-validates capability overlap on every update. Performance can be revisited when an adopter reports it as a bottleneck.
async def maybe_intercept_finalize(platform: Any,
params: Any,
ctx: RequestContext[Any],
*,
executor: ThreadPoolExecutor,
registry: TaskRegistry) ‑> dict[str, Any] | None-
Expand source code
async def maybe_intercept_finalize( platform: Any, params: Any, ctx: RequestContext[Any], *, executor: ThreadPoolExecutor, registry: TaskRegistry, ) -> dict[str, Any] | None: """Intercept ``buying_mode='refine' + action='finalize'`` requests. Returns the wire response dict when the framework intercepts the finalize call; returns ``None`` when the framework should fall through to the standard ``get_products`` dispatch (no finalize entry, no finalize-capable manager, etc.). Sequence per § D2 + D7: 1. Detect a finalize action on the request. 2. Resolve the per-tenant manager + store. 3. Verify the manager declares ``finalize=True`` AND has a ``finalize_proposal`` method (Resolutions §7). 4. Hydrate the draft from the store. Missing → ``PROPOSAL_NOT_FOUND``. 5. Verify state == DRAFT (refine sequence). Otherwise ``PROPOSAL_NOT_COMMITTED``. 6. Build the :class:`FinalizeProposalRequest` and call the manager. 7. Project the union return: * :class:`FinalizeProposalSuccess` → commit + emit ``proposal.finalized path=inline`` log + return wire response with the committed proposal. * :class:`TaskHandoff[FinalizeProposalSuccess]` → kick off background task that commits on completion + return Submitted envelope. The handoff path embeds a wrapper function that does the commit after the adopter's handoff body returns. The buyer sees the Submitted envelope immediately; the proposal lands as committed when the handoff resolves. """ finalize_entry = detect_finalize_action(params) if finalize_entry is None: return None refine_index, proposal_id, ask = finalize_entry field_path = f"refine[{refine_index}].proposal_id" manager, store = _resolve_manager_and_store(platform, ctx) if not _has_finalize_capability(manager) or store is None: # Finalize requested but the framework can't service it. Don't # intercept; let the v1 path raise UNSUPPORTED_FEATURE / # whatever the manager's own response is. return None account_id = ctx.account.id raw = await _await_maybe(store.get(proposal_id, expected_account_id=account_id)) record = cast("ProposalRecord | None", raw) if record is None: raise AdcpError( "PROPOSAL_NOT_FOUND", message=( f"Proposal {proposal_id!r} not found. The buyer must call " "get_products with buying_mode='brief' or 'refine' to " "obtain a draft proposal_id before finalizing it." ), recovery="correctable", field=field_path, ) # Finalize requires a draft; finalizing an already-committed proposal # is a developer error — the buyer should be calling create_media_buy # at that point. from adcp.decisioning.proposal_store import ProposalState if record.state != ProposalState.DRAFT: raise AdcpError( "PROPOSAL_NOT_COMMITTED", message=( f"Proposal {proposal_id!r} is in state " f"{record.state.value!r}; only draft proposals can be " "finalized. Already-committed proposals should be " "accepted via create_media_buy(proposal_id=...) directly." ), recovery="correctable", field=field_path, ) finalize_req = FinalizeProposalRequest( proposal_id=proposal_id, recipes=dict(record.recipes), proposal_payload=dict(record.proposal_payload), ask=ask, parent_request=params, ) # mypy: manager is non-None here per the _has_finalize_capability check. assert manager is not None method = manager.finalize_proposal # type: ignore[attr-defined] if asyncio.iscoroutinefunction(method): result = await method(finalize_req, ctx) else: ctx_snapshot = contextvars.copy_context() loop = asyncio.get_running_loop() result = await loop.run_in_executor( executor, functools.partial(ctx_snapshot.run, method, finalize_req, ctx), ) if is_task_handoff(result): # HITL slow path. Per § D2 + § D3: framework projects Submitted # immediately; the handoff fn completes via the standard # TaskRegistry flow. The framework wires the proposal_store # commit as the on_complete hook so the SAME asyncio task that # calls registry.complete also calls store.commit — single # ledger, no race. If either fails, registry.fail is called and # the proposal stays DRAFT (handler at dispatch._project_handoff # treats on_complete failures identically to handoff fn # failures). from adcp.decisioning.dispatch import _project_handoff async def _commit_on_handoff_completion(success: Any) -> None: # Type-check the handoff fn's return shape. Adopter mistakes # here surface as INTERNAL_ERROR on tasks/get rather than # silently corrupting state. if not isinstance(success, FinalizeProposalSuccess): raise AdcpError( "INTERNAL_ERROR", message=( f"finalize_proposal handoff fn returned " f"{type(success).__name__}; expected " "FinalizeProposalSuccess." ), recovery="terminal", ) await _await_maybe( store.commit( proposal_id, expires_at=success.expires_at, proposal_payload=dict(success.proposal), expected_account_id=account_id, ) ) finalize_succeeded_log( proposal_id=proposal_id, account_id=account_id, expires_at=success.expires_at, path="handoff", ) return await _project_handoff( result, ctx, method_name="finalize_proposal", registry=registry, executor=executor, on_complete=_commit_on_handoff_completion, ) if not isinstance(result, FinalizeProposalSuccess): raise AdcpError( "INTERNAL_ERROR", message=( f"finalize_proposal returned {type(result).__name__}; " "expected FinalizeProposalSuccess or " "TaskHandoff[FinalizeProposalSuccess]." ), recovery="terminal", ) # Commit the proposal in a single write. Idempotent on re-call with # equal expires_at + payload (per InMemoryProposalStore.commit). await _await_maybe( store.commit( proposal_id, expires_at=result.expires_at, proposal_payload=dict(result.proposal), expected_account_id=account_id, ) ) finalize_succeeded_log( proposal_id=proposal_id, account_id=account_id, expires_at=result.expires_at, path="inline", ) # Project the wire response. Per the storyboard expectations # (proposal_finalize.yaml § finalize_proposal phase): return the # committed proposal in ``proposals[]``. Products echo from the # draft so refinement_applied[] aligns; refinement_applied is # synthesized to acknowledge the finalize entry. return _project_finalize_response( params=params, committed_proposal=result.proposal, draft_record=record, finalize_proposal_id=proposal_id, )Intercept
buying_mode='refine' + action='finalize'requests.Returns the wire response dict when the framework intercepts the finalize call; returns
Nonewhen the framework should fall through to the standardget_productsdispatch (no finalize entry, no finalize-capable manager, etc.).Sequence per § D2 + D7:
- Detect a finalize action on the request.
- Resolve the per-tenant manager + store.
- Verify the manager declares
finalize=TrueAND has afinalize_proposalmethod (Resolutions §7). - Hydrate the draft from the store. Missing →
PROPOSAL_NOT_FOUND. - Verify state == DRAFT (refine sequence). Otherwise
PROPOSAL_NOT_COMMITTED. - Build the :class:
FinalizeProposalRequestand call the manager. - Project the union return:
- :class:
FinalizeProposalSuccess→ commit + emitproposal.finalized path=inlinelog + return wire response with the committed proposal. - :class:
TaskHandoff[FinalizeProposalSuccess]→ kick off background task that commits on completion + return Submitted envelope.
The handoff path embeds a wrapper function that does the commit after the adopter's handoff body returns. The buyer sees the Submitted envelope immediately; the proposal lands as committed when the handoff resolves.
async def maybe_persist_draft_after_get_products(platform: Any, response: Any, ctx: RequestContext[Any]) ‑> None-
Expand source code
async def maybe_persist_draft_after_get_products( platform: Any, response: Any, ctx: RequestContext[Any], ) -> None: """Persist proposals returned by ``get_products`` / ``refine_products`` as drafts in the wired :class:`ProposalStore`. Per § D4 round-4: validate ``overlap ⊆ wire`` for any returned recipes before persisting. Mismatch → ``INTERNAL_ERROR`` (adopter bug, not buyer bug). Quietly returns when: * No store wired for this tenant (v1 path). * Response carries no ``proposals[]`` (catalog mode). * No recipes attached to products (v1 ``implementation_config`` flow without typed recipes). Per #723: when the manager declares :attr:`ProposalCapabilities.auto_commit_on_put_draft`, the framework calls :meth:`ProposalStore.commit` immediately after :meth:`put_draft` to promote ``DRAFT → COMMITTED`` so the subsequent ``create_media_buy(proposal_id=X)`` can call ``try_reserve_consumption`` without a separate finalize step. Used by managers issuing directly-consumable proposals from ``get_products``. """ manager, store = _resolve_manager_and_store(platform, ctx) if store is None: return proposals = _extract_list(response, "proposals") if not proposals: return products = _extract_list(response, "products") or [] # #723: cache the capability flag once per call — avoid attribute # lookups inside the per-proposal loop. ``manager`` may legitimately # be ``None`` (catalog-mode adopter wired a ProposalStore without # a ProposalManager); in that case auto-commit is off by default. caps = getattr(manager, "capabilities", None) if manager is not None else None auto_commit = bool(getattr(caps, "auto_commit_on_put_draft", False)) auto_commit_ttl = int(getattr(caps, "auto_commit_ttl_seconds", 7 * 24 * 3600)) for proposal in proposals: proposal_id = _read(proposal, "proposal_id") if proposal_id is None: continue proposal_payload = _to_dict(proposal) # #727: enrich allocations with the product's single # pricing_option_id when missing — saves the seller from having # to populate it explicitly at proposal-assembly time. Runs # BEFORE the recipe collection so the enriched payload persists. _enrich_allocations_with_pricing_options(proposal_payload, products) # Hydrate recipes from products' typed implementation_config. # Adopters return Recipe-typed implementation_config on Products; # the framework projects them into the store. v1 dict-shaped # implementation_config skips this path (no typed recipe). recipes = _collect_recipes_from_products(products, proposal_payload) if recipes: validate_overlap_subset_of_wire(recipes=recipes, products=products) await _await_maybe( store.put_draft( proposal_id=str(proposal_id), account_id=ctx.account.id, recipes=recipes, proposal_payload=proposal_payload, ) ) draft_persisted_log( proposal_id=str(proposal_id), account_id=ctx.account.id, recipes_count=len(recipes), ) if auto_commit: # Promote DRAFT → COMMITTED in the same dispatch so the # next call's ``try_reserve_consumption`` finds a COMMITTED # record. The manager's ``auto_commit_ttl_seconds`` sets # the expires_at horizon. expires_at = datetime.now(timezone.utc) + timedelta(seconds=auto_commit_ttl) await _await_maybe( store.commit( str(proposal_id), expires_at=expires_at, proposal_payload=proposal_payload, expected_account_id=ctx.account.id, ) )Persist proposals returned by
get_products/refine_productsas drafts in the wired :class:ProposalStore.Per § D4 round-4: validate
overlap ⊆ wirefor any returned recipes before persisting. Mismatch →INTERNAL_ERROR(adopter bug, not buyer bug).Quietly returns when:
- No store wired for this tenant (v1 path).
- Response carries no
proposals[](catalog mode). - No recipes attached to products (v1
implementation_configflow without typed recipes).
Per #723: when the manager declares :attr:
ProposalCapabilities.auto_commit_on_put_draft, the framework calls :meth:ProposalStore.commitimmediately after :meth:put_draftto promoteDRAFT → COMMITTEDso the subsequentcreate_media_buy(proposal_id=X)can calltry_reserve_consumptionwithout a separate finalize step. Used by managers issuing directly-consumable proposals fromget_products. async def maybe_validate_refine_proposal_refs(platform: Any, params: Any, ctx: RequestContext[Any]) ‑> None-
Expand source code
async def maybe_validate_refine_proposal_refs( platform: Any, params: Any, ctx: RequestContext[Any], ) -> None: """Reject proposal-scoped refine entries that reference no draft. ``refine_products`` adopters should not need to re-implement proposal store lookup just to return the canonical ``PROPOSAL_NOT_FOUND`` shape. Finalize is handled by :func:`maybe_intercept_finalize`; this guard covers ordinary refine actions before the manager can accidentally emit and persist an unrelated proposal payload. """ _manager, store = _resolve_manager_and_store(platform, ctx) if store is None: return for refine_index, entry in enumerate(list(getattr(params, "refine", None) or [])): inner = getattr(entry, "root", entry) scope = _read(inner, "scope") scope_str = str(getattr(scope, "value", scope)) if scope is not None else None if scope_str != "proposal": continue action = _read(inner, "action") action_str = str(getattr(action, "value", action)) if action is not None else None if action_str == "finalize": continue proposal_id = _read(inner, "proposal_id") if proposal_id is None: continue account_id = ctx.account.id raw = await _await_maybe(store.get(str(proposal_id), expected_account_id=account_id)) record = cast("ProposalRecord | None", raw) if record is None: raise AdcpError( "PROPOSAL_NOT_FOUND", message=( f"Proposal {proposal_id!r} not found. The buyer must call " "get_products with buying_mode='brief' to obtain a draft " "proposal_id before refining it." ), recovery="correctable", field=f"refine[{refine_index}].proposal_id", )Reject proposal-scoped refine entries that reference no draft.
refine_productsadopters should not need to re-implement proposal store lookup just to return the canonicalPROPOSAL_NOT_FOUNDshape. Finalize is handled by :func:maybe_intercept_finalize(); this guard covers ordinary refine actions before the manager can accidentally emit and persist an unrelated proposal payload. async def release_proposal_reservation(platform: Any, proposal_record: ProposalRecord, ctx: RequestContext[Any]) ‑> None-
Expand source code
async def release_proposal_reservation( platform: Any, proposal_record: ProposalRecord, ctx: RequestContext[Any], ) -> None: """Roll back the consumption reservation: ``CONSUMING → COMMITTED``. Called when the adapter's ``create_media_buy`` raises after :func:`maybe_hydrate_recipes_for_create_media_buy` reserved the proposal. The buyer can retry without ``PROPOSAL_NOT_COMMITTED`` blocking them. Idempotent — releasing an already-COMMITTED record or a record that's been finalized to CONSUMED is a no-op. """ _, store = _resolve_manager_and_store(platform, ctx) if store is None: return # Best-effort rollback: log but don't raise if the store rejects # the release — the original adapter exception is the one the # buyer should see. try: await _await_maybe( store.release_consumption( proposal_record.proposal_id, expected_account_id=proposal_record.account_id, ) ) except Exception: logger.exception( "Failed to release consumption reservation for proposal %s; " "the record may be stuck in CONSUMING until eviction. " "Original adapter exception will still propagate.", proposal_record.proposal_id, )Roll back the consumption reservation:
CONSUMING → COMMITTED.Called when the adapter's
create_media_buyraises after :func:maybe_hydrate_recipes_for_create_media_buy()reserved the proposal. The buyer can retry withoutPROPOSAL_NOT_COMMITTEDblocking them. Idempotent — releasing an already-COMMITTED record or a record that's been finalized to CONSUMED is a no-op.