Skip to content

Let a format name the viewport widths at which the page asks for it - #1317

Closed
Jamesr51d wants to merge 674 commits into
IABTechLab:mainfrom
jwrosewell:split/29-viewport-sizes
Closed

Jamesr51d wants to merge 674 commits into
IABTechLab:mainfrom
jwrosewell:split/29-viewport-sizes

Conversation

@Jamesr51d

@Jamesr51d Jamesr51d commented Oct 12, 2026 •

Copy link
Copy Markdown

Stacks on device signals (#1316). The additions that follow the seven pull requests open here are listed in #777.
Compare split/28-device-signals with split/29-viewport-sizes
to see only this pull request's change.

What this pull request does

[[creative_opportunities.slot]]
id = "billboard"
page_patterns = ["/**"]
formats = [
  { width = 970, height = 250, min_viewport_width = 1040 },
  { width = 728, height = 90, min_viewport_width = 800, max_viewport_width = 1039 },
  { width = 300, height = 250, min_viewport_width = 600, max_viewport_width = 799 },
  { width = 320, height = 50, max_viewport_width = 599 },
]

A format may name the viewport widths, in CSS pixels, at which the page asks for it, so a slot asks for a large size only where the page has room for one. Either bound may be left out, and a minimum above a maximum is refused when the settings load. When any format of a slot names a bound, the slot's page data carries viewport_sizes, one entry for each band of widths, widest first. A slot whose formats name no bound asks for every format at every width, as before.

The bands reach two places in the browser. The Google ad tag's head bootstrap and the bundle's slot definition apply them as a size mapping through defineSizeMapping, so the ad server request carries only the band's sizes. The bundle's refresh auction offers a bidder only the sizes of the band the viewport is in, and leaves a slot whose band asks for no size out of the auction. The server-side auction does not filter its sizes and is still offered every format.

A binary released before these fields refuses a format carrying either of them, which fails every request, so the binary that reads them is promoted before any configuration uses them. The configuration guide says so.

Why

A page that shows a billboard on a wide screen and a small banner on a phone has one slot and several formats, and without bounds the slot asks for every format at every width, so a bidder is offered a billboard the page cannot show. The bounds are the publisher's statement of what fits where. The ad tag passes them to the ad server as its size mapping, and the refresh auction holds a bidder to them.

How it was verified

CI passed on fc048f625 on the fork. Not exercised: the ad server's own handling of a size mapping. The tests prove the mapping is defined with the bands the page data carries, through the bootstrap and the bundle.

References #1287.

`GET /_ts/admin/ec` and `GET /_ts/admin/ec/{id}` read any reader's identity
graph record, and `GET /_ts/admin/eids` decoded the caller's EID cookies,
on the publisher's own domain behind one shared password. `GET /_ts/data`
shows a reader what is held against that reader's own Edge Cookie and asks
for no password, so the operator lookups go.

What goes with them: `ec/admin.rs`, `KvIdentityGraph::lookup_raw`, the
decoder's diagnostic view of an EID cookie, the two probes that established
handler coverage for the lookup by id, the adapters' routes and their
not-supported stand-ins, and the tests and guide sections for all of it.

What stays is the check that keeps administration paths away from the
origin. It moves to `closed_paths.rs` as `closed_path_response`, with the
matcher unchanged: every path beneath `/_ts/admin`, and the `/admin/keys`
alias beside it, as sent and after each of up to four percent-decoding
rounds. A request there that no route claims is answered `404` by the
server, so a caller's `Authorization` header and body are never forwarded.
The three removed paths are answered that way for every method, where a
method other than GET was answered `405`.
`POST /_ts/admin/keys/rotate` and `POST /_ts/admin/keys/deactivate` let
anyone holding one shared password replace the keys the service signs with,
over the domain every reader reaches. `ts keys rotate` and
`ts keys deactivate` do the same work from an operator's machine with the
operator's own token, so the two routes and their handlers go. Only Fastly
served them. Axum, Cloudflare and Spin registered them to answer `501`.

The `/admin/keys/rotate` and `/admin/keys/deactivate` routes go with them.
They existed to refuse those paths, so that a call to one was not forwarded
to the origin with its credentials. `closed_path_response` already answers
every path beneath `/admin/keys` and `/_ts/admin` before the publisher
fallback, on each adapter, so the four paths are still answered `404` here
for every fallback method. Each adapter's
`key_administration_paths_are_answered_here_and_never_proxied` holds that
with the credentials a caller would send.

`kid_is_creatable` and the two validators behind it stay in
`request_signing/endpoints.rs`, because `ts keys` refuses a key id with
them before it writes.

The tests of the password challenge ask at `/_ts/admin/anything`, since the
path they asked at is not a route any more, and the two probe tests take
`/first-party/proxy` as their named route for the same reason.

The key rotation guide's diagram had `current-kid` written before
`active-kids`. The code writes `active-kids` first, and the diagram now
agrees with the text beside it.

`[request_signing]` still requires `config_store_id` and `secret_store_id`,
which only the removed handlers read. They go in the next commit.
`[request_signing]` required `config_store_id` and `secret_store_id`, the
platform ids of the two stores the keys live in. Only the removed rotate
and deactivate handlers read them, to write those stores. The service finds
its keys by the names the stores are linked under, `jwks_store` and
`signing_keys`. With the handlers gone, the two ids were settings an
operator had to supply with real values for nothing to read.

They go, with the placeholder and padding checks on them in startup and
deploy validation, and the auction plan's rule that enabled signing needs
both. `ts keys` takes the ids on its command line.

A configuration that still carries either key is refused with directions,
through the refusal the removed tables use, when it is validated and when
the service starts. Serializing the settings writes `enabled` alone, so a
configuration the service writes is one it reads back.

Fixtures whose `[request_signing]` section held only the two ids now say
`enabled = false`, which is what an absent `enabled` meant.
`management_api.rs` was a client for the Fastly management API. The
deployed service read an API token from a secret store named `api-keys` and
used it to write its own config store and secret store, which is how the
key rotation routes stored a new key. Nothing calls it now that keys are
rotated with `ts keys`, so the client goes, with the `api-keys` entries in
`fastly.toml` and the Viceroy template.

The Fastly platform's store writes, `put`, `delete` and `create`, refuse
with a message saying the service does not write its stores. They remain on
the traits because `ts keys` implements them over the `fastly` CLI.

`BackendConfig::from_url` and its two variants built the backend the client
reached the API through. Nothing else called them, so they go as well, and
the one test that parsed a URL through them states the parsed parts
directly. The adapter's `base64` and `url` dependencies go with their last
users.

A deployment that linked an `api-keys` secret store can unlink it and
revoke the token it held.
`POST /_ts/admin/cache/purge` let anyone holding one shared password empty
the service's cache over the publisher's own domain, with nothing limiting
how often. `ts cache purge` asks the hosting platform to purge the same
surrogate keys with the operator's own token, so the route goes, with its
handler in core, the stand-ins that answered `501` on Axum, Cloudflare and
Spin, the harness mode and workflow step that drove it, and the parity
tests of it. `auth::authenticated_username`, which named the caller in the
purge log, goes too.

Nothing in the service purges by surrogate key or purges everything now, so
`purge_url_surrogate_key` and `purge_all` leave the template cache
interface and every implementation of it. `purge_url`, which core uses to
drop an entry it finds invalid, stays.

The harness's purge mode was the end to end check that purging a key
empties the cache. Two Fastly tests keep that evidence where it can still
be had without the route: purging `ts-template` through the platform clears
a stored template, and purging one page's reader key clears that page and
leaves another.

No admin endpoint is left, so the rule that every one must be covered by a
`[[handlers]]` entry goes: `ADMIN_ENDPOINTS`, the coverage check at startup
and at deploy validation, and the test that held the list against the
Fastly route table. A configuration with no handler starts. Handler
passwords are still checked.

A request to the removed path is still answered `404` by the server and
never forwarded, as for any path beneath `/_ts/admin`.
HTTP Basic authentication existed to gate the endpoints beneath
`/_ts/admin`. None is left, so the gate goes: `auth.rs` in core, the
`AuthMiddleware` each of the four adapters registered, the `Handler` type
with its regex and its placeholder and weak password checks, and
`handlers[*].password` as a declared secret.

A configuration that still carries `[[handlers]]` is refused with
directions when it is validated and when the service starts, through the
refusal the removed tables use. Loading it and ignoring the list would
leave an operator who gated a path of their own, a staging site for
instance, believing it was still gated. An empty list is refused too, so is
a handler given by an environment variable, and the settings are never
written with one.

The upgrade has an order to it. A build before this series refuses a
configuration without a handler covering `/_ts/admin`, and this build
refuses one with any handler, so no one configuration starts both. The new
configuration and the new build go live together.

The publisher path treats an `Authorization` header as bound for the origin
in every case. It shared a cached page across readers when the edge had
checked the header itself, and with no check at the edge that case is gone,
so a request carrying the header always bypasses the shared template.

The closed path check is unchanged and still answers `/_ts/admin` and
`/admin/keys` with `404` before the publisher fallback, so a caller that
sends a password to either never has it forwarded. Each adapter's tests of
that send the credentials a caller would.

What goes with the gate:

- the tests of the challenge in the four adapters and the parity suite, and
  the tests that proved other routes were not challenged;
- `[[handlers]]` in every test fixture, 52 entries;
- the `handler_password` secret in `fastly.toml`, the Spin manifest, the
  Viceroy template, the integration fixtures, the three harnesses and the
  four smoke scripts, whose missing-secret cases cover the two secrets that
  remain;
- the handlers section of the configuration guide, the Basic
  Authentication section of the API reference, the `Handler.password` row of
  the configuration guide's table of exceptional fields, and "unless an
  operator adds a handler" in the contracts of `/first-party/sign` and
  `/_ts/debug/ja4`.

The configuration page's tests take their example list from
`proxy.asset_routes`, having taken it from `handlers`.

The page-bids alias comments named a `[[handlers]]` pattern covering `/_ts`
as one reason a browser falls back to the alias. They name a gate in front
of the service, which is the case that remains.
The DataDome module skipped its protection API for every path beneath
`/admin/` and `/_ts/admin/`, listing them with the server's own routes.
Nothing of the server's is served beneath either. `/_ts/admin` and
`/admin/keys` are answered by the closed path check before any request
filter runs, and everything else beneath `/admin/` is the publisher's own
site, which the list left out of protection.

`only_the_servers_own_paths_are_internal` holds the list both ways.
`[robots-txt] modules` names, in order, what makes the file. `refuse_all`
and `allow_all` are core's own and need nothing else. Any other name is a
contributor a module declared with `with_robots_txt_contributor`, and core
names none of them.

A contributor returns groups of records and core writes every byte. That is
what lets these hold whatever a contributor returns.

- With `refuse_all` selected the file is the refusal and nothing else.
- A path in `always_allow` stays open to every crawler a contribution
  refuses the whole site to, by a group of core's own below the
  contributions, which are not edited.
- The `Sitemap` line and the publisher's own text go around the
  contributions.

Each contributor's answer is held in the key-value store, under a key that
is a hash of the publisher, the contributor's name and the contributor's
fingerprint, and the contributor is asked again when its own refresh is due.
A contributor that fails has its held answer served past its age. With
nothing held the file fails closed with `503`, because leaving a
contributor's rules out would allow by omission what those rules refused,
and a `404` or an empty file reads to a crawler as permission. A store that
cannot give an entry a lifetime is written without one, so the answer is
still there to serve.

A contributor is asked through the module context, with the request's
method, path, host and scheme and without its query or anything about who
asked, because its answer is served to every crawler.

`[robots-txt]` is refused when the settings load if `modules` is absent,
empty or names one thing twice, if a key is not a setting, or if a block
belongs to a contributor `modules` does not name. A name nothing supplies is
refused when the registry is built, so the file does not fail on the first
request for it.
Declared on the probe's one registration, beside its geo, Edge Cookie and
device modules, so an adapter's tests can select a contributor from a crate
core does not know. Its one rule names the host, path and query it was asked
for, which lets a test read back what a contributor is handed.
The route is registered only when the settings carry a `[robots-txt]`
section, for `GET` and `HEAD`, beside each adapter's other fixed routes. On
Fastly and Axum it is a row of the named-route table, gated by
`named_route_is_served`, so a request for it passes what every named
route's request passes there. Every other method on the path goes to the
publisher fallback. Without the section the path reaches the publisher's
origin like any other, so a publisher keeping their own file is left alone.

With `refuse_all` selected every response an adapter finalizes carries
`X-Robots-Tag: noindex, nofollow`. It is written last when a response is
finalized, and replaces any tag already there, so that neither an origin's
header nor an operator's `[response_headers]` can loosen it.

The comment on the Axum adapter's one preparation point now names the
attestation pages as the other route outside it.
The configuration guide gains the section, its settings, how the file is
made and what is refused when. The API reference gains the route and its
contract. The integration guide gains the contributor extension point, and
the configuration rules list `robots-txt` among the types. The example
configuration carries the section commented out.
`x-served-by` names the node that answered, and `x-cache` and `x-cache-hits`
say how a cache treated the request. They arrive on a response from the
publisher's origin or from a cache on the way to it.

`strip_diagnostic_headers` removes the three from a response unless the
request was for a path in the namespace it is given. `ROUTE_NAMESPACE` names
`/_ts`, the prefix of every path this server answers itself. A path that
only starts with the namespace's text, `/_tsx` for one, is not in it.
The finalize middleware of each adapter takes the request's path before the
handler runs, and removes the three headers from the response unless the
path is beneath `/_ts`. The removal comes before the finalize step, so a
header of one of those names that an operator sets in `[response_headers]`
is still sent.

Each adapter has three tests of its own middleware. One sends a response
naming its node for a publisher's page and finds the headers gone, one does
the same for a path beneath `/_ts` and finds them kept, and one finds an
operator's `X-Cache` on a publisher's page.
The response headers section of the configuration guide names the three
headers and says where they are kept, and the changelog records the change.
`[tinybird]` is now `[analytics.tinybird]`, and `[analytics] module =
"tinybird"` selects it, which replaces the table's `enabled` line. Every
other thing a deployment can switch on is selected that way, and telemetry
was the one section named after its vendor and switched on by a flag.

- `Settings::telemetry()` returns the module's settings only when the
  section selects it, and the Fastly sink is built from that, so the sink
  has no enabled flag of its own.
- A section that selects nothing, or a module this build does not have, is
  refused when the settings load, and the table refuses a key it does not
  know.
- The secret reference is `analytics.tinybird.auction_token_secret`. The
  references of a table nothing selects are cleared before secrets are
  resolved, so the refusal is what an operator sees and not a failed lookup.
- A configuration that still carries `[tinybird]` is refused with
  directions. An earlier version wrote the table into every stored
  configuration, on or off, so the message says to push the configuration
  again with this version.
- The messages about the table name it through one constant.
- An empty or absent token reference is refused as the settings load, where
  an empty one becomes absent as they normalize, and at deploy by the
  declared secret field, so the deploy validation's own check of the token
  is removed. Both refusals are tested.

The example configuration carries the new block, which a test uncomments and
loads.
The configuration guide's Tinybird section becomes the analytics section,
with the selector, what is refused and where `[tinybird]` went. The
telemetry guide, the configuration rules and the Tinybird project's README
name the settings by their new table, and the changelog records the change
as breaking.
`COOKIE_MTM_PREF` sat in core's constants, and the Model Terms for Marketing
signal crate is the only code that reads the cookie. The constant moves to
that crate, so core names one standard less. The Axum adapter's test of the
signal takes the name from the crate.
`[cmp] module = "inmobi"` selects it, with the account's Choice loader in
`[cmp.inmobi] script_url` and the IAB CMP id in `cmp_id`, which is 10 unless
written. Where a `[[fetch]]` entry names `cmp.inmobi`, the module's
middleware writes three things into the head, in this order: the
IAB TCF v2 stub, which queues every `__tcfapi` call made before the prompt
arrives and answers `ping` as a stub; InMobi's GPP stub, which answers the
generic `__gpp` commands at once with the CMP id and queues the rest; and
the Choice loader, deferred, so a slow consent vendor never holds up the
page and the loader never runs ahead of a stub the publisher's own head
carries. Each stub defines nothing where the page already has its
interface. The module serves no browser module, because the stubs and the
Choice script are the whole client side. The prompt writes the reader's
answer to the cookie the TCF permission signal reads, so the answer reaches
the permission model on the next request the way every signal does.

The script URL becomes a `<script src>` on every page, and a settings
document reaches the service from a config store rather than from a person
reading it, so it is refused when it is empty, is not a URL, is not `https`,
is on a host other than `cmp.inmobi.com` or `cdn.inmobi.com`, or carries a
user name or password. A host that merely contains an allowed name is
refused by what it is rather than by what it looks like, and the URL is
escaped again when it is written, because a URL on the right host can still
carry a quote in its query. A document that selects the prompt and names it
in no entry is refused, because the prompt would never be written, and the
table refuses a field it does not read.

The two stubs are tested in a stand-in for the browser with Node's test
runner, `node --test "crates/cmp/inmobi/tests/*.test.mjs"`, which runs the
scripts as the page carries them. The module's tests cover each refusal,
the tag's parts and their order, that a vendor's tag named after the prompt
is written after it, and the page a reader receives, recorded in
`src/fixtures/page-change.recorded.html`.

The stock list offers the module after Didomi and lists its middleware in
the fetch phase, the example configuration names it in its fetch entry and
shows its table, and the cargo aliases build and test it with the other
modules. 51Degrees maintain the crate until InMobi adopt it, which its
manifest states.
A module that writes a script into pages keeps that script's tests beside
it, as `tests/*.test.mjs`, run with Node's test runner and no packages. The
vitest job runs every such file after the browser bundle's tests, from the
repository root, so a stub that changes is tested the way a page runs it.
The InMobi Choice module's two stubs are the first.
A guide page for the module, with its settings, where to place it, what is
checked, and what a consent prompt module keeps to, in the sidebar beside
the other consent management platforms. The configuration guide's section
table, its list of the modules this repository ships, its placing table and
its integration sections carry the module, the integrations overview lists
it, the crate list in AGENTS.md names it, and the changelog records it.
An entry may cover any media type core streams as text: `text/html`,
another `text/` type other than `text/x-component`, whose rows are length
prefixed, `application/javascript`, `application/json` or `image/svg+xml`,
written as a bare `type/subtype` in lower case. A response is matched by the
type and subtype of its content type, without the parameters.

A middleware says which media types it works on with `handles_media_type`,
HTML alone unless it says otherwise, so every module keeps its behavior
with no change to its code. An entry naming a middleware under a media type
it does not handle is refused when the registry is built, naming the entry
and the middleware. A chain carries its media type, and on one other than
HTML its plan refuses an action that decides about HTML, being head markup
or an element or text handler, by the middleware's name, because those are
decisions about HTML and a stream processor is the one thing that applies
to any body.

The publisher runs the covering entries' stream processors on a body of
such a media type after it has moved the origin's addresses in it: the
fetch entry's on what is stored, and the serve entry's on each reader's
copy, whether that copy is assembled from the store or fetched for the
request, as on a page.

Core's test fixture gains a middleware that works on `text/plain` and adds
a line at the end of a body, and the tests cover the entries accepted and
refused, the media type of a content type, a chain refusing an HTML
decision on text and taking a stream, the registry refusing a middleware
under a media type it does not handle in either direction, and a text body
through the publisher with and without a covering entry.
The configuration guide says which media types an entry may cover, how a
response is matched, what runs on a body that is not HTML and what is
refused, with an entry for a text file as the example. The integration
guide shows `handles_media_type` on a middleware that works on
`text/plain`. The changelog records the change.
`[ads-txt] modules = ["sellers"]` selects it, with the lines to add in
`[ads-txt.sellers] lines`. The module is a middleware for `text/plain`,
named in a `[[fetch]]` entry on the file's path, which passes the origin's
file through and writes the lines after its last one, on a line of their
own, so a seller the deployment introduces is declared beside the
publisher's own. The publisher's file stays authoritative, and a publisher
whose origin has no file gets the origin's answer, because the module adds
to the publisher's file and never invents one.

Every buyer's crawler reads the file and a typo there costs the publisher
revenue silently, so each line is checked as the settings load against the
file's grammar: a data record is a domain, an account id, DIRECT or
RESELLER and an optional certification authority id, separated by commas,
and a variable is one of CONTACT, SUBDOMAIN, INVENTORYPARTNERDOMAIN,
OWNERDOMAIN or MANAGERDOMAIN with a value. A comment, an empty line and an
empty list are refused, naming the line and what is wrong with it, and so
is a document that selects the module but names it in no entry, where the
lines would never be written.

The stock list offers the module after the page modules and lists its
middleware in the fetch phase, the example configuration shows its tables
and entry, the cargo aliases build and test it with the other modules, and
the crate is the project's own, which its manifest states. The tests cover
each refusal, the lines the grammar accepts, the file with and without a
final line break, an empty file, a file read in pieces, and the module's
place in a registry.
The configuration guide gains the section, its module's table, how the file
is made and what is refused, with the entry that places the middleware,
and lists the module in its key sections and placing tables. The rules
guide names the section among the types and in its table. The changelog
records the module.
`cmp.inmobi`, `cmp.didomi` and `cmp.sourcepoint` declare both phases, so an
entry of either phase may name them. What a prompt writes is the same for
every reader, so the fetch phase stores it in the page every reader is
served from, which is what a generated configuration places, and the serve
phase writes it on each reader's copy for a publisher who wants the stored
page without it. Selecting the InMobi module with no entry of either phase
naming it is still refused.

The stock list keeps each prompt's row in the fetch phase, which is the
phase `ts audit generate` writes, and its test allows a middleware that
runs in several phases to be listed in one of them, checking that what is
listed for a phase is registered for it and that everything registered is
listed somewhere.
The InMobi guide places the prompt by an entry of either phase and says
what each phase does with it, the configuration guide's middleware table
says the same for every consent management platform, and the changelog
records the change.
…ts path

When the origin answers `404 Not Found` for a path a `[[fetch]]` entry with
a path names middleware for, core runs that middleware on an empty document
of the entry's media type and its output is the file, stored and served as a
fetched one is. A GET and a HEAD are answered alike. With no such entry, on
any other answer from the origin, or where only the entry covering every
path applies, the origin's answer stands, because a page the origin does not
have is the origin's answer.

`PhaseEntries::creating` finds the entry. The sellers crate says its file is
made from the lines alone where the publisher has none, which its middleware
already does on an empty document.
The configuration guide says it with the entries for other media types and
in the ads.txt section, the integration guide tells a middleware it may be
run on an empty document, and the changelog records the rule.
`Middleware::prepare` answers a future for a document whose decision needs
the request's services or has to wait, on the key-value store or the
network. The request's handler runs it before the document is processed,
once per phase the document goes through, as the middleware's module and
with the permissions it declares, and keeps what it answers under the
middleware's name in the context's `Prepared`, where `create` takes it as
the type it prepared. `create` and every streaming path stay synchronous,
so the Fastly adapter is as it was. A middleware that prepares nothing is
asked `create` as before.

A preparation may answer a `Refusal` instead, with a status, an optional
`Retry-After` and a message. The handler answers the request with that
status and the message as plain text, marks the response private and
unstorable, and processes no document, so a middleware responsible for a
file fails closed when it cannot make it. The first refusal ends the
preparations of a document. A copy read from the store prepares its serve
chain the same way before it is assembled.

The middleware fixture gains one that prepares and one that refuses, and the
tests cover what is prepared reaching `create` in either phase, the refusal's
status, headers and body, the chain keeping each answer under its name, and
the first refusal ending the preparation.
The section says when a middleware prepares, what it is called with, where
its answer goes, how a refusal is answered, and what each phase's
preparation must not depend on. The changelog records the addition.
Jamesr51d and others added 24 commits October 13, 2026 17:49
Core's version_header module is removed, with its call from the finalize
step of the Axum, Cloudflare and Spin adapters and from the Fastly adapter's
health probe and its settings fallback, so no response carries x-ts-version
unless a [[serve]] entry names the service_version middleware. That
middleware now writes the git version the build compiled in from
TRUSTED_SERVER__GIT_VERSION or local git as X-TS-Version, when the build
knows one, and the version the adapter reports for the service as
X-TS-Service-Version, when it reports one. HEADER_X_TS_FASTLY_VERSION, which
nothing wrote, goes with the module.

The health tests of the three adapters prove the probe carries no version,
the middleware's own tests prove each header is written when its version is
known and nothing when neither is, and the publisher tests prove both
headers reach a buffered page and neither reaches a page no entry names the
middleware for. The configuration guide, the getting started guide, the
EdgeZero guide and the changelog say the same.
# Conflicts:
#	docs/guide/configuration.md
# Conflicts:
#	docs/guide/configuration.md
@jwrosewell

Copy link
Copy Markdown
Collaborator

Moved into this repository as #1370, one branch of the stack, so the review continues there. This pull request is closed in its favor and its comments stay here for the record.

@jwrosewell jwrosewell closed this Oct 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants