Correctness index

This is the engineering index of Aurora Meter Pro’s correctness invariants. For each invariant it records four things that are usually left implicit: what is guaranteed, what must be true for that guarantee to hold (prerequisites), what is deliberately not guaranteed (known limits), and the tests that prove it.

It is not the customer-facing contract. The core’s guarantees page is the prose contract and links back to this file; where the two disagree, this file is the one derived from the source.

How to read and maintain this file

Every section is one invariant, in ascending id order, with exactly these fields in this order: Guarantee, Prerequisites, Known limits, Tests. Every field except Tests begins its text on the field’s own line.

Test bullets come in two forms:

  • - `Module` / `test <full ExUnit name>` names a test that exists now. The full ExUnit name is what mix test prints and what --only matches: test <describe> <description> inside a describe block, test <description> outside one.
  • - PLANNED: `Module` / `test <full ExUnit name>` names a test that a future release will add. It must not exist yet; the change that writes it promotes the bullet to the first form.

test/aurora_meter/pro/correctness_index_test.exs enforces all of that mechanically:

  • every existing-form bullet resolves to exactly one test/property macro in test/, so renaming or deleting a test fails the suite;
  • every planned-form bullet resolves to none, so writing the test without promoting the bullet fails the suite;
  • every test whose description begins with an invariant id (I01 through I22) is listed here, so an invariant test that is never indexed fails the suite;
  • every invariant this package owns has a section, all four fields, and at least one test bullet.

A test that does not carry an invariant id in its description is not indexed. That is the reason the id prefix exists.

Invariants owned by this package: I13, I14 and I15, plus Pro’s half of the shared I08, I16, I19 and I20. Everything else is the core’s and lives in aurora_meter/docs/correctness.md; I22 is the storefront’s and is indexed in the storefront release manifest. Where an invariant is shared, this file states the Pro half only. A prerequisite may name a core guarantee by its id; it must not restate the core’s guarantee text, because two copies drift, and this package is built against the core from Hex as often as from the sibling checkout, where the core’s docs/ is not on disk at all.

I08 One input source yields one commercial usage effect

Guarantee. The Pro half of this invariant is that the reporter bills from one source and one source only: persisted counters. AuroraMeter.Pro.UsageReporter flushes this node first, then reads counter rows from the database, computes the delta since the last acknowledged report and sends that. Usage that exists only as another node’s gossip, or as an unfinished quota reservation, is not persisted and therefore cannot become a charge. Rollups read durable event rows only when the core’s day history is switched off, and a rollup is a reporting read, never a billing read.

Prerequisites. The core’s I01 and I02 (a flush is idempotent and atomic), so the counter the reporter reads is a committed total rather than a partial one. The Ecto storage adapter and a metered plan with a configured provider price.

Known limits. Nothing prevents a host from reading aurora_meter_events and billing it in addition to the counters; the invariant covers what this library writes, not what a host does with the rows afterwards.

AuroraMeter.Pro.Cutover.status/1 reports what the reporter has recorded, not what is still sitting in an ETS counter, so drained? means “no staged window before the watermark is unacknowledged”, not “no usage exists anywhere”. The procedure in the cutover guide closes that by having the operator run the reporter until it stages nothing new, which is an operational step rather than something this package can assert.

Between the watermark passing and the host’s deploy of feature_sources, neither path produces usage for the new period. The counter keeps accumulating in ETS, harmlessly, because the reporter will not stage a period at or after the watermark; the event path starts when the deploy lands. An operator who schedules a watermark and never deploys stops reporting that feature from the watermark onward, which status/1 reports and the guide states as step 4 of 5.

Tests.

  • AuroraMeter.Pro.UsageReporterTest / test what may never be staged unpersisted gossip cannot become a charge
  • AuroraMeter.Pro.UsageReporterTest / test what may never be staged successful pending work is staged only after its callback completes
  • AuroraMeter.Pro.UsageReporterTest / test what may never be staged work that raises after a flush and a report is never billed
  • AuroraMeter.Pro.UsageReporterTest / test what may never be staged a retried flush batch does not change what the reporter stages
  • AuroraMeter.Pro.OutboxTest / test eligibility, first failure wins I08 an event for a period before the feature's cutover quarantines with :period_buffered
  • AuroraMeter.Pro.RollupTest / test rollup_all builds day buckets from durable events when history is off
  • AuroraMeter.Pro.CutoverTest / test I08 across the watermark I08 cutover at watermark: buffered drained, events after, one effect per period
  • AuroraMeter.Pro.CutoverTest / test I08 the reporter stops at the watermark I08 the reporter stages nothing for a period at or after the watermark
  • AuroraMeter.Pro.CutoverTest / test I08 the reporter stops at the watermark I08 the reporter still stages a period strictly before the watermark
  • AuroraMeter.Pro.CutoverTest / test I08 the recommended crontab against an events-source feature I08 the reporter stages nothing for an events-source feature with no watermark
  • AuroraMeter.Pro.CutoverTest / test I08 the recommended crontab against an events-source feature I08 running the recommended crontab leaves the application bootable
  • AuroraMeter.Pro.CutoverTest / test I08 the recommended crontab against an events-source feature I08 a buffered feature is still staged, so the skip is narrow
  • AuroraMeter.Pro.SchedulerMapTest / test the recommended crontab and an events-source feature I08 the recommendation is unconditional, so the reporter's own skip is what makes it safe
  • AuroraMeter.Pro.SchedulerMapTest / test the recommended crontab and an events-source feature I08 the page that recommends the crontab says why taking it whole is safe
  • AuroraMeter.Pro.SchedulerMapTest / test the recommended crontab and an events-source feature I08 the reporter page and the scheduler page agree that the source decides
  • AuroraMeter.Pro.CutoverTest / test I08 the outbox starts at the watermark I08 an event for a period before the watermark is quarantined as period_buffered
  • AuroraMeter.Pro.CutoverTest / test I08 the outbox starts at the watermark I08 an event at the watermark itself is staged, not quarantined
  • AuroraMeter.Pro.CutoverTest / test I08 the outbox starts at the watermark I08 a period already reported as buffered is never re-reported as events
  • AuroraMeter.Pro.CutoverTest / test I08 through AuroraMeter.record/4, with the seam actually wired I08 record/4 for a period before the watermark produces a quarantined period_buffered item
  • AuroraMeter.Pro.CutoverTest / test I08 through AuroraMeter.record/4, with the seam actually wired I08 record/4 for a period at or after the watermark is staged pending

The four reporter bullets carry no invariant prefix even though they are the proof of the core’s I01 and I03 reached through Pro. Both invariants belong to the core, and this index may only carry sections for invariants this package owns, so a prefix here would name an id nothing in this file can index. The core’s docs/correctness.md indexes its half. The same applies to AuroraMeter.Pro.OutboxTest / test enqueue a correction is staged with its own subject_ref and does not disturb the original, which is Pro’s contribution to the core’s I09.

Corrections are the rest of that contribution, and every one of their tests is unprefixed for the same reason. They are the provider half of I09: a correction never fabricates a negative meter event, a correction that cannot be applied at the provider is quarantined with a manual_adjustment reconciliation item rather than being lost, and the two provider requests it makes happen in the order that fails towards under-reporting rather than over-reporting.

  • AuroraMeter.Pro.CorrectionExportTest, the whole module: what a correction stages, including that a second correction cancels what the first one left standing and that a correction of a fact that was never exported is quarantined rather than dropped.
  • AuroraMeter.Pro.Exporter.StripeMeterEventsCorrectionTest, the whole module: what it sends, in what order, under which idempotency keys, and what it does when the provider’s 24 hour cancellation window has closed.
  • AuroraMeter.Pro.Outbox.DelivererCorrectionTest, the whole module: the quarantine and the manual_adjustment item are one transaction or neither.

I13 Refund and dispute reconciliation never over-reverses or restores twice

Guarantee. A refund or dispute signal does not trust the event’s own snapshot. Under the payment’s lock, AuroraMeter.Pro.Credits retrieves the charge and all its disputes from the provider as they are now, and reconciles them to one net reversal capped at what the payment originally granted and at what earlier events already took. A failed or cancelled refund, and a dispute that is won, restore only credit that is not still reversed by another current refund or dispute, so repeated refund cycles and overlapping dispute and refund sequences settle at the net figure rather than accumulating. A reinstatement for a payment that was never reversed is ignored, and one dispute’s reinstatement cannot consume another dispute’s outstanding withdrawal. A reversal that arrives before its own grant is refused so that the provider redelivers it rather than the ledger inventing a negative. A reversal may take the balance negative, which is honest rather than an error. A refund or a lost dispute also switches auto top-up off with a reason, and a dispute drops the saved card, because charging a contested card only earns a second dispute.

Prerequisites. The provider webhook must be subscribed to charge.refunded, refund.created, refund.updated, refund.failed, charge.dispute.funds_withdrawn and charge.dispute.funds_reinstated, and the endpoint must verify signatures. The core ledger at schema version 3 or later. Provider lookups must succeed: a lookup or validation error preserves the credit and answers so that the provider redelivers.

Known limits. Reconciliation is only as current as the provider’s own view at the moment of the lookup. Aurora Meter does not claim automatic resolution of every unknown provider outcome.

The canceled and failed refund lifecycles are handled identically, by construction: retrieve_refund/4 never reads a refund object’s status and derives the outcome from the charge’s current amount_refunded and the charge’s current disputes alone. AuroraMeter.Pro.Credits.SyncRefundTest proves that with the provider fake for both statuses and for a status that contradicts the provider state.

The failed half of that pair is proved against a real provider. A real-provider harness, run in Stripe test mode and never in CI, drove a real refund to failed with the documented test payment method pm_card_refundFail, and recorded that the charge’s amount_refunded fell from the refunded amount back to 0 and that the ledger restored exactly that amount, once, across four deliveries of the transition and one deliberate redelivery. The charge and dispute reads the reconciliation depends on were made at Stripe API version 2025-11-17.clover; the events that triggered them arrived serialised at the account default 2024-06-20. A proof against that pair of versions is not a proof against every future pair, which is why both are recorded rather than reconciled into one number.

The canceled half is unverified against a real provider, and the reason is Stripe’s rather than this package’s. Read on 2026-09-14: https://docs.stripe.com/refunds (section “Cancel a refund”) and https://docs.stripe.com/testing (section “Simulate an asynchronous refund”) both state that a card refund can be cancelled only from the Stripe Dashboard, and the one documented API cancellation path is for payment methods without native refund support (Konbini, PromptPay, Boleto, bank transfers), none of which can be charged off-session with a saved card, which is the flow a prepaid top-up uses. No documented mechanism therefore exists for a script to produce a canceled card refund in test mode, and the harness does not invent one: a mechanism found by experiment and absent from the documentation would prove behaviour Stripe has not committed to.

The supported refund lifecycle is unchanged by that. canceled stays in support and is handled exactly as failed is, because the code cannot tell them apart: retrieve_refund/4 (Credits.retrieve_refund/4) reads neither status. Stripe’s own documentation classes a cancellation as a type of refund failure and gives it the same failure_reason and failure_balance_transaction attributes a failure carries. What changed here is the sentence describing how the behaviour is known, not the commitment.

A reversal that arrives before its grant, carries no tenant key and is older than the one-hour grant-race window is answered 200 and dropped (credits.ex:500-524). Money can therefore leave the provider with nothing recorded locally. That is a deliberate trade (refusing for ever would hold a foreign integration’s events open indefinitely), it is asserted by test I13 a refund before its grant with an unknown tenant is dropped once old (known limit), and the drop opens a reversal_without_grant reconciliation item so a person sees it.

Two defects were proved here and fixed before 1.0.0. The refund_restored: reference prefix used to be read and fed into both the sync reconciliation and the legacy cap, but no code path ever wrote it, so the term was always zero; the real-provider run confirmed it, and the dead reader is gone. And almost every refund and dispute test in this package used to build event objects with no charge "id", so exercised the legacy reverse/3 path rather than the production sync path; the matrix is now on the sync path and exactly one legacy case survives.

Tests.

  • AuroraMeter.Pro.Credits.SyncRefundTest / test I13 a full refund reverses the grant once through the sync path
  • AuroraMeter.Pro.Credits.SyncRefundTest / test I13 a partial refund then a full refund each take only the difference
  • AuroraMeter.Pro.Credits.SyncRefundTest / test I13 a redelivery of the same provider state changes nothing
  • AuroraMeter.Pro.Credits.SyncRefundTest / test I13 a failed refund restores exactly the refunded amount, once
  • AuroraMeter.Pro.Credits.SyncRefundTest / test I13 a canceled refund restores exactly the refunded amount, once, because the status is never read
  • AuroraMeter.Pro.Credits.SyncRefundTest / test I13 two disputes on one charge each take their own amount and never exceed the grant
  • AuroraMeter.Pro.Credits.SyncRefundTest / test I13 a dispute won after a refund restores the combined target without over-restoring
  • AuroraMeter.Pro.Credits.SyncRefundTest / test I13 a dispute re-opened after a win is taken again, once
  • AuroraMeter.Pro.Credits.SyncRefundTest / test I13 an inquiry-only dispute contributes nothing but still stops auto top-up
  • AuroraMeter.Pro.Credits.SyncRefundTest / test I13 a refund before its grant with a known tenant is refused so Stripe redelivers
  • AuroraMeter.Pro.Credits.SyncRefundTest / test I13 a refund before its grant with an unknown tenant is refused while recent
  • AuroraMeter.Pro.Credits.SyncRefundTest / test I13 a refund before its grant with an unknown tenant is dropped once old (known limit)
  • AuroraMeter.Pro.Credits.SyncRefundTest / test I13 a charge naming another payment intent is refused before any ledger write
  • AuroraMeter.Pro.Credits.SyncRefundTest / test I13 a charge in another currency is refused before any ledger write
  • AuroraMeter.Pro.Credits.SyncRefundTest / test I13 a charge lookup failure moves no credit
  • AuroraMeter.Pro.Credits.SyncRefundTest / test I13 a dispute page error moves no credit
  • AuroraMeter.Pro.Credits.SyncRefundTest / test I13 four concurrent deliveries of one failed refund restore once
  • AuroraMeter.Pro.Credits.SyncRefundTest / test I13 refund_restored: is never written by any path (P14, fixed in 06e)
  • AuroraMeter.Pro.Credits.SyncRefundTest / test I13 no delivery in this module reaches the legacy path
  • AuroraMeter.Pro.CreditsRefundLotsTest / test I13 a refund of a spent paid lot does not touch a later promotional lot
  • AuroraMeter.Pro.CreditsRefundLotsTest / test I13 a refund cannot exceed the payment's net reversible grant
  • AuroraMeter.Pro.CreditsRefundLotsTest / test I13 a partial refund then a full refund reverse the difference only
  • AuroraMeter.Pro.CreditsRefundLotsTest / test I13 repeated deliveries of one refund produce one reversal
  • AuroraMeter.Pro.CreditsRefundLotsTest / test I13 a failed refund restores exactly what the earlier reversal took
  • AuroraMeter.Pro.CreditsRefundLotsTest / test I13 a refund of a fully spent payment raises debt and a restore repays it
  • AuroraMeter.Pro.CreditsRefundLotsTest / test I13 a lost dispute reverses only the disputed part and a won one restores only it
  • AuroraMeter.Pro.CreditsRefundLotsTest / test I13 a dispute re-opened after a win reverses again under a distinct reference
  • AuroraMeter.Pro.CreditsRefundLotsTest / test I13 a reversal with no grant, no tenant and older than an hour opens one item
  • AuroraMeter.Pro.CreditsLotsTest / test I13 a refund against a wallet with lots_enabled_at null falls back and marks it
  • AuroraMeter.Pro.CreditsBoundaryTest / test I10 Pro does not query AuroraMeter.Schema.CreditTransaction for provenance
  • AuroraMeter.Pro.CreditsBoundaryTest / test I10 the two functions 06e removed are gone by name
  • AuroraMeter.Pro.CreditsBoundaryTest / test I10 the boundary scan fails on a query it should catch
  • AuroraMeter.Pro.Outbox.ReconcilerTest / test R1 the reconciler writes two things and nothing else I13 the reconciler mutates no credit row under any drift
  • AuroraMeter.Pro.CreditsTest / test webhook: top-up I13 an event whose charge carries no id still reverses through the legacy path
  • AuroraMeter.Pro.CreditsTest / test webhook: top-up failed and canceled refunds restore only net credit across repeated refund cycles
  • AuroraMeter.Pro.CreditsTest / test webhook: top-up a partial refund still takes only the difference
  • AuroraMeter.Pro.CreditsTest / test webhook: top-up refund reconciliation rejects unavailable, foreign or differently denominated charges
  • AuroraMeter.Pro.CreditsTest / test webhook: top-up a dispute we win puts the credit back, once and no more than was taken
  • AuroraMeter.Pro.CreditsTest / test webhook: top-up winning a dispute does not hand back a refund as well
  • AuroraMeter.Pro.CreditsTest / test webhook: top-up an unrelated adjustment cannot reopen a fully reversed payment's cap
  • AuroraMeter.Pro.CreditsTest / test webhook: top-up a reinstatement cannot consume another dispute's outstanding withdrawal
  • AuroraMeter.Pro.CreditsTest / test webhook: top-up a reinstatement for a payment we never reversed is ignored
  • AuroraMeter.Pro.CreditsTest / test webhook: top-up a refund that beats its own grant is refused, so Stripe sends it again
  • AuroraMeter.Pro.CreditsTest / test webhook: top-up a dispute re-opened after we won it is taken again

