Metadata-Version: 2.5
Name: elta-shopify
Version: 0.17.1
Summary: The Shopify provider plugin for ELTA: Admin GraphQL orders, inventory and costs.
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: pydantic-settings<3.0.0,>=2.10.1
Requires-Dist: pydantic<3.0.0,>=2.12.0
Description-Content-Type: text/markdown

# ELTA Shopify plugin 0.17.1

## 0.17.1 — product media display facts

- `ProductMediaFact` (inside the display-only `ProductMediaTargetFacts` of
  `shopify.admin.product_media.set`) gains optional `preview_url` (HTTPS `DisplayImageUrl`, from
  Shopify's `preview.image.url`), `media_type` (open `mediaContentType` text) and `alt_text`, read in
  the same bounded `ProductMediaState` query (measured requested cost 72 -> 94). Facts stay
  display-only: not the drift token, the undo baseline or approval identity.
- Absent values are omitted from stored and hashed bytes, so facts without them keep their 0.17.0
  bytes and hashes. 0.17.0 rejects the new keys, so once a proposal stores facts with any of them
  the rollback floor is the backend that vendors 0.17.1. No new action, scope or permission.

## 0.17.0 — product image list SET

- New `shopify.admin.product_media.set` (SET on `PRODUCT`, products family): the product's whole
  ordered image list; the first item is the main image (see "Product images" below). It requires
  `read_products`, `write_products` and `write_files`; `write_files` is declared on this action
  only, so a store without it gets `PERMISSION_MISSING` (grant hint + Sync now) before any call and
  no other action changes. Existing actions, change models, bytes and hashes are unchanged.
- A stored proposal with this action cannot decode on 0.16.0, so once one is stored the rollback
  floor is the backend that vendors 0.17.0. Its undo also needs that backend's created_version SET
  fence.

## 0.16.0 — create status inputs and List on Online Store

Backend 0.31.0 vendors it with authz generation 5.

- New default-off family `shopify.admin.product_publication` with
  `shopify.admin.product.publish_online_store` and its revert-only undo
  `shopify.admin.product.unpublish_online_store`; both require `write_publications` and
  `read_products` (see "Online Store listing" below). The family is off until a store's stored
  enabled list includes it. It is not fee-bearing, so the Allow-all choice covers it, but a store
  that chose Allow-all before 0.31.0 sees `FAMILY_OFF` with the existing "Turn on" remedy.
- A publish is APPLIED only on empty `userErrors` plus a confirming re-read of the same
  publication id; otherwise it is UNKNOWN and never resent.

`article.create` and `page.create` accept `is_published`, defaulting to `false`; `product.create`
accepts `DRAFT` or `ACTIVE`, defaulting to `DRAFT`. The approval fields and provider mutation inputs
carry the selected values. ACTIVE makes the product ready to sell; whether it appears on the Online
Store depends on the store's sales-channel settings (Shopify publishes it only if that channel
auto-publishes); ELTA lists a product on the Online Store only through the separate
"List on Online Store" action (`shopify.admin.product.publish_online_store`).

A stored proposal with `status` ACTIVE, `is_published` true or a publication action cannot decode
on 0.15.1 (Literal/unknown action), so once one is stored the rollback floor is backend 0.31.0.

## 0.15.1 — plugin-contract compatibility

Accepts plugin-contract `>=0.16.0,<0.17` in the coordinated backend 0.29.0 release.
Constraint-only repin: no runtime code, declaration, catalog byte or facts emission changes.

## 0.15.0 — row thumbnails and merchant action titles

Previews now fill the existing display-only `TargetState.image_url` with the target's https
image: products (description, delete, metafield and SEO rows) use `featuredMedia`'s preview
image, collections and articles (metafield and SEO rows, article update and delete) their
`image`; pages have no image, so page rows stay without one. `file_alt.update` has carried the
MediaImage URL since 0.13.0; a GenericFile has none. The image never enters identity, the
version token or any proposal hash. Four action titles now use merchant words:
`article.create` "New blog post"/"新博客文章", `article.update` "Blog post text"/"博客文章正文",
`page.update` "Page text"/"页面正文" and `product_description.update`
"Product description"/"商品描述". Catalog copy only: action keys, change schemas and stored
records are unchanged. Backend 0.27.0 vendors it.

## 0.14.0 — lookup rows carry storefront handles

`ShopifyProductDescriptionRead`, `ShopifyBlogRead` and `ShopifyArticleSummaryRead` gain an
optional `handle`, and `ShopifyArticleRead` an optional `blog_handle`, read in the existing
queries, so a consumer can build the storefront URL without another lookup. All fields are
additive and optional; no write, change schema, stored key or hash changed. Backend 0.25.0
vendors it.

## 0.13.0 — lookups say what they answer; an alt-text row shows its image

The ten store-reading lookups now declare a one-line meaning: **Product list**, **Products and
media**, **Blogs**, **Blog articles**, **Pages**, **Page content**, **Article content**,
**Themes**, **Theme files** and **Theme preview render**. Each names what the read returns and
which other lookup supplies its inputs, and none claims a search, sort or filter the list query
does not accept. The wording belongs to the plugin that answers the read.

An alt-text preview now carries the target image: the file-alt read selects a MediaImage's
`image { url }`, and the read target reports it as a display-only image URL. A GenericFile has no
image, and a non-https or malformed URL becomes null rather than failing the read. The shared
"File alt text" lookup keeps parsing, and no provider call, change schema, stored key or hash
changed.

Requires plugin-contract `>=0.14.4,<0.15` for `Lookup.meaning` and the display image URL type;
the vendored development wheel is that release. Backend 0.24.0 vendors it.

## 0.12.0 — new search changes use the search actions

New SEO changes must use the SEO actions; undo of earlier generic SEO changes still works.
Generic `*_metafield.set` reads of `global.title_tag`, `global.description_tag` and `seo.hidden`
now carry `SEO_FIELD_REVERT_ONLY`, meaning `REVERT_ONLY`. The typed `*_seo.update` actions and
non-SEO metafields are unchanged. The refusal title directs callers to **Manage search visibility**.

Requires plugin-contract `>=0.14.3,<0.15`: older registration laws refuse this declaration.
Backend 0.23.0 enforces the direct-only meaning and vendors both new wheels. No wire-contract
change or consumer repin; the rollback floor stays 0.22.0. The stored `seo.hidden="0"` limitation
is unchanged.

## 0.11.0 — typed search-visibility writes

Adds `shopify.admin.{product,collection,page,article}_seo.update` to **Manage search visibility**
(`shopify.admin.seo`). Each row uses `SeoFieldUpdate(seo_field=TITLE|DESCRIPTION|HIDDEN, value=...)`:
TITLE/DESCRIPTION accept text or `None`; HIDDEN accepts only integer `1` to hide or `None` to
clear the override and show again. Each field reuses the existing metafield read, compare-digest
write and delete path, with the same conflict field as the generic set and an exactly typed undo.
No new GraphQL document or permission is added.

This cut is additive. The generic metafield sets still accept SEO fields; a later release refuses
them for new changes, so use `*_seo.update`. The generic change class and action declarations are
unchanged. Once a consumer files a typed SEO change, ELTA must not roll back below 0.22.0;
the rollback floor is otherwise 0.21.0.
Inherited limitation: a stored `seo.hidden="0"` cannot be read by either metafield path; mapping it
to absence would break exact undo, so this release does not change that behavior.

## 0.10.5 — 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. Shopify declares neither, so its removal stays `LOCAL` without a URL: the grant is
per shop, and a wrong link would be worse than none. No declaration, provider call, capture, lookup
or write changed.

## 0.10.4 — an Admin 401 is a broken grant, not a missing permission

Shopify answers 401 when the stored Admin token is dead or refused, which is a reconnect rather
than a scope to add, so it now maps to `AUTH_INVALID`. A 403 still maps to `PERMISSION_MISSING`
with the permission to grant, and 429 and the 5xx codes are unchanged. Capture already renews one
refused secret before giving up, so a token that merely expired is renewed rather than condemned.

## 0.10.3 — neutral customer text

The page, product, redirect and theme undo notes now name the original change instead of ELTA,
and the theme-delete action description says which target is refused without naming the service.
Display text only: no schema, action behavior, provider call, key or hash changed, so stored
proposals decode and hash exactly as before.

## 0.10.2 — truthful theme-file undo notes

The three theme-file undo notes (`theme_file.create`, `.update`, `.delete`) now carry one static
text that is true for any target theme: the shopper effect is stated as a live-theme condition, and
an unpublished theme stays unseen unless someone shares its preview link. The display text changed;
no schema, action behavior or provider call changed, and stored keys and hashes stay identical.

## 0.10.1 — callback-account-id exchange compatibility

The unused CREDENTIALS-source `exchange` now accepts and ignores the new optional
`callback_account_id`, replacing the stale SP-API `selling_partner_id`. Repins plugin-contract
0.13.0 (`>=0.13,<0.14`); provider behavior is unchanged.

## 0.10.0 — Shopify's own levels under the 0.12 contract

Declares the eight store-object levels no other provider has (`ShopifyLevel`), with `THEME_FILE`
scoped to `THEME` by its `parent`, so the shared contract keeps no Shopify word; the Shopify template
prose moves out of the Protocol docstrings into the plugin. `presence_reconcile` is called through the
protocol instead of reimplemented, comparisons use `==`, and the pin becomes `>=0.12,<0.13`. Stored
keys and hashes are unchanged.

## 0.9.2 — storefront renderer uses the shared identified-crawler UA

The transient storefront preview renderer now sends Keble's shared identified-crawler token
`ELTA/3.5 (+https://keble.ai/data-crawler)` instead of `ELTA/3.5 (+https://keble.ai; theme
preview)`. Shopify's unsigned-bot storefront limits (in effect May 2026) answer other user-agent
forms with HTTP 429, while this token passes the full render path (HEAD theme pin, `/password`
POST, template GET). The UA stays identifiably ELTA/Keble and is never a browser user agent;
this is interim until Web Bot Auth request signing lands (CG-80). No wire-contract change:
plugin-contract stays 0.11.0.

## 0.9.1 — deleted-theme GraphQL NOT_FOUND

A theme read for a theme Shopify has deleted now recognizes the provider's typed GraphQL
`NOT_FOUND` code as an absent theme. `theme.delete` previews it absent and applies `NO_CHANGE`
(the copy is already gone), while theme-file writes and `theme.duplicate` keep their typed
`THEME_NOT_FOUND` read issue. No wire-contract change: plugin-contract stays 0.11.0.

## 0.9.0 — ELTA 3.5 provider cut

Repins plugin-contract 0.11.0; adds transient storefront theme rendering, product
listing, typed article images and truthful undo notes/version checks. Generated
Admin dataset models preserve archive bytes; test orders are excluded, cancellations
reverse net sales, and quantity-aware refund subtotals feed the same deduction.
Full Admin reconnect repairs required authorization independently of optional
storefront-password edits. Live storefront/provider acceptance remains separate.

## 0.8.1 — content File readiness

Forward-ports the reviewed ELTA 3.3.1 image-readiness fix onto the 3.4.0 / backend
0.11.x line. Uploads, image-preparation reads and READY polling share one 20-second
apply deadline; rejected rows check cleanup receipts for their own created Files.
The plugin-contract pin remains 0.10.0; backend's separate 0.11.1 cut is unchanged.

## 0.8.0 — coordinated contract repin

Repins the vendored plugin-contract to 0.10.0 for the ELTA 3.4.0 train; Shopify's
provider behavior is unchanged. Consume the new wheel with backend 0.11.0.


`elta-shopify` is the Shopify provider plugin for ELTA. It implements
`elta_plugin_contract` only and owns every Shopify-specific fact: the declaration
(one `shopify.admin` source with orders, inventory and variant-cost datasets plus
live theme lookups), merchant credential binding (client-credentials grant), GraphQL
Admin reads, order and refund mapping, and the metric formulas.

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

## Layout and E1 unit ownership

| Path | Protocol | E1 unit |
| --- | --- | --- |
| `src/elta_shopify/declaration.py` | — (Shopify's declaration and its own `ShopifyLevel` keys) | frozen |
| `src/elta_shopify/plugin.py` | `ProviderPlugin` (handle-keyed dispatch) | frozen |
| `src/elta_shopify/admin/client.py` | Admin GraphQL client (new, no port source) | Shopify |
| `src/elta_shopify/theme_reader.py` | Shared theme GraphQL reader | Theme actions and lookups |
| `src/elta_shopify/theme_render.py` | Transient storefront preview reader | Theme render |
| `src/elta_shopify/lookups.py` | `LiveLookups` | Themes and selected theme files |
| `src/elta_shopify/actions.py` | `ProviderActions` router | Declared writes |
| `src/elta_shopify/publication_actions.py` | Online Store listing face | Product publication writes |
| `src/elta_shopify/media_actions.py` | Product image list face | Product media writes |
| `src/elta_shopify/authority.py` | `ConnectionAuthority` (client-credentials grant) | Shopify |
| `src/elta_shopify/readers.py` | `DatasetReader` | Shopify |
| `src/elta_shopify/dataset_documents.py` | Canonical dataset GraphQL operations | Shopify |
| `src/elta_shopify/_generated/` | Schema-derived dataset query/node models | Shopify |
| `src/elta_shopify/mappers.py` | `FactMapper` | Shopify |
| `src/elta_shopify/calculator.py` | `MetricCalculator` | Shopify |

Dataset query-result models are generated offline from the pinned Admin schema;
the transport client remains hand-written. No merchant Admin GraphQL client exists
in the workspace (`.artifacts/elta2/PORT_SOURCES.md` §2), so `admin/client.py` is
written from the Admin API contract. It posts one GraphQL document, respects the
cost bucket Shopify reports in `extensions.cost`, and retries a THROTTLED response.
Readers page GraphQL by cursor and archive each fetched page as self-contained JSON
Lines parts (one provider record per line, at most 8 MiB each). Orders resume the
`updated_at` change lane; refunds count on their processed day; inventory levels and
variant costs are snapshots dayed on the capture day. The calculator computes the
declared metrics from summed daily operands and leaves a ratio unmeasurable on a
zero denominator.

Entered shop hosts normalize to `*.myshopify.com`. An invalid domain returns the typed
credentials refusal before token exchange; arbitrary URLs never receive app credentials.
The regional plugin signature is accepted, but Shopify accounts remain nonregional.

Version 0.5.0 exposes `ShopifyActions.read_targets` for the full canonical product-tag
list and title, then `apply` sends only the approved full-list replacement. ELTA core owns
policy, re-gating, diffs, and pre-write records. Shopify has no conditional tag write, so
there is an unavoidable provider-side race after ELTA's final read.

The runtime-free declaration exports typed action payloads and live lookup schemas from the
package root. `shopify.admin.product_list` is a registered, cursor-paged `read_products`
lookup returning only product id, title and handle. Exact article reads include article/blog
display labels, which do not enter signed identity, and one `ShopifyArticleImage` containing
ArticleImage id, source URL and alt text (not a Files id). The seven exact-resource not-found
codes are `NOT_FOUND / CHECK_REQUEST`; provider codes/messages remain support detail. The theme lookups are `shopify.admin.themes` and `shopify.admin.theme_files`, both
requiring `read_themes`. `ThemeFilesQuery` selects 1–50 distinct paths from one
`gid://shopify/OnlineStoreTheme/<number>`; `include_body` defaults to false, and requested bodies
contain text only. Both lookups use Shopify cursors and the same shared theme reader as theme
writes and page-template validation. An unknown theme returns the typed `THEME_NOT_FOUND` issue;
a selected file that Shopify does not return yields no result row.

## Online Store listing

`shopify.admin.product.publish_online_store` lists one existing product on the shop's Online Store
publication (resolved per shop by the `online_store` app handle) in the default-off
`shopify.admin.product_publication` family; its revert-only undo unpublishes only that
publication and never changes product status. Both need `write_publications` and
`read_products`; ELTA core refuses a missing one with `PERMISSION_MISSING` before any product or
publication call. The drift token is the publication id plus the listed flag, so a reinstalled
channel or a listing changed by someone else refuses `BASE_CHANGED`, and undo of a listing on a
replaced publication refuses `CHANGED_SINCE_APPLY`. A product created in the same batch is listed
by a second proposal on the returned product ref.

## Product images

`shopify.admin.product_media.set` takes `media`, an ordered list of existing media ids (on the
product, or a Files id to attach) and HTTPS URLs (Shopify fetches them). Apply runs: add each URL
image (`productUpdate(media)`, one per call) -> wait until READY or FAILED -> one `fileUpdate`
that attaches listed Files and detaches omitted media (`referencesToRemove`; the file stays in
Files, it is never deleted) -> `productReorderMedia` and its job -> reread. A reread showing the
requested ids in order is APPLIED with `created_version`; a refused first write that changed
nothing is REJECTED; anything else is UNKNOWN ("Images partly changed; check the product") and is
never resent. An UNKNOWN row keeps the reread token as `created_version` only when every step
settled first (each added image READY or FAILED, the reorder job done, every failure a real
Shopify answer). A step still pending (an image processing or a job queued when the wait ended,
a lost answer) makes the row unsettled: undo refused; re-file. It records
`created_version = "__ELTA_SHOPIFY_MEDIA_UNSETTLED__"`, which never equals a live token, so undo
always refuses `CHANGED_SINCE_APPLY`, even after another writer sets the requested list. Only a
lost apply answer (core's bare UNKNOWN, no token) keeps the value comparison. A FAILED image is reported
at `/media/<i>/original_source`. An empty list detaches every image. Omitting a YouTube/Vimeo
video is refused (it is not a File). Shopify product media in one change are limited to 250: a
list of 251 items, a product already holding more than 250, or adds that would briefly take the
product past 250 before the detach are refused before any write.

The drift token is `media:<ordered ids>` plus `|missing:<ids>` when a listed existing id is in
neither the product nor Files. Undo is a SET back to the before ids, fenced by `created_version`,
so a later edit or a deleted before-file refuses `CHANGED_SINCE_APPLY`. Fidelity is BEST_EFFORT:
removing an image clears it from variants that use it, and undo does not re-select variant images.
Preview facts (`ProductMediaTargetFacts.media[].used_by_variants`) flag each current image a
variant shows, so an approval can warn only on removals that clear a variant image;
`variants_read_complete` is false when the product has more than 100 variants or a variant
shows more than 3 media.

## Theme preview and file readiness (3.5 implementation)

`shopify.admin.theme_render` accepts a theme GID, a rooted storefront path, optional
`view` and `section_id`. It pins the theme through `preview_theme_id`, then uses the
storefront password form and `_shopify_essential` cookie as a visitor. It sends ELTA's own
user agent, never the Admin access token or a Shopify CLI identity. Password protection
is established from the actual password form, not a `/password` redirect. Missing/wrong
passwords return `PERMISSION_MISSING` with `STOREFRONT_PASSWORD_REQUIRED/INVALID`; they do
not put the Admin connection into reconsent. Rate limits and bot challenges return
`RATE_LIMITED` with a retry instant. Redirects stay on the same shop's HTTPS origin.

The optional `storefront_password` may be omitted on CONNECT and is edited separately
as an updatable secret on a healthy held connection. A RECONSENT_REQUIRED connection
uses full Admin credentials through the existing reconnect path, never the password
editor (PD-24). Admin-token refresh preserves the entered password. No lookup returns passwords or cookies. Only session
cookies are cached, in process per connection/theme, for
`ELTA_SHOPIFY_STOREFRONT_COOKIE_TTL_SECONDS` (default **1800 s**). Credential changes invalidate
that session. HTML is streamed to `ELTA_SHOPIFY_THEME_RENDER_MAX_HTML_BYTES` (default
**2,000,000 bytes**), marked `truncated` when capped, returned transiently and never cached,
stored or logged. Page-reported theme name/role are absent for section-only HTML.

The themes lookup includes `processing` and `processing_failed`. Theme-file writes to a
processing theme are **REJECTED**, retryable `THEME_PROCESSING`, before sending any mutation.
Upserts wait at most `ELTA_SHOPIFY_THEME_FILE_JOB_WAIT_SECONDS` (default **10 s**) for their
returned job to finish; a deadline leaves the write **UNKNOWN**, never resends it, and the
existing target body/checksum read supports reconciliation. Theme duplication remains
APPLIED once Shopify returns the new theme, even if the copy is still processing.

Recorded-shape HTML fixtures use synthetic names/text only. Wave 2 runs mock tests; real
storefront acceptance belongs to the separate read-only Wave 4 check.

## Content image preparation

`ShopifyContentSettings` is the plugin-local settings owner, injected into the content
writer when the action router is built. `ELTA_SHOPIFY_FILE_READY_TIMEOUT_SECONDS`
defaults to **20 seconds per apply call** and `ELTA_SHOPIFY_FILE_READY_POLL_INTERVAL_SECONDS`
to **1 second**. One request-local deadline starts at the public `ShopifyActions.apply`
entry, before any row or upload, and is shared by every row's uploads, body/cover readiness
polls, image-preparation reads (the article.delete cover restore-source reread) and
processing-lock cleanup retries. Each of these, including its transport and throttle waits,
is bounded by the remaining deadline; later rows needing Files are refused without uploading
once it expires, and an article.delete whose restore-source reread cannot start or finish in
time is `REJECTED / FILE_NOT_READY` before any delete is sent. Because its cover is unknown
until that reread, an article.delete after earlier rows spent the budget is refused (retryable)
even if it has no cover. Other rows without images can still apply. Created File ids remain per row, never shared cleanup candidates, and are recorded the
moment the Admin client returns them, so a deadline cancellation after Shopify answered still
leaves them for cleanup. A File whose id never reached the client is an unavoidable residual.

File creation and existing-file resolution wait for a CDN URL; processing failures
reject the unsent content write at its image pointer, and readiness timeouts reject it
with the existing `PROVIDER_UNAVAILABLE` issue (`FILE_NOT_READY`), which is retryable
later. Upload timeouts also return `REJECTED / PROVIDER_UNAVAILABLE / FILE_NOT_READY`, never
`UNKNOWN`, before sending the primary mutation. One-second polling avoids hammering an operation
that polling cannot accelerate. Neither number is a Shopify guarantee. The authorized live C3
rerun measures real readiness latency; if routine preparation exceeds 20 seconds, async apply
is the ELTA 3.5 answer.

The guarantee is narrow: **image work is bounded at 20 s per apply, plus at most one 1 s
cleanup allowance; pre-existing AdminClient throttling on non-image reads and on primary
writes is unchanged.** The caller's whole-apply timeout remains **30 seconds**, but it is not
an enforced bound on primary writes or on the page-template preflight read, a pre-existing
3.3.0 AdminClient throttle residual. A sent or ambiguous write must not be relabelled
`REJECTED` or have referenced Files deleted.

Each rejected row attempts one `fileDelete` for only the Files ids it created, including
a pre-delete cover copy. A `fileDelete` that Shopify still reports as locked or processing
is retried, for the ids it did not confirm, only while the apply's shared preparation budget
remains. After expiry, the one rejected row that was preparing still gets one attempt bounded by
the polling interval, with no fresh processing-retry budget; later rows hold no new Files. A
cleanup that
leaves ids unresolved keeps the row's original issue or refusal and adds one
`FILE_CLEANUP_UNCONFIRMED` row problem naming them, so the owner can see the remaining
orphan risk. Confirmed or uncertain primary writes retain their Files because the content
may reference them; existing Files references are never cleanup candidates.

### 0.8.1 release notes (forward-port of ELTA 3.3.1)

If Shopify throttles the main write (or a non-image read such as the page-template check), the apply may still take longer than 30s; the change then shows as not confirmed (结果待确认), never as failed. Image work alone is bounded at 20s per apply.

## Checks

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

`tests/test_graphql_documents.py` validates every module-level query and mutation against
Shopify's Admin schema for `DEFAULT_API_VERSION` (`tests/fixtures/admin_schema_<version>.graphql`),
and the offline action/lookup fakes also coerce each request's variables against it. After
changing the API version or adding a document that reaches new schema types, regenerate the
pruned fixture from Shopify's public, unauthenticated explorer proxy (no store credentials):
`uv run python scripts/build_admin_schema_fixture.py` (or `--introspection saved.json`).

### Dataset result generation (F-2)

Run `uv run python scripts/build_dataset_models.py` after changing a dataset operation
or the pinned schema, then `uv run python scripts/build_dataset_models.py --check`.
The Git-pinned script uses `graphql-core==3.2.13` (also pinned in `uv.lock`) and records
schema/operation SHA-256 in each generated module. It validates all three operations before
writing. `tests/test_generated_models.py` checks exact declaration-key coverage, recursive
selected-field totality, generated artifact parity, lossless archival and baseline fact hashes.
Only dataset operations are generated; lookup/action results are outside F-2.

Generated models retain unknown fields (`extra="allow"`); omissions are test-visible, not
runtime provider failures. Wire `DateTime`/`Decimal` values remain strings until mapping,
and archival uses aliases plus `exclude_unset=True`, preserving unknown keys and historical
absent/null fields without normalizing bytes. Explicit compatibility exceptions are
`Order.test` (absent in older archives, represented as unknown), `Refund.processedAt`
(historical unprocessed refunds accepted as null) and `RefundLineItem.subtotalSet`
(absent in price-only legacy archives). They are documented in the generator.

Current refund facts use Shopify's quantity-aware `subtotalSet`, not the per-unit
`priceSet`, and that same amount feeds cancellation deduction (PD-23). Price-only
legacy archives keep their historical amount without multiplying quantity or rewriting
bytes. Existing REFUNDS facts are re-derived under unchanged identities on the next
capture; old archives are not reinterpreted.

The orders operation now captures `test` and `cancelledAt`; test orders produce no facts
and complete their retirement parent so previously counted test children are removed.
A cancellation is a sales reversal inside the existing `shopify.refunds` family, with no
new family: one row per line under the stable identity `(cancel:<order_id>, <line_item_id>)`,
dated on the cancel instant's shop-local day, carrying the line's product/variant dims and
the order parent. Its amount is
`max(0, line gross − line discounts − that line's actual refund rows known at this capture)`,
re-derived under the same identity on every capture, so a refund arriving after the
cancellation lowers it and the reversal never exceeds the line's net. It prices money only:
PD-16 defines a cancellation as a monetary sales reversal, not a physical return, so
`refunded_quantity` stays unknown rather than invented. The order count is unchanged — a placed
order still counts on its creation day. No live writes run here.
