@kurtbruns/kestrel 1.12.0 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -10,6 +10,107 @@ To move a running instance from one version to another, follow [Upgrade to a new
10
10
 
11
11
  <!-- Add entries under Added / Changed / Fixed / Breaking. One operator-facing line each; see .claude/rules/changelog.md. Upgrade steps (a migration, a new setting) go once, in the Upgrading paragraph, never on an entry. -->
12
12
 
13
+ ## [1.13.0] - 2026-10-19
14
+
15
+ **Upgrading from 1.12.0.** One database migration, which `npm run sync-migrations` brings in: apply it before you deploy with `npx wrangler d1 migrations apply DB --remote --env production` (and `--local` for local development). `0012_unsubscribe_cause` records why each subscriber left and leaves the suppressions only bounces and blocks. Every unsubscribe before it reads as the reader's own, since the record never said otherwise. Each complaint becomes an unsubscribe by a complaint, and an address it held with no subscriber is recorded as one, unsubscribed and holding only the address. Each erasure's marker becomes a subscriber unsubscribed by an erasure, holding only the address and when, except that someone who signed up again after it stays pending, read as having left by the erasure until they confirm. An erasure that found a bounce or a block already on an address left no trace of itself, so that address keeps its suppression alone. It rebuilds the subscribers and suppressions tables, copying every row once, which counts against the day's database writes on Workers Free like any other write. Deploy straight after applying it: until this release is serving, the previous one fails to record a reader's unsubscribe, a confirmation from someone who once left, an erasure, and a complaint report, which the new tables refuse. SES retries a refused report, so complaints arrive once the deploy lands; a reader whose unsubscribe failed meanwhile has to unsubscribe again. Run `npm run check-context` in your instance's repository: this release changes the operator's Claude context, and a copy you've edited is merged by hand. Several API changes can break a script of yours, each listed under Breaking; the table gives every renamed route, field, value, and sort key, old to new. A request that still sends an old name is refused with a 400 naming it, never answered as if it had been read: every API route now refuses a body field, a query parameter, or a filter value it doesn't take, so check a script's requests against the API reference before you deploy.
16
+
17
+ | Where | Was | Now |
18
+ | --- | --- | --- |
19
+ | `GET`, `PUT /api/settings` | `settings.testRecipients` | `settings.test_recipients` |
20
+ | | `settings.emailTemplate` | `settings.email_template` |
21
+ | | `settings.confirmationEmail`, `.confirmationEmailDefault` | `settings.confirmation_email`, `.confirmation_email_default` |
22
+ | | `settings.confirmationEmail.buttonLabel` | `settings.confirmation_email.button_label` |
23
+ | | `deployment.fromAddress`, `sendingDomain`, `appOrigin`, `archiveOrigin`, `archiveBasePath`, `mediaPublicBase`, `awsRegion` | `deployment.from_address`, `sending_domain`, `app_origin`, `archive_origin`, `archive_base_path`, `media_public_base`, `aws_region` |
24
+ | | `deployment.accessConfigured`, `authMode`, `notifyChannel`, `notifyFrom`, `minLeadMs`, `notConnectedPreview`, `freePlanLimits` | `deployment.access_configured`, `auth_mode`, `notify_channel`, `notify_from`, `min_lead_ms`, `not_connected_preview`, `free_plan_limits` |
25
+ | | `inUse`, `inUse.identityFields` (value `logoUrl`) | `in_use`, `in_use.identity_fields` (value `logo_url`) |
26
+ | | `notificationStatus.lastSent`, `.lastFailure` | `notification_status.last_sent`, `.last_failure` |
27
+ | | a 400's `field`, such as `confirmationEmail.buttonLabel` | the new path, such as `confirmation_email.button_label` |
28
+ | `GET /api/settings` | `settings.publication.logoUrl` | `settings.publication.logo_url` |
29
+ | `GET /api/version`, `deployment.build` | `buildTime`, `repoUrl`, `commitUrl`, `tagUrl` | `build_time`, `repo_url`, `commit_url`, `tag_url` |
30
+ | `GET /api/whoami` | `principal.tokenId` | `principal.token_id` |
31
+ | `GET /api/dev/token` | `runId` | `run_id` |
32
+ | `GET /api/dev/outbox` | `messages[].providerId`, `.sentAt`, `notifications[].sentAt` | `provider_id`, `sent_at` |
33
+ | A send's `phase` | `backing-off`, `needs-attention` | `backing_off`, `needs_attention` |
34
+ | `GET /api/posts` `sort` | `updated`, `title`, `scheduled` | `updated_at`, `subject`, `send.fire_at` |
35
+ | `GET /api/sends` `sort` | `fire`, `recipients` | `fire_at`, `audience.count` |
36
+ | `GET /api/subscribers` `sort` | `joined`, `confirmed` | `created_at`, `confirmed_at` |
37
+ | `GET /api/sends/:id/deliveries` `sort` | `updated` | `event_at` |
38
+ | A post, every route | a list row's `fire_at`, `active_send_id`, `active_send_status` | `send`, a reference: `{ id, status, fire_at, rev }` (the whole send is `GET /api/sends/:id`) |
39
+ | | one post's `scheduled`, `sending`, `sent` | `send`, with its `status` |
40
+ | | one post's `author`, beside it | `post.author` |
41
+ | A subscriber, every route | a list row's `suppressed`, `suppression_reason`, `suppression_detail` | `suppression: { reason, detail, created_at }` or null |
42
+ | | `suppressed`, beside one subscriber | `subscriber.suppression` |
43
+ | The re-make: `remake_required`, `remake_too_close`, `in_use.sends`, `remade` | `{ id, post_id, subject, fire_at, remade_at }` | the send's full view, which carries those fields |
44
+ | A send's `counts` | `in_flight` | `dispatched` |
45
+ | `GET /api/sends/:id` `outcomes` | `in_flight` (pending and dispatched together) | `pending`, `dispatched` |
46
+ | `GET /api/sends/:id/deliveries` `view` | `in_flight` (pending and dispatched together) | `pending`, `dispatched` |
47
+ | Lifting a suppression | `DELETE /api/suppressions/:email`, answering `{ email, cleared }` | `POST /api/suppressions/lift` with `{ email, reason }`, answering `{ email, lifted }` (see Breaking) |
48
+ | Describing a post's rendered email | `POST /api/posts/:id/preview` | `GET /api/posts/:id/preview.json`, the same answer (see Breaking) |
49
+ | An import plan | `daily_writes_warning` | `workers_plan_advice` (a different meaning: see Breaking) |
50
+ | An import's outcomes: a plan's and a commit's `counts`, `rows`, and `differed`, the format's `outcomes`, a stored record | `create_confirmed`, `confirm_pending`, `mark_unsubscribed`, `create_unsubscribed`, `blocked_by_suppression`, `suppression_added` | `added_confirmed`, `confirmed_pending`, `unsubscribed`, `added_unsubscribed`, `kept_suppressed`, `suppressed` |
51
+ | A suppression's `reason`, everywhere | `complaint`, `import_complaint`, `erased` | none: the subscriber's `unsubscribe_cause`, `complaint` or `erased` (see Breaking) |
52
+ | `POST /api/subscribers` | `acknowledge_erasure`, and 409 `erasure_acknowledgement_required` with `erased_at` | none: an address that left by a complaint or an erasure is refused with 409 `signup_required`, carrying the `subscriber` |
53
+ | `POST /api/subscribers/:id/erase` | `{ erased: { email, deliveries, suppression } }`, and 404 on a repeat | `{ subscriber, deliveries }`, a repeat answering the same subscriber with `deliveries: 0` |
54
+ | `POST /api/suppressions/lift` | 409 `erasure_marker` | none: an erasure isn't a suppression |
55
+ | The import format, and the export | `kestrel-subscribers/1`, with `suppression` `complaint` or `erased` | `kestrel-subscribers/2`, with `unsubscribe_cause`; a version 1 file still reads |
56
+ | `GET /api/suppressions` | every suppression, newest first, cut off silently past 1,000 | 50 a page with `page`, as the other lists (see Breaking) |
57
+ | Every API route | a body field, query parameter, or filter value it doesn't take, ignored (`GET /api/posts?status=bogus` listed every post) | a 400 naming it in `field` (see Breaking) |
58
+
59
+ ### Breaking
60
+
61
+ - One naming rule on the wire: every JSON field and enum value is snake_case, and a list's sort key is the name of the field it sorts by. The settings (its preferences, its deploy reflection, `in_use`, and `notification_status`), the build info, `whoami`'s token id, the dev routes' `run_id` and the dev outbox's `provider_id` and `sent_at`, the `backing_off` and `needs_attention` phases, and the sort keys of posts, sends, subscribers, and deliveries take their new names; the stored settings keep theirs, so no migration runs. The Upgrading paragraph lists every rename.
62
+ - An import's outcomes are renamed into one form, the past tense of what the import does to the address, and none says "blocked" any more, which in the editor and the API means only your own block: `create_confirmed` is now `added_confirmed`, `confirm_pending` is `confirmed_pending`, `mark_unsubscribed` is `unsubscribed`, `create_unsubscribed` is `added_unsubscribed`, `blocked_by_suppression` is `kept_suppressed`, and `suppression_added` (as an outcome, not the row's field of that name) is `suppressed`. They change in an import plan's and a commit's `counts`, `rows`, and `differed`, the format route's `outcomes`, and an import's record, where one committed before this release reads under the new names too. A script that matched an old name matches the new one (SPEC §7).
63
+ - A send's per-recipient buckets have one vocabulary, the statuses and events its delivery rows store: `counts.in_flight` is now `counts.dispatched`, and `outcomes.in_flight` and the deliveries' `view=in_flight`, which meant pending and dispatched together, are split into `pending` and `dispatched`, so one name never counts two things. A delivery row's `status`, `event`, and `bounce_kind` are now typed to their values (SPEC §12).
64
+ - A post reads the same on every route and refers to its send by one fixed reference, `send`: `{ id, status, fire_at, rev }` of its active send, else the one that sent it, or null, with the whole send one read away at `GET /api/sends/:id` and `rev` saying whether a copy of it is current. It carries `actions` too, what the server would take for it now (`edit`, `delete`, and, while an email provider is connected, `schedule` and `send_now`). They replace a list row's `fire_at`, `active_send_id`, and `active_send_status` and one post's `scheduled`, `sending`, and `sent`, and its `author` moves inside the post. The editor offers a post's controls from its `actions`. The posts list's `fire_at` sort is now `send.fire_at`.
65
+ - A subscriber carries its suppression inside it on every route, `suppression: { reason, detail, created_at }` or null, in place of `suppressed`, `suppression_reason`, and `suppression_detail`, which sat in list rows only, and `suppressed` beside one subscriber's answer.
66
+ - A refusal about a resource's state carries it as it stands: `post_not_draft` and `slug_permanent` the `post`, `settings_changed` and `conflict` the `settings`, an erasure's `run_in_progress` and an Add's `signup_required` the `subscriber`, `suppression_changed` the `suppression`, and every send refusal the `cursor` beside the `send`. The re-make's `sends`, `in_use.sends`, and `remade` are full send views, in place of a smaller reference.
67
+ - `DELETE /api/suppressions/:email` is replaced by `POST /api/suppressions/lift`, which takes `{ email, reason }` in the body, so a subscriber's address is no longer in a logged path, and lifts only the reason named: a different one standing now is refused with `suppression_changed`, carrying it. It lifts a block (`manual`) or a bounce (`bounce`, `import_bounce`), the only reasons a suppression has now; any other is a 400. It answers `{ email, lifted }` in place of `{ email, cleared }`. The old route answers 404.
68
+ - Each mark on an address has one owner (SPEC §7). The consent state is the person's and now keeps why they left, as a subscriber's `unsubscribe_cause`: `reader` (their link or one-click), `publisher` (Unsubscribe on request), `complaint`, `erased`, or `import` (a list that says they left, not why). When someone who left leaves again, the stronger cause stands: an erasure, then a complaint, then the first. The suppression is only the sender's side, so its `reason` is `bounce`, `import_bounce`, or `manual`, and `complaint`, `import_complaint`, and `erased` are gone from it: a complaint and an erasure are unsubscribes with that cause. A script that read a complaint or an erasure from a suppression reads the subscriber's `unsubscribe_cause`.
69
+ - Adding an address that left by a complaint or an erasure (`POST /api/subscribers`) is refused with 409 `signup_required`, carrying the `subscriber`, and sends nothing: only the person's own sign-up through the subscribe form brings them back. `acknowledge_erasure` and `erasure_acknowledgement_required` are gone, and so is `erasure_marker` on a lift. Adding anyone else who left sends a confirmation, as before (SPEC §7).
70
+ - Erasing a subscriber (`POST /api/subscribers/:id/erase`) answers `{ subscriber, deliveries }`, the subscriber as the erasure left them, in place of `{ erased: { email, deliveries, suppression } }`. A repeat answers the same subscriber with `deliveries: 0`, where it answered 404. An erased subscriber's `unsub_token`, `origin`, and `opt_in` are null, so a script that reads them handles null (SPEC §7).
71
+ - The import format is `kestrel-subscribers/2`, which the export now writes: an `unsubscribed` row says why the person left in `unsubscribe_cause` (`reader`, `publisher`, `complaint`, or `erased`), a row that doesn't say is recorded as having left elsewhere, and `suppression` takes only `bounce` or `manual`. A version 1 file still reads, its `complaint` or `erased` suppression read as an unsubscribed row with that cause, whatever its status, and a row naming no version may use either version's values. An erasure is recorded with the address alone. A row that erases someone already on the list does what **Erase** does, past sends' records included, and plans as a new outcome, `erased`, so a script that sums outcomes adds it; the commit answers 409 `run_in_progress` while a send is handing such a person to the provider. A cause on a row that isn't `unsubscribed` is refused with `invalid_field` (SPEC §7).
72
+ - An import plan's `daily_writes_warning` is now `workers_plan_advice`, and means something else: on the Workers Free plan's limits, the advice to move to Workers Paid when the import would leave more than 1,000 confirmed subscribers, rather than a count of the day's database writes. A script that read the old field reads the new one (SPEC §7).
73
+ - `GET /api/suppressions` pages like the other lists: it answers 50 suppressions at a time with `page`, takes `limit`, `offset`, `sort` (`created_at`, `email`, or `reason`), and `dir`, and filters by `reason` and an email contains-search (`search`). It answered up to 1,000 at once and silently left out any past them, which a list imported from another provider can reach on its first day. A script that read the whole list in one call follows `page` (SPEC §7).
74
+ - Every API route takes only what the API reference lists for it: a body field it doesn't name, a query parameter it doesn't list, or a value outside the ones a filter takes is refused with a 400 naming it in `field`, where most routes ignored it and answered as if it had been read. `GET /api/posts?status=bogus` listed every post and `GET /api/subscribers?suppressed=maybe` the whole list; both are now refused, and so is a parameter given twice, a `seed` or `size` the dev seed can't read, and a `kind` other than `human` or `service` on the dev token. A reader page, such as an archive link carrying a tracking parameter, still takes what it is sent.
75
+ - Describing a post's rendered email (where its preview is, its `subject`, `warnings`, and `frozen`) writes nothing, so it is a read: `POST /api/posts/:id/preview` is now `GET /api/posts/:id/preview.json`, with the same answer, and a `POST` to the old path is a 404. The rendered page itself stays at `GET /api/posts/:id/preview`.
76
+
77
+ ### Added
78
+
79
+ - Each subscriber has a page of their own, opened from their row, their address, or **View subscriber** in the row's menu, in place of the View subscriber dialog and the list's How they joined column, which moves to it. It answers "did they get my post?": a line under the address says whether they receive your posts and, if not, why, then every send their address was in, newest first, with what the provider did and reported, each linking to its record. It also shows how they joined, with the import that brought them (its file, when and by whom, and the attestation's version), their suppression with the provider's or your words and since when, why they left (for a complaint, which send it was about), and the row menu's acts. The API adds `GET /api/subscribers/:id/deliveries`, a page at a time, `GET /api/imports/:id`, and `receives_posts` on every subscriber (SPEC §7, §8).
80
+ - Each instance serves its API as an OpenAPI 3.1 document at `GET /api/openapi.json`, behind Access like the reference, so a client generator or an API client such as Postman can import it. It is generated from the same registration as the reference, so it lists the same routes, objects, and refusals, and `/llms.txt` and the reference's index name it.
81
+ - Each instance serves a public `/llms.txt`, so an agent given only its address learns what it is and where to start: the publication's name, its archive and subscribe page, and the API reference and docs behind Access, with a link to Connect Claude to the API for a service token. It is generated from the routes the instance registers, so it always matches the release (SPEC §11).
82
+ - A scheduled post that no test has been sent for says so on the editor's scheduled banner until the first test lands, and the API carries it as an `untested` condition (`info`) on the send and in the feed, so Claude can tell whether the email was tested (SPEC §8).
83
+ - **Clear** in a subscriber's ⋯ menu lifts a bounce, after a dialog that says the address failed and to clear it only when you know its cause is gone, which before only the API could do (SPEC §7). The list's legend, under What you can do, and the API reference say when to use it and when to use **Unblock**, in the same words.
84
+ - The operator's Claude context lifts a suppression through `POST /api/suppressions/lift`, naming the reason it shows, and lifts only a block or a bounce. `npm run check-context` offers the change to an existing instance.
85
+
86
+ ### Changed
87
+
88
+ - A reader who marks a post as spam is unsubscribed by it, as their own link would, instead of suppressed while still confirmed, and comes back only by subscribing again themselves. Every complaint does, whichever email it is about, a confirmation email included; one that reaches no subscriber changes nothing. Each is logged as `subscriber.complained` (SPEC §7, §12).
89
+ - **Erase** unsubscribes the person and keeps their subscriber, stripped to the address and when, in place of deleting it and leaving a do-not-contact marker. It works on someone who already unsubscribed, discards the unsubscribe link their old emails carry, and drops the words of a bounce or block kept on the address. A bounce or block no longer has to stand in for the erasure, so lifting one never frees an erased address (SPEC §7).
90
+ - On the Subscribers screen the **complaint** and **erased** flags are gone, since a complaint and an erasure are now ways of leaving: why an unsubscribed subscriber left shows on their page, not beside their status. **Unsubscribe** is offered on pending subscribers too, **Erase** isn't offered on someone already erased, and **Add subscriber** shows the server's refusal for someone who left by a complaint or an erasure, with no way past it, in place of asking whether an erased person asked to join again.
91
+ - The operator's Claude context: Claude relays `signup_required` as the person having to subscribe themselves, never lifts a complaint or an erasure, describes Erase as an unsubscribe that strips the record, and the migrate-list skill writes a source's complaints and erasures as an unsubscribed row's `unsubscribe_cause`, never as a suppression, and puts an import's `erased` rows to the publisher by name before committing, since they can't be undone. `npm run check-context` offers the change to an existing instance.
92
+ - The operator's Claude context names the default test recipients by their new field, `test_recipients`, so Claude reads them from the settings it now gets.
93
+ - The operator's Claude context tells Claude to act on a post from its `actions`, as on a send, so it never offers to schedule or send a post while no provider is connected, and that every refusal about a post, a send, a subscriber, or the settings carries it as it stands.
94
+ - The API reference no longer cites SPEC sections or invariant numbers ("(SPEC §6)", "(I3)"), which its readers, Claude included, couldn't follow; each rule was already said in its own words beside it.
95
+ - A deployment with no email provider connected now refuses scheduling, Send now, a test, a test notification through the provider, and a confirmation with 503 instead of 409, and the public subscribe route answers 503 too, as JSON and as its "not taking subscribers yet" page. The code (`provider_not_connected`) and message are unchanged, so a client that matches the code sees no difference, and one that matched the 409 status now sees 503: the status now says the deployment needs fixing, as the other refusals fixed outside the API do (SPEC §9).
96
+ - The API reference is reshaped so a person and Claude find a route by what it does and learn it without guessing. Each route leads with a name ("Schedule a post"), its method and path beneath, and lists its body fields with their types, what it returns, and the refusals it gives of its own, each with what to do next. Short topics come first (authentication, requests and what each status range means, errors with a summary of every status, the error body, and the refusals any route can give, paging, and acting safely; how to follow changes to sends is told on Follow changes to sends, the one route it concerns), and each resource opens with the object its routes return, described once field by field, so the send's conditions and full example no longer repeat across routes. In the editor the page reads as a reference, laid out as Stripe's and Mux's are: headings step down from tier to resource to route, every route and object is laid out in full with its examples and curl, each on a card of its own, beside its prose on a wide screen, the rail lists the routes of the resource you're in, the address follows you as you scroll, each section is a heading over unboxed rows, refusals and statuses a ledger by status, and the refusal table folds under Errors. The page moves to `#/api-reference`, an old `#/reference` link opening the same place there. Each route has its own address, named for what it does (`#/api-reference/cancel-send`), as do the topics, resources, and objects the contents and a route's returns link to, so those links open in a new tab and a jump moves focus to where it lands; a route's path has a copy beside it, and every heading is a link to its own address; the filter also finds a route by a refusal code it gives, the object it returns, or a URL pasted with its IDs filled in, and a topic by its title; and descriptions render their inline code and links. For Claude, `GET /api/reference.md` is now an index (the topics, and each route on one line), and `GET /api/reference/<resource>.md` holds one resource in full; `GET /api/reference` carries the new `title`, `body`, `returns`, and `errors` on each route, and `concepts`, `statuses`, `refusals`, and `objects`.
97
+ - The operator's Claude context reads the reference's index at the start of a conversation and a resource's reference before calling its routes, a quarter as much up front as the whole JSON reference it read before; it also says Resolve's choice is the publisher's, and `/migrate-list` reads `GET /api/reference/imports.md`. It now says a route's refusals each say what to do next, and the index's Errors holds only the refusals any route can give, and it points to the reference for how a save names its revision and what an act's answer carries rather than restating them, so the two can't drift apart. `npm run check-context` offers the change to an existing instance.
98
+ - Workers Free's line is drawn once and said the same everywhere: it suits a list of up to about 1,000 subscribers, and past about 5,000 one send can use up the day's database writes and stop the newsletter until midnight UTC. On Free's limits, the Dashboard and the Subscribers screen recommend Workers Paid once the confirmed list passes 1,000, and so does an import that would take it past; `GET /api/subscribers` carries the words as `workers_plan_advice`. A send's daily-writes warning now goes by its audience alone, over 5,000, and no longer counts the day's imports, which also no longer change a scheduled send. The docs move "about 5,000" to the pair (SPEC §7, §8).
99
+ - The operator's Claude context: the migrate-list skill relays an import plan's `workers_plan_advice`, recommending Workers Paid, in place of the old daily-writes warning, which had it offer to wait until midnight UTC between pieces. `npm run check-context` offers the change to an existing instance.
100
+ - The API reference says more of what a route answers and how to act on it. A send's conditions and its halt are objects of their own, linked from the send view's fields: the condition object lists each kind in a table (its severity, fields, meaning, and remedy, `missed` and `stuck` included), and the halt object each cause with one line, its fix left to the `advice` its condition carries; `GET /api/imports/format` describes its answer, `limits` included; the logo upload states its 512 KB cap; and the deliveries list, the deletes, `whoami`, the dev routes, the webhook, and media say what they answer. The index lists each resource once, an off-tier route marked on its line, and images and the SES webhook join Archive and Sends.
101
+ - The API reference and the operator's Claude context name the editor's words where the API's differ, so Claude and the publisher talk about the same thing: `email_only` is the editor's **Include in archive**, unchecked; **Edit schedule** is a reschedule and the post's settings together, so a publisher asking to edit the schedule may mean either or both; and Resolve's `unsent` and `accepted` are **Assume not sent** and **Assume sent**, which Claude now uses when it asks the publisher to choose. `npm run check-context` offers the context's change to an existing instance.
102
+ - Three API reference routes are retitled to match the editor: `POST /api/suppressions/lift` is "Unblock or clear an address", since it is both the editor's **Unblock** and its **Clear**, and its description covers both; `PUT /api/posts/:id/settings` is "Change a post's settings"; and `GET /` is "Show the home page", the name the editor and the docs already give the page at `/`, which SPEC, DESIGN, the reference, and the setup guide now use too.
103
+ - The operator's Claude context: the migrate-list skill relays each import outcome by the name the plan gives it, now that the names read as they stand, and says a `kept_…` outcome means the row's status isn't imported because the list's own record or a suppression here wins. `npm run check-context` offers the change to an existing instance.
104
+ - A send that needs your decision on deliveries no one can confirm now says so on its page ("Some deliveries can’t be confirmed either way, and wait on your decision.") instead of calling them stuck, which the API uses for something else. An import plan says an address is already suppressed here, rather than blocked, since in the editor blocked means only your own block.
105
+ - The API reference opens its Requests topic with the rules a client builds on: the reference is the API's whole contract, so what a route doesn't list isn't part of it; a read is a `GET`; an address goes in the body or a read's query, never a path; and the API changes only in a release, each break listed under Breaking, since the version number doesn't say.
106
+
107
+ ### Fixed
108
+
109
+ - A send stuck awaiting Resolve that still carried an earlier refusal of the account is no longer emailed to the publisher as the provider refusing the account. Nothing retries such a send, so the editor and the API showed only that it waits for Resolve, and the email now agrees (SPEC §8).
110
+ - A send's cached answer (`If-None-Match` on `GET /api/sends/:id`) no longer keeps a client on a stale time to finish: the tag now changes when the dispatch rate or the expected finish does.
111
+ - The API reference documents every field a refusal carries, so Claude can act on one without guessing: `cursor_ahead` lists `field` and the `cursor` where the database stands, `slug_taken` and `slug_permanent` their `field`, Schedule and Send now list `slug_permanent`, an import's `unrecognized_format` its `column` and `allowed`, and the Errors topic says once that every refusal to fix the request names the `field` at fault. A refused image or logo upload and a template with no unsubscribe link now name their `field` too. The reference's objects match the wire: a subscriber's `confirm_token` may be null, the settings' `deployment` lists `access_configured`, `aws_region`, `notify_channel`, `notify_from`, and `simulation`, an import plan's `rows` lists `row`, `email`, and `suppression_added`, and List a send's deliveries lists the `page` it answers.
112
+ - The API reference's description of a send's `phase` said `needs_attention` covers a missed or stuck send, which stay `due` and `progressing` (their conditions say so), and that `backing_off` is only an outage or rate limit, when it also covers recipients waiting on a retry. It now also says which phases each `status` has, and that `status` is the one to filter and route on.
113
+
13
114
  ## [1.12.0] - 2026-10-16
14
115
 
15
116
  **Upgrading from 1.11.0.** Five database migrations, which `npm run sync-migrations` brings in: apply them before you deploy with `npx wrangler d1 migrations apply DB --remote --env production` (and `--local` for local development). `0007_sends_progress_at` adds a column recording when each send last handed anyone off, and writes no rows. `0008_notifications_sandbox` rebuilds the small notifications table to admit a new kind, copying its rows once. `0009_post_email_only` adds each post's listing, reading every existing post as listed in the archive, so the archive shows what it did before; it adds a column with a default and rewrites no rows. `0010_unlisted_links` gives every post its unlisted link, and records that every send made before it links its archive address, so those addresses keep answering whatever the post's listing; a send scheduled between the migration and the deploy counts as one made before. It writes each post row once. `0011_sends_render_warnings` adds a column keeping what the render found wrong with each scheduled email, reading every earlier send as having found nothing; it adds a column with a default and rewrites no rows. Run `npm run check-context` in your instance's repository: this release changes the operator's Claude context, and a copy you've edited is merged by hand. An instance verified with an earlier release has its test post in the archive: open it under **Sent** and select **Remove from archive…**. Two API changes can break a script of yours, listed under Breaking: one that reads a send's halt `cause` reads new values, and one that sets a slug by hand handles a 409 `slug_taken` by choosing another, where it used to get a numbered slug.
@@ -483,7 +584,8 @@ The first tagged release: a self-contained newsletter app on a Cloudflare Worker
483
584
  - A build-version stamp: every instance reports its version, commit, and build time, read-only in the editor and at `GET /api/version`.
484
585
  - This changelog and a semantic-versioning release process.
485
586
 
486
- [Unreleased]: https://github.com/kurtbruns/kestrel/compare/v1.12.0...HEAD
587
+ [Unreleased]: https://github.com/kurtbruns/kestrel/compare/v1.13.0...HEAD
588
+ [1.13.0]: https://github.com/kurtbruns/kestrel/releases/tag/v1.13.0
487
589
  [1.12.0]: https://github.com/kurtbruns/kestrel/releases/tag/v1.12.0
488
590
  [1.11.0]: https://github.com/kurtbruns/kestrel/releases/tag/v1.11.0
489
591
  [1.10.0]: https://github.com/kurtbruns/kestrel/releases/tag/v1.10.0
package/cli/dev-api.mjs CHANGED
@@ -145,7 +145,7 @@ async function devToken(base, tag) {
145
145
  ...(typeof message === "string" ? [message] : []),
146
146
  );
147
147
  }
148
- const { token, runId } = await res.json();
148
+ const { token, run_id: runId } = await res.json();
149
149
  return { token, runId: typeof runId === "string" && runId ? runId : undefined };
150
150
  }
151
151
 
package/cli/seed.mjs CHANGED
@@ -134,7 +134,7 @@ export async function seedCommand(argv) {
134
134
  ` posts: ${summary.posts.sent} sent, ${summary.posts.scheduled} scheduled, ${summary.posts.draft} draft`,
135
135
  );
136
136
  console.log(
137
- ` deliveries: ${summary.deliveries} • images written: ${summary.imagesWritten} • logo written: ${summary.logoWritten}`,
137
+ ` deliveries: ${summary.deliveries} • images written: ${summary.images_written} • logo written: ${summary.logo_written}`,
138
138
  );
139
139
  console.log("");
140
140
  console.log(" view it:");