amount_tickets conflates "remaining" with "total capacity" — inflated ticket totals + double-subtracted buyer cap #34
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Symptom
On aio-demo, the "Transhumance of the Merens" event card renders an
impossible total. The organizer set the event to 10 tickets; the card
shows "10 of 17 tickets left".
There is no value the organizer can type into the current form that
makes the card read
X of 10, short of hand-computing10 - sold.Root cause
amount_ticketsis used with two incompatible meanings.As remaining capacity —
services.py:59-60, on every sale(upstream semantics, predates the aio fork):
As total capacity —
views_api.py:589-594, introduced by thefork's multi-ticket commit
59068fe:Total capacity is never stored.
nostr_publisher.py:103publishestickets_available = amount_tickets(correct under remaining-semantics),and the webapp reconstructs the total by addition
(
src/modules/events/types/event.ts:99):That derivation only holds while the event is never edited. Each edit
re-baselines remaining, so the apparent total jumps by
sold.The organizer form reinforces the wrong reading: the field is labeled
just "Tickets" with helper text "0 = unlimited"
(
webappCreateEventDialog.vue:621), and the edit form prefills itwith the raw remaining value (
:248). An organizer with 7 sold sees9, reads it as "capacity 9", corrects it to10, and silently setscapacity to 17.
Evidence
Event
hyhNjddtsZ7JNbezwX8ERBon aio-demo, DB state after theorganizer set the field to 10:
Published NIP-52 tags at that point:
Drift history for this one row —
amount_tickets + soldshould beinvariant across sales, and is not:
The original capacity for this event is no longer recoverable from the
data.
Consequences
soldon every edit.views_api.py:594computes
remaining = 10 - 7 = 3when the backend actually believes10 are free, so orders above 3 are rejected.
views_api.py:590raises410 GONEoncesold >= amount_tickets, which is unrelated to real availability.Proposed fix
capacitycolumn viamigrations_fork.py(idempotent, per thefork-migrations pattern); backfill
capacity = amount_tickets + sold.amount_tickets; deriveremaining = capacity - sold.tickets_available = capacity - soldand add an explicittickets_totaltag so clients stop inferring the total by addition.views_api.py:589-594check to compare againstcapacity.tickets_totaldirectly; keep theavailable + soldderivation only as a fallback for events published before the change.
at minimum relabel the existing one honestly.
Open question: the backfill lands already-edited rows at their inflated
value (this event → 17). Those need a manual correction pass after
migration; there is no way to recover intent from the data.
Related, not covered here
nostr_hooks.py:46swallows publish failures atwarninglevel with noretry. On 2026-09-05 an nsecbunkerd signing outage
(
no NIP-46 response for 'sign_event' within 15.0s) silently droppedevery republish for three consecutive sales, leaving the relay serving a
month-old copy of this event with no operator signal. nsecbunkerd has
since been fixed, but the swallow-and-forget path remains. Worth its own
issue.
Design note: keep the "remaining" input, store capacity
Supersedes the "split the organizer form into distinct capacity /
remaining fields" bullet in the proposed fix above.
The case for the current remaining-as-truth model
There is a real property worth preserving in the upstream model. Under
capacity-as-truth, "set capacity to 40 when 85 are already sold" is a
representable state, so something has to reject it. Under
remaining-as-truth it is simply unsayable — every value in
[0, ∞)isvalid at every moment, and
0cleanly means "stop selling" with nospecial case.
Why it doesn't hold up as-is
The
10 remaining / 7 sold => 17 totalstate in the issue above isequally nonsensical; it is just unobservable, because there is no
stored total to contradict it. The invalid state wasn't prevented — the
ability to detect it was removed. That is strictly worse than a
validation error, which at least tells the organizer what went wrong.
The deeper problem is two writers on one field.
amount_ticketsisdecremented by sales (
services.py:60) and overwritten wholesale byorganizer edits (
views_api.py:355), with no coordination between them.That is the mechanism behind the drift table in the issue body.
Under capacity-as-truth each field has exactly one writer: sales own
sold, the organizer ownscapacity, andremainingis derived andcannot drift by construction.
Related:
services.py:59is the only site in the extension that mutatessold, and it only ever increments — so refunds never return inventorytoday. Making them do so under remaining-as-truth means introducing a
third writer to
amount_tickets; under capacity-as-truth it is justdecrementing
sold, and remaining recomputes for free.Proposal: separate the storage model from the input affordance
Store
capacity, but keep the organizer-facing input as "ticketsstill available for sale" — the semantics of the current field. The
backend writes:
This gives both properties at once:
0still means "stop selling", exactly as today.soldbecomes structurally impossible — novalidation rule required, since
sold + n >= soldfor anyn >= 0.3 of 10.The organizer's mental model never has to include the word "capacity".
The data model does, because
X of Y tickets leftis a productrequirement that cannot be satisfied without storing
Y.Unchanged caveat
This does not help already-drifted rows. The backfill
capacity = amount_tickets + soldstill lands this event at 17, andintent is not recoverable from the data. A manual correction pass is
needed either way.
Client-side half filed as
aiolabs/webapp#143— theavailable + soldderivation and the misleading "Tickets" form field. Postponed until the
backend semantics here are settled, since both the tag parsing and the
field label depend on the decision in this issue.
Minor correction to the code reference above and in the design-note
comment: the derivation is at
src/modules/events/types/event.ts:98,not
:99.Upstream review — argues against the
capacitycolumnReviewed
lnbits/eventsbefore planning work here (upstream/main@891eaf1, tags through v1.6.8; our merge-base is v1.6.1). It changes the calculus on the proposed fix.Upstream has no ambiguity and no stored total.
amount_ticketsmeans remaining everywhere —services.py:95-105decrements it on sale, and every capacity check is the same line (views_api.py:188,:415):event.soldis never subtracted from it. There's noremaining = amount_tickets - soldupstream becauseCreateTickethas noquantity— one ticket per request. So theviews_api.py:589-594block this issue identifies is purely ours, introduced with the multi-quantity commit, exactly as diagnosed.And v1.6.8 pushes further in that direction. Ticket waves give each wave its own
amount_tickets, decremented per sale, with the event-level field becoming a derived roll-up (models.py:240):Still remaining-semantics, just aggregated across waves.
A
capacitycolumn fights that. Under waves, "capacity" isn't one number on the event — it's per-wave, and the event-level value upstream computes is explicitly a sum of remaining. Adding a column that upstream has no concept of means owning the reconciliation inapi_ticket_createforever, in the file #33 already calls "the hard one" because both sides rewrote it.But this issue identifies a real problem that "just follow upstream" doesn't solve
The
10 of 17evidence stands, and it's worth being precise about what causes it: it isn't the double-subtraction, it's that the webapp reconstructs a total that was never stored (event.ts:100,total = available + sold), and that reconstruction breaks the moment an organizer edits the event and re-baselines remaining.Under strict upstream semantics there is no recoverable total, so the honest options are:
available === undefined → unlimitedbranch as a fallback for foreign NIP-52 events from non-aio clients.extraJSON rather than as a column. If "X of Y" is genuinely wanted, upstream puts its own new features (waves, images, on-chain) inextratoo, so anextra.capacitydiverges far less than a real column and doesn't collide with the wave roll-up. Publish it as an explicittickets_totaltag so clients stop inferring it.Either way the organizer-form trap this issue documents (field labeled "Tickets", prefilled with raw remaining, helper text "0 = unlimited") needs fixing — that's independent of which option wins, and it's what actually produced the 17.
Also settled: drop
0 = unlimitedSeparate collision found while checking.
models.py:96documents0 = unlimited / not ticketedandnostr_publisher.py:99-103omitstickets_availableto signal it, butviews_api.py:287inapi_get_eventis upstream'sif event.amount_tickets < 1: 410 GONE. So a0event advertises "Unlimited tickets" on the card while the public detail endpoint refuses to serve it and every purchase is refused as sold out.Decision (confirmed with @padreug): follow upstream, capacity always required, no unlimited. Free tickets are unaffected — they're gated on the computed charge (
views_api.py:743,if price <= 0: _issue_free_tickets(...)), not on capacity, so a free event isprice_per_ticket=0with a realamount_tickets. The only thing lost is an uncapped event.Sequencing
This should land after #33, not before. Both the double-subtraction fix and whichever total-display option wins live in
api_ticket_createand the publisher — the two places the rebase rewrites. Doing it first means writing it twice and taking the conflict in the hunk we just touched.(I filed #53 against this before spotting this issue — closed as a duplicate; its upstream review is reproduced above.)
amount_ticketsis remaining in one place and capacity in another — event goes "sold out" at half capacity #53