Correctness index
View SourceThis is the engineering index of Aurora Meter'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. docs/guarantees.md (written later in the
V1 programme) 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, Evidence.
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 whatmix testprints and what--onlymatches:test <describe> <description>inside adescribeblock,test <description>outside one.- PLANNED (<unit>): `Module` / `test <full ExUnit name>`names a test that the given build unit must create. It must not exist yet; when the unit writes it, the unit promotes the bullet to the first form in the same change.
test/aurora_meter/correctness_index_test.exs enforces all of that mechanically:
- every existing-form bullet resolves to exactly one
test/propertymacro intest/, 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 (
I01throughI22) is listed here, so an invariant test that is never indexed fails the suite; - every invariant this package owns has a section, all five 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 rule from the V1 invariant map, and it is the reason the id prefix exists.
Invariants owned by this package: I01 to I12 and I16 to I21. I13, I14 and I15 are
Pro's and live in aurora_meter_pro/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 core half only and names the other half without depending on
it.
An Evidence path is repo relative and lives under docs/evidence/v1/. A path
that belongs to another repository carries that repository as a prefix
(core:, pro:, storefront:). The index test checks the shape of the path and
that no two invariants claim the same one; it does not require the file to exist,
because evidence is written when the phase runs, not when the index is parsed.
I01 One flush batch has at most one durable effect
Guarantee. A flush batch carries a UUID. The flush_batch/3 callback of
AuroraMeter.Storage.Ecto opens one transaction, inserts the batch receipt with
on_conflict: :nothing on
the receipt id, and applies the counter and history deltas only when that insert
reported one new row. A redelivery of the same batch, from the same node after an
uncertain response or from any other connection, finds the receipt already
committed, applies nothing and returns the current persisted totals instead. The
Flusher keeps an unacknowledged batch in Store owned ETS, so a Flusher restart
retries the identical batch rather than rebuilding a new one from whatever is
pending at that moment.
Prerequisites. The Ecto storage adapter on PostgreSQL, schema version 5 or
later (the aurora_meter_flush_receipts table), and receipts that are never
deleted while any node could still retry. The receipt id must be generated once
per batch and reused across retries; a caller that generates a fresh id per
attempt gets no protection.
AuroraMeter.Retention is the only thing in this package that deletes a receipt,
and it is what turns "never deleted while any node could still retry" from an
instruction into a mechanism: it refuses unless every node's "flush:<node>"
heartbeat proves no node holds a batch from before the cutoff, refuses when no
node is reporting at all, and offers exactly one override (forget_node/1) for a
node an operator has confirmed is gone. The consequence of removing a receipt
anyway is demonstrated rather than assumed, in
test I01 a retry of a batch whose receipt was pruned would double count.
Known limits. The receipt deduplicates a batch, not a unit of usage. Usage that was never handed to a batch, because the Store or the VM was lost before the flush, is gone and is not recoverable from the receipt table. The guarantee says nothing about how long a retry may take: a failing database keeps the same batch pending across many flush intervals.
Killing the two processes has two different outcomes and both are observed
rather than asserted. Killing the Flusher loses nothing: the pending batch and
every buffered delta live in tables the Store owns, so the restarted Flusher
re-sends the identical batch id. Killing the Store discards every unflushed
delta and the retained batch, because it owns those tables and one_for_one
restarts it with new, empty ones. That second case is the documented
buffered-loss boundary, not a defect; a host that cannot accept it must use a
durable feature.
Tests.
AuroraMeter.FlushBatchConcurrencyTest/test I01 twelve independent connections deliver one batch onceAuroraMeter.FlushBatchConcurrencyTest/test I01 new deltas arriving during a retry are not lost and are not applied twiceAuroraMeter.FlusherTest/test I01 a commit whose response was lost, then a foreign writer, cannot apply a delta twiceAuroraMeter.FlusherTest/test I01 a batch belongs to Store until acknowledged, while later usage stays pendingAuroraMeter.FlusherTest/test I01 a graceful shutdown persists pending countersAuroraMeter.KillTest/test I01 a Flusher killed between the snapshot and the persist keeps the batch in StoreAuroraMeter.KillTest/test I01 a Store killed before the flush loses the buffered deltas, as documentedAuroraMeter.StorageTest/test a batch receipt deduplicates counters and history after unrelated writesAuroraMeter.ClusterTest/test when the database write commits and then reports failure a node that has heard gossip retries its batch without duplicating usageAuroraMeter.RetentionTest/test I01 a receipt is not pruned while a node's heartbeat reports an older pending batchAuroraMeter.RetentionTest/test I01 a receipt is not pruned while a node's heartbeat is itself older than the cutoffAuroraMeter.RetentionTest/test I01 receipts are pruned when every node is idle and currentAuroraMeter.RetentionTest/test I01 receipts are pruned when a node has a pending batch newer than the cutoffAuroraMeter.RetentionTest/test I01 a node with no heartbeat row does not blockAuroraMeter.RetentionTest/test I01 no heartbeat anywhere blocks a prune that would delete somethingAuroraMeter.RetentionTest/test I01 an unreadable pending_since blocks rather than being ignoredAuroraMeter.RetentionTest/test I01 a heartbeat state this release does not write blocksAuroraMeter.RetentionTest/test I01 forget_node/1 removes the block for exactly one node and leaves the othersAuroraMeter.RetentionTest/test I01 a node id containing an @ is handled, which is what every real node id looks likeAuroraMeter.RetentionTest/test I01 forget_node/1 refuses a node it has never heard ofAuroraMeter.RetentionTest/test I01 a retry of a batch whose receipt was pruned would double countAuroraMeter.RetentionTest/test I01 a retry of a batch whose receipt was protected does not double countAuroraMeter.StoreGaugeTest/test I01 store.gauge reports dirty_keys equal to the dirty table size, and 0 with noneAuroraMeter.StoreGaugeTest/test I01 oldest_pending_age_ms is zero while the dirty set is empty and grows once it is notAuroraMeter.StoreGaugeTest/test I01 pending_batch_age_ms grows while a batch is retained and returns to zero on commitAuroraMeter.StoreGaugeTest/test I01 pending_batch_age_ms is zero for a batch map carried across a release with no taken_at_msAuroraMeter.FlusherSpanTest/test I01 a committed flush emits start, stop with result ok, and the legacy event once eachAuroraMeter.FlusherSpanTest/test I01 a flush that fails once and succeeds on retry emits stop result error, then stop result okAuroraMeter.FlusherSpanTest/test I01 storage that raises emits exception and then the legacy error event
Evidence. docs/evidence/v1/phase-01/i01.md
I02 Counter, day history and receipt update atomically
Guarantee. The receipt insert, the counter upsert and the day history upsert
happen inside one repo transaction in the flush_batch/3 callback of
AuroraMeter.Storage.Ecto.
If any statement fails, the whole transaction rolls back, which includes the
receipt, so the batch stays retryable and no partial commercial state is left
behind. There is no state in which a counter moved and its receipt did not, or in
which a period counter moved and its day bucket did not.
Prerequisites. The Ecto storage adapter on a transactional database. A host that calls the Flusher from inside its own transaction inherits that transaction's fate, which is the documented behaviour rather than a second guarantee.
Known limits. Atomicity is per batch, not per track call. The ETS increment that produced the delta is not part of the transaction and is not rolled back by a failed flush; it stays pending and is retried. Nothing here promises that the in-memory view and the database agree between flushes.
Tests.
AuroraMeter.StatementsTest/test I02 a failing receipt insert writes nothing and reaches no later statementAuroraMeter.StatementsTest/test I02 a failing counter upsert rolls back the receiptAuroraMeter.StatementsTest/test I02 a failing history upsert rolls back the counter and the receiptAuroraMeter.StatementsTest/test I02 a retry after a failed statement applies the deltas exactly onceAuroraMeter.FlushBatchConcurrencyTest/test I02 an invalid history row rolls back the counter and the receiptAuroraMeter.ClusterTest/test when the database write fails the deltas are kept pending and flushed on the next attempt
Evidence. docs/evidence/v1/phase-01/i02.md
I03 Failed tentative quota work is never billed
Guarantee. AuroraMeter.Entitlements.with_quota/4 reserves quantity as
reserved rather than as completed usage: Counter.reserve/6 with deferred: true raises value and reserved together, and reserved quantity is excluded
from pending_flush and pending_gossip, so it is never written to the database
and never gossiped as usage. The wrapped function runs inside a try/catch that
catches all three exit kinds (raise, throw and exit), calls
Counter.release_work/4 and re-raises with the original stacktrace. Only a
function that returns normally reaches Counter.commit_work/5, which is the one
place that converts reserved quantity into flushable, billable usage. The period
and the day are captured once, before the callback runs, and both the release and
the commit use those captured values.
Prerequisites. Failure must be catchable: a raise, a throw or an exit in the
calling process. The Store must still be alive when the callback finishes, because
commit_work/5 and release_work/4 operate on its ETS tables.
Known limits. A caller killed with Process.exit(pid, :kill) runs no catch
clause. The reservation stays in that node's ETS until the Store restarts or the
period rolls over. That leaked reservation occupies local quota, which can refuse
later work, but it is never billed, because reserved quantity is never flushed.
This is a documented local-quota effect, not a billing effect, and the
documentation must not promise cleanup after untrappable termination. The size of
the leak is now measured rather than described: one killed caller leaves exactly
the quantity it reserved in reserved, with pending_flush at zero, and
remaining/2 falls by that same quantity (docs/evidence/v1/phase-01/i03.md).
A Store restart during the callback is a second known gap: commit_work/5 and
release_work/4 do not re-seed a cold key (open finding C6), so a with_quota
whose callback outlives a Store crash raises ArgumentError at commit instead of
releasing; 03b seeds the row first. The asymmetry is the defect: a deferred
Counter.reserve/6 on the same cold key does not raise, because
reserve_pending/2 seeds. AuroraMeter.KillTest asserts the current behaviour
so 03b has a before and after.
The period-crossing half of this invariant is partial until 02c. Core has no
clock seam, so the test reserves and commits against an explicitly chosen
period_start and day rather than letting the callback run across a real
midnight; 02c introduces the seam and owns the full with_quota crossing test.
Tests.
AuroraMeter.EntitlementsTest/test I03 a callback that flushed and then raises is not billedAuroraMeter.EntitlementsTest/test I03 a callback that flushed and then throws is not billedAuroraMeter.EntitlementsTest/test I03 a callback that flushed and then exits is not billedAuroraMeter.EntitlementsTest/test I03 a reservation is never in a flush batchAuroraMeter.EntitlementsTest/test I03 a reservation committed against a captured day lands in that day's bucket (partial until 02c)AuroraMeter.KillTest/test I03 a with_quota caller killed with :kill is never billed and leaves a documented reservationAuroraMeter.KillTest/test I03 Counter.commit_work after a Store restart seeds rather than raising (C6, fixed in 03b)AuroraMeter.ExamplesTest/test team-saas.md with_quota/3 gives the reservation back when the work raisesAuroraMeter.FeatureSourceTest/test with_quota over an events-source feature I03 with_quota releases its reservation on success and leaves the recorded quantityAuroraMeter.FeatureSourceTest/test with_quota over an events-source feature I03 with_quota over an events-source feature bills nothing when the callback records nothingAuroraMeter.FeatureSourceTest/test with_quota over an events-source feature I03 with_quota over an events-source feature releases on a raise, a throw and an exitAuroraMeter.FeatureSourceTest/test with_quota over an events-source feature I03 a killed with_quota caller over an events-source feature bills nothing
The three catchable-failure cases that give the capacity back (a raise, a throw and an exit) are named under I04, whose guarantee is the one they measure.
The last four are build unit 03c's contribution. For a feature whose reporting
source is :events the success path releases the reservation instead of
committing it, so "failed tentative work is never billed" is joined by
"successful tentative work is not billed either": the charge is the event the
callback recorded, and the reservation was only ever the gate in front of it.
The killed-caller case is the same documented leak as above, asserted again on
this path because releasing rather than committing is the change that could have
introduced a new one.
Evidence. docs/evidence/v1/phase-01/i03.md
I04 A local hard quota cannot oversubscribe completed plus pending work
Guarantee. Counter.reserve/6 performs a single :ets.update_counter/3, which
is atomic and lock free, and compares the resulting value against the limit. The
value it compares includes both completed usage and outstanding reservations,
because a deferred reserve raises value as well as reserved. When the result
exceeds the limit the increment is undone on the same path (a plain bump is
reversed with bump(key, -qty), a deferred one with release_work/4) and the
caller gets {:error, :limit_exceeded}. Concurrent callers racing for the last
unit therefore admit exactly the number of units the cap allows on that node, and
a refused reserve leaves the counter exactly where it was.
Prerequisites. A :hard limit declared for the feature in the resolved plan.
The guarantee is per node and is evaluated against that node's view of the
cluster-wide total.
Known limits. The cap is enforced against the local view, not a global lock.
On a cluster, a burst spread over N nodes can exceed the cap by what the other
N minus 1 nodes admitted within one :broadcast_interval. Aurora Meter does not
claim a strict global quota. A :metered feature is never refused by design, and
a :counter feature has no denominator at all, so neither participates in this
invariant.
A caller killed with :kill keeps its reservation, so the cap it was counted
against stays that much smaller on that node until the Store restarts or the
period rolls. That is a real cost of the D09 trade and it is measured, not
estimated: see I03's known limits and
docs/evidence/v1/phase-01/i04.md.
Tests.
AuroraMeter.EntitlementsTest/test I04 sixty concurrent with_quota calls against a limit of fifty admit exactly fifty on one nodeAuroraMeter.EntitlementsTest/test I04 a raising callback releases its capacityAuroraMeter.EntitlementsTest/test I04 a throwing callback releases its capacityAuroraMeter.EntitlementsTest/test I04 an exiting callback releases its capacityAuroraMeter.MeteringTest/test I04 Counter.reserve blocks at the hard limit and rolls the increment backAuroraMeter.KillTest/test I04 a killed caller's reservation continues to occupy the limit on that nodeAuroraMeter.ExamplesTest/test team-saas.md with_quota/3 admits exactly the cap under concurrencyAuroraMeter.EntitlementsTest/test check/2 blocks a hard cap at the limitAuroraMeter.FeaturePolicyTest/test I04 denial under :deny never reservesAuroraMeter.FeaturePolicyTest/test I04 declared-feature reservation is policy-invariantAuroraMeter.FeatureSourceTest/test with_quota over an events-source feature I04 with_quota over an events-source feature admits exactly the limit of concurrent callers
The last one is build unit 03c's, and it measures a different quantity from the others, which is why it is worth stating rather than assuming. For a buffered feature the cap counts calls, because an admitted call keeps its unit for the period. For an events-source feature the reservation is released on success, so the cap counts what is in flight at once plus whatever has been durably recorded. Fifty-five callers held inside their callbacks admit exactly fifty; the same fifty-five run to completion recording nothing consume nothing, and that is correct rather than a hole, because nothing was used.
The two before it are build unit 02b's contribution: :undeclared_feature_policy adds
a branch in front of the reservation, so the guarantee above now also has to say
that a denied call takes no capacity and that a declared feature's reservation
behaves the same under all four policy values.
Evidence. docs/evidence/v1/phase-01/i04.md
I05 Cluster guarantees match documented limitations
Guarantee. Two loops converge the nodes and both are bounded.
AuroraMeter.Broadcaster publishes each node's pending_gossip on the
aurora_meter:cluster topic every :broadcast_interval, and receiving nodes add
those deltas to their own view; a node's own messages are ignored. Every flush
writes deltas (not absolute values) and re-bases on the totals the database
returns, then announces them, so anything gossip dropped heals within one
:flush_interval. Counter.rebase/3 applies an announcement only upward, so a
late announcement never drags a fresher view backwards, while a node's own flush
re-bases unconditionally. Because flushes add deltas rather than overwrite, the
persisted row after every node has flushed equals the sum of every increment on
every node.
Prerequisites. A distributed Phoenix.PubSub and cluster_sync: true (the
default). With cluster_sync: false, or a non-distributed PubSub, every node
keeps its own counters and the convergence claims do not apply; a single node is
unaffected either way.
Known limits. Reads are eventually consistent: AuroraMeter.usage/2 on any
node can be short by what the other nodes added in the last :broadcast_interval,
or one :flush_interval if a PubSub message was dropped. Hard limits are enforced
locally, so overshoot is bounded but not zero (see I04). Losing a Store or a VM
loses that node's usage since its last successful flush, and an outage makes that
window longer than one interval. Aurora Meter does not claim no buffered loss.
The overshoot bound is measured rather than reasoned about: with a hard limit of
50 and one peer's admissions in flight, the local node admits up to its own view,
so the total admitted across the cluster exceeds the limit by at most what the
other nodes admitted since the announcement this node last applied. The scripted
interleaving and the number it produced are in
docs/evidence/v1/phase-01/i05.md; 08c re-measures it under load.
The simulations are single-VM: AuroraMeter.Cluster.apply/3 is driven directly,
which exercises handle_batch/3, Counter.apply_remote/2 and Counter.rebase/3
exactly as a received PubSub message would, and delay and loss are scripted by
withholding or dropping a call. It does not exercise Phoenix.PubSub itself, a
netsplit or a real rejoin; a multi-node harness is 11d's.
Tests.
AuroraMeter.ClusterTest/property two writers I05 final database total equals the sum of all deltas and the local view matches itAuroraMeter.ClusterTest/test total announcements from another node I05 a totals announcement never moves a view backwardsAuroraMeter.ClusterConvergenceTest/test I05 two nodes with delayed gossip converge after a flushAuroraMeter.ClusterConvergenceTest/test I05 two nodes with lost gossip converge after a flushAuroraMeter.ClusterConvergenceTest/test I05 four nodes with mixed delayed and lost gossip converge after every node has flushedAuroraMeter.ClusterConvergenceTest/test I05 overshoot is bounded by what other nodes admitted between announcementsAuroraMeter.ClusterTest/test two writers flushes add up in the database instead of overwritingAuroraMeter.ClusterTest/test two writers a seed that races another node's flush heals on the announcementAuroraMeter.ClusterTest/test gossip from another node moves this node's view without making the delta ours to flushAuroraMeter.ClusterTest/test configuration publishing with sync off is a no-opAuroraMeter.FeatureSourceTest/test projection, seeding and the cluster I05 a peer's projected delta moves this node's value and a cold reseed corrects itAuroraMeter.FeatureSourceTest/test projection, seeding and the cluster I05 a peer's totals announcement never rebases an events-source keyAuroraMeter.ClusterLagTest/test I05 cluster.lag reports peers seen and since_last_message_ms after remote batchesAuroraMeter.ClusterLagTest/test I05 a peer not heard from for ten broadcast intervals leaves the peer mapAuroraMeter.ClusterLagTest/test I05 a pruned peer is removed from the state and not merely from the reportAuroraMeter.ClusterLagTest/test I05 unreconciled_keys counts keys carrying peer value and falls to zero after a rebaseAuroraMeter.ClusterLagTest/test I05 unreconciled_keys is omitted above the scan ceiling, never reported as zeroAuroraMeter.ClusterLagTest/test I05 no lag event is emitted at all when cluster_sync is false
The last two are build unit 03c's contribution, and the convergence story for an
events-source feature is the easier one: the durable total is shared state, so a
node that is cold for the key reads every node's events in one query rather than
waiting for gossip. A projected delta still gossips, so warm peers move within a
:broadcast_interval, and nothing ever rebases the key, because no node puts it
in a flush batch and Cluster's totals branch refuses an announcement that would
move a key backwards.
Evidence. docs/evidence/v1/phase-01/i05.md
I06 Durable accepted events survive process loss without duplication
Guarantee. Today the core writes a durable event row on track/4 when the
feature is marked :durable or listed in :durable_features, through
AuroraMeter.Storage.insert_events/1. A committed event row is on disk and
survives the loss of the process that wrote it. The stronger half of this
invariant, that an accepted event is recorded exactly once across a kill before
commit, a kill after commit before reply, and a same identity retry, is the
subject of the durable events work in phase 03 and is not guaranteed by the
current code.
Prerequisites. The Ecto storage adapter and schema version 4 or later (the
aurora_meter_events table). The feature must be durable, per call or by
configuration.
Known limits. The current durable insert happens after the ETS bump, outside a
transaction and without a rescue, so inside a host transaction the row can roll
back while the ETS increment stays (open finding C14). The track/4 path still
has no caller identity and therefore no deduplication on retry: build unit 03a
gives each of its rows a freshly generated track: identity so the column can be
NOT NULL, which satisfies the database and changes nothing about retries, and a
caller that retries a durable track/4 still writes a second row.
AuroraMeter.record/4 (03b) is the path that deduplicates. Event quantity is no
longer a 32 bit column: core schema version 7 widened it to bigint (open
finding C13, closed by 03a). The rest are named so that a reader cannot mistake
the current behaviour for the phase 03 guarantee.
Tests.
AuroraMeter.MeteringTest/test a durable feature writes an event row on trackAuroraMeter.StorageTest/test insert_events/1 appends rowsAuroraMeter.MigrationV8Test/test one identity, one fact I06 two inserts of the same (tenant_key, event_id) leave exactly one rowAuroraMeter.MigrationV8Test/test one identity, one fact I06 the same event_id under two tenants is two factsAuroraMeter.RecordTest/test identity I06 a retry with the same id and payload is a duplicate with no second effectAuroraMeter.RecordTest/test identity I06 the same id in two tenants creates two eventsAuroraMeter.RecordBatchTest/test I06 a storage failure mid-batch rolls back every new rowAuroraMeter.RecordConcurrencyTest/test one identity, twelve connections I06 12 independent connections submitting one identity create one fact, one totals delta and one outbox itemAuroraMeter.RecordConcurrencyTest/test one identity, twelve connections I06 concurrent distinct ids in one tenant and period produce one totals row with the exact sumAuroraMeter.RecordConcurrencyTest/test process death I06 killing the caller before commit leaves no row, no delta, no outbox item and no ETS deltaAuroraMeter.RecordConcurrencyTest/test process death I06 killing the caller after commit before the reply leaves exactly one row, and the same-id retry returns duplicate without a second delta or outbox itemAuroraMeter.RecordConcurrencyTest/test generations I06 a record transaction in flight blocks generation activation and completes against the generation it readAuroraMeter.RecordProjectionTest/test a host's own transaction I06 an outer host transaction rollback leaves no event, no totals delta, no outbox item and no ETS deltaAuroraMeter.RecordProjectionTest/test a host's own transaction I06 an outer host transaction commit plus after_commit/1 hydrates ETS and publishes one messageAuroraMeter.CorrectConcurrencyTest/test process death I06 killing a corrector before commit leaves no row, no delta and no outbox itemAuroraMeter.CorrectConcurrencyTest/test process death I06 killing a corrector after commit before the reply leaves exactly one of each and the retry is a duplicateAuroraMeter.EventsReplayTest/test I06 restart and replay reproduce the same totalsAuroraMeter.EventsReplayTest/test the announcement I06 the announcement waits for an in-flight record transactionAuroraMeter.EventsReplayTest/test the scan I06 a rebuilt generation reproduces the live totals for usage eventsAuroraMeter.EventsReplayTest/test the scan I06 the scan is bounded by the watermark and the tail arrives by the dual writeAuroraMeter.EventsReplayTest/test the comparison I06 require_match refuses to activate when a difference existsAuroraMeter.EventsReplayLargeTest/test I06 100,000 facts and corrections replay to exact totals after a kill and a restartAuroraMeter.EventsReplayLargeTest/test I06 a replay interleaved with 12 concurrent recorders reproduces exact totalsAuroraMeter.EventsReplayLargeTest/test I06 a replay interrupted at the activation leaves a consistent state either wayAuroraMeter.PlanAttributionTest/test I06 a retry of one event_id across a plan change is a duplicate and keeps the first stamp
Evidence. docs/evidence/v1/phase-03/i06.md
I07 Conflicting reuse of event identity is rejected
Guarantee. Not yet guaranteed end to end. The mechanism ships in core
schema version 8 (build unit 03a): a unique index on (tenant_key, event_id)
with event_id, payload_hash and occurred_at NOT NULL, so a second row
under one identity is refused by the database rather than by application code,
on any connection and in any order. What does not ship yet is the facade that
turns that refusal into a result a caller can act on and that tells a duplicate
from a conflict by comparing the payload hash. Phase 03b adds it, after which
reusing an identity with a changed quantity, timestamp, feature, dimension set
or metadata is rejected rather than silently accepted or silently ignored,
including under same tenant races and inside batches, where a single conflict
rolls the batch back.
Prerequisites. The phase 03 durable event path, the Ecto storage adapter and the schema version that adds the event identity constraint.
Known limits. The database now refuses a duplicate identity, but nothing in
the facade yet turns that refusal into a result a caller can act on, and
AuroraMeter.track/4 generates a fresh identity per call, so on that path there
is still no identity to conflict with. Until 03b lands, a host that needs this
must deduplicate before calling track/4. A guarantee statement must not be
copied from this section into customer-facing documentation before its tests
exist.
Tests.
AuroraMeter.MigrationV8Test/test it refuses rather than guess I07 it refuses while any event_id is null, and names the countAuroraMeter.MigrationV8Test/test it refuses rather than guess I07 it runs once the backfill has filled themAuroraMeter.MigrationV8Test/test it refuses rather than guess I07 it promotes event_id, payload_hash and occurred_at to NOT NULLAuroraMeter.RecordTest/test validation, before any database call I07 record rejects a missing id, an oversized id and a reserved prefixAuroraMeter.RecordTest/test validation, before any database call I07 record rejects a non-UTF-8 idAuroraMeter.RecordTest/test the canonical payload I07 canonical json sorts keys recursivelyAuroraMeter.RecordTest/test the canonical payload I07 map key ordering does not change the payload hashAuroraMeter.RecordTest/test identity I07 a changed quantity, occurred_at, feature, dimension or metadata value each conflictAuroraMeter.RecordTest/test identity I07 the same id for two features in one tenant conflictsAuroraMeter.RecordBatchTest/test I07 repeated ids with identical payloads collapse to one insert and two ordered resultsAuroraMeter.RecordBatchTest/test I07 repeated ids with different payloads are rejected before any I/OAuroraMeter.RecordBatchTest/test I07 a conflicting element rolls back every new row in the batchAuroraMeter.RecordConcurrencyTest/test one identity, twelve connections I07 12 independent connections submitting one identity with two different payloads never produce two rowsAuroraMeter.CorrectTest/test identity I07 a correction id reused with a different magnitude conflictsAuroraMeter.CorrectTest/test identity I07 a correction id reused with different metadata conflictsAuroraMeter.RecordTest/property the canonical encoder, as a property I07 two different payloads never share an encoding, and key order never changes one
Evidence. docs/evidence/v1/phase-03/i07.md
I08 One input source yields one commercial usage effect
Guarantee. Every feature has exactly one reporting source, declared as
feature_sources: %{name => :buffered | :events} and defaulting to :buffered.
The guarantee is negative and is discharged by closing every route from a durable
event into aurora_meter_counters, which is the only table a Pro reporter bills
from (AuroraMeter.Pro.UsageReporter.report_one/5 reads it through
AuroraMeter.Storage.load_counter/3).
Three writers can put a quantity into that table, and each is closed for an
events-source feature. AuroraMeter.track/4 raises before resolving the tenant
key, so nothing partial is left. AuroraMeter.reserve/2,3 raises for the same
reason one function deeper: it takes the non-deferred path, which bumps
pending_flush immediately. AuroraMeter.Entitlements.with_quota/4 releases its
reservation on success instead of committing it, so the reservation stays
admission control and the recorded event stays the charge. What remains is
Counter.apply_projection/2, which writes value and pending_gossip and
neither pending_flush nor the dirty table, so a projected quantity cannot enter
AuroraMeter.Store's flush-batch snapshot and therefore cannot reach
AuroraMeter.Storage.flush_batch/3.
A declaration that would make the question ambiguous is refused at boot: a
feature listed in both :durable_features and :feature_sources as :events
raises from AuroraMeter.Config.validate!/0, and therefore from
AuroraMeter.start_link/1.
Prerequisites. A single configured source per feature, decided at a period
boundary and applied by a deploy: the source is read from a cache that
AuroraMeter.Config.validate!/0 fills, so changing it at runtime changes nothing until the
node is validated again. While both paths exist, the host must not also bill from
the event log by hand.
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. Pro rollups do read
durable events directly when history: false (open finding C15), which is a
reporting read rather than a billing read.
An events-source feature has no day history at all, and
AuroraMeter.history/3 returns zeros for one. That is deliberate: a projected
quantity in aurora_meter_history would feed the Pro day rollup from the same
units the outbox exports. AuroraMeter.Events.stream/1 is the durable series.
The migration from :buffered to :events is the half core cannot make safe on
its own. Core refuses a dual declaration but has no way to know whether a
feature's existing counter rows were ever reported to a provider, so nothing here
stops a host flipping a source mid-period and leaving that period's usage split
across a counter row and an event total. The watermark, the boot refusal when
usage_reports rows exist without a cutover, and the tests for both are Pro's
(build unit 04c) and are evidenced in Pro; there is deliberately no planned core
test for them, because core has no usage_reports table to refuse against.
Tests.
AuroraMeter.MeteringTest/test a durable feature writes an event row on trackAuroraMeter.RecordProjectionTest/test I08 the projection never reaches the flush path I08 a projected event never appears in a flush batchAuroraMeter.RecordProjectionTest/test I08 the projection never reaches the flush path I08 apply_projection writes value and gossip but never pending_flush or dirtyAuroraMeter.FeatureSourceTest/test configuration I08 declaring a feature in durable_features and as an events source fails at bootAuroraMeter.FeatureSourceTest/test the track and reserve guards I08 track/4 raises for an events-source feature and writes nothingAuroraMeter.FeatureSourceTest/test the track and reserve guards I08 track/4 with durable: true raises for an events-source feature and writes no rowAuroraMeter.FeatureSourceTest/test the track and reserve guards I08 reserve/2 and reserve/3 raise for an events-source feature and write nothingAuroraMeter.FeatureSourceTest/test flush isolation I08 a thousand recorded events reach no flush batch, no counter row and no reporter readAuroraMeter.FeatureSourceTest/test flush isolation I08 one flush writes the buffered feature and not the events-source oneAuroraMeter.EventsReplayTest/test side effects I08 a replay enqueues nothing, grants nothing, notifies nothing and flushes nothing
Evidence. docs/evidence/v1/phase-03/i08.md
I09 Corrections preserve immutable history and bounded net quantity
Guarantee. AuroraMeter.correct/4 appends a new immutable event that reduces
an earlier one; no historical row is ever updated or deleted. The cumulative
magnitude of the corrections of one original can never exceed that original's
quantity, checked while the original row is held under SELECT ... FOR UPDATE,
so concurrent correctors of one fact cannot between them credit more than was
charged. A repeated correction id is a duplicate rather than a second credit,
including when the original is by then fully corrected, because the duplicate
check precedes the bound check both before and under the lock. A correction
inherits the original's feature, period, period source, plan attribution and
dimensions, so it changes the invoice the original was on. A correction of a
correction is refused. Every correction is handed to the export seam with an
explicit eligibility reason and none is dropped. AuroraMeter.replace/4 writes
a full reversal and a replacement in one transaction, idempotent under one
caller id.
Prerequisites. Core schema version 7 or later (kind, original_event_id,
the pairing check constraint and the corrections partial index) and a storage
adapter declaring the :corrections capability. The in-memory view of a
corrected key is advisory as always; AuroraMeter.Events.total/3 is
authoritative.
Known limits. Core records a correction it cannot know is deliverable.
Whether a meter event can still be adjusted at a payment provider is Aurora
Meter Pro's decision (build unit 04d), which quarantines the ones that cannot
with a manual_adjustment reconciliation item; core's contribution is that the
item always exists and carries a reason. The legacy negative track/4 still
exists for buffered features, mutates the counter in place and is bounded by
nothing; hosts using it keep their own audit trail. Corrections of corrections
are not supported in 1.0.
Tests.
AuroraMeter.ExamplesTest/test allowance-and-overage.md a negative track/3 corrects an overcountAuroraMeter.CorrectTest/test the cumulative bound I09 a correction reduces the projected total by its magnitudeAuroraMeter.CorrectTest/test the cumulative bound I09 cumulative corrections cannot exceed the originalAuroraMeter.CorrectTest/test the cumulative bound I09 a full reversal then a further correction is rejectedAuroraMeter.CorrectTest/test the cumulative bound I09 a duplicate correction id is idempotentAuroraMeter.CorrectTest/test the cumulative bound I09 a duplicate correction id is idempotent even when the original is already fully correctedAuroraMeter.CorrectTest/test identity I09 a correction to a correction is rejectedAuroraMeter.CorrectTest/test inside a host transaction I09 a refusal does not roll back the host's transactionAuroraMeter.CorrectTest/test the in-memory projection I09 the ETS projection subtracts and never shows a negative valueAuroraMeter.CorrectTest/test the in-memory projection I09 a correction whose magnitude exceeds this node's view re-seats it from the durable totalAuroraMeter.CorrectTest/test replay arithmetic I09 recomputing every total from the event rows reproduces the projection exactlyAuroraMeter.CorrectConcurrencyTest/test twelve correctors of one original I09 12 concurrent partial corrections of one 10-unit original never exceed itAuroraMeter.CorrectConcurrencyTest/test twelve correctors of one original I09 12 concurrent submissions of one correction identity produce one row, one delta and one outbox itemAuroraMeter.CorrectConcurrencyTest/test twelve correctors of one original I09 concurrent corrections of two different originals in one key both commitAuroraMeter.CorrectConcurrencyTest/test twelve correctors of one original I09 the lock that serialises correctors is the one on the original rowAuroraMeter.EventsReplayTest/test the scan I09 a correction contributes a negative quantity and a positive event countAuroraMeter.EventsReplayTest/test a correction while a generation is building I09 a correction for an unscanned original commits and lands in both generationsAuroraMeter.PlanAttributionTest/test I09 a correction copies the original's plan_id, plan_version and attribution
The provider half of I09, "a correction after the provider window opens a reconciliation item instead of silently diverging", is Aurora Meter Pro's and is evidenced in Pro (build unit 04d). It is deliberately not a planned bullet here: core has no provider window and no reconciliation table to refuse against, so no core test could ever resolve one, and a bullet that can never be promoted is a guard that can never fire. 04d must read this paragraph.
Evidence. docs/evidence/v1/phase-03/i09.md
I10 Every ledger amount has exact provenance and conservation
Guarantee. Every credit movement is an append to aurora_meter_credit_transactions
inside the same transaction that updates the balance row, and every entry records
what the promotional figure became, so any balance can be reconstructed by
replaying its entries. Amounts are integer micro-dollars throughout, so no
rounding happens inside the ledger; conversion to and from cents and decimal
dollars is explicit in AuroraMeter.Credits.Money. A refusal is returned rather
than rolled back, so a host that wraps a ledger call in its own transaction keeps
its own writes when the ledger says no. The ledger re-establishes
0 <= promotional <= max(balance, 0) after every entry, which is what lets
expire_due/1 never push a balance below zero. Side effects (telemetry, PubSub,
the low balance hook) run only after the transaction commits, so a handler never
sees a balance that later rolled back. Each of those figures is checked against an
independent pure model after every command of a generated history, not only at
the end, together with six conservation aggregates read straight from SQL: balance
against the sum of every entry's amount, held against both the sum of every
held_delta and the reservations of the holds still open, the promotional bound,
the newest entry's three snapshot columns against the balance row, reference
uniqueness per kind, and hold closure.
Prerequisites. The Ecto storage adapter on PostgreSQL and schema version 3 or
later. Credits do not work through a non-Ecto storage adapter, because the ledger
needs a row lock plus an append in one transaction. The property tests below rest
on a second prerequisite of their own: AuroraMeter.Test.LedgerModel is an
independent reimplementation of the ledger's arithmetic, and it is only an oracle
while it is right. Thirteen self-tests in AuroraMeter.CreditsModelTest (the
describe blocks the model itself and the dormant lot view, all of which run
without a database) pin it against cases whose answer is obvious without reading
either implementation. They deliberately do not claim an invariant id: they
prove the model, not the ledger.
Known limits. Conservation is behavioural today: it is re-established by the
allocator after each entry rather than refused by a database constraint. Phase 06
adds lot rows with check constraints, after which the database itself rejects a
violating row. The index must state both strengths, because the guarantee before
the lot cutover is weaker than the guarantee after it: today's form is "the
ledger's arithmetic matches an independent model", and the stronger form, "a
write that breaks conservation is rolled back", only exists after 06a. The
Evidence path below names the proof that exists today; 06a's will be
docs/evidence/v1/phase-06/i10.md. expire_due/1 assumes at most one live
promotional grant per tenant.
"After the transaction commits" means after the outermost transaction
commits, and from build unit 06c that is enforced rather than assumed. A ledger
call made inside a host's own transaction sees a savepoint release, not a commit,
so its telemetry, PubSub and low-balance effects are queued on the calling
process and run by AuroraMeter.Credits.after_commit/1; the rollback branch calls
after_commit(discard: true) and nothing fires at all (finding L18, closed).
The queue is process bound, which is where Ecto's transaction scope lives, so the
two have the same lifetime and neither can outlive the other. The limit that
follows is stated rather than hidden: a process that dies between the commit and
the drain loses that round of effects. The money is committed and correct; one
telemetry event, one PubSub message and possibly one low-balance alert are not
delivered. AuroraMeter.Credits.deferred_effects?/0 is how a host asserts in its
own tests that no path forgot the call.
Three further limits the generated histories exposed, each with a test that asserts today's behaviour so its fixing unit has a before and an after:
- L17: closed by build unit 06c.
AuroraMeter.Credits.Money.assert_range!/1refuses an amount beyond 9e15 micro-dollars, andgrant/3,hold/4,debit/4,reverse/4,settle/3andset_low_balance_threshold/2all call it before any database work, so the caller gets anArgumentErrornaming the limit rather than aDBConnection.EncodeErrorfrom the driver two layers down. The limit is three orders of magnitude below thebigintceiling becausebalance_afterand the conservation aggregate are sums of amounts. - L19:
expire_due/1selects the due grant ids with noorder_by(ledger.ex:251-259) whileexpire_locked/3clamps each grant by the wallet as it stands when its turn comes, so two grants due in one pass can expire different amounts on two runs of the same history. The property generator therefore never produces more than one due grant; one named test records the ambiguity and asserts only the total. 06a adds the D07 order. - L20: the ledger stamps
inserted_atfrom the host wall clock (ledger.ex:452) and then orders its own account of what happened first by that column, inremaining_on_grant/3(:345),pending_holds/1(:63) andhistory/2. A wall clock is not monotonic. Backwards steps were observed unprompted on 2026-09-14, three times in one 500-history run and five in another, and each leaves a promotional grant the expirer believes is untouched and can then never finish. 06a must order by something the database controls rather than by a stamp.
expire_due/1's own documentation used to say expiry assumes at most one live
promotional grant, which contradicted AuroraMeter.Credits.Promotions's
soonest-expiry-first attribution. The model implemented the code and the doc was
wrong; build unit 06c corrected the doc (finding L15, closed). Several live
promotional grants are supported, each grant's remainder is well defined, and the
first to expire cannot reclaim credit a later one contributed.
Tests.
AuroraMeter.CreditsTest/test the ledger as a record every entry says what the promotional figure becameAuroraMeter.CreditsTest/test promotional credit is consumed before paid creditAuroraMeter.CreditsTest/property Money cents round-trip through micro-dollarsAuroraMeter.CreditsTest/test Money rounding modes and precisionAuroraMeter.CreditsTest/test history/2 is newest first, hides holds and releases by default, filters by kind and pagesAuroraMeter.CreditsConcurrencyTest/test I10 a refusal does not destroy the caller's own transactionAuroraMeter.CreditsConcurrencyTest/test I10 a host transaction that rolls back emits nothing, because the side effects were deferred (L18)AuroraMeter.CreditsModelTest/property generated histories I10 a generated history of grants, holds, settles, releases, debits and reversals matches the pure modelAuroraMeter.CreditsModelTest/property generated histories I10 a generated history including expiry matches the pure modelAuroraMeter.CreditsModelTest/property generated histories I10 balance equals the sum of every transaction amount after every stepAuroraMeter.CreditsModelTest/property generated histories I10 held equals the sum of open holds after every stepAuroraMeter.CreditsModelTest/property generated histories I10 the newest row's snapshot columns equal the balance rowAuroraMeter.CreditsModelTest/property generated histories I10 no two rows of one kind share a referenceAuroraMeter.CreditsModelTest/test integer bounds and rounding I10 cents round-trip through micro-dollars at the boundariesAuroraMeter.CreditsModelTest/test integer bounds and rounding I10 to_cents rounds half away from zero at the boundariesAuroraMeter.CreditsRegressionsTest/test every saved seed file parses and names an invariant
The first three of the five below were the "before" halves of findings L17 and
L18 and are now the "after" halves: build unit 06c added Money.assert_range!/1
at the facade and deferred the side effects of a ledger call made inside a host
transaction. The last two still assert a defect rather than a guarantee, and each
names the unit that replaces it:
AuroraMeter.CreditsModelTest/test integer bounds and rounding I10 a grant above the documented limit is refused at the facade, not by the driver (L17)AuroraMeter.CreditsModelTest/test integer bounds and rounding I10 a reversal above the documented limit is refused at the facade, not by the driver (L17)AuroraMeter.CreditsModelTest/test integer bounds and rounding I10 the range guard refuses before any database work (L17)AuroraMeter.CreditsModelTest/test ordering the query does not fix I10 two grants due in one pass expire in an order the query does not fix (L19, fixed in 06a)AuroraMeter.CreditsModelTest/test the clock the ledger orders itself by I10 a backwards step in the wall clock changes nothing, because the ledger orders by seq (L20, fixed in 06a)AuroraMeter.Credits.AllocatorTest/test I10 the spend order is promotional, then earliest expiry, then oldest grant, then seqAuroraMeter.Credits.AllocatorTest/test I10 a 6 USD debit against promotional A=3, promotional B=5 and paid P=10 takes A=3 and B=3AuroraMeter.Credits.AllocatorTest/test I10 a lot past its expires_at is not eligible for a debit and is not consumedAuroraMeter.Credits.AllocatorTest/test I10 a grant while debt is outstanding repays the debt before creating availabilityAuroraMeter.Credits.AllocatorTest/test I10 settle below the reservation consumes the reserved subset and unreserves the restAuroraMeter.Credits.AllocatorTest/test I10 settle above the reservation consumes remaining availability then records debtAuroraMeter.Credits.AllocatorTest/test I10 reverse takes available, then consumed, then reserved, and only consumed creates debtAuroraMeter.Credits.AllocatorTest/test I10 restore is capped by the lot's reversed amount and repays debt firstAuroraMeter.Credits.AllocatorTest/property I10 every planned movement conserves: each lot's five buckets still sum to its amountAuroraMeter.CreditsLotsTest/test I10 a grant writes one lot whose quantities, state and reference match the ledger rowAuroraMeter.CreditsLotsTest/test I10 a 6 USD debit against promotional A=3, promotional B=5 and paid P=10 takes A=3 and B=3AuroraMeter.CreditsLotsTest/test I10 every lot quantity is reproduced by folding that lot's allocationsAuroraMeter.CreditsLotsTest/test I10 a settle stamps hold_transaction_id and updated_at on both rowsAuroraMeter.CreditsLotsTest/test I10 a lot edited by hand is refused by aurora_meter_credit_lots_conservation_checkAuroraMeter.CreditsLotsTest/test I10 a balance row edited by hand raises ConservationError and the write is rolled backAuroraMeter.CreditsLotsTest/test I10 a settle above its hold records debt, which blocks a new hold until a grant repays itAuroraMeter.CreditsLotsTest/test I10 credit past its expires_at is not spendable before the sweep reaches itAuroraMeter.CreditsLotsTest/test I10 a wallet with lots_enabled_at null uses the legacy arithmetic and writes no lotAuroraMeter.CreditsLotsTest/test I10 enable_lots! refuses a wallet that has a ledger row rather than cutting it overAuroraMeter.CreditsLotsTest/test I10 the balance row refuses a negative held, which nothing enforced before version 9AuroraMeter.CreditsLotsTest/test I10 a grant's :source lands on the lot, which is how a refund finds the paymentAuroraMeter.CreditsLotReversalTest/test I10 reverse_lot takes only the lots matching the sourceAuroraMeter.CreditsLotReversalTest/test I10 reverse_lot above the cap returns exceeds_source and writes nothingAuroraMeter.CreditsLotReversalTest/test I10 reverse_lot of a spent lot raises debt by the unrecoverable amountAuroraMeter.CreditsLotReversalTest/test I10 restore_lot is capped by the lots' reversed totalAuroraMeter.CreditsLotReversalTest/test I10 restore_lot repays outstanding debt before any of it becomes spendableAuroraMeter.CreditsLotReversalTest/test I10 reverse_lot and restore_lot are idempotent on the referenceAuroraMeter.CreditsLotReversalTest/test I10 reverse_lot with no matching lots returns no_matching_lotsAuroraMeter.CreditsLotReversalTest/test I10 reverse_lot writes one reverse allocation per lot touchedAuroraMeter.CreditsLotReversalTest/test I10 the :source option is validated rather than widenedAuroraMeter.CreditsLotReversalTest/test I10 a reversal that reaches a reservation takes it last and leaves held consistentAuroraMeter.CreditsLotsTest/property I10 a generated history on a lot wallet reconstructs the balance row, the debt, the held amount and every lot's allocationsAuroraMeter.CreditsLotsFaultsTest/test I10 a writer killed before the ledger row is inserted leaves the wallet untouchedAuroraMeter.CreditsLotsFaultsTest/test I10 a writer killed after the ledger row and before the allocations leaves the wallet untouchedAuroraMeter.CreditsLotsFaultsTest/test I10 a writer killed after the allocations and before the lot update leaves the wallet untouchedAuroraMeter.CreditsLotsFaultsTest/test I10 a writer killed after the lot update and before the balance update leaves the wallet untouchedAuroraMeter.CreditsLotsFaultsTest/test I10 a writer killed after commit, before its reply, leaves exactly one effect and no second one on retryAuroraMeter.CreditsLotsFaultsTest/test I10 the control: with no fault armed the same writer completes and the wallet movesAuroraMeter.CreditsLotsFaultsTest/test I10 forcing the allocation insert to fail leaves the lot and the balance untouchedAuroraMeter.CreditsLotsFaultsTest/test I10 forcing the lot update to fail leaves the ledger row uncommittedAuroraMeter.CreditsLotsFaultsTest/test I10 forcing the balance update to fail rolls back the allocationsAuroraMeter.CreditsLotsFaultsTest/test I10 forcing the new lot's insert to fail leaves the grant uncommittedAuroraMeter.CreditsLotsFaultsTest/test I10 the fault control: the same three statements without a fault all commit togetherAuroraMeter.CreditsModelTest/property I10 a generated history on a cut-over wallet agrees with 01e's independent lot model, lot for lotAuroraMeter.CreditsModelTest/test I10 the cross-oracle comparison can fail: a lot bucket moved by hand is caughtAuroraMeter.CreditsLotsConcurrencyTest/test I10 concurrent grants and debits on one wallet leave the projection exactAuroraMeter.CreditsLotMigrationTest/test I10 the cutover stamps projection_checked_at, which only the conservation check writesAuroraMeter.CreditsTest/test I10 a reverse and a debit may share one reference (L2)AuroraMeter.CreditsTest/test I10 a reversal written before V9 still reads as a reversalAuroraMeter.CreditsTest/test I10 a grant whose reference belongs to another tenant returns duplicate_reference (L3)AuroraMeter.CreditsTest/test I10 a grant whose changeset fails for another reason still returns the changesetAuroraMeter.CreditsTest/test I10 spend_history rejects :reverse as a spend kindAuroraMeter.CreditsLotsApiTest/test I10 Lots.list returns lots in the documented spend orderAuroraMeter.CreditsLotsApiTest/test I10 Lots.list with states: :all includes exhausted, expired and reversed lotsAuroraMeter.CreditsLotsApiTest/test I10 Lots.get accepts a lot id and a grant referenceAuroraMeter.CreditsLotsApiTest/test I10 Lots.allocations reconstructs a settlement: consume then unreserve on the same lotAuroraMeter.CreditsLotsApiTest/test I10 Lots.for_source returns exactly the lots funded by one payment intentAuroraMeter.CreditsLotsApiTest/test I10 Lots.for_source with an unsupported key raisesAuroraMeter.CreditsLotsApiTest/test I10 Lots.list paginates by cursor without skipping or repeating a lotAuroraMeter.CreditsLotsApiTest/test I10 a wallet that has not been cut over has no lots to readAuroraMeter.CreditsLotsApiTest/test I10 Lots refuses an option it does not understand rather than ignoring itAuroraMeter.CreditsFiguresTest/test I10 a legacy wallet reports spendable equal to available, debt zero and expired zeroAuroraMeter.CreditsFiguresTest/test I10 balance stays signed and spendable is the figure the ledger refuses onAuroraMeter.CreditsFiguresTest/test I10 Money.assert_range! refuses an amount above the documented limit before any writeAuroraMeter.CreditsFiguresTest/property I10 for any generated operation sequence, summary/1's figures agree with the lot tablesAuroraMeter.CreditsAfterCommitTest/test I10 a ledger call inside a host transaction emits nothing until after_commit is calledAuroraMeter.CreditsAfterCommitTest/test I10 a host transaction that rolls back and calls after_commit(discard: true) emits nothingAuroraMeter.CreditsAfterCommitTest/test I10 after_commit runs the deferred effects in orderAuroraMeter.CreditsAfterCommitTest/test I10 deferred_effects? reports an undrained queueAuroraMeter.CreditsAfterCommitTest/test I10 a ledger call that owns its transaction is unaffectedAuroraMeter.CreditsAfterCommitTest/test I10 a low-balance crossing inside a host transaction does not invoke the handler before commitAuroraMeter.CreditsHistoryTest/test I10 history paginates by cursor across rows sharing one microsecondAuroraMeter.CreditsHistoryTest/test I10 history with :before is documented as lossy and still worksAuroraMeter.CreditsHistoryTest/test I10 history includes reversals by defaultAuroraMeter.CreditsHistoryTest/test I10 history rejects :before together with :cursorAuroraMeter.CreditsRecurrencesTest/test I10 a recurring lot carries recurrence_key, plan_id and plan_version in its source
Evidence. docs/evidence/v1/phase-01/i10.md
I11 Holds cannot spend the same available funds twice
Guarantee. A hold, a settle, a release and a debit each run in one transaction
that takes the tenant's balance row lock first, so concurrent callers serialise on
that row. A hold is refused with :insufficient_credits when it would take
balance - held below zero (less :credits_overdraft_tolerance), and the refusal
returns rather than raising, so it does not destroy the caller's own transaction.
A hold's terminal transition is taken once: exactly one of a concurrent settle and
release wins, and the loser is told it lost. Twenty concurrent holds of ten cents
against one dollar admit exactly ten.
Prerequisites. The Ecto storage adapter on PostgreSQL and schema version 3 or
later. Every write carries a reference as its idempotency key.
Known limits. A settle may exceed its hold, which takes the balance negative on
purpose and reports the overrun rather than failing the work that already
happened. :credits_overdraft_tolerance widens the refusal threshold by design.
The lock is per tenant balance row, so throughput on one hot wallet is bounded by
that row.
The fifty-connection proof runs in two waves of twenty-five, because
AuroraMeter.Test.Connections.run/3 refuses more tasks than the pool can serve
(the pool is 30 and four connections are reserved). Fifty holds are still
attempted against one wallet and the admitted count is asserted cumulatively; the
arithmetic is in docs/evidence/v1/phase-05/i11.md.
Two things about a hold are documentation rather than mechanism, and repair unit
R7 wrote both down where a host reads them. with_credits/4 releases from the
calling process, so an untrappable death (a Process.exit(pid, :kill), a
supervisor shutdown past the timeout, a VM or node that goes away) leaves the
hold open; reconcile_holds/1 is the only thing that closes one, and the
decision is the host's because age is not evidence. The word amount means
two different things across the two halves of this job: on a pending_holds/1
row it is the balance delta, which a hold does not move, so it is zero, while the
reconciler callback's amount is the row's held_delta, which is the
reservation (open-findings.md X396, X397).
A hold's terminal transition is protected by application code and a row lock, and
by nothing in the schema. Ledger.pending_hold/3 locks the hold row FOR UPDATE
and re-reads status = 'pending' inside that lock, which is what makes a settle
and a release of one hold serialise. The (kind, reference) unique index refuses
a second :settle row, but a :settle and a :release for one reference differ
in kind, so the index permits both; there is no check constraint on
aurora_meter_credit_balances.held either. Remove the lock or the status re-read
and held goes negative with nothing to stop it. Both halves are asserted
directly in AuroraMeter.CreditsReconcileHoldsTest, under "what stops a hold
being closed twice".
Tests.
AuroraMeter.CreditsConcurrencyTest/test I11 twenty concurrent $0.10 holds against $1.00 admit exactly tenAuroraMeter.CreditsConcurrencyTest/test I11 concurrent settle and release of one hold: exactly one winsAuroraMeter.CreditsConcurrencyTest/test I11 fifty independent connections holding against one hot wallet admit exactly the funded countAuroraMeter.CreditsTest/test hold/4, settle/3, release/1 a hold is refused when the available balance does not cover itAuroraMeter.CreditsTest/test with_credits/4 releases when the function raises, throws or exits, then propagatesAuroraMeter.CreditsReconcileConcurrencyTest/test I11 a reconciler release and a caller settle produce exactly one terminal transitionAuroraMeter.CreditsReconcileConcurrencyTest/test I11 a reconciler decision applied after a concurrent settle is refused by the hold row lockAuroraMeter.CreditsReconcileConcurrencyTest/test I11 a with_credits caller whose hold the reconciler released records the executed costAuroraMeter.CreditsReconcileConcurrencyTest/test I11 twenty-four concurrent reconciler runs over one hot wallet conserve the balanceAuroraMeter.CreditsTest/test with_credits/4 I11 returns its result when the hold was settled by someone elseAuroraMeter.CreditsTest/test with_credits/4 I11 records the executed cost when the hold was released by someone elseAuroraMeter.CreditsReconcileHoldsTest/test the two halves of one job and the word amount I11 on a hold row the amount column is zero and the reservation is held_deltaAuroraMeter.CreditsReconcileHoldsTest/test the two halves of one job and the word amount I11 the reconciler callback's amount is the row's held_delta, not its amountAuroraMeter.CreditsReconcileHoldsTest/test a death the release cannot clean up after I11 an untrappable kill inside with_credits/4 leaves the hold openAuroraMeter.CreditsReconcileHoldsTest/test a death the release cannot clean up after I11 and reconcile_holds/1 is what closes it, for the cost the host namesAuroraMeter.CreditsLotsConcurrencyTest/test I11 fifty independent holds against one wallet funded with ten admit exactly tenAuroraMeter.CreditsLotsConcurrencyTest/test I11 twenty concurrent holds across twenty wallets do not interfereAuroraMeter.CreditsLotsConcurrencyTest/test I11 without the balance row lock the same fifty holds oversubscribe the walletAuroraMeter.CreditsLotsTest/test I11 every ledger write takes the balance row, then the transaction row, then the lotsAuroraMeter.CreditsLotsTest/test I11 settling one hold below its reservation leaves a second hold's reservation on the same lot intactAuroraMeter.CreditsFiguresTest/test I11 summary reports spendable, held, expired, debt and promotional_spendable independentlyAuroraMeter.CreditsFiguresTest/test I11 debt appears in debt and makes spendable zeroAuroraMeter.CreditsLowBalanceTest/test I11 the low-balance handler fires once per crossing and not again while below the thresholdAuroraMeter.CreditsLowBalanceTest/test I11 a recovery above the threshold then a second crossing fires twice with different crossing idsAuroraMeter.CreditsLowBalanceTest/test I11 a replayed reconciliation cycle that re-crosses the same threshold fires onceAuroraMeter.CreditsLowBalanceTest/test I11 a raising low-balance handler does not fail the ledger writeAuroraMeter.CreditsLowBalanceTest/test I11 a low-balance handler that never returns is shut down at the timeout and the write standsAuroraMeter.CreditsLowBalanceTest/test I11 the low-balance trigger uses spendable, not balance minus heldAuroraMeter.CreditsLowBalanceTest/test I11 lowering the threshold below the current spendable clears the standing crossingAuroraMeter.CreditsLowBalanceTest/test I11 no threshold means no crossing, no alert and no flagAuroraMeter.CreditsLowBalanceTest/test I11 a handler that needs a connection gets one while the writer holds a pinned one (X269)
Evidence. docs/evidence/v1/phase-05/i11.md
I12 Grant expiry cannot consume later or unrelated funds
Guarantee. expire_due/1 removes only what is left of an expired promotional
grant. It never takes the balance below zero, never claws back credit that a hold
has already reserved, never reaches into credit a later grant contributed, and
never absorbs spending that happened before the expiring grant existed. Expiry is
idempotent: a grant expires once, and the entry records what the promotional
figure became.
Prerequisites. The Ecto storage adapter, schema version 3 or later, and a
scheduler that calls expire_due/1. Only :promotional grants may carry an
:expires_at; a paid grant with one is rejected.
Known limits. They now depend on which writer owns the wallet.
On a wallet that has not been cut over to credit lots (lots_enabled_at IS NULL, which is every wallet until mix aurora_meter.credits.migrate_lots runs)
the legacy arithmetic is unchanged and both of the limits below still hold. The
single promotional figure per tenant cannot say which grant a spend came out of,
so overlapping promotions are handled correctly only for the cases in the tests
below. And expiry is a pass rather than a property of the money: value a hold had
reserved on an already expired grant is spendable again the moment the hold is
released and stays spendable until the next expire_due/1 pass (finding L1).
AuroraMeter.CreditsTest asserts that behaviour so there is a before and after.
On a wallet the allocator owns, both are closed. Each grant is its own lot with
its own five quantities, so an overlapping promotion expires exactly its own
remainder; and a reservation released or settled on a lot past its expires_at
becomes expired rather than available, so nothing of an expired lot is ever
spendable again. A third difference goes with them and is a deliberate
compatibility change: credit whose expires_at has passed is not spendable even
before the sweep reaches it, which is what makes expiry bookkeeping instead of a
race.
Tests.
AuroraMeter.CreditsTest/test promotional credit I12 expiry never claws back credit a hold has reservedAuroraMeter.CreditsTest/test promotional credit I12 a grant expires only its own remainderAuroraMeter.CreditsTest/test promotional credit I12 a new expiring grant cannot absorb spending that predates itAuroraMeter.CreditsTest/test promotional credit I12 a release after the grant expired returns spendable credit (L1, fixed in 06a)AuroraMeter.CreditsReconcileHoldsTest/test recovery beside expiry I12 a reconciler release on an expired grant returns spendable credit (L1, fixed in 06a)AuroraMeter.CreditsTest/test promotional credit expire_due/1 expires only what is left, once, and never below zeroAuroraMeter.CreditsTest/test promotional credit a promotional grant landing on a negative balance first repays the debtAuroraMeter.Credits.AllocatorTest/test I12 a reservation released on a lot past its expiry becomes expired, never availableAuroraMeter.Credits.AllocatorTest/test I12 expiry moves only the due lot's available and refuses when it is all reservedAuroraMeter.CreditsLotsTest/test I12 expiry moves only the due lot's available to expired and leaves reserved aloneAuroraMeter.CreditsLotsTest/test I12 overlapping promotions expire their own remainder and never each other'sAuroraMeter.CreditsLotsTest/test I12 a reservation released on an expired lot becomes expired, never spendableAuroraMeter.CreditsLotsTest/test I12 a hold taken before expiry settles against its reserved portion afterwardsAuroraMeter.CreditsLotsTest/test I12 the expiry sweep run twice writes one expire row, one allocation and the reference expire:<lot_id>:0AuroraMeter.CreditsLotsConcurrencyTest/test I12 expiry racing a release conserves and leaves no spendable expired value
The wallet migration reproduces the legacy semantics for the history it replays, because the balance row it is checked against was produced by them: applying the fixed rule to a release that already happened would move money the ledger did not move. The difference appears from cutover onward, and the two tests below are what say so.
AuroraMeter.Credits.LotMigrationReplayTest/test I12 a hold spanning a partial expiry replays without moving the moneyAuroraMeter.Credits.LotMigrationReplayTest/test I12 a hold still open on an expiring lot is reported, and nothing movesAuroraMeter.CreditsFiguresTest/test I12 expired value appears in expired and never in spendable
Evidence. docs/evidence/v1/phase-06/i12.md
I16 Worker scheduling is not an exactly-once assumption
Guarantee. The core half of this invariant is that a periodic job run twice
produces one effect. expire_due/1 is written to be run from a scheduler and
expires a grant once however often it is called, and a flush with nothing pending
writes nothing. Aurora Meter does not claim exactly-once host job execution
anywhere: every scheduled entry point is idempotent on its own state instead.
Prerequisites. The Ecto storage adapter. The host owns the scheduler; the core ships no job runner.
Known limits. Nothing here promises that two schedulers cannot run
concurrently; it promises that the effect is the same if they do. The core has no
lease tokens and will not grow any: a lease is a duration, and on this hardware
the one clock every node shares steps backwards by up to 439 ms
(open-findings.md X100), so a short lease cannot be trusted to order two
instants. Exclusion, where it is needed at all, is a row lock or a session
advisory lock, neither of which has a clock in it.
The core does now have checkpoints. AuroraMeter.Operations keeps a cursor, a
counter map and a pause flag per named operation in aurora_meter_checkpoints,
and AuroraMeter.Operations.run_batches/3 is the batch loop every core sweep
runs: the pause is read before every batch, the cursor is read from the row and
is never a job argument, and a per-item failure is counted and stepped over
rather than failing the run.
One window is left open deliberately and is worth stating where the guarantee is: a crash between a batch's commit and its checkpoint write re-runs that batch. The checkpoint is written as a statement of its own, outside the batch's transaction, so a rolled-back batch cannot roll back an unrelated cursor. Re-running a batch is safe only because every operation using the loop re-reads the thing it is about to change under that row's own lock, and a later operation whose effects were not idempotent could not use this loop.
AuroraMeter.Oban.PlanTransitions keeps no checkpoint, and that is the
statement rather than an omission. Its operation resumes by predicate, not
by position: a killed run leaves every un-applied tenant with
transition_state = 'pending', and the next run finds exactly those from the
partial index. There is no cursor to rewind, no cursor to go stale, and nothing
a checkpoint could add except a second thing to keep in step with the rows. The
kill test asserts the complement rather than a cursor (open-findings.md X242:
when a worker's resume rests on a predicate, its row should say that instead of
borrowing a control that cannot discriminate for it). The cursor the operation
returns is paging within one run, not durable state.
The optional AuroraMeter.Oban.* workers add no guarantee of their own. Each
perform/1 runs bounded batches of an operation that is already idempotent; the
worker opens no transaction, takes no lock and holds no state between runs, so
two nodes running one worker is the same case as two callers of the operation.
The unique option each worker declares is defence in depth: removing it on a
copy of the expiry worker leaves the duplicate-run result unchanged, which is
what AuroraMeter.ObanConcurrencyTest measures and records rather than claiming
a pass for a layer nothing tested.
AuroraMeter.Credits.reconcile_holds/1 has no lease on a hold on purpose. A lease
would let a crashed reconciler leave a hold nothing could reconcile until a second
recovery mechanism cleared it, which trades a real problem for a worse one; and a
lease is a duration, which on this hardware the shared clock cannot be trusted to
order at a sub-second scale (open-findings.md X100). Mutual exclusion comes from
the hold row's own FOR UPDATE and the status re-read inside it, so two nodes
sweeping at the same instant produce one terminal transition and the loser is told
:already_closed. A run killed between the host callback and the decision writes
nothing and the next run asks again.
Tests.
AuroraMeter.CreditsTest/test promotional credit expire_due/1 expires only what is left, once, and never below zeroAuroraMeter.MeteringTest/test flush persists ETS values and repeated flushes are idempotentAuroraMeter.CreditsTest/test grant/3 credits the balance and is idempotent per referenceAuroraMeter.CreditsReconcileConcurrencyTest/test I16 two reconcilers on two connections release one hold onceAuroraMeter.CreditsReconcileConcurrencyTest/test I16 killing the reconciler between the callback and the application leaves the hold pendingAuroraMeter.CreditsReconcileConcurrencyTest/test I16 killing the reconciler after the application commits leaves exactly one terminal transitionAuroraMeter.Oban.WorkersTest/test I16 a second CreditExpiry run for the same tick expires nothing and adds no ledger rowAuroraMeter.ObanConcurrencyTest/test I16 two CreditExpiry runs on independent connections expire each grant onceAuroraMeter.ObanConcurrencyTest/test I16 the CreditExpiry run that loses the grant row lock expires nothingAuroraMeter.ObanConcurrencyTest/test I16 the same is true with Oban's uniqueness removed, so uniqueness is not what answersAuroraMeter.ObanJobControlsTest/test I16 CreditExpiry processes at most batch_size grants per batch and records a cursorAuroraMeter.ObanJobControlsTest/test I16 CreditExpiry killed between batches resumes at the checkpoint without skipping workAuroraMeter.ObanJobControlsTest/test I16 CreditExpiry re-processes one batch when killed between the batch commit and the checkpoint write, with no second effectAuroraMeter.ObanJobControlsTest/test I16 a rewound cursor re-examines committed work and the ledger refuses it, not the cursorAuroraMeter.ObanJobControlsTest/test I16 CreditExpiry cancels with :paused and continues from the cursor after resumeAuroraMeter.ObanJobControlsTest/test I16 CreditExpiry counts a grant the ledger refuses and expires the ones before itAuroraMeter.ObanJobControlsTest/test I16 CreditExpiry counts a grant whose transaction raises, advances past it, and expires the restAuroraMeter.ObanJobControlsTest/test I16 an expire entry stamped before its own grant expires the right amount, because the fold orders by seqAuroraMeter.ObanJobControlsTest/test I16 HoldReconciliation pages with a cursor and finishes the scanAuroraMeter.ObanJobControlsTest/test I16 HoldReconciliation cancels with :paused within one batchAuroraMeter.ObanJobControlsTest/test I16 the cutoff is pinned across the pages of one scanAuroraMeter.RetentionControlsTest/test I16 the retention worker resumes at its checkpoint after a killAuroraMeter.RetentionControlsTest/test I16 two retention jobs on independent connections delete every eligible row and none twiceAuroraMeter.RetentionControlsTest/test I16 the retention worker cancels nothing and reports a paused tableAuroraMeter.CreditsRecurrencesTest/test I16 the scan is bounded by limit and the next run continues from the cursorAuroraMeter.CreditsRecurrencesTest/test I16 a run killed mid-tenant resumes and reaches the same state as an uninterrupted oneAuroraMeter.CreditsRecurrencesTest/test I16 a paused run writes nothing and says it is pausedAuroraMeter.PlanTransitionsConcurrencyTest/test I16 a cancel racing an apply yields exactly one terminal stateAuroraMeter.PlanTransitionsConcurrencyTest/test I16 a provider sync racing an apply yields one terminal state and one planAuroraMeter.PlanTransitionsConcurrencyTest/test I16 the applier killed after commit and before cache invalidation convergesAuroraMeter.PlanTransitionsConcurrencyTest/test I16 the applier killed before commit leaves the transition pendingAuroraMeter.PlanTransitionsConcurrencyTest/test I16 the applier killed mid-batch leaves the applied tenants applied and the rest pendingAuroraMeter.PlanTransitionsConcurrencyTest/test I16 twelve independent connections applying one due transition apply it onceAuroraMeter.PlanTransitionsConcurrencyTest/test I16 twelve independent connections scheduling one ref produce one transition rowAuroraMeter.PlanTransitionsConcurrencyTest/test I16 two Oban jobs from two nodes apply one transitionAuroraMeter.MigrationGuardsTest/test I16 a re-run of the whole history is a no-op I16 up(from: 1, version: latest) twice raises nothing and changes no catalogueAuroraMeter.MigrationGuardsTest/test L-11b-4: version 8 converges on one valid index I16 it drops the index a real aborted concurrent create left behindAuroraMeter.MigrationGuardsTest/test L-11b-4: version 8 converges on one valid index I16 applying version 8 twice leaves exactly one valid index
Evidence. docs/evidence/v1/phase-05/i16.md
I17 Historical plans remain stable
Guarantee. A plan is identified by (plan_id, version). A subscription
records the version it was sold, and AuroraMeter.Entitlements.plan/1 resolves
that version: from the compiled plans module when it is still there, and
otherwise from the snapshot AuroraMeter.Plans.register!/0 stored in
aurora_meter_plan_versions. So deploying a new version of a plan does not move
a tenant already on an older one, and deleting a retired version's block does
not change what the tenants on it are entitled to.
Editing a version in place is refused rather than applied:
register!/0 compares a fingerprint of each compiled version's commercial
content (the plan id, the version, the price, every feature and every recurring
credit) against the stored one and raises
AuroraMeter.PlanVersionConflictError, or logs the same message under
plan_version_conflict: :warn, which is the 0.5.x default.
Subscriptions written before core schema version 10 are named at the first boot
after the upgrade, with the plan's base version and that version's
fingerprint, in resumable batches. Registration never changes a plan_id, a
plan_version or a fingerprint that is already set.
Prerequisites. Core schema version 10, and a storage adapter that declares
the :plan_versions capability. An adapter that does not logs one warning and
leaves compiled code as the only authority, in which case a version deleted from
code becomes unreadable and the tenants on it fall back to the default plan.
Known limits. A feature or recurring credit name in a stored snapshot is read
back with String.to_existing_atom/1. A name whose atom does not exist on the
node is dropped from the resolved plan and logged once per version per node:
invisible to check/2, entitled?/2, quota/2 and feature_value/3, which all
take atoms, and not invisible to code that enumerates plan.features.
A version's :effective_at is deliberately outside the fingerprint, so moving it
is not a conflict. It decides which version a new subscription gets and
cannot move a tenant already pinned to one.
Two of the three adversarial proofs v1-release.md section 17 asks of I17 are
proven here: the changed definition, and the explicit scheduled migration. The
third, a stale provider notification that cannot move the version, is Aurora
Meter Pro's and is proven in 07c.
Attribution (build unit 07c). A recorded event carries the
(plan_id, plan_version) in force when the usage occurred, resolved once by
AuroraMeter.Plans.effective_for/2 at record time and never recomputed. A
correction copies its original's stamp rather than resolving it again, so a
credit issued after an upgrade is priced as the fact it reverses was. An instant
no recorded assignment or applied transition covers is stored as
attribution: "plan_unresolved" with both plan columns NULL, and its export
intent is staged {:ineligible, :plan_unresolved}: an unattributable fact is a
state, never a guess at today's plan. The stamp is deliberately not part of
payload_hash: it is derived rather than supplied, and including it would turn
an ordinary retry that straddles a plan change into a conflict rather than the
duplicate it is (finding X302).
A scheduled transition is the only thing in the package that rewrites
plan_version on a subscription that already has one, and it is explicit,
referenced by the caller, audited in aurora_meter_plan_transitions, and
conditional on the audit row still being pending. It never rewrites history: the
previous (plan_id, version) is copied into the transition row before the
subscription row changes, and the applied row carries the boundary the change
took effect at rather than the instant a worker noticed.
Tests.
AuroraMeter.PlansTest/test a duplicate feature raises at compile timeAuroraMeter.PlansTest/test a negative limit raises at compile timeAuroraMeter.SubscriptionsTest/test a subscription that is not in an entitled status falls back to the default planAuroraMeter.PlanRegistryConcurrencyTest/test I17 SKIP LOCKED makes a concurrent assignment take the rows another transaction is not holdingAuroraMeter.PlanRegistryConcurrencyTest/test I17 register! killed between assignment batches resumes and completes on the next runAuroraMeter.PlanRegistryConcurrencyTest/test I17 register! killed inside a batch commits nothing from that batchAuroraMeter.PlanRegistryConcurrencyTest/test I17 twelve concurrent put_plan_version calls for one key leave exactly one rowAuroraMeter.PlanRegistryConcurrencyTest/test I17 twelve concurrent register! calls insert one row per plan id and versionAuroraMeter.PlanRegistryConcurrencyTest/test I17 twelve concurrent register! calls reach the same conflict decisionAuroraMeter.PlanRegistryConcurrencyTest/test I17 two concurrent assignments take disjoint batches and leave every row namedAuroraMeter.PlanVersionsPropertyTest/property I17 encode then decode preserves the fingerprintAuroraMeter.PlanVersionsPropertyTest/property I17 the fingerprint is a pure function of the commercial contentAuroraMeter.PlanVersionsPropertyTest/property I17 two plans with different commercial content have different fingerprintsAuroraMeter.PlanVersionsTest/test I17 a future-dated version is not effective before its instant and is after itAuroraMeter.PlanVersionsTest/test I17 a non-UTC effective instant raises at compile time and says whyAuroraMeter.PlanVersionsTest/test I17 a plan block without a version compiles as version 1 with no effective instantAuroraMeter.PlanVersionsTest/test I17 a plan id whose only version is future dated raises at compile timeAuroraMeter.PlanVersionsTest/test I17 a storage adapter without snapshot support warns once and leaves code authoritativeAuroraMeter.PlanVersionsTest/test I17 a subscription pinned to a version in neither code nor storage falls back and says soAuroraMeter.PlanVersionsTest/test I17 a tenant keeps version 1's limits when version 1's block is deleted from the moduleAuroraMeter.PlanVersionsTest/test I17 a tenant subscribed before version 2 exists keeps version 1 after version 2 is effectiveAuroraMeter.PlanVersionsTest/test I17 all/0 keeps its %{id => plan} shapeAuroraMeter.PlanVersionsTest/test I17 an invalid version string raises at compile timeAuroraMeter.PlanVersionsTest/test I17 an unknown option in the plan keyword list raises and lists the accepted keysAuroraMeter.PlanVersionsTest/test I17 base/1 is the version with no effective instant, whatever the clock saysAuroraMeter.PlanVersionsTest/test I17 duplicate plan id and version in one module raises at compile timeAuroraMeter.PlanVersionsTest/test I17 get/2 returns a compiled version and nil for a version in neither code nor storageAuroraMeter.PlanVersionsTest/test I17 plan_version_conflict warn logs the same message once and does not raiseAuroraMeter.PlanVersionsTest/test I17 put_subscription never writes a transition column, even when asked toAuroraMeter.PlanVersionsTest/test I17 put_subscription still invalidates the subscription cacheAuroraMeter.PlanVersionsTest/test I17 put_subscription with a full provider attribute map updates every syncable columnAuroraMeter.PlanVersionsTest/test I17 put_subscription with a partial attribute map leaves every column it did not sendAuroraMeter.PlanVersionsTest/test I17 register! names a subscription whose plan id is in no compiled module, with no fingerprintAuroraMeter.PlanVersionsTest/test I17 register! names the contract of a subscription that has none, and leaves a named one aloneAuroraMeter.PlanVersionsTest/test I17 register! raises PlanVersionConflictError naming the plan, version and both fingerprintsAuroraMeter.PlanVersionsTest/test I17 register! stamps first_seen_at from the database, not from the node clockAuroraMeter.PlanVersionsTest/test I17 register! stores one snapshot per compiled version and the second run writes nothingAuroraMeter.PlanVersionsTest/test I17 subscribe/2 selects the version effective now and stamps its fingerprintAuroraMeter.PlanVersionsTest/test I17 subscribe/3 with an explicit version pins it, including one not yet effectiveAuroraMeter.PlanVersionsTest/test I17 subscribe/3 with an unknown version is a changeset error naming plan_versionAuroraMeter.PlanVersionsTest/test I17 the legacy assignment names the plan's base version, not the literal 1AuroraMeter.PlanVersionsTest/test I17 the legacy assignment takes plan_effective_at from the past, never from the upgrade clockAuroraMeter.PlanVersionsTest/test I17 two versions of one plan id both compile and versions/1 lists them oldest firstAuroraMeter.PlanVersionsTest/test I17 two versions of one plan with the same effective instant raise at compile timeAuroraMeter.PlansSnapshotTest/test I17 a boolean feature value and the integer 1 produce different fingerprintsAuroraMeter.PlansSnapshotTest/test I17 an integer and a float unit price produce different fingerprintsAuroraMeter.PlansSnapshotTest/test I17 decode drops a feature name with no existing atom and names itAuroraMeter.PlansSnapshotTest/test I17 decode refuses a plan id with no existing atom rather than creating oneAuroraMeter.PlansSnapshotTest/test I17 encode then decode round-trips a planAuroraMeter.PlansSnapshotTest/test I17 short/1 renders twelve hex characters, or says there is no fingerprintAuroraMeter.PlansSnapshotTest/test I17 the canonical form lists features in name order, whatever order the map iterates inAuroraMeter.PlansSnapshotTest/test I17 the canonical form renders every commercial field with visible separatorsAuroraMeter.PlansSnapshotTest/test I17 the effective instant is not commercial content and does not change the fingerprintAuroraMeter.PlansSnapshotTest/test I17 the fingerprint changes when any commercial field changesAuroraMeter.PlansSnapshotTest/test I17 the fingerprint is independent of feature declaration orderAuroraMeter.PlansSnapshotTest/test I17 the fingerprint is independent of map iteration order above the small-map boundaryAuroraMeter.PlansSnapshotTest/test I17 the fingerprint is independent of recurring credit declaration orderAuroraMeter.PlansSnapshotTest/test I17 the fingerprint is the sha256 of the canonical form and is 32 bytesAuroraMeter.PlansSnapshotTest/test I17 the separators cannot appear in a name the DSL acceptsAuroraMeter.PlanPreviewTest/test I17 preview_transition for a tenant with no subscription previews from the default planAuroraMeter.PlanPreviewTest/test I17 preview_transition of a zero-price transition reports price 0 and no prorationAuroraMeter.PlanPreviewTest/test I17 preview_transition of an unknown plan is refusedAuroraMeter.PlanPreviewTest/test I17 preview_transition of an unknown version is refusedAuroraMeter.PlanPreviewTest/test I17 preview_transition reports a limit change, an added feature and a removed featureAuroraMeter.PlanPreviewTest/test I17 preview_transition reports an added and a removed feature by nameAuroraMeter.PlanPreviewTest/test I17 preview_transition reports an unchanged feature not at allAuroraMeter.PlanPreviewTest/test I17 preview_transition reports recurring credit changesAuroraMeter.PlanPreviewTest/test I17 preview_transition returns the effective time and the tenant's periodAuroraMeter.PlanPreviewTest/test I17 preview_transition returns the old and new entitlements without writing anythingAuroraMeter.PlanPreviewTest/test I17 preview_transition with Billing.Noop reports provider status :not_configuredAuroraMeter.PlanPreviewTest/test I17 preview_transition with a provider implementing describe_plan_change shows itAuroraMeter.PlanPreviewTest/test I17 preview_transition with a provider that raises keeps the diffAuroraMeter.PlanPreviewTest/test I17 preview_transition with a provider that returns an error keeps the diffAuroraMeter.PlanTransitionPrecedenceTest/test I17 a cancellation wins over a sync that also names the scheduled targetAuroraMeter.PlanTransitionPrecedenceTest/test I17 a cancelled transition is never applied by a later runAuroraMeter.PlanTransitionPrecedenceTest/test X296 a provider naming the plan id without the version cancels, and Aurora Meter Pro no longer produces that shapeAuroraMeter.PlanTransitionPrecedenceTest/test I17 a provider sync that does not change the plan leaves the transition pendingAuroraMeter.PlanTransitionPrecedenceTest/test I17 a provider sync to a different plan cancels the pending transitionAuroraMeter.PlanTransitionPrecedenceTest/test I17 a provider sync to exactly the scheduled target applies the transition earlyAuroraMeter.PlanTransitionPrecedenceTest/test I17 a provider sync touching only provider ids leaves the transition pendingAuroraMeter.PlanTransitionPrecedenceTest/test I17 a provider sync writing a non-entitled status cancels the pending transitionAuroraMeter.PlanTransitionPrecedenceTest/test I17 a stale sync for an ended subscription cannot revive a cancelled transitionAuroraMeter.PlanTransitionPrecedenceTest/test I17 a transition effective before a later cancellation applies normallyAuroraMeter.PlanTransitionPrecedenceTest/test I17 put_subscription with no pending transition opens no transactionAuroraMeter.PlanTransitionsTest/test I17 a second ref with replace: false returns a conflict naming the pending refAuroraMeter.PlanTransitionsTest/test I17 a second ref with replace: true cancels the first and schedules the secondAuroraMeter.PlanTransitionsTest/test I17 a transition to a version in neither code nor snapshots is failed and not retriedAuroraMeter.PlanTransitionsTest/test I17 a zero-price transition issues exactly the statements a priced one doesAuroraMeter.PlanTransitionsTest/test I17 an applied transition changes plan_id, plan_version, fingerprint and effective timeAuroraMeter.PlanTransitionsTest/test I17 an applied transition writes applied_at on the audit rowAuroraMeter.PlanTransitionsTest/test I17 an explicit scheduled migration is the only thing that moves a tenant's versionAuroraMeter.PlanTransitionsTest/test I17 an invalid custom period source raises InvalidPeriodError naming the moduleAuroraMeter.PlanTransitionsTest/test I17 apply_due_transitions applies a provider transition once provider_ref is setAuroraMeter.PlanTransitionsTest/test I17 apply_due_transitions applies a transition whose effective_at has passedAuroraMeter.PlanTransitionsTest/test I17 apply_due_transitions does not apply a transition before its effective_atAuroraMeter.PlanTransitionsTest/test I17 apply_due_transitions pages with a keyset cursor and returns :doneAuroraMeter.PlanTransitionsTest/test I17 the applier refuses a row whose mirror says applied and whose audit row says pendingAuroraMeter.PlanTransitionsTest/test I17 apply_due_transitions skips a provider transition with no provider_refAuroraMeter.PlanTransitionsTest/test I17 applying a transition deletes no counter, history, event, transaction or lot rowAuroraMeter.PlanTransitionsTest/test I17 cancel_transition moves pending to cancelled and clears the pending stateAuroraMeter.PlanTransitionsTest/test I17 cancel_transition on an already cancelled transition is idempotentAuroraMeter.PlanTransitionsTest/test I17 cancel_transition on an applied transition returns a conflictAuroraMeter.PlanTransitionsTest/test I17 cancel_transition with an unknown ref returns not_foundAuroraMeter.PlanTransitionsTest/test I17 confirm_transition applies immediately when the provider boundary has passedAuroraMeter.PlanTransitionsTest/test I17 confirm_transition is idempotent under redelivery of the same provider_refAuroraMeter.PlanTransitionsTest/test I17 confirm_transition with a different provider_ref on an applied transition conflictsAuroraMeter.PlanTransitionsTest/test I17 confirm_transition with an unknown ref returns not_foundAuroraMeter.PlanTransitionsTest/test I17 confirm_transition without a provider_ref is refusedAuroraMeter.PlanTransitionsTest/test I17 no transition source names a price, so no branch can depend on oneAuroraMeter.PlanTransitionsTest/test I17 schedule_transition copies the current plan and version into from_plan_idAuroraMeter.PlanTransitionsTest/test I17 schedule_transition defaults effective_at to the end of the tenant's current periodAuroraMeter.PlanTransitionsTest/test I17 schedule_transition defaults version to the version effective at the effective timeAuroraMeter.PlanTransitionsTest/test I17 schedule_transition for a row with a null plan_version is registration_incompleteAuroraMeter.PlanTransitionsTest/test I17 schedule_transition for a tenant with no subscription is not_foundAuroraMeter.PlanTransitionsTest/test I17 schedule_transition merges the caller's detail into the audit rowAuroraMeter.PlanTransitionsTest/test I17 schedule_transition to a future-dated version is accepted when it is effective thenAuroraMeter.PlanTransitionsTest/test I17 schedule_transition to a version not yet effective at the effective time is refusedAuroraMeter.PlanTransitionsTest/test I17 schedule_transition to an unknown plan is refusedAuroraMeter.PlanTransitionsTest/test I17 schedule_transition to an unknown version of a known plan is refusedAuroraMeter.PlanTransitionsTest/test I17 schedule_transition to the tenant's current plan and version is refusedAuroraMeter.PlanTransitionsTest/test I17 schedule_transition with a non-UTC effective_at is refusedAuroraMeter.PlanTransitionsTest/test I17 schedule_transition with a past effective_at is refusedAuroraMeter.PlanTransitionsTest/test I17 schedule_transition with a ref over 128 bytes is refusedAuroraMeter.PlanTransitionsTest/test I17 schedule_transition without a ref is refusedAuroraMeter.PlanTransitionsTest/test I17 schedule_transition writes the audit row and mirrors it on the subscriptionAuroraMeter.PlanTransitionsTest/test I17 the due scan's keyset pages partition the pending set with no repeat and no gapAuroraMeter.PlanTransitionsTest/test I17 the default effective_at under a subscription-aligned source is current_period_endAuroraMeter.PlanTransitionsTest/test I17 the default effective_at under a weekly host source is the end of the current weekAuroraMeter.PlanTransitionsTest/test I17 the default effective_at under the calendar source is the first of next monthAuroraMeter.PlanTransitionsTest/test I17 the same ref with different parameters returns a conflict naming bothAuroraMeter.PlanTransitionsTest/test I17 the same ref with identical parameters returns the existing transitionAuroraMeter.PlanTransitionsTest/test I17 usage recorded before the boundary stays in its period after the transition applies- PLANNED (07c):
AuroraMeter.Pro.PlanAttributionTest/test I17 a stale provider notification cannot move the version AuroraMeter.CreditsRecurrencesVersionsTest/test I17 deploying version 2 leaves a tenant on version 1 with an unchanged allowanceAuroraMeter.PlanAttributionTest/test I17 a plan definition redeployed under a tenant does not change a recorded stampAuroraMeter.PlanAttributionTest/test I17 an unresolved period is not asked for a plan at allAuroraMeter.PlanAttributionTest/test I17 effective_for answers the current assignment for an instant inside itAuroraMeter.PlanAttributionTest/test I17 effective_for costs no query at all for an instant inside the current assignmentAuroraMeter.PlanAttributionTest/test I17 effective_for ignores a cancelled or pending transition and reads only applied onesAuroraMeter.PlanAttributionTest/test I17 effective_for is unresolved for a tenant with no subscriptionAuroraMeter.PlanAttributionTest/test I17 effective_for is unresolved for an instant before an assignment with no historyAuroraMeter.PlanAttributionTest/test I17 effective_for is unresolved while the row still has no plan_versionAuroraMeter.PlanAttributionTest/test I17 effective_for resolves a backdated instant through an applied transition's to sideAuroraMeter.PlanAttributionTest/test I17 effective_for resolves an instant before the first applied transition from its from sideAuroraMeter.PlanAttributionTest/test I17 record for a tenant with no subscription stores attribution unresolvedAuroraMeter.PlanAttributionTest/test I17 record of a backdated event resolves the version through applied transition historyAuroraMeter.PlanAttributionTest/test I17 record of an event predating recorded history stores attribution unresolved and quarantines its outbox itemAuroraMeter.PlanAttributionTest/test I17 record stamps plan_id and plan_version from the plan effective at occurred_atAuroraMeter.PlanAttributionTest/test I17 usage recorded before a transition keeps the old version after the transition applies
Evidence. docs/evidence/v1/phase-07/i17.md
I18 Recurring grants occur once per entitlement period
Guarantee. For one tenant, one entitlement name, one plan id, one plan
version and one period, at most one recurring grant exists, and at most one
rollover lot derives from the period before it. Two guards make that true and
both are evaluated inside the wallet's balance row lock:
aurora_meter_credit_recurrences is UNIQUE (tenant_key, key) and the period's
row is inserted ON CONFLICT DO NOTHING, and the grant carries the period's own
reference into aurora_meter_credit_transactions, which is
UNIQUE (kind, reference). They are deliberately redundant because this is
money. Neither is a lease and neither is a duration, so neither can be inverted
by a clock that steps backwards.
Downtime catch-up processes missed periods in chronological order with bounded
work, and a period that had already ended when it was processed is granted and
expired in the same transaction, recorded issued_and_expired, so history is
complete and nothing owed months ago arrives spendable today. Rollover carries at
most the configured cap of one period's unused allowance into the next, as a new
lot with its own reference, and does not accumulate: two idle periods carry the
cap, not twice the cap. The cap is read from the previous period's stored
policy snapshot, so editing a plan cannot change what an already-issued period
may carry out of itself.
Prerequisites. Core schema version 9, a plan declaring
AuroraMeter.Plans.recurring_credits/2, a storage adapter implementing
AuroraMeter.Storage.list_subscriptions/2, and a host scheduler (Oban's
AuroraMeter.Oban.RecurringGrants, or any other caller of
AuroraMeter.Credits.Recurrences.run/1).
Known limits. The wallet must be on the lot engine: a wallet whose
lots_enabled_at is null is skipped with reason: :lots_disabled rather than
granted through the legacy projection, because the allowance, the cap and the
catch-up are all defined over lots. A tenant is never back-paid for periods
before its first recurrence row, and a host adopting an allowance mid-period
gets the whole of that period rather than a pro-rated part. A plan
transition landing inside a period is build unit 07b's to order; this engine
re-reads the subscription under the lock and refuses a tenant whose plan changed,
rather than granting the old plan's allowance.
Each period's key and each period's policy carry the version that period was
sold under, resolved through AuroraMeter.Plans.effective_for/2, so a
catch-up across an upgrade grants each period at its own contract rather than
all of them at today's. The version the sweep falls back to when nothing
recorded covers a period is the subscription's own; it never invents one. Two
narrower limits follow. The entitlement names a run considers are the ones the
tenant's current version declares, so an allowance that existed only in a
retired version is not back-paid; and a version that resolves to neither
compiled code nor a stored snapshot keeps the current version's policy, because
withdrawing an allowance a live contract declares would be a worse answer than
paying it.
Tests.
AuroraMeter.CreditsTest/test grant/3 credits the balance and is idempotent per referenceAuroraMeter.CreditsTest/test grant/3 a duplicate grant reports duplicate: true in telemetry and does not broadcastAuroraMeter.PlansTest/test I18 recurring_credits stores name, amount, category, rollover and expires on the planAuroraMeter.PlansTest/test I18 a plan without recurring_credits has an empty listAuroraMeter.PlansTest/test I18 declaration order is preserved and features are untouchedAuroraMeter.PlansTest/test I18 a duplicate entitlement name in one plan raises at compile timeAuroraMeter.PlansTest/test I18 a float amount raises at compile timeAuroraMeter.PlansTest/test I18 rollover greater than zero with expires: :never raises at compile timeAuroraMeter.PlansTest/test I18 an expiring allowance must be promotional, because only promotional grants expireAuroraMeter.PlansTest/test I18 an unknown option, category or expiry raises at compile timeAuroraMeter.PlansTest/test I18 a name or plan id that cannot be read back out of a recurrence key raisesAuroraMeter.PlansTest/test I18 a never-expiring promotional allowance compiles and warnsAuroraMeter.Credits.RecurrencesPeriodsTest/test I18 a tenant with no previous recurrence gets only the current periodAuroraMeter.Credits.RecurrencesPeriodsTest/test I18 a tenant already granted for the current period is owed nothingAuroraMeter.Credits.RecurrencesPeriodsTest/test I18 the walk from three months ago yields three periods in chronological orderAuroraMeter.Credits.RecurrencesPeriodsTest/test I18 the walk stops at max_periods and reports the remainderAuroraMeter.Credits.RecurrencesPeriodsTest/test I18 a weekly custom period source yields weekly periodsAuroraMeter.Credits.RecurrencesPeriodsTest/test I18 a source exporting containing/2 is asked through itAuroraMeter.Credits.RecurrencesPeriodsTest/test I18 a period source that does not advance is detected and does not loopAuroraMeter.Credits.RecurrencesPeriodsTest/test I18 a period source that raises is reported rather than propagatedAuroraMeter.Credits.RecurrencesPeriodsTest/test I18 a source that cannot place a past instant is an error, not a guessAuroraMeter.CreditsRecurrencesTest/test I18 one run grants one lot per entitled tenant for the current periodAuroraMeter.CreditsRecurrencesTest/test I18 two tenants on one plan reach the same period without collidingAuroraMeter.CreditsRecurrencesTest/test I18 running the job five times in one period produces one grant and four duplicatesAuroraMeter.CreditsRecurrencesTest/test I18 a cancelled subscription receives nothing and a past_due one receives its allowanceAuroraMeter.CreditsRecurrencesTest/test I18 a plan that declares no allowance grants nothing at allAuroraMeter.CreditsRecurrencesTest/test I18 a wallet the allocator does not own is skipped and told whyAuroraMeter.CreditsRecurrencesTest/test I18 the recurrence reference namespace is rejected for a manual grantAuroraMeter.CreditsRecurrencesTest/test I18 an unused allowance rolls over capped at the policy cap and expires the restAuroraMeter.CreditsRecurrencesTest/test I18 a tenant that spent most of its allowance carries only what was leftAuroraMeter.CreditsRecurrencesTest/test I18 a fully spent allowance rolls over nothingAuroraMeter.CreditsRecurrencesTest/test I18 rollover is not compounded: two idle periods carry at most the cap into the thirdAuroraMeter.CreditsRecurrencesTest/test I18 a rollover is unaffected by whether the expiry sweep or the recurrence ran firstAuroraMeter.CreditsRecurrencesTest/test I18 a plan with no rollover carries nothing and expires the whole allowanceAuroraMeter.CreditsRecurrencesTest/test I18 three missed periods are issued and expired in order and leave no fresh availabilityAuroraMeter.CreditsRecurrencesTest/test I18 a historical period's grant, rollover and expiries chain exactly in one runAuroraMeter.CreditsRecurrencesTest/test I18 a catch-up bounded by max_periods resumes chronologically on the next runAuroraMeter.CreditsRecurrencesTest/test I18 a tenant seen for the first time is not back-paidAuroraMeter.CreditsRecurrencesTest/test I18 a plan edited between two periods grants the new amount and keeps the old capAuroraMeter.CreditsRecurrencesTest/test I18 a run one microsecond before the boundary grants the old period, and at it the newAuroraMeter.CreditsRecurrencesTest/test I18 a dry run reports what it would grant and writes nothingAuroraMeter.CreditsRecurrencesTest/test I18 status/1 reports the tenant's periods newest firstAuroraMeter.CreditsRecurrencesTest/test I18 a tenant whose period source raises is skipped and the run continuesAuroraMeter.CreditsRecurrencesTest/test I18 a subscription cancelled between the scan and the lock is refused under the lockAuroraMeter.CreditsRecurrencesTest/test I18 a storage adapter without list_subscriptions is refused rather than silently idleAuroraMeter.CreditsRecurrencesTest/test I18 a naive per-period reference double-grants, which is what the engine replacesAuroraMeter.CreditsRecurrencesConcurrencyTest/test I18 two schedulers against fifty tenants issue one grant and one rollover per periodAuroraMeter.CreditsRecurrencesConcurrencyTest/test I18 a rendezvous that guarantees the race issues exactly one grant per periodAuroraMeter.CreditsRecurrencesConcurrencyTest/test I18 the ledger's reference index refuses a second grant even with the recurrence guard bypassedAuroraMeter.CreditsRecurrencesConcurrencyTest/test I18 a per-run reference double-grants under the same rendezvousAuroraMeter.CreditsRecurrencesConcurrencyTest/test I18 a debit racing the recurrence leaves the carry computed from the committed availabilityAuroraMeter.CreditsRecurrencesVersionsTest/test I18 a catch-up period before an upgrade is granted at the version that period was sold underAuroraMeter.CreditsRecurrencesVersionsTest/test I18 a recurrence key written before this unit is not granted a second timeAuroraMeter.CreditsRecurrencesVersionsTest/test I18 a recurring grant uses the plan version effective for the periodAuroraMeter.CreditsRecurrencesVersionsTest/test I18 a tenant pinned to version 2 is granted version 2's allowance and keyAuroraMeter.CreditsRecurrencesVersionsTest/test I18 a transition at a period boundary produces one grant for each period at its own versionAuroraMeter.CreditsRecurrencesVersionsTest/test I18 a version 2 that drops the allowance does not stop a version 1 tenant being paid
Evidence. docs/evidence/v1/phase-06/i18-once-per-period.md
I19 All supported schema histories preserve commercial state
Guarantee. AuroraMeter.Migration exposes one pinned, versioned migration per
schema step, and the package's own 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, and the test database is migrated through every one
of them by pinned up(from:, version:) calls. An unpinned up(from: n) is
refused, because it would run to whatever the latest version happened to be on the
day it first applied, so two databases built from the same migrations would end up
with different schemas.
Prerequisites. PostgreSQL. The host generates its migration with
mix aurora_meter.gen.migration, which embeds the template in code rather than
shipping priv/.
Known limits. The structural checks prove that the version ladder is complete and pinned. Build unit 03a adds running proof for core schema versions 7 and 8: every published starting point reaches both, a fresh install and an incremental upgrade produce identical catalogues, and an interrupted backfill resumes to the same bytes. Those run against small disposable databases the tests seed themselves. They do not prove that a populated customer database upgrades without losing commercial state, or that a backup and restore round trips: those need the populated upgrade fixtures of the phase 11 migration rehearsal, whose evidence lives in the storefront repository because it exercises core, Pro and the storefront together.
Tests.
AuroraMeter.MigrationTest/test every version up to the latest has a module, and none beyond itAuroraMeter.MigrationTest/test the moduledoc describes every versionAuroraMeter.MigrationTest/test the test database is migrated through every versionAuroraMeter.MigrationTest/test I19 up/1 records the reached version in the checkpoints tableAuroraMeter.MigrationV7Test/test the ladder I19 a fresh install and an incremental upgrade produce the same catalogueAuroraMeter.MigrationV7Test/test the ladder I19 each published schema history reaches 7 and then 8AuroraMeter.MigrationV7Test/test the ladder I19 the schema marker is absent below version 7AuroraMeter.MigrationV9Test/test I19 a fresh install and an incremental upgrade to 9 produce the same catalogueAuroraMeter.MigrationV9Test/test I19 version 9 creates the lot, allocation and recurrence tables with their constraintsAuroraMeter.MigrationV9Test/test I19 version 9 is additive over a database that already holds credit rowsAuroraMeter.MigrationV9Test/test I19 version 9 refuses a database whose balance rows already violate the new checksAuroraMeter.MigrationV9Test/test I19 down(version: 9) without confirm_data_loss raisesAuroraMeter.MigrationV9Test/test I19 version 9 is idempotent: running it twice changes nothingAuroraMeter.EventsBackfillTest/test filling every legacy row I19 it fills every row, and no row twiceAuroraMeter.EventsBackfillTest/test filling every legacy row I19 it is idempotent: a second run updates nothing and changes no hashAuroraMeter.EventsBackfillTest/test filling every legacy row I19 a run killed between batches resumes byte for byteAuroraMeter.EventsBackfillTest/test filling every legacy row I19 rows an old writer inserts during the run are picked up by the same passAuroraMeter.MigrationV10Test/test I19 a fresh install and an incremental upgrade to 10 produce the same catalogueAuroraMeter.MigrationV10Test/test I19 a populated version 9 database upgrades with no subscription changedAuroraMeter.MigrationV10Test/test I19 down of version 10 removes both tables and every column it addedAuroraMeter.MigrationV10Test/test I19 version 10 creates both plan tables and every subscription column with its checksAuroraMeter.MigrationV10Test/test I19 version 10 is on the data-loss list, so its down needs confirm_data_lossAuroraMeter.MigrationV10Test/test I19 version 10 refuses a plan version fingerprint that is not 32 bytesAuroraMeter.MigrationV10Test/test I19 version 10 refuses a transition state it does not knowAuroraMeter.MigrationV10Test/test I19 version 10 run twice is a no-opAuroraMeter.MigrationMapTest/test I19 X362 every version the binding map calls destructive is in data_loss_versions/0AuroraMeter.MigrationMapTest/test I19 X362 the quotation above is still what the binding map saysAuroraMeter.MigrationMapTest/test I19 X362 every listed version is given a reason, and no unlisted version isMix.Tasks.AuroraMeter.Gen.MigrationTest/test validate_checks I19 X428 --no-validate-checks emits the option on the version 8 file and nowhere elseMix.Tasks.AuroraMeter.Gen.MigrationTest/test validate_checks I19 X428 without the flag nothing is emitted, so the default standsMix.Tasks.AuroraMeter.Gen.MigrationTest/test validate_checks I19 X428 the emitted option is one AuroraMeter.Migration.up/1 actually acceptsAuroraMeter.Credits.LotCutoverGateTest/test I19 X426 the cutover is permitted in a process that has never touched AuroraMeter.CreditsAuroraMeter.Credits.LotCutoverGateTest/test I19 X426 the probe still refuses when the refund path really is absentAuroraMeter.Credits.LotCutoverGateTest/test I19 X426 the escape hatch is not what permits itAuroraMeter.Credits.LotCursorTest/test the documented operator sequence I19 X427 shadow then real, with no extra flags, migrates the walletsAuroraMeter.Credits.LotCursorTest/test the documented operator sequence I19 X427 the rehearsal leaves the real cursor untouchedAuroraMeter.Credits.LotCursorTest/test a run that migrates nothing I19 X427 says so when it skipped everything, and failsAuroraMeter.Credits.LotCursorTest/test a run that migrates nothing I19 X427 says so when there was nothing to do, and does not failAuroraMeter.Credits.LotCursorTest/test a run that migrates nothing I19 X427 does not erase the cursor it did not advanceAuroraMeter.Credits.LotCursorTest/test retry-blocked I19 X434 reaches a wallet behind the cursorAuroraMeter.Credits.LotCursorTest/test retry-blocked I19 X434 does not drag the forward cursor backwardsAuroraMeter.Credits.LotCursorTest/test the cursor rows themselves I19 X427 the shadow cursor cannot collide with a wallet's checkpoint
Two of those bullets are about upgrades that could not be run at all rather than
upgrades that lost money, and build unit 11a found both by building the
core6_pro9 fixture and watching it stop. X426: cutover_blocked/0 asked
function_exported?(AuroraMeter.Credits, :reverse_lot, 4) without loading the
module first, which is false in any host that has not referenced it, so
mix aurora_meter.credits.migrate_lots --no-shadow refused every cutover in
every install, quoting a finding that had been closed. All three existing
cutover test files set the :allow_lot_cutover escape hatch, and or
short-circuits, so the branch that decides it in production had never been
evaluated. X428: the backfill tells an operator to run version 8 with
validate_checks: false for a database holding rows the V1 contract refuses,
and until 11a the generator could not emit that option, so the only route from
that advice to a working upgrade was to hand-edit a generated migration.
- PLANNED (11a):
AuroraMeter.MigrationFixtureTest/test I19 a populated core1 database upgrades with every total preserved - PLANNED (11a):
AuroraMeter.MigrationFixtureTest/test I19 an interrupted backfill resumes without double counting
schema-migration-map.md section 3 names the versions whose down destroys a
commercial fact, and AuroraMeter.Migration names them too. For five phases
those were two lists and nothing compared them: the map said core 1, 3, 4, 7, 8,
9, 10 and the code said 7, 9, 10, so a host generating an upgrade over version 1,
3, 4 or 8 got a down with no confirmation on it (open-findings.md X362). The
MigrationMapTest bullets above compare them, in both directions, and refuse to
go quiet when the map is missing unless the whole storefront is.
The wallet migration onto credit lots is the one data step in this package that
rewrites how a wallet's money is accounted for. It is checked against figures
the legacy ledger itself wrote: every row's balance_after and held_after
during the fold, and the balance row before the cutover commits. The first
group below replays populated wallets; the second is the refusals, one wallet
per reason; the third is the migration killed, resumed and raced.
AuroraMeter.Credits.LotMigrationReplayTest/test I19 a paid-only wallet replays into one lot per grant, spent oldest firstAuroraMeter.Credits.LotMigrationReplayTest/test I19 overlapping promotional grants replay soonest-expiry first, before paidAuroraMeter.Credits.LotMigrationReplayTest/test I19 a non-expiring promotional lot sorts last within its categoryAuroraMeter.Credits.LotMigrationReplayTest/test I19 a settlement above its hold replays into consume and debtAuroraMeter.Credits.LotMigrationReplayTest/test I19 a released hold hands its reservation back to the lot it came fromAuroraMeter.Credits.LotMigrationReplayTest/test I19 a refund is attributed to the payment that funded it, not to spend orderAuroraMeter.Credits.LotMigrationReplayTest/test I19 a dispute reversal carrying a dispute id in its reference resolves the paymentAuroraMeter.Credits.LotMigrationReplayTest/test I19 a reconciled reversal and its restore adjustment both name the paymentAuroraMeter.Credits.LotMigrationReplayTest/test I19 a reinstatement adjustment is a lot the same payment's reversal can reachAuroraMeter.Credits.LotMigrationReplayTest/test I19 a promotional grant that landed on debt keeps its amount and repays the debtAuroraMeter.CreditsLotMigrationTest/test I19 every fixture wallet migrates with balance, held and promotional unchangedAuroraMeter.CreditsLotMigrationTest/test I19 one lot per grant row, and every allocation names the ledger row that caused itAuroraMeter.CreditsLotMigrationTest/test I19 migration writes debt and expired and leaves the three legacy figures aloneAuroraMeter.CreditsLotMigrationTest/test I19 a shadow run reaches the same verdict as the real run that follows itAuroraMeter.CreditsLotMigrationTest/test I19 a migrated wallet keeps answering the public credit APIAuroraMeter.CreditsLotMigrationTest/test I19 a recurring grant cannot reach a wallet the migration has yet to replayAuroraMeter.CreditsLotMigrationPropertyTest/property I19 a generated legacy history replays into lots that reproduce its balance row
Refusals. A migration that cannot reproduce a wallet exactly must leave it alone, and a refusal nobody can demonstrate is not a refusal:
AuroraMeter.Credits.LotMigrationReplayTest/test I19 a reversal with no resolvable payment intent blocks the walletAuroraMeter.Credits.LotMigrationReplayTest/test I19 a reversal larger than the lots that payment funded blocks the walletAuroraMeter.Credits.LotMigrationReplayTest/test I19 a reversal that would take reserved value blocks the walletAuroraMeter.Credits.LotMigrationReplayTest/test I19 an adjustment whose restore reference names no payment blocks the walletAuroraMeter.Credits.LotMigrationReplayTest/test I19 a hold the overdraft tolerance allowed but no lot can back blocks the walletAuroraMeter.Credits.LotMigrationReplayTest/test I19 a settle with no hold in the history blocks the walletAuroraMeter.Credits.LotMigrationReplayTest/test I19 a release with no hold in the history blocks the walletAuroraMeter.Credits.LotMigrationReplayTest/test I19 a row of a kind the fold has no rule for blocks the walletAuroraMeter.Credits.LotMigrationReplayTest/test I19 an expire row that names no grant blocks the walletAuroraMeter.Credits.LotMigrationReplayTest/test I19 an expire row larger than the grant it names blocks the walletAuroraMeter.Credits.LotMigrationReplayTest/test X261 an expiry that destroyed a grant a hold had reserved blocks the walletAuroraMeter.Credits.LotMigrationReplayTest/test X276 an expiry the legacy attribution over-counted because a hold blocked a spendAuroraMeter.Credits.LotMigrationReplayTest/test I19 a history no ordering can reconcile blocks rather than migrating wrongAuroraMeter.Credits.LotMigrationReplayTest/test I19 every flag the fold raises is classified, and a blocking one halts the foldAuroraMeter.CreditsLotMigrationTest/test I19 a blocked wallet keeps lots_enabled_at null, has no lots and records its reasonAuroraMeter.CreditsLotMigrationTest/test I19 every blocking flag has a wallet that triggers it and none of them migrateAuroraMeter.CreditsLotMigrationTest/test I19 a wallet larger than max_rows is deferred, paused and left untouchedAuroraMeter.CreditsLotMigrationTest/test I19 a run with a blocked wallet fails the Mix task rather than reporting successAuroraMeter.CreditsLotMigrationTest/test I19 a wallet a previous run blocked is still counted as blocked by the next oneAuroraMeter.CreditsLotMigrationTest/test I19 down(version: 9) refuses without confirm_data_loss, which is what protects the lots
Interrupt, resume and concurrency, on independent connections:
AuroraMeter.CreditsLotMigrationTest/test I19 the run reports every wallet it examined, and the summary counts themAuroraMeter.CreditsLotMigrationTest/test I19 the scan resumes from the aggregate cursor and skips what is behind itAuroraMeter.CreditsLotMigrationTest/test I19 report-only re-reports a migrated wallet without demoting its verdictAuroraMeter.CreditsLotMigrationTest/test I19 status reports the aggregate cursor and every wallet verdictAuroraMeter.CreditsLotMigrationResumeTest/test I19 a ledger write committed during the snapshot lands in the tail, not in the snapshotAuroraMeter.CreditsLotMigrationResumeTest/test I19 a rerun after a completed run writes nothing and reports the wallet as skippedAuroraMeter.CreditsLotMigrationResumeTest/test I19 the aggregate checkpoint records the run and its cursor across two runs
The wallet migration's own run reports, fixture catalogue, blocked catalogue
and lock durations are under docs/evidence/v1/phase-06/, named from the
phase 11 evidence below.
AuroraMeter.CreditsLotMigrationTest/test I19 a wallet holding a reverse row migrates and reconciles, and so does one holding the legacy shape (X266)
Build unit 09b, the generated host migrations. A history is only reproducible
if the files that built it name the versions they ran: an unbounded
AuroraMeter.Migration.up() runs to whatever version the installed package has
reached on the day it is applied, so the same committed file produces one schema
in the database it was written against and another in a database created after
the next release (open-findings.md S1). Both installers emitted exactly that
until this unit; both generators had already been fixed, which is how two
supported ways of installing one package came to produce two different files.
Mix.Tasks.AuroraMeter.InstallTest/test the generated migration I19 the body names an explicit range and never calls up/0 or down/0Mix.Tasks.AuroraMeter.InstallTest/test the generated migration I19 the installer and the generator emit the same bodyMix.Tasks.AuroraMeterPro.Gen.MigrationTest/test pinned ranges I19 a fresh install pins both ends of the whole range(pro)Mix.Tasks.AuroraMeterPro.Gen.MigrationTest/test pinned ranges I19 an upgrade names both ends of every range it generates(pro)Mix.Tasks.AuroraMeterPro.Gen.MigrationTest/test pinned ranges I19 the range covers every version from --from to the latest(pro)Mix.Tasks.AuroraMeterPro.Gen.MigrationTest/test pinned ranges I19 every generated down names the same bounded range as its up(pro)Mix.Tasks.AuroraMeterPro.Gen.MigrationTest/test pinned ranges I19 it refuses a --from above the latest version(pro)Mix.Tasks.AuroraMeterPro.Gen.MigrationTest/test the shared plan I19 Pro declares no concurrent version, so one range becomes one file(pro)Mix.Tasks.AuroraMeterPro.Gen.MigrationTest/test the shared plan I19 no generated Pro file claims a data-loss confirmation nothing reads(pro)Mix.Tasks.AuroraMeterPro.InstallTest/test I19 the installer and the generator emit the same migration body(pro)AuroraMeter.MigrationGuardsTest/test L-11b-1: an unknown option raises rather than being dropped I19 up/1 raises ArgumentError naming the unknown optionAuroraMeter.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 givenAuroraMeter.MigrationGuardsTest/test L-11b-1: an unknown option raises rather than being dropped I19 down/1 raises ArgumentError naming the unknown optionAuroraMeter.MigrationGuardsTest/test L-11b-1: an unknown option raises rather than being dropped I19 down/1 names prefix when prefix is givenAuroraMeter.MigrationGuardsTest/test L-11b-1: an unknown option raises rather than being dropped I19 a misspelled known option is refused tooAuroraMeter.MigrationGuardsTest/test L-11b-1: an unknown option raises rather than being dropped I19 every option the version modules read is acceptedAuroraMeter.MigrationGuardsTest/test L-11b-1: an unknown option raises rather than being dropped I19 a non-keyword argument is refused by nameAuroraMeter.MigrationGuardsTest/test L-11b-2: a repository that would migrate into another schema refuses to boot I19 validate! raises PrefixError when the repo sets migration_default_prefixAuroraMeter.MigrationGuardsTest/test L-11b-2: a repository that would migrate into another schema refuses to boot I19 validate! raises PrefixError when default_options carries a prefix for readsAuroraMeter.MigrationGuardsTest/test L-11b-2: a repository that would migrate into another schema refuses to boot I19 validate! raises PrefixError when default_options carries a prefix for insert_allAuroraMeter.MigrationGuardsTest/test L-11b-2: a repository that would migrate into another schema refuses to boot I19 validate! passes when the prefix is nilAuroraMeter.MigrationGuardsTest/test L-11b-2: a repository that would migrate into another schema refuses to boot I19 validate! passes when the prefix is publicAuroraMeter.MigrationGuardsTest/test L-11b-2: a repository that would migrate into another schema refuses to boot I19 validate! says nothing about a module that is not a repositoryAuroraMeter.MigrationGuardsTest/test L-11b-2: a repository that would migrate into another schema refuses to boot I19 the suite's own repository passes the checkAuroraMeter.MigrationGuardsTest/test L-11b-2: a migration running under a prefix refuses before it creates anything I19 mix ecto.migrate --prefix raises PrefixError and creates no tableAuroraMeter.MigrationGuardsTest/test L-11b-2: a migration running under a prefix refuses before it creates anything I19 the same migration with no prefix runsAuroraMeter.MigrationGuardsTest/test L-11b-3: down is refused unless the loss is confirmed I19 down(version: 10, to: 1) raises DataLossError and changes nothingAuroraMeter.MigrationGuardsTest/test L-11b-3: down is refused unless the loss is confirmed I19 down of a range with no destructive version needs no confirmationAuroraMeter.MigrationGuardsTest/test L-11b-3: down is refused unless the loss is confirmed I19 down with confirm_data_loss: true drops the tablesAuroraMeter.MigrationTest/test I19 the test repo applies versions in ascending order
Evidence. storefront:docs/evidence/v1/phase-11/i19.md
I20 Optional integrations remain optional and tenant-safe
Guarantee. Phoenix LiveView, Phoenix HTML, Igniter, Oban, plug,
telemetry_metrics, phoenix_live_dashboard and opentelemetry_api are
declared optional in mix.exs, and the metering, entitlement and credit paths do
not reference them. Seven pieces of code do, and each is compiled behind a guard
rather than assumed:
lib/aurora_meter/live_dashboard/page.ex opens with
if Code.ensure_loaded?(Phoenix.LiveDashboard.PageBuilder) do and
lib/aurora_meter/open_telemetry.ex with if Code.ensure_loaded?(:otel_tracer) do,
and in both cases everything the page or the bridge is judged on lives in a
module that is not guarded (AuroraMeter.LiveDashboard.Sections,
AuroraMeter.LiveDashboard.Auth, AuroraMeter.OpenTelemetry.Bridge), so a build
with no optional dependency still compiles and still tests the behaviour;
lib/aurora_meter/components.ex opens with if Code.ensure_loaded?(Phoenix.Component) do,
so on a build without LiveView the components module simply is not defined;
lib/aurora_meter/oban.ex and every file under lib/aurora_meter/oban/ open
with if Code.ensure_loaded?(Oban) do, so on a build without Oban there is no
AuroraMeter.Oban namespace and every operation those workers wrap is still a
public function any scheduler can call; and
mix aurora_meter.install is defined either way, falling back from the Igniter
one-step installer to a plain Mix task that generates the migration and prints
the remaining steps;
lib/aurora_meter/plug/ensure_entitled.ex opens with
if Code.ensure_loaded?(Plug.Conn) do, so on a build without plug there is no
AuroraMeter.Plug.EnsureEntitled and every decision it takes is still a public
function a host can call in a controller; and
lib/aurora_meter/live_view.ex puts only its socket functions
(on_mount/4, switch_tenant/2, handle_usage/2, handle_credits/2) inside
if Code.ensure_loaded?(Phoenix.LiveView) do, leaving subscribe/1,2,
unsubscribe/1,2 and topics/1,2 outside it, so a headless host keeps the
subscription and loses only the socket conveniences. On a build with no optional
dependency present, the facade, the credit ledger, the migration ladder and
AuroraMeter.LiveView.subscribe/1 all still work.
The plug is advisory, and that is a correctness claim rather than a caveat.
It takes no reservation, so it cannot create one that outlives a failed
controller action (I03) and cannot admit concurrent requests whose controllers
each reserve again on top of it (I04). The refusal to ship a reserving
pre-request plug is the protection, AuroraMeter.with_quota/4 around the work
itself is the strict admission, and every row of
AuroraMeter.Plug.EnsureEntitledTest records the feature's usage value before
and after the request and requires them equal.
A missing tenant is never the default tenant. Both the plug and every entry
point of AuroraMeter.LiveView refuse nil before AuroraMeter.Tenant.to_key/1
can see it, because AuroraMeter.Tenant.Default stringifies what it is given and
would map nil to "", which the 0.5.x transition mode still allows with a
warning (open finding C12). The plug answers 401 and reads nothing; the tests assert the
absence of an ETS row, a database counter row and a subscription for "" rather
than assuming it.
This is asserted in both directions, which matters more than it sounds: a
one-directional assertion would pass for the wrong reason the day an optional
dependency quietly stopped being fetched. AuroraMeter.OptionalIntegrationsTest
carries no tag and runs on every CI leg, asserting that the optional modules are
loaded exactly when they were not switched off, and that the components module is
compiled exactly when Phoenix.Component is. AuroraMeter.HeadlessTest asserts
the absence half on the one leg where absence is true.
Prerequisites. None for the headless path. The components require
phoenix_live_view and phoenix_html; the one-step installer requires igniter.
The absence half additionally requires a build made with AURORA_HEADLESS=1,
which is what removes the optional dependencies; its setup refuses to run
otherwise rather than passing vacuously.
Known limits. The absence tests are tagged :headless and excluded by
default, so an ordinary mix test proves only the presence half. The absence half
is proved on exactly one CI leg. That is a deliberate trade and not a gap, but it
means a developer who breaks the headless build locally will not learn it until
CI runs. Every presence assertion is written as an equality against its own
dependency rather than as a bare refute on the headless leg, so each one is
also a positive control on every other leg and none of them can pass vacuously.
plug was in an ordinary resolved build before it was ever declared, dragged in
by phoenix_live_view through phoenix (open-findings.md X331's shape), so
AURORA_HEADLESS was the only leg that could have shown the guard closing and
it removes seven other things at the same time. AURORA_NO_LIVEVIEW=1 is the
narrow leg that answers the question about the plug specifically: LiveView, HTML
and the dashboard out, plug, Oban, Igniter and telemetry_metrics in, and the
narrowness is asserted by name rather than claimed.
The LiveView helpers are covered here at the socket level, against a hand-built
%Phoenix.LiveView.Socket{}. Browser-level coverage through a real endpoint,
router and Phoenix.LiveViewTest is build unit 09c's sample, because core adds
no Phoenix endpoint of its own.
The headless leg proves that the package compiles and that its facade, credits and
migrations work without the optional dependencies. It does not prove that a
dashboard is safe to expose: host-level authentication and tenant scoping for the
components, and the Pro LiveView dashboard's session tenant requirement, are phase
08 and phase 09 work. The components currently use LiveView 1.0 interpolation
syntax while mix.exs claims ~> 0.20 or ~> 1.0 (open finding C9), so the
supported range is itself unproven until that is resolved, and the liveview-0.20
CI leg fails by design until 09b settles it.
Tests.
AuroraMeter.OptionalIntegrationsTest/test I20 the optional integrations are present exactly when they were not switched offAuroraMeter.OptionalIntegrationsTest/test I20 AuroraMeter.Components is compiled exactly when Phoenix.Component is availableAuroraMeter.OptionalIntegrationsTest/test I20 the install task exists either way, with or without IgniterAuroraMeter.OptionalIntegrationsTest/test I20 the AuroraMeter.Oban namespace is compiled exactly when Oban is availableAuroraMeter.HeadlessTest/test I20 the AuroraMeter.Oban namespace is absent without ObanAuroraMeter.HeadlessTest/test I20 Components are not compiled without Phoenix.ComponentAuroraMeter.HeadlessTest/test I20 the installer prints steps instead of raising without IgniterAuroraMeter.HeadlessTest/test I20 the facade, credits and migrations work with no optional dependency presentAuroraMeter.OptionalIntegrationsTest/test I20 AuroraMeter.Telemetry.Metrics is compiled exactly when Telemetry.Metrics is availableAuroraMeter.OptionalIntegrationsTest/test I20 AuroraMeter.Telemetry itself never depends on the optional dependencyAuroraMeter.HeadlessTest/test I20 AuroraMeter.Telemetry.Metrics is absent without telemetry_metricsAuroraMeter.OptionalIntegrationsTest/test I20 the LiveDashboard page is compiled exactly when phoenix_live_dashboard is availableAuroraMeter.OptionalIntegrationsTest/test I20 AURORA_NO_DASHBOARD removes the dashboard dependency and nothing elseAuroraMeter.OptionalIntegrationsTest/test I20 AuroraMeter.OpenTelemetry is compiled exactly when opentelemetry_api is availableAuroraMeter.OptionalIntegrationsTest/test I20 AURORA_NO_OTEL removes the OpenTelemetry pair and nothing elseAuroraMeter.HeadlessTest/test I20 the dashboard page and the OpenTelemetry bridge are absent without their dependenciesAuroraMeter.LiveDashboard.PageTest/test I20 dashboard page refuses without host auth: init/1 raises ArgumentError when :authorized_by is absentAuroraMeter.LiveDashboard.PageTest/test I20 dashboard page refuses without host auth: an unrecognised form raises and names the threeAuroraMeter.LiveDashboard.PageTest/test I20 dashboard page refuses without host auth: a check returning false renders the refusal panel and no section dataAuroraMeter.LiveDashboard.PageTest/test I20 dashboard page refuses without host auth: a check that raises is treated as false and logs a warningAuroraMeter.LiveDashboard.PageTest/test I20 {:assign, key} requires the assign to be exactly true, not truthyAuroraMeter.LiveDashboard.PageTest/test I20 the core page accepts :host_route, because it renders no tenant-identifying valueAuroraMeter.RealtimeTest/test usage_meter renders the value and progressbar semanticsMix.Tasks.AuroraMeter.InstallTest/test wires config, supervision child, a plans module and the migration- NOT 08b's, and recorded rather than dropped:
AuroraMeter.ComponentsAuthTest/test I20 a usage component refuses to render without a host resolved tenant. This line said PLANNED (08b). 08b's build document scopes the components out by name (it owns the two dashboard pages, the OpenTelemetry bridge and the Pro LiveView tenant fix), andAuroraMeter.Componentstakes the tenant from the caller and has no authorization decision of its own to refuse. The LiveView helpers are 09a's. Seeopen-findings.mdX333. AuroraMeter.EntitlementsTest/test I20 every Noop billing provider callback returns :not_configuredAuroraMeter.RealtimeTest/test I20 every quota kind renders its own wordingAuroraMeter.RealtimeTest/test I20 a single-point and an empty series render without a broken chart
Build unit 09b, the installer and the support matrix. Two claims about
optionality that nothing ran until this unit: that the installer never makes a
host depend on an optional integration by installing one, and that the floors
mix aurora_meter.install --check-support prints are the floors mix.exs
declares. The second was a sentence in a comment naming a test file that did not
exist, and it was wrong by five dependencies (open-findings.md X153).
The stale-build row is the trap the check exists for: a dependency present in a
host's tree whose guarded module was never compiled, which is what a host gets
by adding an optional dependency after aurora_meter was built. It is induced
through the real row code rather than by constructing the row a test wants to
see, and it is paired with a control that runs the same code with the module
compiled, so the verdict is about the build and not about the probe (X325).
Mix.Tasks.AuroraMeter.InstallTest/test --check-support I20 the declared floors and mix.exs name the same dependenciesMix.Tasks.AuroraMeter.InstallTest/test --check-support I20 the phoenix_live_view floor is 1.0.0 and agrees with mix.exsMix.Tasks.AuroraMeter.InstallTest/test --check-support I20 a dependency present whose guarded module was not compiled is an error rowMix.Tasks.AuroraMeter.InstallTest/test --check-support I20 control: the same probe with the module compiled is okMix.Tasks.AuroraMeter.InstallTest/test --check-support I20 a dependency below its floor beats the stale-build check to the verdictMix.Tasks.AuroraMeter.InstallTest/test --check-support I20 the abort message names every problem and the command that fixes itMix.Tasks.AuroraMeter.InstallTest/test --check-support I20 an absent optional dependency is neither an error nor a stale buildAuroraMeter.InstallShellTest/test --oban on a host that cannot have it (X375) I20 no Oban at all: the message names the line to add to mix.exsAuroraMeter.InstallShellTest/test --oban on a host that cannot have it (X375) I20 Oban present and the workers not compiled: the stale build, and the one line that fixes itAuroraMeter.InstallShellTest/test --oban on a host that cannot have it (X375) I20 control: a host that has both is not refusedAuroraMeter.InstallShellTest/test --oban on a host that cannot have it (X375) I20 the version the refusal asks for is the floor the support matrix declaresAuroraMeter.InstallShellTest/test the queue name the installer writes I20 the two places it is written agree with each otherAuroraMeter.InstallShellTest/test the queue name the installer writes I20 and both agree with AuroraMeter.Oban.queue/0Mix.Tasks.AuroraMeter.InstallTest/test --feature-policy I20 a first run writes :deny with no flag givenMix.Tasks.AuroraMeter.InstallTest/test --feature-policy I20 --feature-policy warn writes :warn and not :denyMix.Tasks.AuroraMeter.InstallTest/test --feature-policy I20 every documented value is accepted and writtenMix.Tasks.AuroraMeter.InstallTest/test --feature-policy I20 --feature-policy bogus creates no file at allMix.Tasks.AuroraMeter.InstallTest/test --feature-policy I20 a second run keeps a host-edited value and says which one it keptMix.Tasks.AuroraMeter.InstallTest/test --feature-policy I20 an explicit flag does not override a host's existing value eitherMix.Tasks.AuroraMeter.InstallTest/test --events-source I20 writes feature_sources with every pair givenMix.Tasks.AuroraMeter.InstallTest/test --events-source I20 one pair writes exactly that pairMix.Tasks.AuroraMeter.InstallTest/test --events-source I20 no flag writes no feature_sources key at allMix.Tasks.AuroraMeter.InstallTest/test --events-source I20 --events-source tokens:bogus creates no file at allMix.Tasks.AuroraMeter.InstallTest/test --events-source I20 a malformed pair creates no file at allMix.Tasks.AuroraMeter.InstallTest/test --events-source I20 a feature that is not a feature name creates no file at allMix.Tasks.AuroraMeter.InstallTest/test --events-source I20 the same feature twice creates no file at allMix.Tasks.AuroraMeter.InstallTest/test --events-source I20 a second run keeps a host-edited feature_sources valueMix.Tasks.AuroraMeter.InstallTest/test a second run I20 a second run adds no duplicate supervision childMix.Tasks.AuroraMeter.InstallTest/test what the installer will not write I20 no route, no component import and no LiveView reference anywhereMix.Tasks.AuroraMeter.InstallTest/test the fallback without Igniter I20 the printed steps carry the config block and the options that were passed
And the legs themselves, because a leg that is not in the gate is a leg nobody is
watching. AURORA_HEADLESS was red at HEAD for two phases and nothing said so:
the leg exists, it blocks, and the workflow has never run, while three units each
reported a hand-run of it accurately (open-findings.md X246, X356). These two
tests cannot make CI run. They refuse the two silent ways a leg stops being
watched, and both were watched failing before they were trusted passing
(docs/evidence/v1/phase-09/09b-optional-deps.md).
AuroraMeter.CIContractTest/test I20 every optional-dependency leg is in the matrix, and every one of them blocksAuroraMeter.CIContractTest/test I20 every AURORA_ switch a leg sets is one mix.exs actually readsBuild unit 09a, the Phoenix helpers:
AuroraMeter.LiveViewTest/test subscribe/1 and subscribe/2 I20 subscribe/1 still subscribes to the usage topic onlyAuroraMeter.LiveViewTest/test subscribe/1 and subscribe/2 I20 subscribe/2 with topics [:usage, :credits] registers on bothAuroraMeter.LiveViewTest/test subscribe/1 and subscribe/2 I20 subscribe/2 receives both message familiesAuroraMeter.LiveViewTest/test subscribe/1 and subscribe/2 I20 subscribe/2 unsubscribes what it had subscribed when a later topic failsAuroraMeter.LiveViewTest/test subscribe/1 and subscribe/2 I20 subscribe refuses a nil tenant and registers nothing for the empty keyAuroraMeter.LiveViewTest/test subscribe/1 and subscribe/2 I20 subscribe refuses a topic it does not knowAuroraMeter.LiveViewTest/test topics/2 and unsubscribe/2 I20 topics/2 returns the canonical strings for both familiesAuroraMeter.LiveViewTest/test topics/2 and unsubscribe/2 I20 unsubscribe/2 stops delivery of both topic familiesAuroraMeter.LiveViewTest/test topics/2 and unsubscribe/2 I20 unsubscribe/1 leaves a credits subscription the caller did not nameAuroraMeter.LiveViewTest/test on_mount/4 I20 on_mount with an explicit function resolver assigns the tenant and subscribes only when connectedAuroraMeter.LiveViewTest/test on_mount/4 I20 on_mount with assign: :current_org reads the assign set by an earlier hookAuroraMeter.LiveViewTest/test on_mount/4 I20 on_mount carries topics: through to the subscriptionAuroraMeter.LiveViewTest/test on_mount/4 I20 on_mount halts with :missing_tenant when the resolver returns nil and never subscribesAuroraMeter.LiveViewTest/test on_mount/4 I20 on_mount bare :subscribe without live_view_tenant raises ArgumentError naming the config keyAuroraMeter.LiveViewTest/test on_mount/4 I20 on_mount bare :subscribe resolves through live_view_tenant when it is setAuroraMeter.LiveViewTest/test on_mount/4 I20 on_mount refuses an argument shape it does not knowAuroraMeter.LiveViewTest/test switch_tenant/2 I20 switch_tenant unsubscribes the old topics and subscribes the new onesAuroraMeter.LiveViewTest/test switch_tenant/2 I20 switch_tenant with the same tenant is a no-op and does not duplicate the subscriptionAuroraMeter.LiveViewTest/test switch_tenant/2 I20 switch_tenant raises on a nil tenant and leaves the old subscription aloneAuroraMeter.LiveViewTest/test switch_tenant/2 I20 switch_tenant on a disconnected socket only re-assignsAuroraMeter.LiveViewTest/test handle_usage/2 and handle_credits/2 I20 handle_usage records value and period_start per featureAuroraMeter.LiveViewTest/test handle_usage/2 and handle_credits/2 I20 handle_usage drops a message whose tenant_key is not the socket'sAuroraMeter.LiveViewTest/test handle_usage/2 and handle_credits/2 I20 a socket switched to a second tenant keeps only the second tenant's valueAuroraMeter.LiveViewTest/test handle_usage/2 and handle_credits/2 I20 handle_credits drops a foreign tenant_key and records available, held and low_balanceAuroraMeter.LiveViewTest/test handle_usage/2 and handle_credits/2 I20 a credits message back above the threshold clears low_balanceAuroraMeter.LiveViewTest/test handle_usage/2 and handle_credits/2 I20 an unrelated message leaves the socket untouchedAuroraMeter.OptionalIntegrationsTest/test I20 AuroraMeter.Plug.EnsureEntitled is compiled exactly when Plug.Conn is availableAuroraMeter.OptionalIntegrationsTest/test I20 AURORA_NO_LIVEVIEW removes the LiveView pair and the dashboard, and nothing elseAuroraMeter.HeadlessTest/test I20 AuroraMeter.Plug.EnsureEntitled is not compiled without PlugAuroraMeter.HeadlessTest/test I20 AuroraMeter.LiveView.subscribe/1 works with no Phoenix.LiveView loadedAuroraMeter.Plug.EnsureEntitledTest/test a missing tenant I20 a resolver returning nil halts with 401 and :missing_tenantAuroraMeter.Plug.EnsureEntitledTest/test a missing tenant I20 a resolver returning nil never resolves the default tenantAuroraMeter.Plug.EnsureEntitledTest/test a denial I20 a denied boolean feature halts with 403 and :not_entitledAuroraMeter.Plug.EnsureEntitledTest/test a denial I20 a hard limit at the cap halts with 403 and :limit_exceededAuroraMeter.Plug.EnsureEntitledTest/test a denial I20 on_denied {module, function} receives the conn and the reason atomAuroraMeter.Plug.EnsureEntitledTest/test a denial I20 an on_denied callback that does not halt raises a RuntimeError naming the callbackAuroraMeter.Plug.EnsureEntitledTest/test a denial I20 an on_denied callback returning a non-conn raises a RuntimeError naming the callbackAuroraMeter.Plug.EnsureEntitledTest/test a passing request I20 an allowed request assigns :aurora_meter_quota equal to AuroraMeter.quota/2AuroraMeter.Plug.EnsureEntitledTest/test a passing request I20 an allowed request assigns :aurora_meter_tenant with the term the resolver returnedAuroraMeter.Plug.EnsureEntitledTest/test a passing request I20 a tenant term the host's AuroraMeter.Tenant module refuses propagates, not 503AuroraMeter.Plug.EnsureEntitledTest/test a passing request I20 assign_quota: false skips the quota read and still assigns the tenantAuroraMeter.Plug.EnsureEntitledTest/test the plug never reserves I20 usage is unchanged after a passing requestAuroraMeter.Plug.EnsureEntitledTest/test the plug never reserves I20 twelve passing requests at the cap boundary leave usage at its starting valueAuroraMeter.Plug.EnsureEntitledTest/test the undeclared-feature policy I20 an undeclared feature under :deny halts with 403 and :not_entitledAuroraMeter.Plug.EnsureEntitledTest/test the undeclared-feature policy I20 an undeclared feature under :allow passes and assigns a quota with kind :undeclaredAuroraMeter.Plug.EnsureEntitledTest/test the undeclared-feature policy I20 an undeclared feature under :raise re-raises UndeclaredFeatureError rather than answering 503AuroraMeter.Plug.EnsureEntitledTest/test modes I20 mode :entitled? passes at the hard cap because it never reads usageAuroraMeter.Plug.EnsureEntitledTest/test modes I20 mode :entitled? reads no counter, and the same arming makes :check answer 503AuroraMeter.Plug.EnsureEntitledTest/test failures the plug classifies I20 a storage failure during a cold counter seed halts with 503 and :unavailableAuroraMeter.Plug.EnsureEntitledTest/test failures the plug classifies I20 InvalidPeriodError from a custom period source is re-raised, not turned into 503AuroraMeter.Plug.EnsureEntitledTest/test failures the plug classifies I20 a resolver that raises propagates unchangedAuroraMeter.Plug.EnsureEntitledTest/test init/1 I20 init/1 raises on an unknown optionAuroraMeter.Plug.EnsureEntitledTest/test init/1 I20 init/1 raises when :tenant is missingAuroraMeter.Plug.EnsureEntitledTest/test init/1 I20 init/1 raises when :feature is a binaryAuroraMeter.Plug.EnsureEntitledTest/test init/1 I20 init/1 raises when :mode is not one of the twoAuroraMeter.Plug.EnsureEntitledTest/test init/1 I20 init/1 accepts a one-argument function and a {module, function} pairAuroraMeter.RealtimeTest/test I20 the usage broadcast carries the tenant_keyAuroraMeter.RealtimeTest/test I20 a clause matching only the three 0.4.0 keys still matchesBuild unit 09a, the idiom that made two repair units chase a flake:
AuroraMeter.ExportedIdiomTest/test I20 no guarded file asks function_exported?/3 without loading the module firstAuroraMeter.ExportedIdiomTest/test I20 every allow-list entry still matches a real call siteAuroraMeter.ExportedIdiomTest/test I20 the guard sees a bare call, and does not see a wrapped oneAuroraMeter.ExportedIdiomTest/test I20 X426 no shipped lib file asks function_exported?/3 without loading the module firstAuroraMeter.ExportedIdiomTest/test I20 the lib sweep finds a site the file list would have missedAuroraMeter.ExportedIdiomTest/test I20 X426 the guard reads the variable forms the package actually usesAuroraMeter.ConfigSchemaColdTest/test I20 X426 ensure_exports!/4 accepts a module nothing has loadedAuroraMeter.ConfigSchemaColdTest/test I20 X426 and still refuses a module that really lacks the functionAuroraMeter.ConfigSchemaColdTest/test I20 and still refuses a module that cannot be loaded at all
Evidence. docs/evidence/v1/phase-08/i20.md
I20's first real proof is earlier than its evidence path suggests: the headless CI
leg landed in phase 01 and its record is docs/evidence/v1/phase-01/ci.md
(section 11.1). The phase-08 file is the invariant's home and must link back to
it. invariant-map.md currently gives I20 phase-08 and phase-09 only, and should
list phase-01 as well.
I21 Public installation instructions resolve real artifacts
Guarantee. The code printed in the guides is executed, not merely proofread.
AuroraMeter.ExamplesTest extracts the snippets and plans modules from
docs/examples/*.md and asserts that each one compiles and behaves exactly as the
guide says, so a guide that drifts from the API fails the suite. mix docs builds
in the package gate, so a reference to a function that moved, or an extra that does
not exist, fails there.
Prerequisites. The guides under docs/examples/ keep the fenced block shapes
the test extracts.
Known limits. This proves the snippets are true of the source in this working tree. It does not prove that a clean-room install from the published Hex archive resolves, that the version numbers printed in the install instructions exist on Hex, or that the storefront and email snippets match the package. Those are the phase 11 package smoke and clean-room installs, whose evidence lives in the storefront repository.
Tests.
AuroraMeter.ExamplesTest/test concepts.md track/3 adds, and usage/2 reads it backAuroraMeter.ExamplesTest/test team-saas.md the plans module carries exactly what the guide printsAuroraMeter.ExamplesTest/test allowance-and-overage.md the plans module carries exactly what the guide printsAuroraMeter.ExamplesTest/test prepaid-credits.md the plans module carries exactly what the guide printsAuroraMeter.ExamplesTest/test showing-usage.md spend_history/2 is zero-filled and oldest first- PLANNED (11c):
AuroraMeter.InstallInstructionsTest/test I21 every version named in the install instructions exists in the candidate archive
Evidence. storefront:docs/evidence/v1/phase-11/i21.md