The real-provider proof is deliberately not listed here as a planned ExUnit test. The real-provider harness stays out of the suite and out of CI altogether, so a bullet in this file could never be satisfied without breaking that rule. It is a separate script run against a Stripe test account, summarised under Known limits.

  • AuroraMeter.Pro.RecoveryTest / test abandon_payment I13 abandon_payment reverses nothing and grants nothing

The recovery side: abandon_payment/2 clears the four pending payment columns and nothing else: it cannot grant, cannot reverse and cannot restore, because the core credit ledger is not reachable from AuroraMeter.Pro.Recovery at all.

I14 Repeated successful payment grants once

Guarantee. Every grant is keyed on the PaymentIntent id, so the same payment funds an account once however many times it is delivered. The Checkout Session and the PaymentIntent for one top-up carry the same reference, so whichever arrives first grants and the other is a no-op that still saves the card. An off-session auto top-up holds the account lock and reuses one idempotency key, so overlapping attempts cannot create two PaymentIntents, and an attempt that died after charging is retried on the same key rather than charging again. A failure redelivered after a later one is not counted twice against the card.

Prerequisites. The provider webhook subscribed to checkout.session.completed, payment_intent.succeeded and payment_intent.payment_failed, with signature verification. Oban for the auto top-up worker. The core ledger’s own grant idempotency (core I10’s reference key) underneath.

Known limits. The idempotency key protects a charge for as long as the provider honours it. An unknown payment older than that window is surfaced rather than retried, because a retry outside the window would create a second charge. A timed-out or 500-answered charge keeps its key precisely because the provider may already hold the money; that uncertainty is preserved rather than resolved.

Fixed before 1.0.0: a paid Checkout Session that carried no payment_intent fell back to the session id as the grant’s reference, and the payment_intent.succeeded for the same money then granted again under the intent id: one payment funded an account twice and nothing reconciled it. The session is now refused, which becomes a 500 and brings Stripe back once the intent exists. test I14 a paid session with no payment intent grants nothing and the intent event grants once is the before-and-after.

Fixed before 1.0.0: record_payment/4 is public and decided whether to clear the account’s failure state (reset?) from an unlocked read of the account, before the grant was written. A card replaced between that read and the grant meant an old card’s payment cleared the replacement card’s failure run, which is the exact protection the payment_method option exists to give. The read is now inside the account lock.

test I14 record_payment computes reset? inside the account lock carries the claim: its mutating writer holds that lock, so the lock is what decides the outcome. The original form of the test mutated the account without taking the lock and therefore passed both before and after the fix; it is kept, renamed to test I15 a card edit that does not take the account lock still wins the race with record_payment, because the behaviour it does describe is real and a host needs to know it.

Fixed before 1.0.0: the card save made two provider calls while holding the account advisory lock and before the grant was written, so a slow provider delayed settled money and blocked every other operation on that tenant’s account. The grant now commits first and the two calls happen after the lock is released, with a bounded retry and a card_not_saved reconciliation item when they fail. test I15 the grant is committed and the account lock released before the first card call asserts the grant is present and the lock free from a second connection while the first card call is held open by a fault.

Aurora Meter does not claim exactly-once process execution either: the three kill tests show what survives an untrappable death at each of the three windows between the charge, the grant and the key being cleared, and what the resume then has to do.

Tests.

  • AuroraMeter.Pro.Credits.ChargeConcurrencyTest / test I11 overlapping attempts cannot charge two PaymentIntents
  • AuroraMeter.Pro.Credits.AutoTopUpWorkerTest / test I14 a failure redelivered after a later one is still not counted twice
  • AuroraMeter.Pro.Credits.AutoTopUpWorkerTest / test I14 a grant lost between the charge and the commit is not charged twice (row-edited simulation)
  • AuroraMeter.Pro.Credits.AutoTopUpWorkerTest / test an unknown payment older than the idempotency window cannot create another charge
  • AuroraMeter.Pro.Credits.AutoTopUpWorkerTest / test what the key is kept for a timed-out charge keeps its key: Stripe may already have the money
  • AuroraMeter.Pro.Credits.PendingPaymentTest / test I14 a duplicate webhook delivery grants once
  • AuroraMeter.Pro.CreditsLotsTest / test I14 a checkout session grants a paid lot carrying payment_intent_id and checkout_session_id
  • AuroraMeter.Pro.CreditsLotsTest / test I14 a payment_intent.succeeded grants a paid lot with the payment and no session
  • AuroraMeter.Pro.CreditsLotsTest / test I14 a duplicate payment delivery grants one lot and notifies once
  • AuroraMeter.Pro.CreditsLotsTest / test I14 record_payment computes reset? inside the account lock
  • AuroraMeter.Pro.CreditsLotsTest / test I14 record_payment called from inside a handler that already holds the lock does not deadlock
  • AuroraMeter.Pro.CreditsLotsTest / test I14 a card-save network failure leaves the grant intact and opens one item
  • AuroraMeter.Pro.CreditsLotsTest / test I14 a card save that loses the newer-card race does not overwrite the newer card
  • AuroraMeter.Pro.Credits.PendingPaymentTest / test I14 an event arriving before the local intent grants once
  • AuroraMeter.Pro.Credits.PendingPaymentTest / test I14 a worker killed after the charge and before the grant is retried on the same key
  • AuroraMeter.Pro.Credits.PendingPaymentTest / test I14 a worker killed after the grant commits and before the key is cleared grants once
  • AuroraMeter.Pro.Credits.PendingPaymentTest / test I14 a worker killed between recording a definitive failure and clearing the key does not charge twice
  • AuroraMeter.Pro.Credits.PendingPaymentTest / test I14 a paid session with no payment intent grants nothing and the intent event grants once
  • AuroraMeter.Pro.CreditsTest / test webhook: top-up a paid payment-mode session grants once; the PaymentIntent saves the card without double-granting
  • AuroraMeter.Pro.CreditsTest / test webhook: top-up a PaymentIntent arriving first grants, and the session is then a no-op
  • AuroraMeter.Pro.CreditsTest / test webhook: top-up a redelivered session for the same customer keeps the saved card

test I11 overlapping attempts cannot charge two PaymentIntents carries an I11 prefix because the guarantee it proves is the core’s I11 (two operations cannot spend the same funds), reached through Pro’s account advisory lock. This package does not own I11 and so has no I11 section, and the bullet is listed under the invariant of Pro’s that the same test also proves. The index has no “contributes to” form yet.

  • AuroraMeter.Pro.RecoveryTest / test attach_payment_intent I14 attach_payment_intent creates no charge and grants nothing by itself

attach_payment_intent/2 records a PaymentIntent an operator identified and then resumes the existing attempt through the GET-only path. It creates no charge and grants nothing by itself, and the grant it may cause is still keyed on the intent id, so attaching an intent that was already granted is a no-op.

I15 Retry preserves financial intent and cannot cross tenants or accounts

Guarantee. The reporter stages an immutable payload before it sends, and only an acknowledgement advances the ledger. An uncertain outcome retries that frozen payload, unchanged, before any later usage is reported, and the request identifier names the reporting window, so an identical retry deduplicates at the provider. A rejection or a rate limit puts the ledger back, which keeps the delta alive for the next run rather than losing it. Two overlapping runs cannot bill the overlap twice, and what prevents it is the identity rather than the scheduler: a window’s subject_ref contains its own bounds, so a second staging of one window is the same outbox identity and collapses into the first row. The Oban uniqueness over every incomplete state is kept beside that, because one run at a time is less database work, and since 1.0.0 its period is finite: an infinite one meant a job left executing by a dead node deduplicated every later enqueue for ever. Pending usage is recovered even after the subscription is cancelled, and usage completed before the first report is not discarded by a cancellation.

Prerequisites. Oban with the :aurora_meter queue running, the usage report ledger at its current schema version, and a provider that honours idempotency keys for the deduplication window.

Known limits. A received event’s mode and account are checked against this installation’s configuration, and a disagreement is a 500 and a redelivery. What is not checked is the Stripe API version the event was serialised at: that is the webhook endpoint’s version, or the account’s default, and this package cannot set either. A difference is recorded on the event row, carried in telemetry and logged once per distinct version, and the event is applied anyway, because it is a real fact about real money and losing it would be worse than reconciling it. A field that moved between the two versions can therefore still be reconciled against itself.

An uncertain outcome older than the provider’s deduplication window (23 hours) is surfaced for reconciliation and is never replayed, because a replay outside that window could bill twice. Aurora Meter does not claim guaranteed provider invoice finalisation, and it does not claim that every unknown outcome is resolvable. The auto top-up worker’s staged payload against a settings or card change is asserted here; the reporter’s is the outbox’s frozen payload, asserted in the outbox tests.

