13 Commits (880b5f19982d51c9ecf42fdaf3b2e51fe5163643)
| Author | SHA1 | Message | Date |
|---|---|---|---|
|
|
865fd16f58 |
Put the price recognition back, all 3526 lines of it
|
|
|
|
2321854967 |
Discard an unconfirmed registration, and sweep the abandoned ones
tests / pest (push) Failing after 8m17s
Details
tests / assets (push) Successful in 19s
Details
tests / release (push) Has been skipped
Details
"Abmelden und neu registrieren" was advice that could not work. Signing out frees nothing: the address is still held by the unique index, so registering again with the address somebody meant fails — and every abandoned attempt stayed in `users` for ever. Reported exactly that way, and it was right. The button discards the account instead. Only an UNCONFIRMED one, and only one with nothing behind it: the webhook creates a customer's login from a paid checkout, so a user can sit on this page unconfirmed while already having a contract, and deleting that is data loss dressed up as a convenience. It refuses and says where to go. A confirmed account is closed, not discarded — that is ConfirmCloseAccount, with an invoice history behind it. R23: confirmed in a modal. The modal mutates nothing; it dispatches the event the page listens for, so the checks stay in the one place they already are rather than being duplicated where they could drift. And nothing has to be discarded by hand. clupilot:prune-unverified removes registrations nobody confirmed after five days — long enough for somebody who signed up on a Friday and found the mail in a spam folder on Monday, short enough that a typo does not hold the correct address for a month. It skips a confirmed account whatever its age, and any account with a customer record behind it, matched on user_id AND on the address, because either link means somebody is a customer. Every removal is logged with the address: a line that disappears is what somebody asks about later, and a count answers nothing. The deadline is said on the page as well as in the mail. Somebody who never received the mail is looking at the page, and the deadline is the reason they do not have to do anything about an attempt they abandon. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
3cf16ecd63 |
Name every property that decides what a price charges
The money gate compared amount, currency and interval and left the rest to a filter that excluded two fields. A recurring, licensed, monthly price with transform_quantity divide_by 10, at our exact figure, on our product, carrying our addon key passed all five conditions. Modules bill BY quantity — a pack is one item at quantity n — so a customer holding three would have been charged one. A tiered price was excluded only by accident: Stripe reports unit_amount null for it and we read that as 0, which fails the amount match unless the caller's own figure is 0, and PlanPrices::ensure() has no zero guard. activePricesFor() now also skips a non-empty transform_quantity and any billing_scheme other than per_unit. Absent keys stay Stripe's defaults, which is the direction that matters: read the other way round the filter would refuse every legitimate price and turn recognition into a permanent no-op. The contract docblock names the four properties instead of claiming to cover all of them, and says the list is not everything Stripe can put on a price — only what is known to change what one charges. And a comment that says a gap is covered is worse than the gap. The sync said a crash between Stripe creating a product and us storing its id gives back the same product next run: true for twenty-four hours, false afterwards, and the exact claim this branch was written to refute. There is no recognition step for products at all, so an interrupted run leaves an orphan and the next run past the expiry mints a second one — worse than a duplicate price, because activePricesFor() then looks at the wrong product and recognition goes blind for the whole family. Written down as a known gap at both createProduct call sites, with the trade this branch chose: folding name and metadata into the key means a renamed family now mints a second product where it used to answer 400, and a blockade reaches a paying customer while a duplicate product does not. The sweep no longer reports "created" for what it adopted — the count is taken before ensure() runs, and telling those two apart is the whole point. The test named after 2026-07-29 could not reproduce it: the fake's key ledger was empty, so createPrice() could not have thrown and the test could not tell "adoption prevented the 400" from "no 400 was possible". Seeded now with the key today's call sends and a fingerprint over the metadata the pre-9da1358 call sent. Remove the adopt() call from AddonPrices::ensure() and the test dies with Stripe's own sentence instead of an assertion. Also: the plan document still instructed migrate:fresh --env=testing. There is no .env.testing here, so it targets the live development database; struck through with the reason, not deleted, and a Korrekturen section records the five defects the plan's own reviews found in it. Four comments corrected — "below" that meant above, a counter claimed unable to tie that ties with a planted price, the unique index's second meaning for remember()'s catch, and that MariaDB DDL is not transactional so the dedupe commits before the index. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
06c214e597 |
Make the money gate compare the whole recurrence
activePricesFor() reported interval alone, never interval_count or usage_type. A Price we could not have created ourselves — billed every three months, or on metered usage — could carry our own metadata (Stripe's dashboard "duplicate price" copies it) and pass AdoptStripePrice's amount/currency/interval check on 'month' alone. Adopting it would move money onto a different recurrence, which is exactly what nothing here may do. Filtered at the boundary instead of in the predicate: HttpStripeClient now skips anything besides Stripe's own defaults (interval_count 1, usage_type licensed), so activePricesFor() can promise callers it returns only prices we could have minted. FakeStripeClient needed no filter — it only ever holds prices its own createPrice()/plantPrice() made, all standard — but says so, so the omission doesn't read as forgotten. Two more silent-failure paths, closed with the same three lines each: - The metadata-differs check was a bare !==, which is key-order sensitive and blind to Stripe's own merge. A Price carrying an extra key of its own, or metadata handed back in a different order, could never compare equal and would be rewritten every sweep forever. Compared through array_intersect_key() plus ksort() on both sides instead, so only what WE would send is what gets compared. - Metadata is normalised to strings once, up front, so an un-cast value from a caller can't make confirms() fail forever and silently turn adoption off for that Price. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
ca575e6df1 |
Let the catalogue ask Stripe what it already has
Nothing here could. The client could create, archive and unarchive a Price and had no way to list one, so a run that died between Stripe's create and our insert left an orphan our table never learned about — and once the key expired, the next run made a second live Price for the same money. Paged to the end like invoiceLines(), because a family Product collects a Price per version, term, treatment and rate change, and stopping at the first page would leave the orphan unfound and mint the duplicate anyway. updatePriceMetadata() alongside it: metadata is one of the few fields a Price lets you change, which is why the metadata format is not part of a Price's identity and why an adopted orphan is brought up to date rather than replaced. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
9da1358802 |
Stop charging VAT to the customers who owe us none
A verified EU business outside Austria gets reverse charge — rate 0, no VAT line, the note on the invoice — and was still charged 214,80 € for a 179,00 € package, because one Stripe Price carried the domestic gross for everybody. There is no VAT line on their document to reclaim, so it was a flat 20 % surcharge on exactly the customers who read their invoices, and the document then stated the whole 214,80 € as net at 0 %, so they self-accounted their own VAT on a base a fifth too large. A Stripe Price can ask who is buying after all: there are two per sellable thing now, the domestic gross and the bare net, both live on one Product, and the checkout picks by TaxTreatment. The rule is Austria B2B 20 %, other EU B2B without VAT — a domestic business still pays the gross, reclaims it as input tax, and the price on the website is still the price charged for them. - stripe:sync-catalogue mints and archives both halves of every pair, per plan and per module; `reverse_charge` on stripe_plan_prices/stripe_addon_prices says which is which, and joining it on subscriptions.stripe_price_id says which one a running contract is billed on. A rate change moves the gross Price and leaves the net one alone, because the net owes nothing to the rate. - A status that changes after the sale converges: a verification (or a lapse) makes stripe:reprice-subscriptions move the contract onto the other Price with PRORATE_NONE, because the term is already paid for. The module items follow the same way. - Downstream follows: the invoice total equals what was taken, the proof register expects the figure this customer is actually charged (so a correct reverse-charge sale is no longer flagged as a mismatch, and an overcharged one is), the setup fee obeys the same rule, and the booking page quotes what it will charge. - StripeClient gained activatePrice(). Archived Prices were brought back in our own table and left inactive at Stripe, so a rate that moved and moved back pointed the catalogue at a Price no checkout could be opened for. An unverified number is still charged the gross — an unchecked string must never be a discount — and where the net Price is missing the checkout refuses rather than reaching for the domestic one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
8ca0d4d257 |
Say the same thing to the customer, the register and the bank
tests / pest (push) Failing after 8m5s
Details
tests / assets (push) Successful in 25s
Details
tests / release (push) Has been skipped
Details
Five places where the product told somebody a figure that nothing behind it produced. **The invoice page showed five invoices nobody was ever issued.** A mock that outlived its purpose: CP-2026-0003 to CP-2026-0007, a prefix belonging to no configured series, all "paid", a spend curve from 179 to 198 € and a next charge of 198 € on 01.08.2026 — behind ['auth','verified','customer.active'], so every signed-in customer saw it. Their real invoices existed in `invoices`, were rendered as PDFs and mailed to them, and appeared nowhere in the portal at all. The page now lists their own documents, newest first, with the numbers, dates and totals off the frozen snapshot, a Storno marked as one, and a download. The PDF route resolves the document against the signed-in customer in the query — a URL anybody can type is not made private by not printing it. No next charge and no chart: nothing here computes what Stripe will take next cycle, and a figure nobody has worked out is worse than a blank space. **The setup fee was quoted everywhere and charged nowhere.** On the price sheet, on the booking page, set by the operator in the console — and CompanyProfile::setupFeeCents() had no reader outside the two sentences that quoted it. It is charged now, as a second non-recurring line on the same checkout session, which Stripe puts on the initial invoice only. `orders.setup_fee_cents` records how much of the charge it was, so the whole total still goes back on a withdrawal while the register holds the PACKAGE's charge against the contract price and the invoice prints the fee as a line of its own. The booking page was also quoting the fee NET under the same sentence the website quotes GROSS — 99,00 against 118,80 — and now quotes what will be taken. **An upgrade click cancelled a booked downgrade and delivered nothing.** Nothing in this application marks a cart order paid, so the customer traded a downgrade they had genuinely booked for a cart line that would never be fulfilled. The booking now stands until the upgrade actually lands on the contract, where ApplyPlanChange clears it. The fulfilment seam is reachable for every type that can be paid — a module and a storage pack book through BookAddon, which was the missing path from a paid order to a SubscriptionAddon and left AddonPrices, SyncStripeAddonItems and clupilot:end-cancelled-addons with no caller behind a payment. A paid traffic pack logs an error rather than pretending: the metered allowance is a standing count of packs while a top-up is sold for one month, and which of the two is meant is not a guess to make here. That no path to `paid` exists yet is now stated in both places rather than in one. **The proof register called correct charges wrong.** expected_gross_cents used the CUSTOMER's rate, which is zero under reverse charge, against the domestic gross Stripe actually took — so matches_catalogue was false for every EU business sale charged exactly the advertised amount, and for every renewal on a contract with a module, which was held against `price_cents` alone. It compares against TaxTreatment::chargedCents() and against the whole term now. Where no money moved, charged and the flag are null instead of the expected figure agreeing with itself, and a plan change records the terms with the pro-rata figures in the snapshot rather than money that arrives again on Stripe's proration invoice. **The seller's own VAT id was on the invoice, unlabelled**, between the register number and the court. Labelled — and the footer's composition moved out of the TCPDF method, because inside one the only way to read what it says is to render a PDF and un-subset its fonts. One test enshrined a figure the code does not produce: a reverse-charge purchase recorded with no charged amount, asserting 17900/0/17900, where OpenSubscription always passes the charged figure and a real one records 21480/0/21480. Another asserted that an upgrade click cancels a booked downgrade. Full suite: 1676 passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
8440266ed3 |
Stop charging for a service that has ended
Nothing in this application could cancel a Stripe subscription — there was no such method on the client at all. So a customer who cancelled and a consumer who withdrew both stayed subscribed, the card went on being charged every month, and every one of those payments arrived as invoice.paid, drew a real number out of the gapless Austrian series and mailed the PDF, for a machine that had been switched off. Indefinitely. StripeClient::cancelSubscription() closes the first half, with the timing passed explicitly at the call site — CANCEL_AT_PERIOD_END for a cancellation, because the customer keeps the term they paid for, and CANCEL_IMMEDIATELY for a withdrawal, because that unwinds the contract and the whole amount has just gone back. The immediate form states prorate and invoice_now as false rather than inheriting them: a credit note of Stripe's beside our own Storno would be the same money twice. The second half is ApplyStripeBillingEvent::owesADocument(). Refusing every invoice on a cancelled contract would lose the legitimate final one — the cycle for a term the customer really did use, unpaid, dunned for three weeks and settled after the contract ended. So the question is not when the payment landed but what it is for: an invoice whose billed period STARTED before the contract ended is documented, one whose period begins at or after that moment is not. Neither is a contract marked cancelled with no date, nor an invoice with no period, because a number handed out in error can never be withdrawn while a missing document can still be issued. The register entry is written either way — the money moved, and that is where an operator finds what has to go back. A cancellation also took its date from the order date plus MONTHS, whatever the term said, so a yearly customer who cancelled in March lost the nine months they had already paid for. It reads current_period_end now, which Stripe keeps current for both terms; the month-walk survives only for a machine with no contract behind it, where nothing records a term at all. Stripe is asked first and our rows are written only if it agreed: a cancellation we could not make effective is the defect above wearing a "gekündigt" label on the settings page, and a cancellation has no deadline, so being asked to try again costs nobody anything. A withdrawal cancelled the OPENING invoice only, found by invoices.order_id — which a renewal and a module document leave null on purpose. A storage pack booked on day three and withdrawn from on day ten was therefore neither cancelled nor refunded. Every charge inside the window is covered now, one Storno per document and one refund per payment, each against the payment that actually took it: a single refund of the total against the opening PaymentIntent is what Stripe would have refused for exceeding it, and one idempotency key for the whole withdrawal would have had Stripe replay the first refund's answer for the second. StartCustomerProvisioning::resume() opened a missing contract and returned, and the only production call to IssueInvoice::forOrders() is after the order commits — so a worker killed in between cost that customer their invoice for good, silently, with nothing anywhere sweeping for it. resume() finishes that work too, guarded by the invoice already filed against the order and by invoices.sent_at, so however often Stripe redelivers it comes out as one invoice, one number, one mail. The order confirmation stays on the first pass alone: it has nothing to stamp. subscriptions.cancel_requested_at is new because the billing half of the application had no way to tell a cancelled contract from an untouched one. Deliberately not a status: until the term runs out this is a paying customer, which is the distinction EndInstanceService is built around, and `cancelled_at` beside it still means the day it genuinely ended. clupilot:verify-vat-ids is scheduled monthly on the first, and its docblock no longer points at a note in routes/console.php that never existed. Reverse charge rested on a one-off VIES answer, a withdrawn registration keeps earning rate 0 for ever, and the unpaid VAT on those invoices is the seller's — so the cadence is the width of that window, and the first of the month is the rhythm the return is filed on. Two tests that proved nothing are now about behaviour. SettingsTest asserted service_ends_at was not null, a column the code had just written and which the broken monthly arithmetic satisfied exactly as well as the right answer; it states the yearly date, the contract's own record and the order to Stripe. EndInstanceServiceTest travelled to whatever value the implementation had computed, so it recomputed the code instead of checking it, and never looked at billing at all; the boundary is a literal date now and the money stopping is asserted beside the address coming down. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
f02e86769b |
Charge the price the website shows, and hand a withdrawal back in full
tests / pest (push) Failing after 9m21s
Details
tests / assets (push) Successful in 21s
Details
tests / release (push) Has been skipped
Details
Stripe was charging the catalogue's NET figure while the document added the domestic rate on top: a customer quoted 214,80 paid 179,00 and was then invoiced for VAT nobody had collected. Under para 11(12) UStG that VAT is owed to the tax office whether or not it ever arrived, so every document issued created a liability against revenue that did not contain it. The Stripe Price now carries the GROSS figure. price_cents stays net - it is frozen onto every contract and PlanChange prorates against it, so redefining it would corrupt every pro-rata sum ever computed. Only the amount at the till moved, and it is formed by the one call TaxTreatment already answers for the price sheet and for the invoice, so displayed, charged and invoiced cannot drift apart. Stripe's automatic_tax is deliberately not used. TaxTreatment is the single tax authority here, and a second rate computed by Stripe would take 19 % from a German consumer while our document said 20 %. A Stripe Price cannot be edited, so a changed figure means a new Price and the old one archived - which stops it being SOLD and leaves every subscription already on it billing the old amount for ever. stripe:reprice-subscriptions is the second half, and a command of its own because it touches live money. A withdrawing consumer is refunded in full, as the owner decided. That is more generous than FAGG para 16 requires, and it makes the express-request consent the statute hinges on irrelevant - so the gate is gone rather than left looking load-bearing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
6d7c2bdf35 |
Ask whether they are a consumer, and let one change their mind
tests / pest (push) Failing after 8m35s
Details
tests / assets (push) Successful in 22s
Details
tests / release (push) Has been skipped
Details
Three things the product owed its customers and did not have. **Who is on the other side.** There was no consumer/business flag anywhere, and `vat_id` was standing in for one — which it cannot: a business without a VAT number is an ordinary small business, and a consumer with one does not exist. It is asked at sign-up now, correctable in the portal, and NULL where nobody has been asked. Unknown is read as CONSUMER everywhere it decides a right, because that mistake costs us a refund while the other one takes a statutory right away from somebody who has it. Reverse charge asks the recorded type instead of the number: an explicit consumer is charged the domestic rate whatever `vat_id` says — previously they were not, and anyone could zero their own VAT by getting a number verified. An unrecorded type still falls back to the verified number, so no contract that is already running changes rate. **The fourteen-day right of withdrawal** (FAGG, §312g BGB), for consumers only, at every door: the window is stamped on the contract when it is concluded, the customer exercises it from the portal, an operator records one that arrived by telephone or post, and both go through one action that refuses a business customer on the server rather than by hiding a card. The money follows the paperwork rather than being computed beside it. The invoice is cancelled by a Storno with its own gapless number — nothing is ever edited or deleted — a new invoice states the pro-rata value of the service actually delivered (FAGG §16, by days over the term paid for), and the refund is exactly the difference between the two documents. Where the consumer never expressly asked for the service to begin at once, they owe nothing and the whole amount goes back. `StripeClient::refund()` is new, keyed so a retry cannot send the money twice. The service ends through EndInstanceService and the `cancellation_scheduled` machinery that was already there. **The cancel button for modules.** BookAddon::cancelAtPeriodEnd() had no caller in the interface at all, so a customer could book a recurring charge in two clicks and had no way to stop it. It is on the module card now, with the date it runs until and a way back while the cancellation is still pending — and putting a module back settles nothing, because the term it was cancelled for was paid for in advance. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
8f1630ba91 |
Bill a booked module every month, and put it on the invoice
A module was never an item on the Stripe subscription, so it was charged in the month it was booked and never again. And the renewal document was written from our own contract snapshot, so it would not have said so even if the money had been taken. Two halves of one fault, closed together. Modules are items now. stripe:sync-catalogue mirrors a Product and a Price per module on both terms — a Stripe Price carries its own interval, and a booking frozen at an older figure needs its own Price, so they are keyed by module, amount, currency and interval in stripe_addon_prices. Booking adds an item with always_invoice: prorated by days AND charged there and then, which is the invoice the owner wants for a module booked mid-term. A second storage pack is a quantity on one item, not a second item. Cancelling takes the item off with no proration — no credit, and the next cycle is simply smaller — while the module itself is kept until the period end on subscription_addons.cancels_at and ended by clupilot:end-cancelled-addons. Documents are built from Stripe's own invoice lines. Stripe is what charged the customer; a document assembled from our figures would state a sum that is not the one taken. So every paid Stripe invoice gets one document with one number, idempotent on stripe_invoice_id — a cycle carrying package and every module together, a mid-period booking carrying just its prorated line, an upgrade carrying its proration. Only the wording is ours: each line is named from the catalogue rather than printed as a Price id, and a line nobody can name stays on the document under Stripe's own description and is logged. The checkout's own invoice is still the one exception; that purchase already has a document. Stripe being away never undoes a booking that has already reached the machine. The failure is parked on the contract in stripe_addon_sync and swept by the same clupilot:sync-stripe-subscriptions that retries a plan change, which finds its work from the bookings themselves rather than from the marker. A granted contract has no Stripe subscription and is not touched at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
35b312d5ef |
Invoice every renewal, and tell Stripe when the package changes
Two things Stripe was doing on its own, without us. **A renewal produced no invoice and no mail.** InvoiceMail went out from exactly one place — the first purchase — so a customer paying every month for a year received one invoice, for month one. Stripe already says when the money lands: invoice.paid with billing_reason subscription_cycle. What was missing was the document. A renewal has no Order behind it, and one was not invented for the occasion: an Order is something a customer bought, and writing a purchase nobody made would corrupt the record of what they have actually ordered. IssueInvoice::forBilledPeriod() issues from the CONTRACT instead, at its frozen net price, for the term Stripe billed. Idempotent against the Stripe invoice id, which is unique in the database rather than checked in PHP — and because the number is drawn inside the same transaction as the row, a redelivery takes its number back with it and the series keeps its sequence. Nothing in the paperwork can fail the webhook: issuing and mailing are both caught and logged, exactly as confirmByMail() does. Only a cycle renewal gets a document. The checkout's own invoice already has one; an upgrade's proration was worked out against Stripe's boundaries and would not match a document written from our snapshot; a charge raised by hand is for something this catalogue cannot describe. All three stay in the register, and are logged once as money no document of ours covers. **After a plan change, Stripe went on billing the old price.** There was no way to move a subscription onto another price at all, so a customer who upgraded paid for the smaller package every month afterwards. The client can now swap one, the item id it needs is learnt from Stripe's own events (and asked for once, for contracts older than the column), and the behaviour is chosen per direction: an upgrade settles immediately, so the cycle invoice stays exactly the contract price the renewal document states; a downgrade lands at the period boundary and settles nothing. A granted contract has no Stripe subscription and is left untouched. A change that reaches the machine and not Stripe is never rolled back — it is parked on the contract with the error and the behaviour it needed, logged as an error, and retried hourly by clupilot:sync-stripe-subscriptions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
0560ae743d |
feat(billing): Stripe owns the billing cycle, we own capability
A Product per plan family and a Price per priced row of a published version, plus the four subscribed webhook events that were arriving and being ignored. `stripe:sync-catalogue` mirrors the catalogue. Idempotent twice over: a stored id is skipped, and each call carries an idempotency key derived from our own row, so a crash between Stripe creating a Price and us recording its id gives back the same object rather than a second one. That matters more here than usual — a Stripe Price cannot be edited or deleted, so a duplicate is permanent. Drafts are not synced at all: a version that has promised nothing has no business in a price list. --dry-run shows what would be created. The webhook now handles invoice.paid, invoice.payment_failed, customer.subscription.updated and customer.subscription.deleted. It never re-derives an amount: the invoice is the authority for what was charged, and recomputing it from our catalogue would produce a second, disagreeing answer. A failed payment is recorded and nothing else — Stripe runs the dunning schedule, and cutting a customer off on the first failure would punish an expired card as though it were a refusal to pay. Only a cycle renewal moves the term on. Stripe also sends paid invoices for prorations and manual charges, and the checkout's own invoice — which is already in the register as the purchase, and would otherwise double every customer's first payment. Stripe does not guarantee delivery order, which is the source of most of the care here: - state changes are judged and written in one transaction with the row held, so two deliveries cannot both pass a check made on a stale copy; - an older event never overwrites a newer one, with a rank breaking ties between events sharing a second — their timestamps have one-second resolution, and a failed attempt and its successful retry routinely do; - a term is never shortened, and an ended contract is never revived; - an event for a contract that does not exist yet is HELD and replayed the moment it appears, rather than acknowledged and forgotten. A cancellation dropped that way would leave us serving someone who had already left. Held rows that never match are pruned after a week. Register entries are keyed by what they are about — the invoice, the attempt, the subscription — with a unique index doing the work, because a check two concurrent deliveries can both pass is not idempotency. PlanChange is now documented as the preview shown before someone confirms, not the invoice: Stripe's proration accounts for tax, existing credit and the exact second the change lands, and ours cannot. Not run against the live account — `stripe:sync-catalogue` creates objects that cannot be deleted, so that is the owner's call. The dry run lists 12. 457 tests green. Codex review clean after seven rounds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |