* feat: compare the decoded event body's fields and not only its type, fill arrays so every uuid carries a real value instead of the zero one a codec could drop unnoticed, and decode once in a process that has never encoded, because production is four processes and one of them only ever reads what another wrote
* feat: register the union body types at package load instead of on first schema build, which is what a process that only ever decodes never reached, so every worker command arrived as a map keyed by its branch name, went through the JSON fallback, and became a struct with every field zero and no error anywhere
* feat: make both bus envelopes Avro-encodable by deriving each one's schema from a declared registry of body types, with a union branch per body and our own struct walk that skips unexported fields and honours avro:"-" before descending, so the schema describes exactly what encoding/json already puts on the wire, and narrow the two sync cursors on the wire DTOs to int64 because Avro has no unsigned 64-bit type
* feat: stop the instance health check, the config registry and the docs all claiming Avro cannot serialize a worker envelope, which stopped being true once the envelopes carried a declared union, and check the one thing that is still a real misconfiguration instead: avro selected with no SCHEMA_REGISTRY_URL to resolve against
* feat: emit a reference the second time a record appears in an envelope schema instead of defining it again, because Avro names a record once and a document that defines warmbly.events.Token three times is rejected outright, and keep the Schema Registry round-trip as a skip-by-default test since only a registry judges the document rather than the objects it was built from
* feat: carry uint64 as Avro fixed(8) rather than long, which lets the sync cursors keep their unsigned type instead of being narrowed, name every event field after its json tag so the schema and the JSON wire agree, and populate every field in the round-trip test because zero values are why a uint64 mapped to long passed in the first place
* feat: frame Avro in Confluent's wire format and encode through hamba's default API instead of going through avrov2, whose private avro.API holds a type resolver avro.Register cannot reach, so a union body failed there with unable to resolve type while encoding cleanly against the same schema, and keep the registry round-trip as a skip-by-default test
* fix: save a contact's edited email address by giving models.UpdateContact the Email field the dashboard was already sending, normalizing it to a bare lowercased address, refusing one another contact in the workspace holds with a 409 contact_email_taken instead of letting the unique index 500, and dropping the verification verdict plus the delivery evidence that belonged to the old mailbox (issue #511)
* fix: reset a contact's verification evidence behind a watermark the delivery-credit job honours, clear the cached esp_provider the old domain produced, write a case-only edit in place instead of answering 200 and changing nothing, and normalize the address on create and import too so the two paths cannot disagree about what an address is
* fix: refuse a verification observation whose campaign step provably left before the contact's address was edited, so a hard bounce or an open for the old mailbox arriving afterwards cannot mark the corrected address invalid or valid, read the credit watermark against dispatched_at rather than the sent_at a worker result stamps later, and answer a racing address collision with the documented 409 instead of the unique index's 500
* fix: build the deliverability evidence step from the resolved campaign task so both halves name one real row, drop the email format hint from the OpenAPI schema now that the endpoint also accepts a display-name address, and drain the shared delivery-credit backlog in the live test instead of assuming one pass reaches this contact
* fix: make a campaign's linked segments the audience rather than an accumulator, so detaching one withdraws the leads it enrolled instead of leaving the old list mixed in with the new, tracked by a new campaign_leads.source that keeps a hand-picked lead, an overlapping segment's member and anyone the campaign has already emailed out of the withdrawal, and reported back as withdrawn/contacted counts the dialog confirms and explains (issue #510)
* fix: serialize the linked-segment sweep against a link replacement by taking the same campaign lock, so a sweep that read the old set cannot re-enrol the audience the replacement just withdrew, and word the dialog's confirm and toast so the campaign, not the segment, is what has already emailed a lead
* fix: stop the one-shot segment enrol from re-stamping leads that are already in the campaign, since the Leads tab's Add back runs through it and pinning a whole linked audience as hand-picked because one held-out member was restored is the accumulation this change exists to end
* fix: lock the leads a detachment is about to withdraw before deciding, because a send is reserved by stamping campaign_contact_progress and only then locking the lead row, so a reservation committing mid-pass was invisible to the delete's snapshot and could withdraw a lead whose first email had already gone; and report the already-emailed count on every toast branch, since a detach where the whole audience had been emailed changed no count and said nothing after confirming a removal
* fix: add the campaign_leads.source check constraint NOT VALID, which still enforces every insert and update while skipping a full scan of the largest table in the product under ACCESS EXCLUSIVE to learn that a one-statement-old column holds its own default everywhere
* fix: retire the warmup spam score, a ratchet that grew with volume rather than misbehaviour and that no band ever read, dropping the column from the pool row and the reputation ledger and explaining a pool finding with the band's own reason instead (#491)
* test: pin the advisor snapshot's pool columns against the scan, since the band's reason now reaches the finding through that select alone (#491)
* fix: hold a warmup sentence's score and reason with the sentence itself, keep the retired spam_score key on the published analytics payload as a deprecated zero, seed the sandbox with severity-shaped scores, and record the raw spam report when the warmup service is absent (#491)
* perf: cut the warmup health sweep from twelve round-trips per mailbox to seven by reading the participant row once, counting placements and complaints in one scan, complaints and bounces in one scan, and taking the spam score from the row already in hand (#492)
* perf: take the warmup health sweep to two round-trips per mailbox (one metrics statement, the write returns the row), list participants stalest first and stop at the deadline, and drop the dead spam-score and count surface (#492)
* fix: gate a warmup partner borrowed from the other tier against the pool it is in rather than the sender's, since the thin-tier fallback had rejected every borrowed candidate and a thin tier failed instead of borrowing
* fix: make cross-tier warmup borrowing one-directional and gate borrowed partners in their own pool, so a thin premium tier can actually borrow proven free mailboxes (#495)
* fix: pin the borrow floor at the exact boundary so a premium tier at the floor including its sender still borrows (#495)
* fix: put the warmup borrowing rule in one repository method (direction, floor, proven age, workspace standing) that the selector and scheduler both read, pin every drawn partner's gate to its own pool, fall through buckets when a stale row fails the gate, and allow only reply-backs across tiers (#495)
* fix: end the warmup partner draw by candidate exhaustion instead of a fixed attempt cap, and fail closed when a free mailbox's workspace standing cannot be read before it answers into a paid inbox (#495)
* fix: end the warmup partner draw by candidate exhaustion instead of a fixed attempt cap, and fail closed when a free mailbox's workspace standing cannot be read before it answers into a paid inbox (#495)
* feat: make the self-hosted pool plan buyable by resolving its Stripe price server-side behind a new /pool-link/offer and /pool-link/checkout pair, adding the plans.price_yearly column the yearly price id never had, and landing the instance's Unlimited button on a dialog that names the workspace and the billing period instead of a plans grid the non-public plan never appears in
* feat: apply the pool dialog's yearly default once per opening rather than on every offer result, so a background refetch cannot move the billing period out from under someone who already chose monthly
* feat: excuse one named account from the emailed login code, so a vendor reviewer who cannot read this instance's mail can sign in without turning codes off for everyone, with the reason recorded beside it and every run of warmblyctl status naming the accounts that hold one
* feat: create and manage tester accounts from the admin panel, so letting a reviewer in is a form rather than a shell, with the password shown once and every live exemption listed on one page because forgetting one is the way this goes wrong
* fix: give tester management its own permission bit rather than borrowing ban_users, create the account and its exemption in one transaction so no invisible orphan survives a failure, require an accountable operator on the CLI grant, stop a halted row scan reading as the whole exempt list, and show a failed query as an error instead of as no testers
* chore: re-run CI after the aggregator tripped on a cancelled job from the branch update, with every underlying job green
* chore: retrigger CI, the previous run sat queued indefinitely while other branches ran
* feat: roll back a half-created tester when its workspace step fails and backfill the manage-testers bit onto admins already holding every other permission, so the address is not left taken by an unusable account and the new routes are not 403 for the existing admin
* feat: make the 000151 manage-testers backfill one-way, because clearing bit 22 on the way down would also revoke it from an admin granted it explicitly afterwards and the up migration would not restore that
* fix: let a granted plan unlock the dashboard, since the client decided paid from the Stripe status a managed subscription never touches, and replace the Turnstile size Cloudflare removed so the widget renders and can issue a token at all
* feat: tell people on a preview deployment that it is a public beta, once in a dialog and thereafter as a header pill they can reopen, driven by a config value rather than a hostname so one image stays reusable, and bind both Turnstile modals through onLoad because the component is not forwardRef and execution=execute never fires without the widget instance
* fix: keep the beta pill outside the desktop-only header group so the notice stays reopenable on a phone, and say in the docs that the value is baked into config.js at container start rather than read per load
* feat: add operator-granted plans so a workspace can be paid without Stripe, surfaced in the admin panel as a badge, a filter and a card carrying who granted it and why, because the only alternative was writing a fake stripe subscription id into the database
* fix: hold a granted plan beside the paid one rather than over it so a Stripe workspace returns to the plan it pays for when the grant ends, route entitlement lookups through EffectivePlanID, separate a repository failure from an unknown plan, end a grant at local end of day, and drop an index that served no query