Fixed before 1.0.0: one provider error value had two classifications. The reporter treated a 429 as a definite non-ingest and the auto top-up worker treated it as indeterminate. AuroraMeter.Pro.Provider.Outcome.classify/1 is now the one classifier, and a 429 is a retry everywhere.

Fixed before 1.0.0: a disabled account with a pending idempotency key was selected by the sweeper on every sweep and refused by the worker, so a stuck account produced a discarded job for ever and nothing surfaced it. The outcome is now a state rather than an error return, and the sweeper counts such an account instead of enqueueing it.

Fixed before 1.0.0: a failed unlock could hide the real exception. with_lock/2 released its advisory lock from an after block, so an exception raised there replaced the body’s. AuroraMeter.Pro.Lock logs the unlock failure and re-raises the body’s exception.

Fixed before 1.0.0: timing guards compared node clocks. Every timing guard in the auto top-up worker used to compare two node clock instants: in_cooldown?/1 was DateTime.diff(DateTime.utc_now(), last_attempt_at) < cooldown_seconds with last_attempt_at stamped from the same node clock, and resume/1‘s 23-hour uncertainty horizon was the same arithmetic. A node clock is not monotonic, and none is available that is: measured on the development host, DateTime.utc_now/0 steps backwards 13 times in 420 seconds under load and Erlang system time steps further still. When it steps backwards, a timestamp written a moment ago is in the future and the difference goes negative, so at a cooldown of zero the predicate became exactly “is last_attempt_at in the future” and refused an attempt that should have been made. In production the same step makes a stale pending operation look fresh, and a forward step makes a fresh one look past the horizon and demand reconciliation.

The fix is on both sides at once, which is the only way it can be fixed: last_attempt_at, pending_started_at and the reporter’s pending_since are now stamped by the database with clock_timestamp(), and every comparison against them reads AuroraMeter.Clock.db_now/0. Stamp and compare with the same clock, and for anything persisted that clock is the database’s. The size of any one clock error was never the point; comparing two clocks was. The cooldown boundary test drives the boundary in both directions under a frozen clock, and test I15 last_attempt_at is stamped by the database, not by the node clock proves the stamping half by freezing the node clock at an absurd instant and watching the column ignore it.

%Stripe.Error{} exposes no response headers in stripity_stripe 3.3.2 (deps/stripity_stripe/lib/stripe/error.ex:94-112), so Retry-After is unavailable and no Aurora Meter retry can honour the provider’s own backoff hint. That is a constraint of the SDK rather than a defect.

Tests.

  • AuroraMeter.Pro.UsageReporterTest / test the worker only one run at a time: the second insert is refused as a duplicate
  • AuroraMeter.Pro.UsageReporterTest / test I15 the payload is the intent, frozen I15 settings, card and plan changes after staging do not alter the payload
  • AuroraMeter.Pro.UsageReporterTest / test I15 the payload is the intent, frozen I15 an item for tenant A can never carry tenant B's customer
  • AuroraMeter.Pro.UsageReporterTest / test I15 the payload is the intent, frozen I15 an uncertain window past the horizon stays uncertain and is never resent
  • AuroraMeter.Pro.UsageReporterTest / test identity across runs an identical restage of an unacknowledged window is a no-op
  • AuroraMeter.Pro.UsageReporterTest / test identity across runs a restage after the previous window was acknowledged produces a new identity
  • AuroraMeter.Pro.UsageReporterTest / test selection a window staged while active is still delivered after the subscription is cancelled
  • AuroraMeter.Pro.UsageReporterTest / test selection cancellation before the first staging does not discard completed metered usage
  • AuroraMeter.Pro.Provider.OutcomeTest / test the rows that cost money I15 a 429 is a retry and never a rejection
  • AuroraMeter.Pro.Provider.OutcomeTest / test the rows that cost money I15 a rate limit is retried after the configured delay, not the backoff base
  • AuroraMeter.Pro.Outbox.DelivererTest / test the money-bearing outcomes I15 a 429 leaves the item pending with the same identifier and a delay ahead
  • AuroraMeter.Pro.Outbox.DelivererTest / test guards I15 an item staged in one mode is quarantined rather than sent in another
  • AuroraMeter.Pro.Outbox.DelivererTest / test guards I15 an item for one account is never delivered against another
  • AuroraMeter.Pro.Outbox.ConcurrencyTest / test I15 twelve independent connections delivering one item produce one accepted transition and one provider effect
  • AuroraMeter.Pro.Outbox.ConcurrencyTest / test I15 two producers staging the same window insert one item and one ledger pointer
  • AuroraMeter.Pro.Exporter.StripeMeterEventsCorrectionTest / test I15 a correction item is an ordinary outbox item I15 a correction retried after a mapping change delivers the payload as staged
  • AuroraMeter.Pro.TelemetryMetricsTest / test I15 the outbox gauge has a preset per state, so a backlog is visible without a tag
  • AuroraMeter.Pro.OutboxGaugeTest / test I15 one deliverer run emits exactly one gauge, with a measurement per state
  • AuroraMeter.Pro.OutboxGaugeTest / test I15 the gauge counts what the store counts, state by state
  • AuroraMeter.Pro.OutboxGaugeTest / test I15 no gauge sample is emitted when the aggregate query fails
  • AuroraMeter.Pro.OutboxGaugeTest / test I15 a deliverer run that cancels for legacy pending reports still emits a gauge
  • AuroraMeter.Pro.Credits.ChargeConcurrencyTest / test I11 overlapping distinct failures count once each under the account lock
  • AuroraMeter.Pro.Credits.ChargeConcurrencyTest / test I11 a rejected failure write leaves neither a count nor a deduplication reference
  • AuroraMeter.Pro.Credits.PendingPaymentTest / test I15 an answer that never came is not counted against the card
  • AuroraMeter.Pro.Credits.PendingPaymentTest / test I15 settings changed after staging do not alter the posted payload
  • AuroraMeter.Pro.Credits.PendingPaymentTest / test I15 a card changed after staging does not alter the posted payload
  • AuroraMeter.Pro.Credits.PendingPaymentTest / test I15 a delayed success for an older card does not replace a newer saved card
  • AuroraMeter.Pro.Credits.PendingPaymentTest / test I15 an operation older than the uncertainty horizon requires reconciliation and grants nothing
  • AuroraMeter.Pro.Credits.PendingPaymentTest / test I15 P20 a disabled account with a pending operation is counted, not enqueued
  • AuroraMeter.Pro.Credits.PendingPaymentTest / test I15 a rate-limit error is indeterminate in the worker (P2, fixed in 04b)
  • AuroraMeter.Pro.Credits.PendingPaymentTest / test I15 the cooldown boundary is decided by the database clock (B01)
  • AuroraMeter.Pro.Credits.PendingPaymentTest / test I15 last_attempt_at is stamped by the database, not by the node clock
  • AuroraMeter.Pro.Credits.PendingPaymentTest / test I15 the grant is committed and the account lock released before the first card call
  • AuroraMeter.Pro.Credits.PendingPaymentTest / test I15 an unlock failure no longer masks the body's exception (P21, fixed in 04b)
  • AuroraMeter.Pro.CreditsRefundLotsTest / test I15 a card change between the payment and the refund does not change the reversal
  • AuroraMeter.Pro.Credits.PendingPaymentTest / test I15 a card edit that does not take the account lock still wins the race with record_payment
  • AuroraMeter.Pro.WebhookGuardsTest / test I15 the mode check I15 an event whose livemode disagrees with stripe_mode answers 500 and applies nothing
  • AuroraMeter.Pro.WebhookGuardsTest / test I15 the mode check I15 an event with no livemode key answers 500
  • AuroraMeter.Pro.WebhookGuardsTest / test I15 the account check I15 an event carrying a foreign account answers 500 and applies nothing
  • AuroraMeter.Pro.WebhookGuardsTest / test I15 the account check I15 a payment event for a tenant stamped with another account grants nothing
  • AuroraMeter.Pro.OutboxTest / test eligibility, first failure wins I15 a mode that disagrees with the configured API key quarantines with :mode_mismatch
  • AuroraMeter.Pro.OutboxTest / test eligibility, first failure wins I15 a live installation that never confirmed its account quarantines with :account_unconfirmed
  • AuroraMeter.Pro.RetentionTest / test I15 prune/1 deletes no outbox item that is not confirmed
  • AuroraMeter.Pro.TransitionsApplierTest / test I15 a configuration change between the attempt and the retry does not change the price sent
  • AuroraMeter.Pro.TransitionsApplierTest / test I15 schedule stores the target price, the mapping version and the proration on the row
  • AuroraMeter.Pro.TransitionsApplierTest / test I15 the applier sends a stable idempotency key derived from tenant, ref and mapping_version

The reporter’s half of the guarantee is the three I15 the payload is the intent, frozen cases and the two guards cases in the deliverer; the auto top-up worker’s half is the two settings changed and card changed tests.

The two I11-prefixed charge-concurrency bullets are here for the same reason as the one under I14: the guarantee is the core’s I11, reached through Pro’s account advisory lock, and this package has no I11 section because it does not own the invariant.

  • AuroraMeter.Pro.RecoveryTest / test acknowledge_item refusals I15 acknowledging an item whose subject_ref differs from the expected one refuses
  • AuroraMeter.Pro.RecoveryTest / test acknowledge_item refusals I15 recovery cannot change an item's identity, and does not silently accept a claim to
  • AuroraMeter.Pro.Recovery.ActionTest / test allow_keys!/3 I15 an expected map naming an identity column raises rather than being ignored

Recovery operations cannot change an item’s identity. The expected: map takes only the keys the operation compares, so a claim about a tenant, a payload, an exporter, a destination account, a mode or a mapping version raises rather than being quietly ignored; the statements name the columns they set and none of those is among them; and the table’s BEFORE UPDATE trigger refuses the write even from raw SQL. The three layers are independent, and the negative controls in AuroraMeter.Pro.RecoveryTest disable one at a time and name which one answered.

I16 Worker scheduling is not an exactly-once assumption

Guarantee. No Pro worker assumes it runs exactly once. The reporter is unique over every incomplete Oban state, so a second insert while one is in flight is refused rather than queued. The auto top-up sweeper does not stack a second attempt on an account that already has one pending, and the worker skips an account inside the cooldown of its last attempt. Where a worker cannot be made unique, its effect is keyed instead: the grant is keyed on the PaymentIntent, the charge on an idempotency key, so duplicate execution converges on one effect.

And uniqueness is not what makes that safe, which was measured three ways. Two inserts sequentially on one connection give one job: Oban’s unique refuses the second. Two inserts from two real BEAM nodes separated by an :rpc hop give one job. Two inserts on two independent connections released together by a barrier give two, in ten races out of ten. Oban’s unique is a check inside the insert rather than a constraint on the table, so two that arrive together can both pass it.

What converges the effect is the effect’s own guard in every case: the idempotency key the second job finds already set and resumes rather than replaces, the outbox claim’s FOR UPDATE SKIP LOCKED and lease token, and the alert dedupe row written before the alert. A reader who took “the second insert is refused” as the protection would have believed the wrong layer was guarding the card.

Prerequisites. Oban with its uniqueness support and the :aurora_meter queue. A single Oban instance per database; uniqueness is enforced in the jobs table.

Known limits. Aurora Meter does not claim exactly-once host job execution.

The export outbox has a lease token as of 1.0.0, and a stale owner writing against it affects zero rows: every transition out of leased carries AND lease_token = ? AND state = 'leased', and the claim itself is decided by SELECT ... FOR UPDATE SKIP LOCKED rather than by comparing an instant. No other Pro worker has one.

Checkpoints and pause arrived in 1.0.0. Every scheduled Pro worker can be stopped at its next batch boundary with AuroraMeter.Operations.pause/1, and the reporter, the alert sweep and the auto top-up sweeper keep a keyset cursor in aurora_meter_checkpoints so a killed run resumes rather than restarting. The rollup and the audit-log pruner are pausable and keep no cursor, and that is a statement rather than an omission: the rollup’s month aggregate is one GROUP BY with no per-item effect to resume between, and each of the pruner’s batches deletes the rows it examined, so the scan advances by doing the work.

Pro reaches those rows only through AuroraMeter.Operations, never with a query of its own, which is the free and Pro boundary’s rule and is what keeps this from becoming a sixth direct core-table access.

And the resume is measured in Pro, not inferred from core’s copy of the batch loop. AuroraMeter.Pro.Test.Kill.after_batches/3 kills a worker from inside the telemetry handler that fires at a committed batch boundary, on a real connection, and the reporter, the alert sweep, the auto top-up sweeper and the retention pruner are each killed and resumed with the checkpoint’s counts and the next run’s examined count asserted exactly. AuroraMeter.Pro.Outbox.Reconciler is the one cursor-keeping worker that is not, and the reason is that it does not use the batch loop: its two checkpoints are written by its own budgeted polling loop, which emits no batch event for a handler to attach to.

Tests.

  • AuroraMeter.Pro.UsageReporterTest / test the worker only one run at a time: the second insert is refused as a duplicate

  • AuroraMeter.Pro.Credits.AutoTopUpSweeperTest / test does not stack a second attempt on top of a pending one

  • AuroraMeter.Pro.Credits.AutoTopUpWorkerTest / test skips within the cooldown of the last attempt

  • AuroraMeter.Pro.AuditLogTest / test prune/1 the Oban worker prunes with the args it is given

  • AuroraMeter.Pro.Outbox.DelivererTest / test G04.2 stale workers I16 lease expiry then a stale owner write affects zero rows and emits stale_worker

  • AuroraMeter.Pro.Outbox.ConcurrencyTest / test I16 twelve independent connections claiming one item produce exactly one claim

  • AuroraMeter.Pro.Outbox.ConcurrencyTest / test I16 fifty items and four workers produce fifty accepted items and no duplicate effect

  • AuroraMeter.Pro.JobControlsTest / test I16 every scheduled Pro worker names its operation and cancels with :paused

  • AuroraMeter.Pro.JobControlsTest / test I16 the same workers do not cancel when they are not paused

  • AuroraMeter.Pro.JobControlsTest / test I16 pausing leaves the cursor exactly where it was

  • AuroraMeter.Pro.JobControlsTest / test I16 a completed run leaves an empty cursor, so the next run starts the scan again

  • AuroraMeter.Pro.JobControlsTest / test I16 the batch budget is respected and leaves the cursor in place

  • AuroraMeter.Pro.RestartTest / test I16 AutoTopUpSweeper killed between batches resumes at the checkpoint without skipping accounts

  • AuroraMeter.Pro.RestartTest / test I16 two sequential AutoTopUpSweeper runs enqueue exactly one job per tenant

  • AuroraMeter.Pro.RestartTest / test I16 two AutoTopUpSweeper runs on independent connections enqueue at most two jobs per tenant

  • AuroraMeter.Pro.RestartTest / test I16 a forced race on one tenant shows how often Oban's unique loses it

  • AuroraMeter.Pro.RetentionTest / test I16 the retention pruner deletes the same rows as the operation

  • AuroraMeter.Pro.RetentionTest / test I16 a second retention pruner run deletes nothing and does not fail

  • AuroraMeter.Pro.KillResumeTest / test I16 the Pro retention pruner killed between batches resumes at its checkpoint

  • AuroraMeter.Pro.KillResumeTest / test I16 UsageReporter killed between batches resumes at the checkpoint without skipping subscriptions

  • AuroraMeter.Pro.KillResumeTest / test I16 Alerts killed between batches resumes at the checkpoint without skipping subscriptions

  • AuroraMeter.Pro.KillResumeTest / test I16 the kill harness refuses to pass when the batch boundary was never reached

  • PLANNED: AuroraMeter.Pro.Credits.AutoTopUpSweeperTest / test I16 a sweep that runs twice over one below-threshold account enqueues one attempt

  • PLANNED: AuroraMeter.Pro.AlertsTest / test I16 two alert sweeps fire each threshold once

  • AuroraMeter.Pro.RecoveryTest / test acknowledge_item refusals I16 a stale lease token is refused with the current token named

  • AuroraMeter.Pro.Recovery.ConcurrencyTest / test I16 two operators acknowledging one item: exactly one applies, both are recorded

  • AuroraMeter.Pro.Recovery.ConcurrencyTest / test I16 an operator racing a deliverer that re-leases the item is fenced by the token

  • AuroraMeter.Pro.TransitionsApplierTest / test I16 an indeterminate outcome leaves the transition pending and changes nothing locally

  • AuroraMeter.Pro.TransitionsApplierTest / test I16 an indeterminate outcome whose change did in fact land is confirmed, not sent again

  • AuroraMeter.Pro.TransitionsConcurrencyTest / test I16 the applier killed between the provider update and confirm_transition converges on the next run with exactly one Stripe update

  • AuroraMeter.Pro.TransitionsConcurrencyTest / test I16 two appliers against a transition Stripe has ALREADY applied send nothing at all

  • AuroraMeter.Pro.TransitionsConcurrencyTest / test I16 two appliers on independent connections produce one Stripe update and one applied transition

Recovery extends the fence to the operator. A recovery mutation is a conditional update whose predicate carries the state and the lease token the operator claimed to have seen, so an operator acting on a stale view is refused by exactly the mechanism that refuses a stale worker, and is told the row’s current values so the call can be re-issued. Both attempts are recorded.

I17 Historical plans remain stable

Guarantee. A Stripe price id belongs to exactly one {plan_id, version}, forever, and boot refuses a mapping in which two versions claim the same one. AuroraMeter.Pro.Subscriptions.sync/1 resolves the pair from the retrieved subscription object rather than from a webhook payload, writes both columns, and never nulls a version the row already names. A new commercial version gets a new price object with a new lookup key; an existing price is never re-pointed.

