Metadata-Version: 2.5
Name: elta-amz
Version: 0.22.0
Summary: The Amazon provider plugin for ELTA: Amazon Ads and Seller Central over elta-plugin-contract.
Requires-Python: <3.14,>=3.13
Requires-Dist: elta-plugin-contract<0.17,>=0.16.0
Requires-Dist: httpx<0.29.0,>=0.28.1
Requires-Dist: keble-helpers<2,>=1.65
Requires-Dist: pydantic-settings<3.0.0,>=2.10.1
Requires-Dist: pydantic<3.0.0,>=2.12.0
Description-Content-Type: text/markdown

# ELTA Amazon plugin 0.22.0

## 0.22.0 — listing image slots: add, remove and its undo (IMG-3)

Backend 0.32.1 vendors it. One plugin level and three Seller actions in the existing
`amazon.seller.listings` family; no contract or core change, and existing actions keep their bytes.

- Level `LISTING_IMAGE` (parent `LISTING`): the entity is the slot attribute name
  (`main_product_image_locator`, `other_product_image_locator_1..8`), the parent the SKU.
- `amazon.seller.add_listing_image` (ADD on the listing, creates the slot, `ListingImageAdd
  {slot, url}`, HTTPS only): an empty slot is ACCEPTED after Amazon's `VALIDATION_PREVIEW`; an
  occupied slot (MAIN included) refuses `ALREADY_EXISTS` / `IMAGE_SLOT_OCCUPIED` (use Change listing
  details to replace it). Apply sends one `patchListingsItem` `op=add` on `/attributes/<slot>` with
  `[{media_location, marketplace_id}]` (RFC 6902 `add` creates an absent member; `replace`
  requires it). No automatic undo (fidelity NONE): Amazon re-hosts the image, so ELTA cannot
  prove the slot still holds its upload; use Remove image.
- `amazon.seller.remove_listing_image` (REMOVE on the slot, `ANY_WITH_SNAPSHOT`, drift REFUSE):
  MAIN refuses `PROTECTED_TARGET` ("Amazon needs a main image; replace it instead") before any
  request; an empty slot is NO_CHANGE. The snapshot `ListingImageSnapshot {slot, media_location}`
  keeps Amazon's exact as-read URL bytes; the drift token covers only that slot's value. Apply
  sends `op=delete` with only the marketplace selector.
- `amazon.seller.restore_listing_image` (revert-only ADD, BEST_EFFORT, "Restores Amazon's copy;
  Amazon may re-process it") writes the snapshot back with `op=add`, and refuses
  `ALREADY_EXISTS` when the slot is occupied again; it never overwrites.
- Apply's final listing read re-checks the slot after ELTA's gate: an add or restore into a slot
  filled since then refuses `ALREADY_EXISTS` / `IMAGE_SLOT_OCCUPIED`, and a remove whose slot
  token no longer equals its gated `base_version` (changed or emptied) refuses `BASE_CHANGED`;
  neither sends a PATCH. A change between that read and the PATCH is not caught: Amazon has no
  conditional write, so this narrows the race and is not atomic.
- A 5xx answer to any image patch is UNKNOWN (labelled, never resent); UNKNOWN adds and restores
  always reconcile UNKNOWN: an occupied slot (and Amazon's re-hosted URL) proves no writer, so
  a seller's image is never recorded as ELTA's created entity.
- Known gap (core): ELTA's conflict keys do not overlap a `LISTING_IMAGE` ref with a
  `set_listing_attribute` on the same slot attribute (different targets in `ChangeKey.overlaps`),
  so only the apply-time re-read and drift tokens separate them.
- Catalog: additions only (the level and three actions); authz digest unchanged (generation 5).

## 0.21.1 — A+ create sends a document language

Backend 0.31.1 vendors it. Amazon requires `contentDocument.locale` on `createContentDocument`;
0.21.0 always sent null, so every live A+ create was refused before anything was saved.

- `CreateAplusDocument.locale` (optional `AplusLocale`, Amazon's `LanguageTag` pattern
  `^[a-z]{2,}-[A-Z0-9]{2,}$`, hyphen form such as `en-US` or `es-US`; the underscore form that
  publish records report is refused). An unset locale is omitted from every dump, so stored creates
  keep their bytes and hashes.
- Apply sends `locale`, else the marketplace's primary language from
  `APLUS_MARKETPLACE_LOCALES` (US en-US, CA en-CA, MX es-MX, BR pt-BR, UK en-GB, DE de-DE,
  FR fr-FR, IT it-IT, ES es-ES, NL nl-NL, SE sv-SE, PL pl-PL, TR tr-TR, IN en-IN, AU en-AU,
  SG en-SG, JP ja-JP). Multi-language or unverified marketplaces (BE, IE, ZA, EG, SA, AE) are
  absent: an unset locale there refuses the row in preview and apply before any request
  (`PROVIDER_VALIDATION`, `/locale` `LOCALE_REQUIRED`), never sending null.
- The create preview and outcome label is `<name> (<locale>)`, so the approval shows the language
  even when it is the default; the undo target's label stays the bare name.
- Catalog adds only the optional `locale` property; no authz change. Rollback floor: a stored
  create with a set `locale` cannot decode on 0.21.0.

## 0.21.0 — A+ writes are submitted for review; real A+ documents edit losslessly; A+ create undo

Backend 0.31.0 vendors it. Three changes ship together: every A+ write is submitted for Amazon
review, the A+ read projects real documents typed so a SET resends them without loss, and an A+
create can be undone by unlinking all its products (Amazon keeps the document).

### Real A+ documents read typed and round-trip

- One `AplusModuleProjection.to_provider` owns both the SET wire body and the read/SET-current
  round-trip guard. A module is typed only when its round trip equals the normalized provider
  JSON; otherwise it stays the opaque `{module_type, raw, supported: false}` marker (for example a
  `STANDARD_COMPANY_LOGO` with `companyLogo: null`). Every body leaf (`body`, `block.body`, ...) is
  written as a `textList` paragraph, and a path the generated module model does not declare is
  refused rather than sent as an extra.
- `AplusImageField.crop` (`AplusImageCrop {size, offset?}` of `AplusImageLength {value, units}`)
  carries Amazon's `imageCropSpecification`: read as-is and written back byte-identical. An unset
  crop or offset is omitted from every dump, so pre-crop proposals keep their bytes, hashes and
  `model_version`.
- An undecorated empty text component in a declared optional `TextComponent` property of a known
  module type (live `headline: {"value": "", "decoratorSet": []}`) reads as absent and a SET omits
  it, because Amazon's schema requires `value` minLength 1. Any other `{value: ""}` field keeps the
  module opaque. Required body text keeps `min_length=1`; an empty body row or decorated empty
  headline stays opaque.
- Exact-key `aplus_documents` reads wrap each typed module as `AplusReadModule {module, images}`;
  `images` holds one read-only `AplusImagePreview {name, preview_url}` per image field. `preview_url`
  is set only for `aplus-media-library-service-media/<id>.(jpg|jpeg|png|gif)` asset IDs and is
  never part of a write model, hash or SET version.
- Conversion runs inside the existing module-shape problem boundary for lookup and SET preview
  reads: a SET preview whose current document has a module the typed SET cannot rebuild exactly
  returns the existing `MALFORMED_RESPONSE` issue instead of a lossy undo value, and never raises.

### A+ writes are submitted for Amazon review

Every A+ write now ends with one `postContentDocumentApprovalSubmission`
(`POST /aplus/2020-11-01/contentDocuments/{key}/approvalSubmissions?marketplaceId=…`).
APPLIED = saved and submitted for Amazon review (async: SUBMITTED → APPROVED/REJECTED via
`aplus_documents` status); with no ASIN, create saves a draft only.

- `create_aplus_document`: create → relations → submission. Amazon needs one or more ASINs
  before submission (create-edit-publish-aplus-content, Prerequisites), so a create with no ASIN
  is APPLIED with the label `<name>: saved as draft; attach a product to submit for review`.
- `set_aplus_content`: update → submission. Its BEST_EFFORT undo runs the same apply, so it
  resubmits too.
- `set_aplus_asins`: relations → submission for a non-empty set, because posting relations
  resets an approved document to DRAFT and Amazon says to submit again (a-plus-troubleshooting,
  "An approved document went back to DRAFT"); the empty detach set submits nothing.
- A submission that fails after a successful write (non-2xx, transport error, timeout, or an
  unreadable 2xx body) is UNKNOWN with the label `<name or key>: content saved, but the review
  submission is unconfirmed; check status in Seller Central, or edit and re-file`. The content
  is written exactly once and nothing is resent.

The DRAFT/equal-content "submit only" retry is deferred: an unchanged SET still previews as
NO_CHANGE. The submission is behaviour only: no gate, comparison, current/version, stored-shape
or contract change; its only catalog change is the three A+ action `changeSchema.description`
texts.

### A+ create undo unlinks all products; Amazon keeps the document

`create_aplus_document` now has an undo, the revert-only REMOVE `detach_aplus_document`
(`ELTA_CREATED`, `REFUSE`). Amazon cannot delete A+ content, so undo posts the empty ASIN set
(no submission), re-reads it to prove it empty, and the document stays in the account. Create is
`EXACT` (the contract's fidelity for a revertible ADD) with a revert note saying so. An APPLIED
create records `created_version`, the token of its sorted ASIN set; once the seller links other
products the undo refuses `CHANGED_SINCE_APPLY`, and a draft with nothing linked is `NO_CHANGE`.
An unlink whose re-read still shows products is UNKNOWN and never resent; so is an unlink answered
by a 5xx, transport error, timeout or unreadable body, because it may have landed (only a 4xx
refusal is REJECTED). A relation page missing Amazon's required `asinMetadataSet` or a row's
`asin` is `MALFORMED_RESPONSE`, never an empty set, so it cannot bypass the changed-ASIN guard
or falsely confirm an unlink. The undo reads only the relations and names the document from the
created target's label (older targets show the document key), so no module content can block it.
Direct proposals of the detach refuse `REVERT_ONLY`.

Creates stored before 0.31.0 or reconciled from UNKNOWN carry no `created_version`, so their
undo is guarded only by the preview's product count and REFUSE drift.

Catalog for 0.21.0: the three descriptions above, the crop leaves under the create/SET change
schemas, the crop/preview/read-module leaves under the `aplus_documents` result schema, the new
`detach_aplus_document` action, and create's `fidelity` (EXACT), `revertActionKey` and
`revertNote`. A stored proposal that carries a crop or a detach cannot decode on 0.20.0
(extra-forbid / unknown action), so once one is stored the rollback floor is backend 0.31.0.
The detach reuses family `amazon.seller.aplus`, so the authz manifest is unchanged by it.

## 0.20.0 — an UNKNOWN keyword ADD reconciles by its natural key

`amazon.ads.add_keyword` and `amazon.ads.add_negative_keyword` reconcile an UNKNOWN send once
from the natural key apply reports: the ad group plus the exact, case-sensitive keyword text and
match type, listed with `adGroupIdFilter`, `keywordTextFilter` (EXACT_MATCH), `matchTypeFilter`
and `stateFilter` ENABLED/PAUSED only. Exactly one live match returns its provider keyword id as
`created`, so `archive_keyword` passes the ELTA-created gate; zero, several, a case variant, an
archived-only row or a failed read stays UNKNOWN. A duplicate positive keyword ADD now previews
as NO_CHANGE when an equal ENABLED/PAUSED row exists, so that create is never sent, while an
archived equal row still allows a fresh ADD. No action, family, declaration, catalog or
stored-byte change: the two ADDs still declare no `created_from`.

## 0.19.0 — current listing and bounded published A+ facts on previews

The four listing actions declare `ListingTargetFacts`: `current` is the unchanged snake_case
`ListingCurrentFacts` projection already returned by `listing_items`, from the same listing read.
Every row whose target read obtained that listing retains its facts even when validation, a prior
value or inventory check refuses it. A row without a successful listing read never fabricates facts.

`aplus` performs one `contentPublishRecords` page read per SKU's own-summary ASIN, retaining only
that account's marketplace. Its `state` is PUBLISHED for an observed matching reference,
NOT_PUBLISHED only for an exhausted page with none (a draft may still exist), or UNREAD for an
empty continuation, missing ASIN or failed/skipped read. `records` keeps only reference key, raw
content type and locale. No metadata, workflow status, draft scan or ASIN relation read; A+ failure
cannot reject the listing preview or discard `current`. Same-SKU attribute rows share the read.
A no-try boundary records transport, built-in timeout and payload failures as UNREAD, warning with
only the exception type; unexpected defects and cancellations propagate without hiding them.

Facts describe preview-time observations and refresh on re-preview. They are display-only,
separate from the write's current value, version, undo baseline and approval/replay hashes.

Repins plugin-contract `>=0.16.0,<0.17`; ship with backend 0.29.0 after BE readers accept contract
4.5.0. No action key, family, permission or authz-generation change.

## 0.18.1 — a refused listing row still names the product

A `SET_LISTING_ATTRIBUTE` row whose shared `VALIDATION_PREVIEW` returns a provider issue became
`present=False` with the issue and no display, so the change-list preview showed a nameless,
imageless row although the same request had already read the listing. The `listing_display`
projection is now taken once from that read and kept on every issue state the same method
returns: the failed validation, the listing that omitted its product type, and the row whose
prior attribute is missing. No extra provider call, contract or change-schema field: `label`
and `image_url` already existed on `TargetState` and stay display-only, outside versions and
hashes. Stored previews keep their bytes.

## 0.18.0 — current A+ content and typed listing reads

`amazon.seller.aplus_documents` exact-key reads now include ordered current modules, content type,
locale, update time and badges. The shared GET sends `includedDataSet=CONTENTS,METADATA` as one CSV
parameter, including SET content previews. Supported modules reuse the existing write converter;
unknown or unsupported modules remain read-only `{module_type, raw: true, supported: false}`
markers, so they cannot hide the rest of the document. Lookup-only envelopes accept raw Amazon
content types EBC (Seller Central A+), EMC (Vendor Central A+), BrandStory and future values.
Generated models are not edited; strict write models and write acceptance remain unchanged.

The existing `asin` query now uses `contentPublishRecords`, not an all-document relation scan.
It finds **published** documents only; drafts remain reachable by exact name or reference key.
All A+ metadata/relation/publish continuations send Amazon's pageToken alone, as required.
ASIN/name pages omit module content; a matched reference supplies live metadata and current ASIN
relations. `rejection_reasons` is removed: Amazon exposes workflow status and badges, not document
rejection explanations. Those explanations must be checked in Seller Central.

A+ image modules still expose uploaded asset references, not guessed URLs. `getMedia` can return a
`mediaUrl` for a known mediaId, but this model supplies no read-only mapping from an image module's
uploadDestinationId to that mediaId. The documented resolver is the mutating `createMedia` POST;
no media lookup or mutation is added. ASIN-relation eligibility validation remains a follow-up.

`amazon.seller.listing_items` adds optional typed `current` from the same provider response while
retaining all raw groups. It includes marketplace-language text, ordered bullets, HTTPS image
slots, a current B2C amount/currency and minimal product attributes. Marketplace-scoped values win
then unscoped values; scalar values use the first applicable row, with no translation. Price is
None if the exact-marketplace B2C offer is missing or ambiguous; no B2B or scheduled-price fallback.

No action/family key, permission, authz generation, hash algorithm or stored write schema changes.
The corrected A+ dataset request can change newly observed SET content versions when Amazon now
includes metadata/content; existing proposals retain their stored preview/hash bytes. Listing
display helpers are shared without changing their existing write-side selection or versions.

## 0.17.2 — Ads accounts name their country

One advertiser's per-country Ads profiles share one name, so every choice read "Asiboan". Ads
discovery now titles a profile "name · country" ("Asiboan · US") with the same
`elta_amz.regions.account_title` rule as Seller's "store · marketplace" ("Asiboan · Amazon.com").
The plugin holds no marketplace-name table, so Ads uses the profile's country code; a profile
without one keeps its name. Titles are display only and enter no hash, version or identity.
A stored connection keeps its old titles until it is rediscovered: a capture request
("sync now"), a reconnect, or a changed permission declaration.

## 0.17.1 — new and restored listing rows show the product

`add_listing` and `restore_listing` previews target a SKU that does not exist yet, so they used
to label the row with the SKU only. They now take the row's own `item_name` (this marketplace's
entry, else the unscoped one; SKU fallback) and, for a create, its MAIN image URL; a restore
snapshot keeps no images, so it shows none. Their marketplace-mismatch refusals show the same.
SET and REMOVE rows already carried the live title and image since 0.16.0. The label is part of
the preview hash, so only newly read create and restore previews hash differently; a stored
proposal re-hashes from its stored previews. No action, change schema or refusal key changed.

## 0.17.0 — FBA placement and carrier confirms, committed-plan cancel, shipment labels

BL-16 finishes the FBA inbound steps after packing, behind the new default-off
`amazon.seller.fba_inbound_shipments` family: these three calls charge the seller, so a store with
the fee-free `amazon.seller.fba_inbound_plans` switch on never gains them.
`amazon.seller.confirm_fba_placement` is an ADD of the plan's one placement option
(`created_from` the option id): an identical resend once it is accepted is `NO_CHANGE`, a
different option after confirmation is `WINDOW_CLOSED`, and Amazon offers no undo.
`amazon.seller.confirm_fba_transportation` is an ADD of one carrier per shipment
(`selections`) plus one top-level private freight contact for pallets, quoted with the carrier cost
and the void deadline Amazon returns only after the confirm; the contact is copied into every
selection at apply and never lives under an array item. `amazon.seller.cancel_confirmed_fba_inbound_plan`
is the REMOVE that voids a committed plan, a forward filing in the same charged family whose read
allows it only inside Amazon's window. Both confirms refuse on drift; the cancel applies anyway.

The same release adds three lookups: `amazon.seller.fba_placement_options` (a plan's options with
their shipment split, placement fees, discounts, expiry and one `options_version`, generating free
on the first page while no unexpired option is offered or accepted), `amazon.seller.fba_transportation_options`
(one placement's options per shipment with carrier, mode, quoted cost and expiry, generating only
with a configuration and never once transport is confirmed) and `amazon.seller.fba_shipment_documents`
(box labels, pallet labels or bill of lading with `READY` and a short-lived link,
`AWAITING_PLACEMENT`/`AWAITING_TRANSPORT`, or `NOT_APPLICABLE` without pallets; the link expires in
seconds and is never stored). The levels `INBOUND_PLACEMENT` and `INBOUND_TRANSPORT` join
`INBOUND_PLAN`, the inbound plan status reading gains `accepted_placement_option_id` and
`shipments`, and two issue codes are new: `PLACEMENT_OPTION_NOT_FOUND` and
`INBOUND_SHIPMENT_NOT_FOUND`. No new refusal key. The floor is 0.25.1 for every record stored
today and 0.26.0 once a proposal holding one of the three new action keys is stored, because
0.16.1 refuses that row with `UnknownKey`.

## 0.16.1 — the packing preview returns Amazon's current, so the core SET gate accepts it

`amazon.seller.set_fba_inbound_packing` is a SET whose target read returned presence without a
current, so the core gate refused every packing row as `REQUEST_REJECTED`. The read now returns
the typed current: the `ACCEPTED` packing option's id, else none, plus the saved boxes from
`listPackingGroupBoxes` in the option's `packingGroups` order, mapped into the request's own
`FbaPackingBox` shape. An identical resend is `NO_CHANGE`; nothing saved yet gives the empty
`SetFbaInboundPacking()`, accepted as a new change. A saved box missing a usable field keeps the
option and claims no boxes, so it can only cause a resend, never a false `NO_CHANGE`. The current
admits that empty shape, so the change schema widens: `packing_option_id` is optional and
`groupings` may be empty. A request in that shape is refused as `PROVIDER_VALIDATION` at preview
and at apply, before any read or write. Quote and version are computed from the same inputs and
are unchanged. No new kind, family or refusal key. The rollback floor stays 0.25.0 for every
record stored today, but a filed packing proposal keeps the empty current in its preview
(`packing_option_id: null`, `groupings: []`), which 0.16.0 refuses: never roll ELTA below 0.25.1
once such a proposal is stored.

## 0.16.0 — change rows show the product; a create never attaches to an existing ASIN; product ads pause

Listing change rows (SET and REMOVE, including their refusals) are labelled with the listing's
full `itemName`, falling back to the SKU, and carry the main image (`mainImage`, then the
marketplace's `main_product_image_locator`); A+ rows are labelled with the document name. The
label is part of the hashed preview, as the SKU was before, so a new proposal hashes the title;
stored proposals verify against their own stored previews. `CreateListing` refuses ASIN-binding
rows (`merchant_suggested_asin`, an ASIN identifier, legacy `external_product_id_type=ASIN`) as
`IDENTITY_ATTRIBUTE` before any provider call, and a GTIN-family row that `searchCatalogItems`
matches in the target marketplace as `CATALOG_MATCH`; the check runs at preview and again at
apply. New `amazon.ads.set_product_ad_state` (SET_STATE at AD, SP only) pauses or enables one SP
product ad behind its own default-off `amazon.ads.product_ad_state` family; EXACT undo, applies
over drift. No new refusal key or change class; the live product-ad route is fixture-proven only.

## 0.15.2 — the listing lookup explains its own edit time

`amazon.seller.listing_items` now declares its meaning: it reads the requested SKUs from
Seller Central at request time with their summaries, attributes, issues and offers, and a
summary's `lastUpdatedDate` is when the seller last edited the listing, not when it was read.
The plugin owns that fact, so a consumer no longer restates it in its own guidance; every other
Amazon lookup stays without one. Repins plugin-contract `>=0.14.4,<0.15`, which refuses blank,
multi-line or dotted-key guidance at registration. No provider call, capture, write or runtime
backend pin changes.

## 0.15.1 — repins plugin-contract 0.14.0

Repins plugin-contract 0.14.0 (`>=0.14,<0.15`), which lets a `Source` declare its removal reach
and revoke page. Amazon declares neither, so its removal stays `LOCAL` without a URL: Amazon Ads is
region-specific and Seller Central is per marketplace, and a wrong link would be worse than none.
No declaration, provider call, capture, lookup or write changed.

## 0.15.0 — Ads money writes refuse a moved value, and an unknown MSKU is the caller's

The three Ads money writes `amazon.ads.set_target_bid`, `set_campaign_budget` and
`set_ad_group_bid` now carry `DriftPolicy.REFUSE`, and their readers record the live bid or daily
budget as the version token, so an apply whose value moved after the preview refuses with
`BASE_CHANGED` instead of overwriting it. Amazon Ads has no conditional write, so the re-read just
before sending is the boundary. An Ads bid or budget proposal saved before 0.19.0 and approved
after it has those rows refused with `BASE_CHANGED`; propose it again.
`amazon.seller.fba_prep_details` answers Amazon's refusal of a batch that
names an MSKU it does not know as `NOT_FOUND` with Amazon's own line, so the caller checks the
MSKU instead of reading `REQUEST_REJECTED` with `SUPPORT`.

## 0.14.2 — A+ field shape and prep-details MSKUs

The A+ content preview now refuses a text field that does not fit Amazon's module shape, such as
`body.textList` on `STANDARD_TEXT`, with `PROVIDER_VALIDATION` at `/content/modules/<i>` instead
of failing the read, because the caller's own field name is theirs to fix and never a provider
outage.

`listPrepDetails` sends one comma-joined `mskus` value per 100-MSKU batch. Amazon reads only the
first of repeated parameters, so the `amazon.seller.fba_prep_details` lookup and the FBA create
preview now read every requested MSKU rather than the first of each batch, with each MSKU's own
`%`, `+` and `,` still double-encoded. No wire change.

## 0.14.1 — accepted placement and never-stocked FBA inventory

Amazon reports an accepted FBA placement option as `ACCEPTED`, so the FBA cancel check and the
`amazon.seller.fba_inbound_plan_status` lookup's `committed` now count it. Before, the check
looked for `CONFIRMED`, a value Amazon never returns, so only selected transportation or a
shipped shipment closed the window.

The FBA inventory read behind the `amazon.seller.fba_inventory` lookup and the remove-listing
check now reads only each summary's SKU and quantities, so a summary with an unusable
last-updated time, as Amazon sent for an SKU that never held stock, no longer fails the read.
A malformed quantity still fails it, because those totals gate the removal refusal. No wire
change.

## 0.14.0 — FBA prep details, honest preview, Amazon's failure message

The new read-only `amazon.seller.fba_prep_details` lookup reads Amazon's saved prep details for
1 to 100 exact MSKUs (1-255 characters each). Rows carry Amazon's own strings, and Amazon omits
an MSKU it does not know and prep entered only in Seller Central, so a missing row says nothing
about the item.

The create preview refuses the Brazil account before any read, and otherwise refuses an item
only when it breaks an explicit prep-owner or label-owner constraint, or when a MUST_MATCH
all-owners constraint meets two different owners. A missing row and an UNKNOWN category pass,
because Amazon states its own constraints and validates the rest at apply. A failed FBA
operation now shows Amazon's own message first, with its details in parentheses.

`amazon.ads` declares no operator app renewal row: the shared public Ads app has no expiry, so
`ELTA_OAUTH_APP_SECRET_ROTATED_AT` is retired and ignored if still set. Token refresh and the
365-day customer consent clock are unchanged, and the Seller public app keeps its 180-day row.

## 0.13.0 — keyword write family and inbound plan history

Positive keyword writes leave the negative-keywords switch. The new `amazon.ads.keywords` family
("Add and remove keywords") allows `add_keyword` and `archive_keyword`; the existing
`amazon.ads.negative_keywords` family keeps its key for saved policies and is retitled "Add and
remove negative keywords", so one switch no longer allows both kinds. Store owners must enable
the new switch to keep positive keyword adds.

Three read-only lookups read earlier FBA inbound plans: `amazon.seller.fba_inbound_plans` lists
the bound marketplace's plans (status filter, last-update or creation order, 30 per page) and
`amazon.seller.fba_inbound_plan_items`/`amazon.seller.fba_inbound_plan_boxes` read one plan's
MSKU lines and packed boxes (up to 500 per page; an unknown plan is `INBOUND_PLAN_NOT_FOUND`).
Amazon's plan filter can leave a page short or empty while a cursor is returned, so an agent
keeps paging. The plan's ship-from address is never returned. The listing and A+ undo notes now
address the seller instead of naming ELTA; no action behavior, key or hash changed.

## 0.12.1 — Ads consent renewal deadline

`amazon.ads` declares `consent_lifetime_days=365`, so existing connection projections show
renewal from the latest successful consent (legacy grants fall back to first bind). Amazon's
`https://advertising.amazon.com/API/docs/en-us/guides/get-started/retrieve-access-token`
states that refresh tokens issued on or after July 30, 2026 expire 365 days after advertiser
consent; earlier tokens have no fixed expiration and receive a conservative renewal prompt.
Token refresh does not restart this clock. No plugin-contract or wire schema changes.

## 0.12.0 — Seller consent style

Pins plugin-contract `>=0.13.0,<0.14`. `amazon.seller` now offers the website-authorization
CONSENT way beside the seller's own private app, but only once the operator configures the
client's public SP-API app; otherwise `connect` stays `{CREDENTIALS}` and the source stays
available (never `available=False`).

`start` returns one Seller Central site per Amazon store of the store's region (23 stores, each
with a bilingual country label), all sharing one 600-second round state, with `url` set to the
first site for clients predating site selection. `exchange` requires the callback's selling
partner id (Amazon's `selling_partner_id`, forwarded neutrally as `callback_account_id`) and
returns a grant sealing only `refresh_token`, `access_token` and `selling_partner_id`; a missing
or blank id is a typed reconnect. `refresh` uses the app that issued the grant: the seller's
sealed private pair for a credentials grant, otherwise ELTA's operator public app, keeping the
selling partner id either way. A consent grant declares `consent_lifetime_days = 365` (Amazon's
reauthorization rule), selected by `bound_with` instead of the private secret's 180 days. With no
seller-owned secret to name, an expired operator public secret classifies as
`REQUEST_REJECTED`/SUPPORT, never a customer reconnect. `ELTA_SELLER_PUBLIC_APP_SECRET_ROTATED_AT`
tracks the public pair through the operator renewal path; the shared Ads pair declares no
renewal row (0.14.0).

## 0.11.0 — LWA secret lifecycle

Pins plugin-contract `>=0.12.1,<0.13`. Seller client secrets declare a 180-day lifetime and
bilingual renewal steps: rotate in Seller Central, allow up to about 15 minutes for Amazon
to propagate the new secret, then update it. A 403 whose details say the secret expired becomes
AUTH_INVALID naming the seller's secret; role denials still name the missing permission.
Seller LWA `invalid_client` also names the secret, and A+ upload preserves this issue. The report
fatal-detail fallback remains PROVIDER_UNAVAILABLE so the next createReport classifies it;
MCF reconciliation deliberately retains UNKNOWN to avoid claiming a write's outcome.

The operator-owned Ads pair declares no renewal row: the shared public Ads app has no expiry,
so `ELTA_OAUTH_APP_SECRET_ROTATED_AT` is retired and ignored if still set, and no date is
inferred from import history. Token refresh is unchanged: ELTA refreshes on demand within five
minutes of expiry and re-seals a rotated token. Rotation must still update the secret in every
ELTA2 Infisical scope holding the pair (`hk-services` prod `/` and `test-shared` prod `/elta2`);
config-roll all three services in each environment. hzads ELTA1 updates its own stored pair through its own path and has no rotated-at
setting. With no customer-owned secret to name, an expired operator secret is REQUEST_REJECTED
(SUPPORT), never a customer reconnect instruction.

Amazon Ads hosts follow the store's sales region: consent uses that region's LWA prefix and the
token exchange uses that region's LWA token host, so an EU or FE advertiser signs into its own
Amazon account system instead of an amazon.com account that may hold no advertising access.

## 0.10.0 — Amazon's own levels, refusals and MCF quote choice

Declares Amazon's vocabulary under plugin-contract 0.12.0: the seven entity levels no other provider
has (`AmazonLevel`) and the five provider-only refusal codes, each naming the standard target reason
it is a case of, so `PLACEMENT`, `PURCHASED_PRODUCT` and the rest leave the shared contract. The MCF
create action gains `quote_choice`, and the reader-less `fba_inbound_plans` and `mcf_orders` datasets
with their three families move to `retired` for the operator retirement run. `exchange()` drops
`selling_partner_id` and `presence_reconcile` is called through the protocol. Stored keys, action
keys and approval hashes are unchanged; the pin becomes `>=0.12,<0.13`.

Private address text now reparses ELTA's retention token without weakening ordinary provider bounds or changing schemas.

## 0.9.2 — keyword ADDs send the parent campaign id

`amazon.ads.add_keyword` and `amazon.ads.add_negative_keyword` now resolve the target ad group's
parent `campaignId` through the same SP ad-group read the ADD preview already uses and send it in
the create body. Amazon SP v3 requires both `adGroupId` and `campaignId`, so the two actions
previously previewed as accepted but were refused at apply with HTTP 400 `INVALID_ARGUMENT`
("Value null at 'keywords.1.member.campaignId'") and wrote nothing. A missing or uneditable
parent now returns the preview's typed prerequisite issue instead of a provider failure. No
wire-contract change: plugin-contract stays 0.11.0.

## 0.9.1 — FBA source address is required in its change schema

`amazon.seller.create_fba_inbound_plan` now declares `source_address.city` and
`source_address.phone_number` required in its change schema, matching the validator it
already had and SP-API v2024-03-20 `AddressInput`. The typed `FbaSourceAddress` owns that
rule, so the rule is no longer duplicated in an after-validator. Requests without either
field were already refused with 422, so no previously accepted request changes; the shared
MCF destination address keeps both optional.

## 0.9.0 — ELTA 3.5 provider cut

Repins plugin-contract 0.11.0, adopts localized declarations and standard ad metrics,
and validates schema-generated dataset payloads. Adds live Seller status/product-type
reads, retires reader-less capture declarations and returns typed resource-not-found
issues. Regional routing and approved-write identity remain intact; live provider
acceptance is separate from this packages-only cut.

## 0.8.0 — neutral Ads intents

Repins plugin-contract 0.10.0, declares seven neutral intents, and adds SP campaign
pause/enable plus ad-group default bids. Campaign state is a separate default-off
switch; stored subclass names, action keys and approval hashes stay unchanged.
Ship with the ELTA 3.4.0 paired contract/client/backend consumer train.


`elta-amz` is the Amazon provider plugin for ELTA. It implements
`elta_plugin_contract` only and owns every Amazon-specific fact: the declaration
(Amazon Ads + Seller Central sources, 18 capturing Ads datasets - 15 ordinary daily
reports plus 3 UNVERIFIED gross-and-invalids - and settings, families,
measures and metrics), LWA consent and refresh, report reads, report mapping and
the metric formulas, including TACOS.

ELTA core owns storage, timing and history; it never imports this package. The
plugin never imports ELTA core or `arch/`.

## Centralized Ads mutations

`AdsActions.read_targets` reads Sponsored Products settings for the declared writes.
It reports current values, labels, presence and typed provider issues; ELTA core owns
policy, re-gating, diffs and pre-write records. `AdsActions.apply` sends only approved
writes and never compares with an earlier preview. Amazon has no conditional writes,
so a provider-side race remains after ELTA's final read. Keyword creation returns its
provider id only when confirmed, authorizing the declared archive inverse.

| Action key | Neutral intent / target level | Store switch | Refresh |
| --- | --- | --- | --- |
| `amazon.ads.set_target_bid` | SET_BID / TARGET | BIDS | SP keyword and target settings |
| `amazon.ads.set_target_state` | SET_STATE / TARGET | BIDS | SP keyword and target settings |
| `amazon.ads.set_campaign_budget` | SET_DAILY_BUDGET / CAMPAIGN | BUDGETS | SP campaign settings |
| `amazon.ads.set_campaign_state` | SET_STATE / CAMPAIGN | CAMPAIGN_STATE | SP campaign settings |
| `amazon.ads.set_ad_group_bid` | SET_BID / AD_GROUP | BIDS | SP ad-group settings |
| `amazon.ads.set_product_ad_state` | SET_STATE / AD | PRODUCT_AD_STATE | SP product-ad settings |
| `amazon.ads.add_keyword` | ADD_KEYWORD / AD_GROUP | KEYWORDS | none |
| `amazon.ads.archive_keyword` | none / TARGET | KEYWORDS | none |
| `amazon.ads.add_negative_keyword` | ADD_NEGATIVE_KEYWORD / AD_GROUP | NEGATIVES | none |
| `amazon.ads.archive_negative_keyword` | none / TARGET | NEGATIVES | none |

Keyword writes have one switch each. The new `amazon.ads.keywords` family ("Add and remove
keywords") allows `add_keyword` and `archive_keyword` only; the existing
`amazon.ads.negative_keywords` family keeps its key for saved policies and is retitled
"Add and remove negative keywords", so it now allows the two negative actions only.

Campaign state has the new `amazon.ads.campaign_state` family, **off by default**;
existing enabled bid/budget families never authorize it. BIDS keeps `amazon.ads.bids`
and explicitly includes the ad-group default bid. Campaign pause/enable uses
`PUT /sp/campaigns` with `state`; ad-group bids use `PUT /sp/adGroups` with numeric
`defaultBid`. Both have EXACT undo by restoring the live before-value: a state toggle
applies over drift, while the ad-group default bid refuses a live bid that moved after the
preview. An SB/SD or unknown id misses the SP settings list and is NOT_FOUND at preview;
archived entities retain the existing typed lifecycle issue.
Product-ad pause/enable (CG-ADS-PAUSE-1) follows the same campaign-state pattern with its own
`amazon.ads.product_ad_state` family ("Pause or enable product ads"), **off by default**: it
reads `POST /sp/productAds/list` for the before-state and sends `PUT /sp/productAds` with
`{"productAds": [{"adId", "state"}]}`. It is SP only; an SD product ad misses the SP list and
is NOT_FOUND. No provider writes are performed by the fixture tests; Asiboan live acceptance remains a later gated step.

ELTA owns the neutral change bases. Amazon retains `SetBid`, `SetDailyBudget`,
`SetTargetState`, `AddKeyword` and `AddNegativeKeyword` as subclasses because stored
outcomes name those concrete types. Field names and approval hashes stay unchanged.
`TargetEnablement` aliases neutral `EntityState`; positive keywords use
`KeywordMatchType.EXACT` (the former `PositiveMatchType` is removed). Amazon's
`AddKeyword` still requires a positive 12-digit/2dp bid; unlike the neutral base it
never admits an omitted or null bid. Later SB/SD support extends these same keys and
dispatches per ad product, rather than declaring competing intent/level actions.

Version 0.6.0 adds the Wave M Seller action schemas and four live lookup handles, all
re-exported from the package root. Seller lookups now include Catalog Items by ASIN,
Listings Items by SKU, A+ documents by ASIN/name/content reference, FBA packing options by
inbound plan, and live FBA inventory by seller SKU. Every lookup uses the supplied account's
region and marketplace with the existing Seller permission; results are returned live and are
not persisted. The listing, A+, packing-option and inventory HTTP reads are shared with their
existing Seller action paths. The public A+ response schema exposes workflow status but no
rejection-reason field, so `rejection_reasons` remains empty. The vendored
`elta-plugin-contract` (`>=0.12,<0.13`) validates action `fidelity`/`drift` metadata.

Product type lookups (additive): `amazon.seller.product_type_search` searches the bound
marketplace by keywords or item name, and `amazon.seller.product_type_definition` reads the
seller-specific Product Type Definition (`sellerId` from the grant). Its JSON Schema body is
fetched only from Amazon's returned `schema.link.resource` through `fetch_guarded` (JSON only,
20 MB, 15 s); `schema_sha256` is the SHA-256 of those exact bytes. An unknown type returns
`PRODUCT_TYPE_NOT_FOUND`; a guard refusal returns a typed issue with the refusal code.

Inbound plan status lookup (additive): `amazon.seller.fba_inbound_plan_status` reads one plan
through the GETs the FBA actions use and returns Amazon's free-string status unchanged (documented:
`ACTIVE`, `VOIDED`, `SHIPPED`, `ERRORED`; newer values pass through), `committed` (placement confirmed, transportation selected or shipped) and the
accepted packing option from the plan's own summaries. An unknown id returns
`INBOUND_PLAN_NOT_FOUND`. `amazon.seller.mcf_order_status` reads one seller order through
`getFulfillmentOrder` and returns its existing typed status. Both reads use the bound
account's regional host. These are live reads, not capture-day or apply-success claims.
The reader-less `amazon.seller.fba_inbound_plans` and `amazon.seller.mcf_orders` datasets
and their three fact families are removed. No action refreshes them. Existing empty
lane/coverage metadata has a bounded dry-run-first operator cleanup in the backend;
it never deletes stored facts and refuses if a retired family contains any.
`LISTING_NOT_FOUND`, `APLUS_DOCUMENT_NOT_FOUND`, `INBOUND_PLAN_NOT_FOUND` and
`PRODUCT_TYPE_NOT_FOUND` now mean `NOT_FOUND / CHECK_REQUEST`, keeping their provider detail.
Amazon Ads state dimensions use uppercase spellings across older/newer API payloads.

Inbound plan history (additive): `amazon.seller.fba_inbound_plans` lists the bound
marketplace's plans, filterable by `ACTIVE`/`VOIDED`/`SHIPPED`, ordered by last update or
creation in `ASC`/`DESC` order, at most 30 per page. Amazon's own filter can leave a page short
or empty while a next cursor is still returned, so an agent keeps paging until no cursor
comes back. The plan's ship-from address is never returned.
`amazon.seller.fba_inbound_plan_items` reads one plan's MSKU lines (identity, quantity, label
and prep owners, expiration and lot code) and `amazon.seller.fba_inbound_plan_boxes` its
packed boxes (contents, dimensions, weight, content source and template name), up to 500 per
page; an unknown plan returns `INBOUND_PLAN_NOT_FOUND`. All three use the FBA inventory
permission of `fba_inbound_plan_status` on the bound account's regional host.

FBA prep details (additive): `amazon.seller.fba_prep_details` reads the bound marketplace's
saved prep details for 1 to 100 exact MSKUs (1-255 characters each, as `mskus`) and returns,
per MSKU, Amazon's own strings: the prep category, the prep types and the prep-owner,
label-owner and all-owners constraints. Amazon refuses a batch that names an MSKU it does not
know (400 "The following MSKUs are not valid: [...]"), which ELTA answers as `NOT_FOUND` with
Amazon's own line, so the caller checks the MSKU; prep entered only in Seller Central is not
returned, so a missing row only means Amazon returned nothing for that MSKU. Reads use the FBA
inventory permission of `fba_inbound_plan_status` on the bound account's regional host, 100
MSKUs per request with 0.5 s between batches (Amazon's 2 requests/s), and persist nothing.

FBA placement, transport and documents (BL-16, additive, never on snapshots):
`amazon.seller.fba_placement_options` and `amazon.seller.fba_transportation_options` read every
Amazon page and answer the whole option set on the first page, each row carrying one
`options_version` (sha256 of the set, `live_reads.placement_options_version` /
`transportation_options_version`, which the confirm reads reuse). Only the first page generates
(free): placement when no unexpired option is OFFERED or ACCEPTED, transport when the query has a
`configuration` (ready-to-ship start, plus pallets and freight per shipment) and no shipment has a
selected transport yet. Generation polls about 20 s inside the 30 s client bound; still running
is a retryable `PROVIDER_UNAVAILABLE`. `amazon.seller.fba_shipment_documents` decides READY,
AWAITING_PLACEMENT, AWAITING_TRANSPORT or NOT_APPLICABLE (no pallets) from getShipment, then
mints v0 box labels (UNIQUE, every box id), pallet labels (PALLET, the pallet count) or the bill of
lading on the confirmation id; the ~15 s link is a result field only, never stored.
`fba_inbound_plan_status` adds the accepted placement and each listed shipment (confirmation id,
status, warehouse, selected transport) from the same single getShipment pass `committed` uses.

FBA placement and transport writes (BL-16) sit in their own family
`amazon.seller.fba_inbound_shipments` ("Confirm or cancel FBA shipments (Amazon fees apply)"),
**off by default**, so the fee-free `fba_inbound_plans` switch never grants them.
`amazon.seller.confirm_fba_placement` and `amazon.seller.confirm_fba_transportation` are ADDs at
INBOUND_PLAN that create `INBOUND_PLACEMENT` / `INBOUND_TRANSPORT` from `placement_option_id`,
quoted, NONE undo, drift REFUSE on the lookup's `options_version`. Their reads only list:
the same confirmed choice is NO_CHANGE, a different one is `WINDOW_CLOSED`, and transport needs
the placement ACCEPTED, one partnered option per placement shipment, one mode (small parcel or
LTL), one carrier for small parcel, and the private top-level `contact` exactly for LTL (copied
into every selection at apply). A lost answer after send (5xx, no operation id, lost poll) is
UNKNOWN with no issue and reconciles from a fresh read; a FAILED operation re-reads Amazon, and a
mixed transport result is UNKNOWN with `NOT_CONFIRMED` per unmatched selection. The transport
APPLIED outcome re-lists the options once for carrier, cost and Amazon's `voidable_until`.
`amazon.seller.cancel_confirmed_fba_inbound_plan` (REMOVE, ELTA-created plans) cancels a
committed plan before transport or before the smallest void deadline, re-reading at apply;
otherwise `WINDOW_CLOSED`. All three are fixture-proven only.

The create preview reads the same details: it refuses a Brazil account before any read, and
otherwise refuses an item only when it breaks an explicit prep-owner or label-owner constraint
(field `/items/<n>/prep_owner` or `/items/<n>/label_owner`), or when a MUST_MATCH all-owners
constraint meets two different owners (field `/items/<n>`), each with `PROVIDER_VALIDATION`. A
missing row, an `UNKNOWN` category or a category without a constraint passes, because Amazon
states its own constraints and validates the rest at apply; an MSKU Amazon does not know
refuses the row with that `NOT_FOUND` answer before anything is sent. A failed FBA operation
reports Amazon's message first, with its details in parentheses, instead of the generic
resource template alone.

## Layout and E1 unit ownership

| Path | Protocol | E1 unit |
| --- | --- | --- |
| `src/elta_amz/declaration.py` | — (Amazon's declaration and its own `AmazonLevel`/`AmazonRefusal` keys) | Ads and seller declarations, including W4.3 mutation actions and the Wave M types-only block |
| `src/elta_amz/plugin.py` | `ProviderPlugin` (handle-keyed dispatch) | frozen |
| `src/elta_amz/lwa/client.py` | shared LWA login (`LwaClient`) | Ads |
| `src/elta_amz/ads/authority.py` | `ConnectionAuthority` | Ads |
| `src/elta_amz/ads/actions.py` | `ProviderActions` | Ads W4.3 mutations |
| `src/elta_amz/ads/readers.py` | `DatasetReader` | Ads |
| `src/elta_amz/ads/mappers.py` | `FactMapper` | Ads |
| `src/elta_amz/ads/profiles.py` | profile discovery parse | Ads |
| `src/elta_amz/seller/authority.py` | `ConnectionAuthority` | seller + TACOS |
| `src/elta_amz/seller/settings.py` | seller deployment settings (public app) | seller + TACOS |
| `src/elta_amz/seller/stores.py` | Seller Central consent sites | seller + TACOS |
| `src/elta_amz/seller/grant.py` | sealed grant shape and issuing app | seller + TACOS |
| `src/elta_amz/seller/readers.py` | `DatasetReader` | seller + TACOS |
| `src/elta_amz/seller/mappers.py` | `FactMapper` | seller + TACOS |
| `src/elta_amz/calculator/__init__.py` | `MetricCalculator` composer (written) | frozen |
| `src/elta_amz/calculator/ads.py` | `MetricCalculator` | Ads |
| `src/elta_amz/calculator/seller.py` | `MetricCalculator` | seller + TACOS |
| `src/elta_amz/calculator/tacos.py` | `MetricCalculator` | seller + TACOS |
| `src/elta_amz/_generated/` | moved generated Ads + selling_partner + report_models clients | read-only |
| `scripts/build_spapi_spec.py` | upstream SP-API spec builder | Ads |

Every remaining stubbed method body is `raise NotImplementedError("E1: <unit>")`.
The dispatcher and calculator composer remain handle-driven; Ads mutation declarations
and provider I/O are owned by the Ads source package.

## Store regions

The core selects `authority(source, region)` before binding. Seller and Ads account
discovery use that region; Ads discovery excludes profiles from other sales regions.
The catalog advertises NA/EU/FE. Ads is unavailable when its operator app settings
are incomplete; Seller is always available, through the seller's own private app and,
when the operator configures the client's public app, through consent too. Ads readers and actions and Seller readers select NA/EU/FE API hosts from
`AccountRef.region` through `elta_amz.regions`, never deployment endpoint settings.
Opaque read handles retain that account region for later polling, downloads and pages;
old regionless handles cannot resume safely and are refused rather than defaulted to NA.
`ELTA_ADS_API_BASE_URL` and `ELTA_SP_API_BASE_URL` are no longer plugin settings.
Seller LWA tokens remain on `api.amazon.com`: SP-API explicitly documents that host for every
sales region. Ads consent and token hosts are region-keyed in `elta_amz.regions` (NA/EU/FE),
because each region's LWA prefix signs the advertiser into a different Amazon account system
(`ADS_AUTHORIZATION_URLS`, `ADS_TOKEN_URLS`). EU/FE routing is proven offline with recorded
request URLs; Ads consent and live capture/write acceptance remain unverified.

## Seller binding: the seller's private app or public-app consent

Seller Central connects with four values from the seller's self-authorized private
SP-API app (CREDENTIALS): **LWA client ID**, **LWA client secret**, **Refresh token**, and
**Seller ID (Merchant Token)**. The LWA client ID is not the Application ID.
The store-binding form sends these values to `POST /v2/connections/credentials`
(`ConnectWithCredentialsRequest.store_id`, `.source_key` and `.fields`); the
`amazon.seller` declaration owns the credential keys. The plugin trims values,
uppercases the Seller ID, checks the entered pair with one LWA refresh, and returns
the validated values for ELTA to seal. Refresh keeps the issuing pair and Seller ID
and replaces only the access and rotated refresh tokens. Only the secret and refresh
token are declared updatable; core owns the update.

B1 adds the CONSENT way beside it once the operator configures the client's public SP-API
app (below). It stays out of the source's `connect` until then, so an unconfigured deployment
offers the private app alone and the source remains available. The seller picks their Seller
Central store, signs in and approves the app; Amazon returns `state`,
`selling_partner_id` and `spapi_oauth_code`, which ELTA exchanges with the public app pair for a
grant sealing only `refresh_token`, `access_token` and `selling_partner_id`. A consent grant's
deadline counts Amazon's 365-day reauthorization rule from its latest successful consent, never
the private secret's 180 days. `ELTA_LWA_TOKEN_URL` optionally overrides the token endpoint for
Seller apps, private and public; Amazon Ads consent, token and API hosts follow the store's
sales region (NA/EU/FE).

Seller LWA `invalid_client` for the seller's own private app is `AUTH_INVALID`, naming the client
secret, so the seller can update a rotated secret; for our operator public app, and for Ads, the
same code stays `REQUEST_REJECTED`. `invalid_grant` requires reconnect. Permission hints translate
Keble's instructions into English and Chinese while keeping Amazon's role, permission, and menu
names in English inside both texts.

### Operator setup for the public app

1. In Seller Central > Apps and Services > Develop Apps, register the public SP-API app and
   note its **Application ID** (`amzn1.sellerapps.app.…`, for the consent URL) and its **LWA
   client ID/secret** (`amzn1.application-oa2-client.…`, for the code exchange and refresh).
2. Register the app's **Log-in URI** as the keble.ai page that forwards the signed-in seller to
   Amazon's callback (`/stores/login`) and its **Redirect URI** as `/stores/callback`.
   The log-in page accepts `amazon_callback_uri` only over HTTPS from the saved chosen site's
   origin, and forwards `version` exactly as received. Both pages send
   `Referrer-Policy: no-referrer`.
3. Write `ELTA_SELLER_PUBLIC_APP_APPLICATION_ID`, `ELTA_SELLER_PUBLIC_APP_CLIENT_ID` and
   `ELTA_SELLER_PUBLIC_APP_CLIENT_SECRET` **only in the environment whose `/stores/login` Log-in
   URI is registered on the Amazon app** (that environment is where Amazon returns the callback),
   then config-roll the three elta2 services there. Whether one Amazon app accepts more than one
   Log-in URI is unconfirmed; confirm it in Developer Central before activation. A keble-test
   check therefore needs either the app pointed at keble-test first or a second Draft app
   registered for it. While the app is a Draft leave `ELTA_SELLER_PUBLIC_APP_DRAFT=true` (which
   adds `version=beta` to the consent URL); set it to `false` after Amazon publishes the app.
   CONSENT is hidden until all three values are set.
4. Amazon forces every LWA client secret to rotate about every 180 days. When the public pair
   is rotated, write the new secret and `ELTA_SELLER_PUBLIC_APP_SECRET_ROTATED_AT` (that real UTC
   instant, ISO 8601) in the scope that holds the pair (step 3's environment; a second keble-test
   Draft app has its own scope and its own rotation), then config-roll that environment's elta2
   services. Readiness
   then reports `app_renewals` for `amazon.seller` as that instant + 180 days; with no declared
   instant the configured pair still warns with a null `renew_by`. No instant is inferred from
   import history. An expired public secret is ours to rotate: its 403 is `REQUEST_REJECTED`
   (SUPPORT), never a customer reconnect.

## Generated clients

`src/elta_amz/_generated/` is moved verbatim from ELTA1
`elta.backend/amazon/access/_generated/` with only import paths fixed
(`amazon.access._generated` -> `elta_amz._generated`, `schemas` ->
`elta_amz._generated._schemas`). The two hand-written base modules are
self-contained: the `keble_helpers` model config is inlined as a pydantic
`ConfigDict`, and ELTA1's raw-response persistence classes are dropped because
ELTA2 archives raw bytes and stores change-only facts. `pyrightconfig.json`
ignores the generated implementations (types are still resolved on demand), and
ruff excludes the directory.

ELTA1's `amazon/access/generated/*.pyi` overlay was not moved: 185 of its 200
stubs described a `persistence/` wrapper layer that no longer exists, and the
remaining `Any` stubs would shadow the real generated models, whose types the
pyright `ignore` rule resolves on demand (verified: a bogus field is still
reported).

ELTA1's operation registry (`amazon/access/common/catalog/_operation_registry.py`,
10M) and API spec (`amazon/access/_api_spec/`, 32M) were left behind: no moved
generated module imports them; E1 decides whether a reader needs them.

## Checks

```bash
uv sync
uv run pytest -q
npx --yes pyright .
uv run ruff check .
```