Three consequences follow, and each has its own tests below. A stale provider notification cannot move a version, because the content always comes from a fresh retrieve and resurrects?/2 refuses a payload for a subscription that has ended. A Stripe-confirmed upgrade to exactly the version a tenant had scheduled applies that transition rather than cancelling it as an override. And a tenant on version 1 keeps version 1’s caps, metered features and recurring allowance when version 2 ships, because the reporter and the alert sweep resolve the tenant’s own version rather than the one effective today.

Prerequisites. Core schema version 10 and Pro schema version 11, a plans module declaring the versions the price mapping names, and AuroraMeter.Plans.register!/0 having run so a version whose block has been deleted is still readable.

Known limits. A price mapped to a {plan_id, version} that resolves through neither compiled code nor a stored snapshot warns rather than raising: the snapshot half of that question is a database read, and a boot check that needs the database is a boot-order dependency this package will not add. A plan version arriving in Stripe metadata is used only when it names a version this installation can read, and falls back to the base version otherwise, so it can never pin a tenant to a contract that does not exist. stripe_meters stays keyed by feature alone and deliberately not by version: a meter is a measurement channel, not a price.

Tests.

  • AuroraMeter.Pro.PlanMappingTest / test I17 a bare plan id key in stripe_prices is read as version 1
  • AuroraMeter.Pro.PlanMappingTest / test I17 a key that is neither a plan id nor a pair is refused at boot
  • AuroraMeter.Pro.PlanMappingTest / test I17 a {plan_id, version} key resolves that version's prices and no other's
  • AuroraMeter.Pro.PlanMappingTest / test I17 plan_for_price reads the metered map as well as the flat one
  • AuroraMeter.Pro.PlanMappingTest / test I17 plan_for_price returns nil for an unknown price and for nil
  • AuroraMeter.Pro.PlanMappingTest / test I17 plan_for_price returns the pair for a version-keyed price
  • AuroraMeter.Pro.PlanMappingTest / test I17 plan_for_price returns version 1 for a legacy bare key
  • AuroraMeter.Pro.PlanMappingTest / test I17 proration_behavior defaults to none, which is what D05 means to Stripe
  • AuroraMeter.Pro.PlanMappingTest / test I17 the meter map stays keyed by feature alone, across versions
  • AuroraMeter.Pro.PlanMappingTest / test I17 validation accepts one price id claimed twice by the SAME pair
  • AuroraMeter.Pro.PlanMappingTest / test I17 validation passes for a mapping that uses only bare keys
  • AuroraMeter.Pro.PlanMappingTest / test I17 validation raises when a flat mapping and a metered mapping of different versions share a price
  • AuroraMeter.Pro.PlanMappingTest / test I17 validation raises when one price id is claimed by two versions, and names both
  • AuroraMeter.Pro.PlanMappingTest / test I17 validation says nothing about a metered feature that IS mapped
  • AuroraMeter.Pro.PlanMappingTest / test I17 validation warns for a metered feature of a mapped version with no meter, and names both
  • AuroraMeter.Pro.PlanMappingTest / test I17 validation warns, and does not raise, for a version that resolves to nothing
  • AuroraMeter.Pro.PlanRefTest / test I17 P9 an empty status is refused too
  • AuroraMeter.Pro.PlanRefTest / test I17 P9 sync of a subscription object with no status is refused and writes no row
  • AuroraMeter.Pro.PlanRefTest / test I17 P9 sync_from_session with no_payment_required writes active
  • AuroraMeter.Pro.PlanRefTest / test I17 P9 sync_from_session with payment_status paid writes status active
  • AuroraMeter.Pro.PlanRefTest / test I17 P9 sync_from_session with payment_status unpaid is not entitled until the webhook lands
  • AuroraMeter.Pro.PlanRefTest / test I17 Pro builds no query against AuroraMeter.Schema.Subscription
  • AuroraMeter.Pro.PlanRefTest / test I17 X296 sync writes the plan id AND the version, so a confirmed upgrade applies early instead of cancelling
  • AuroraMeter.Pro.PlanRefTest / test I17 X296 the same sync without the version leaves the transition pending for ever
  • AuroraMeter.Pro.PlanRefTest / test I17 a cancelled transition is not reinstated by a later sync
  • AuroraMeter.Pro.PlanRefTest / test I17 a checkout redirect never confirms, applies or cancels a plan transition
  • AuroraMeter.Pro.PlanRefTest / test I17 a stale webhook for an ended subscription does not change plan_version
  • AuroraMeter.Pro.PlanRefTest / test I17 a webhook payload with an outdated price cannot move the version, because the object is retrieved fresh
  • AuroraMeter.Pro.PlanRefTest / test I17 alerts lists entitled subscriptions through Storage.list_subscriptions/2
  • AuroraMeter.Pro.PlanRefTest / test I17 describe_plan_change reports the mapping and makes no network call
  • AuroraMeter.Pro.PlanRefTest / test I17 plan_id/1 is plan_ref/1 without the version
  • AuroraMeter.Pro.PlanRefTest / test I17 plan_ref falls back to metadata plan_id and plan_version
  • AuroraMeter.Pro.PlanRefTest / test I17 plan_ref prefers the item prices over metadata
  • AuroraMeter.Pro.PlanRefTest / test I17 plan_ref refuses a metadata version this installation cannot read
  • AuroraMeter.Pro.PlanRefTest / test I17 plan_ref resolves the pair from the retrieved item prices
  • AuroraMeter.Pro.PlanRefTest / test I17 plan_ref returns unknown with no version when nothing maps
  • AuroraMeter.Pro.PlanRefTest / test I17 sync does not reset plan_effective_at when the pair is unchanged
  • AuroraMeter.Pro.PlanRefTest / test I17 sync of an unmapped price does not null the version the row already names
  • AuroraMeter.Pro.PlanRefTest / test I17 sync of an unmapped price opens an unmapped_price reconciliation item
  • AuroraMeter.Pro.PlanRefTest / test I17 sync with cancel_at_period_end cancels a transition scheduled at or after the period end
  • AuroraMeter.Pro.PlanRefTest / test I17 sync with cancel_at_period_end leaves a transition scheduled before the period end pending
  • AuroraMeter.Pro.PlanRefTest / test I17 sync writes plan_version, plan_fingerprint and plan_effective_at
  • AuroraMeter.Pro.TransitionsApplierTest / test I17 a definitely rejected outcome opens a plan_change_rejected item and does not apply locally
  • AuroraMeter.Pro.TransitionsApplierTest / test I17 a rate limit is indeterminate rather than a rejection
  • AuroraMeter.Pro.TransitionsApplierTest / test I17 a transition Stripe has already applied is confirmed without a second update
  • AuroraMeter.Pro.TransitionsApplierTest / test I17 schedule refuses a target version with no Stripe price
  • AuroraMeter.Pro.TransitionsApplierTest / test I17 the applier confirms the transition once the retrieved subscription shows the target price
  • AuroraMeter.Pro.TransitionsApplierTest / test I17 the applier does not confirm while the retrieved subscription still shows the old price
  • AuroraMeter.Pro.TransitionsApplierTest / test I17 the applier sends Subscription.update with the target price and the configured proration
  • AuroraMeter.Pro.TransitionsApplierTest / test I17 the applier takes no LOCAL transition, only provider ones
  • AuroraMeter.Pro.TransitionsApplierTest / test I17 the applier takes no transition that is not due yet
  • AuroraMeter.Pro.TransitionsApplierTest / test I17 the applier reads the period from the item, and from the top level when an older API puts it there
  • AuroraMeter.Pro.TransitionsApplierTest / test I17 the telemetry event names the tenant, the reference, the target and the result
  • AuroraMeter.Pro.UsageReportAttributionTest / test I17 a period sold under a version that did not meter the feature is not staged
  • AuroraMeter.Pro.UsageReportAttributionTest / test I17 the same period sold under version 1 IS staged
  • AuroraMeter.Pro.UsageReportAttributionTest / test I17 a row with a null plan_version falls back to the subscription pair and is counted
  • AuroraMeter.Pro.UsageReportAttributionTest / test I17 a tenant on version 1 is metered under version 1 after version 2 becomes effective
  • AuroraMeter.Pro.UsageReportAttributionTest / test I17 a usage_report row is stamped with the plan pair effective for its period
  • AuroraMeter.Pro.UsageReportAttributionTest / test I17 a usage_report row keeps the pair it was created with when the plan changes mid-period
  • AuroraMeter.Pro.UsageReportAttributionTest / test I17 no fallback is counted for a row that carries its pair

I19 All supported schema histories preserve commercial state

Guarantee. Pro ships its own pinned, versioned migration module (AuroraMeter.Pro.Migration) layered on the core’s, and the test database is built by running both ladders in order, so the suite exercises the composed schema rather than a hand-made one. That bookkeeping is checked rather than trusted: every version up to the latest has a module and none beyond it, the moduledoc lists exactly those versions, the doctest on latest_version/0 names the real latest version, and the test database is built by pinned up(from:, version:) calls covering every version. The Pro half of this invariant is that a Pro schema step never assumes a core version that the paired core does not provide.

Prerequisites. PostgreSQL, and a core version whose migration ladder reaches the version the Pro step is written against. The host generates its migration with mix aurora_meter_pro.gen.migration.

Known limits. The structural checks prove the Pro ladder is complete and pinned, and that it composes with the core’s. Version 10 additionally checks its own data step against seeded rows, which is the first Pro version whose migration moves commercial state rather than only shape. They still say nothing about a populated upgrade across paired core and Pro versions (core2_pro1, core6_pro9), interrupted backfills or backup and restore round trips: those are covered by a separate migration rehearsal across core, Pro and a host application, run before each release that changes a schema. Nor do they pin which core version a Pro step needs: that pairing is asserted by .core-sha and the CI matrix, not by a migration test.

The one way this package can stop an existing installation from starting. 1.0.0 added a boot refusal: a feature configured to report from durable events, whose aurora_meter_usage_reports rows say it has already been reported from the buffered counter, with no cutover watermark, raises AuroraMeter.Pro.CutoverRequiredError. Its predicate has three conditions and all three must hold, so an installation that has never billed that feature boots unchanged, and so does one that has scheduled its cutover. The check reads two Pro tables, so it warns rather than raising when the repo is not up yet, which means AuroraMeter.Pro belongs after the Repo in a host’s supervision tree. The migration rehearsal includes an untouched core6_pro9 installation and asserts that it boots.

Rolling back over version 10 is not supported. Its data step converts every in-flight staged report into an outbox item and deliberately leaves the pending_* columns populated so a partly upgraded fleet is diagnosable, which means an Aurora Meter Pro 0.3.0 node would resend from them while the deliverer sends the same work from the outbox. Stop every 0.3.0 reporter before running it, and fix forward. AuroraMeter.Pro.Outbox.Deliverer enforces the other half at runtime: it cancels every run while a pending_entry row exists with no matching outbox item.

Tests.

  • AuroraMeter.Pro.MigrationTest / test every version up to the latest has a module, and none beyond it
  • AuroraMeter.Pro.MigrationTest / test the moduledoc describes every version
  • AuroraMeter.Pro.MigrationTest / test the doctest on latest_version/0 names the real latest version
  • AuroraMeter.Pro.MigrationTest / test the test database is built by pinned migrations covering every version
  • AuroraMeter.Pro.MigrationTest / test I19 Pro schema version 10 I19 V10 created every table, index, constraint and trigger the outbox rests on
  • AuroraMeter.Pro.MigrationTest / test I19 Pro schema version 10 I19 the payload size check measures what a caller sent, not what Postgres stored
  • AuroraMeter.Pro.MigrationTest / test I19 Pro schema version 10 I19 the identity index is the tuple 04.01 names, in order
  • AuroraMeter.Pro.MigrationTest / test I19 Pro schema version 10 I19 no constraint in V10 was added NOT VALID
  • AuroraMeter.Pro.MigrationTest / test I19 the V10 data step I19 a fresh pending report becomes a pending item, preserving its identifier
  • AuroraMeter.Pro.MigrationTest / test I19 the V10 data step I19 a report older than the horizon becomes an uncertain item
  • AuroraMeter.Pro.MigrationTest / test I19 the V10 data step I19 a report with a null pending_since becomes an uncertain item
  • AuroraMeter.Pro.MigrationTest / test I19 the V10 data step I19 every migrated item inherits its real age, so the horizon does not restart
  • AuroraMeter.Pro.MigrationTest / test I19 the V10 data step I19 the ledger row points at the item it was migrated into
  • AuroraMeter.Pro.MigrationTest / test I19 the V10 data step I19 running the data step twice inserts nothing the second time
  • AuroraMeter.Pro.MigrationTest / test I19 the V10 data step I19 V10 raises with the configuration key named when stripe_account_id is unset
  • AuroraMeter.Pro.MigrationTest / test I19 the V10 data step I19 with nothing staged, V10 needs no configuration at all
  • AuroraMeter.ProTest / test the core boots against the Pro test repo and plans
  • AuroraMeter.ProTest / test I19 the source cutover check I19 an installation with usage_reports rows and no events source boots unchanged
  • AuroraMeter.ProTest / test I19 the source cutover check I19 an installation that flips a reported feature to events without a cutover refuses to boot The generated host migrations. A history is only reproducible if the files that built it name the versions they ran. Before 1.0.0 both of Pro’s paths emitted an unbounded call: mix aurora_meter_pro.gen.migration wrote AuroraMeter.Pro.Migration.up() for a fresh install and up(from: n) for an upgrade, and mix aurora_meter_pro.install wrote the same, so a file committed today and applied after the next release built a different schema from the one it was written against. Both now read AuroraMeter.Install.Plan, in core, which core’s two tasks read as well, so no two of the four can emit a different file.

And before 1.0.0 the task could not be run in a host at all. It took the repo out of Mix.Ecto.parse_repo/1, which makes an atom and stops there, and never called Mix.Ecto.ensure_repo/2, so the host’s application was never loaded and Ecto.Migrator.migrations_path/1 reached a module that was not there: ** (UndefinedFunctionError) function MyApp.Repo.config/0 is undefined. Core’s generator has always had the call. Neither suite could see the difference, because both hand their task a repo the test VM has already loaded, and every test either file had asserted what the task wrote. The tests below now put the task in the state a host is in, make the call that raised, and compile what it produced rather than matching strings in it.

Pro’s generated down now carries confirm_data_loss: true on every range that holds a destructive version, and AuroraMeter.Pro.Migration.down/1 refuses without it. Before 1.0.0 it had neither: no list and no guard, while six Pro versions needed one.

The test that stood here before was a refute function_exported?(Migration, :data_loss_versions, 0) saying that the day Pro gained the function the generator should start emitting the flag and the test should go. It worked: it failed the day the function was added, so nobody had to remember. Its replacements keep the device and ask the next question, which is whether the flag lands on exactly the ranges that need it and whether down/1 really reads it.

  • Mix.Tasks.AuroraMeterPro.Gen.MigrationTest / test pinned ranges I19 a fresh install pins both ends of the whole range

  • Mix.Tasks.AuroraMeterPro.Gen.MigrationTest / test pinned ranges I19 an upgrade names both ends of every range it generates

  • Mix.Tasks.AuroraMeterPro.Gen.MigrationTest / test pinned ranges I19 the range covers every version from --from to the latest

  • Mix.Tasks.AuroraMeterPro.Gen.MigrationTest / test pinned ranges I19 every generated down names the same bounded range as its up

  • Mix.Tasks.AuroraMeterPro.Gen.MigrationTest / test pinned ranges I19 it refuses a --from above the latest version

  • Mix.Tasks.AuroraMeterPro.Gen.MigrationTest / test the shared plan I19 Pro declares no concurrent version, so one range becomes one file

  • Mix.Tasks.AuroraMeterPro.Gen.MigrationTest / test the shared plan I19 X369 every generated Pro down that needs a confirmation carries one, and only those

  • Mix.Tasks.AuroraMeterPro.Gen.MigrationTest / test the shared plan I19 X369 a generated Pro down that carries the flag is actually accepted by down/1

  • AuroraMeter.Pro.MigrationMapTest / test I19 X369 every Pro version the binding map calls destructive is in data_loss_versions/0

  • AuroraMeter.Pro.MigrationMapTest / test I19 X369 the quotation above is still what the binding map says

  • AuroraMeter.Pro.MigrationMapTest / test I19 X369 down/1 refuses a destructive Pro version and names it

  • AuroraMeter.Pro.MigrationMapTest / test I19 X369 down/1 lets a destructive version through with confirm_data_loss: true

  • AuroraMeter.Pro.MigrationMapTest / test I19 X369 a range with no destructive version needs no confirmation

  • AuroraMeter.Pro.MigrationMapTest / test I19 X369 every listed version is given a reason

  • Mix.Tasks.AuroraMeterPro.Gen.MigrationTest / test the migration the generator produces I19 an unresolvable repo is a named refusal, not an UndefinedFunctionError

  • Mix.Tasks.AuroraMeterPro.Gen.MigrationTest / test the migration the generator produces I19 core's generator and this one answer an unresolvable repo the same way

  • Mix.Tasks.AuroraMeterPro.Gen.MigrationTest / test the migration the generator produces I19 the generated file defines exactly the module its name promises

  • Mix.Tasks.AuroraMeterPro.Gen.MigrationTest / test the migration the generator produces I19 the generated module is one Ecto.Migrator will run

  • Mix.Tasks.AuroraMeterPro.InstallTest / test I19 the installer and the generator emit the same migration body

  • AuroraMeter.Pro.MigrationGuardsTest / test L-11b-1: an unknown option raises rather than being dropped I19 up/1 raises ArgumentError naming the unknown option

  • AuroraMeter.Pro.MigrationGuardsTest / test L-11b-1: an unknown option raises rather than being dropped I19 up/1 names prefix and the default schema when prefix is given

  • AuroraMeter.Pro.MigrationGuardsTest / test L-11b-1: an unknown option raises rather than being dropped I19 down/1 raises ArgumentError naming the unknown option

  • AuroraMeter.Pro.MigrationGuardsTest / test L-11b-1: an unknown option raises rather than being dropped I19 down/1 names prefix when prefix is given

  • AuroraMeter.Pro.MigrationGuardsTest / test L-11b-1: an unknown option raises rather than being dropped I19 a non-keyword argument is refused by name

  • AuroraMeter.Pro.MigrationGuardsTest / test L-11b-1: an unknown option raises rather than being dropped I19 the options the generator emits are named as supported

  • AuroraMeter.Pro.MigrationGuardsTest / test L-11b-1: an unknown option raises rather than being dropped I19 an option the generator emits gets past the guard

  • AuroraMeter.Pro.MigrationGuardsTest / test L-11b-3: down is refused unless the loss is confirmed I19 every version the binding map calls destructive raises DataLossError

  • AuroraMeter.Pro.MigrationGuardsTest / test L-11b-3: down is refused unless the loss is confirmed I19 a range with no destructive version needs no confirmation

  • AuroraMeter.Pro.MigrationGuardsTest / test X432: the Pro schema marker I19 the suite's own database carries schema:pro at the latest version

  • AuroraMeter.Pro.MigrationGuardsTest / test X432: the Pro schema marker I19 it sits beside core's, which is what --upgrade detection needs

  • AuroraMeter.Pro.MigrationGuardsTest / test the assumption Pro's prefix position rests on I19 Pro has no repository setting of its own

  • AuroraMeter.Pro.MigrationTest / test I19 the test repo applies both packages' versions in ascending order

  • AuroraMeter.Pro.MigrationTest / test I19 the V10 data step I19 version 10 applied again over itself is a no-op rather than a duplicate column

  • PLANNED: AuroraMeter.Pro.MigrationTest / test I19 a populated core2_pro1 database upgrades with every credit preserved

I20 Optional integrations remain optional and tenant-safe

Guarantee. Phoenix LiveView and Phoenix HTML are declared optional in mix.exs, and the subscription, credit, reporting and audit paths do not reference them. The dashboard loader is data only and can be asked to skip the ledger reads entirely, so a host that does not use credits pays nothing for them. The audit log plug refuses to be built without a tenant resolver, and skips the write rather than attributing a row to nobody when the resolver returns nil, so an optional integration cannot produce a row that crosses tenants.

The money section conveys every figure as text with its own visible label, never by colour alone: spendable, on hold, owed and expired are four formatted money strings, a zero renders as $0.00 rather than disappearing, and a non-zero debt carries the word “Owed” and an aria-label giving the amount and the state. The colour class on an owed figure is emphasis on top of that, and the test proves it by stripping the class and asserting the text is unchanged. The section also renders against a core that does not have the four figures, showing the three it has always shown, so a Pro release never requires a core release that is not out.

Prerequisites. The dashboard components require phoenix_live_view and phoenix_html. Everything else in Pro requires its hard dependencies.

Known limits. Pro has no headless CI leg, and cannot have one, because mix.exs makes Oban a hard dependency: a Pro build with no job runner is not a supported configuration. “Optional” in Pro therefore means LiveView and HTML only, which is a narrower claim than the core’s, and it is asserted by the absence of references rather than by a build that removes them. The core’s headless leg is what proves the shared half of this invariant; Pro’s half is asserted only in the presence direction.

AuroraMeter.Pro.Live.Dashboard used to read params["tenant"] || session["tenant"], so a signed-in customer who edited the URL read another customer’s usage, plan, balance and spend, and subscribed to their live feed. 1.0.0 fixed it: the tenant comes from session["tenant"] and from nowhere else, params are ignored entirely, and a session with no tenant raises rather than falling back to anything. It is a breaking change for a host that used the route the old moduledoc advertised, and the failure is loud (an ArgumentError at mount) rather than a silent cross-tenant read. See CHANGELOG.md.

The assertion that the other tenant is never read is not a string check over rendered output, because output can coincide: a page that renders nothing passes one, and so does a page that read the wrong tenant and happened to render the right one’s figures. AuroraMeter.Pro.Test.TenantProbe is installed as the :tenant resolver and refuses to resolve the forbidden key, which fails every read, every write and every subscription that would have touched it.

Host authentication around the dashboard remains the host’s: the moduledoc says so in those words, and the host puts a tenant in the session only after authenticating the current user and checking that the user may see that tenant.

Tests.

  • AuroraMeter.Pro.DashboardTest / test load/2 credits: false skips the ledger reads entirely
  • AuroraMeter.Pro.Plugs.AuditLogTest / test init/1 refuses to build without a tenant resolver
  • AuroraMeter.Pro.Plugs.AuditLogTest / test skips the write when the tenant resolver returns nil
  • AuroraMeter.Pro.AuditLogTest / test record/1 skips the write when there is no tenant
  • AuroraMeter.Pro.Live.DashboardTest / test I20 Pro Live.Dashboard requires session tenant: params['tenant'] is ignored
  • AuroraMeter.Pro.Live.DashboardTest / test I20 Pro Live.Dashboard requires session tenant: a session without a tenant raises ArgumentError
  • AuroraMeter.Pro.Live.DashboardTest / test I20 Pro Live.Dashboard subscribes only to the session tenant's topic
  • AuroraMeter.Pro.Live.DashboardTest / test I20 Pro Live.Dashboard renders no data for a tenant other than the session tenant
  • AuroraMeter.Pro.LiveDashboard.PageTest / test I20 the Pro page refuses :host_route with a distinct error
  • AuroraMeter.Pro.LiveDashboard.PageTest / test I20 the Pro page requires :authorized_by, like core's
  • AuroraMeter.Pro.LiveDashboard.PageTest / test I20 the Pro page renders no outbox row, tenant key or provider ref when the check returns false
  • AuroraMeter.Pro.LiveDashboard.SectionsUnavailableTest / test B1 every Pro section is unavailable, never zero, when its query cannot run
  • AuroraMeter.Pro.OptionalIntegrationsTest / test I20 the Pro LiveDashboard page is compiled exactly when phoenix_live_dashboard is available
  • AuroraMeter.Pro.OptionalIntegrationsTest / test I20 the Pro LiveView dashboard is compiled exactly when Phoenix.LiveView is available
  • AuroraMeter.Pro.OptionalIntegrationsTest / test I20 AuroraMeter.Pro.Telemetry.Metrics is compiled exactly when Telemetry.Metrics is available
  • AuroraMeter.Pro.OptionalIntegrationsTest / test I20 AURORA_NO_DASHBOARD removes the dashboard dependency and nothing else
  • AuroraMeter.Pro.OptionalIntegrationsTest / test I20 AURORA_NO_METRICS removes the metrics reporter and the dashboard that requires it
  • AuroraMeter.Pro.OptionalIntegrationsTest / test I20 the OpenTelemetry bridge's behaviour is available to Pro even though the API is core's optional dependency
  • PLANNED: AuroraMeter.Pro.AuditLogTest / test I20 an audit write without the task supervisor running falls back and warns once
  • PLANNED: AuroraMeter.Pro.DashboardTest / test I20 every quota kind renders its own label, level and status wording
  • AuroraMeter.Pro.DashboardTest / test I20 money_section renders spendable, on hold, owed and expired as text
  • AuroraMeter.Pro.DashboardTest / test I20 money_section renders a zero figure rather than hiding the row
  • AuroraMeter.Pro.DashboardTest / test I20 a non-zero debt carries a visible label and an aria-label, not only a colour class
  • AuroraMeter.Pro.DashboardTest / test I20 the dashboard renders when the only state is a debt
  • AuroraMeter.Pro.DashboardTest / test I20 the dashboard falls back to the three old figures when the core summary lacks the new keys
  • AuroraMeter.Pro.DashboardTest / test I20 money_section reports spendable and not available, which differ on a cut-over wallet
  • AuroraMeter.Pro.ExportedIdiomTest / test I20 X426 no shipped lib file asks function_exported?/3 without loading the module first
  • AuroraMeter.Pro.ExportedIdiomTest / test I20 the guard can find a bare call, and does not report a safe one
  • AuroraMeter.Pro.ExportedIdiomTest / test I20 the real call site in this package is the safe form
  • AuroraMeter.Pro.ExportedIdiomTest / test I20 every allow-list entry still matches a real call site