Coupon journeys

The Store's side of a QR coupon journey (MeshWeaver.Plugins#2063). Design of record: QR Coupon Journeys. The link itself is an Essentials action link (MeshWeaver.Plugins#2060); this page is what a coupon link's Continue opens.

Node What it is
ReviewRule The ActionHandlerRule claiming store.coupon-journey v1 for Review. 🚨 It ships disabled (enabled: false) with publishPermission: "Update" and no capabilities, so nothing can publish or follow a coupon journey until the owner enables it β€” and then only someone holding Update on Store/CouponJourney/Review may publish. The review page and its save are gated by the same live resolver (ResolveActionHandler), so a disabled rule also stops a recipient who navigates to Review?link=… directly. The free redemption now reserves from the same slot ledger (CouponBudget, see Coupon journeys β†’ One budget), so the two paths can no longer together over-issue a limited coupon, and a cancelled, revoked or expired claim gives its slot back. The rule still stays disabled until the owner sets the campaign terms.
Review The review page (/Store/CouponJourney/Review?link={linkId}).

What the review page does

It trusts nothing it was handed. As the viewer it checks that a person is signed in and may read the link's page a/{linkId}. As the platform it reads the link's presentation and private definition, the coupon, and any claim the viewer already holds, and decides (CouponClaimRules.Decide), in this order: signed in; the link serves (revoked, expired, unknown and unreadable all render the same line); the link is bound to this site; the journey reads; the coupon can still be claimed (window, plan tier, bounded term, budget); an existing claim.

It shows the exact quote β€” the plan tier, the real benefit start and end dates (and that the term is reduced when the coupon's validity cuts it short), Due today: 0 β€” the method-storage consent when the journey requires a payment setup, and an optional renewal as not available on this site yet. Visiting, refreshing or previewing it writes nothing.

Save my claim re-decides everything as the platform, then reserves a redemption slot and creates the claim. The save itself grants no plan, installs no course, saves no payment method and charges nothing. After the save, the claim's own control plane activates the bounded plan (see Activating the plan below) when the journey needs no payment setup. When the page is opened again, it says where the claim stands: being activated, active until the accepted end, already covered by the recipient's plan, needing the site's attention, or ended. Every line says nothing was charged.

The claim and its consents

CouponClaims/{claimKey} (node type Store/CouponClaim), a platform-owned partition nobody is granted anything on. The key is CouponJourneyRules.ClaimKey β€” site, recipient and coupon offer, never the link β€” and the create of the node is the atomic arbiter of one claim per site, person and offer: a second tab, a double click or a second QR link returns the claim the first one created.

The claim keeps the accepted quote and its hash, the phase (Prepared), the slot, and two separate consent records, each { kind, textHash, textVersion, acceptedAt, acceptedBy, quoteHash }:

The redemption budget

There is no cluster-atomic counter (core: Expressing a Write β†’ Status: a cross-hub fold can lose a count), so the budget is reserved by creating slot nodes: CouponClaims/Slots/{CODE}/{n} for n in 1…maxRedemptions. A listing of the existing slots only picks where to start (CouponClaimRules.ProbeOrder); the first successful create is the reservation; all taken is "no redemptions left". At most maxRedemptions slots can exist, and no two claims hold one. A coupon with no limit takes no slot.

Known limits, stated rather than hidden:

Activating the plan

CouponClaimControlPlane is a watcher on the claim node's own hub. It has the same shape as SubscriptionControlPlane. It re-reads the claim, takes one step, and moves the phase only along CouponClaimPhase.CanAdvance. Steps run one at a time, and nothing else writes a saved claim. A step runs on EVERY emission that finds the claim in its phase. So an operator's repair on a running hub is acted on straight away, and a queued emission of a phase the claim has since left does nothing. The save starts the claim's hub by reading the new claim once.

  1. Prepared, when the journey needs no payment setup. A claim that needs a setup waits in Prepared for the provider (MeshWeaver.Plugins#2064). The control plane follows policy authorize-as-caller-execute-as-system:

    • The recipient's authority is checked again (CouponClaimStore.Recheck, the review page's own decision). The recipient must still be signed in and still allowed to read the invitation; the link must still serve on this site, through an enabled handler rule. If the link no longer serves, the claim is Revoked. If the check cannot be made, the step faults; that is never a verdict.
    • The coupon is checked again (CouponClaimGrantRules.OfferRefusal). If the coupon was withdrawn, is out of its window, no longer grants the accepted plan, or the accepted period is over, the claim is Expired, and the benefit is never granted.
    • The selected course is checked against the coupon (CouponClaimGrantRules.CourseRefusal). The coupon's package list must name it, and a declared scope must cover it. These are the restrictions the free redemption and a coupon's key enforce, so a journey never installs a course its coupon was not issued for. A course outside them needs attention before anything is granted.
    • Then the claim moves to GrantPending, with coupon-claim:{claimKey} as its request id.
  2. GrantPending: the claim's ONE bounded plan is requested on the recipient's subscription record (CouponGrants.Request). The request writes only requestedCouponGrant: the tier, the EXACT accepted end, and the request id. The record's own control plane decides and applies it in ONE write, on the record's own turn (SubscriptionContent.SettleCouponGrant). So the plan it protects is the plan the record holds at that instant, never a reading that another writer has changed since. The decision (CouponGrants.Decide) protects what the recipient already holds:

    • It applies the offer when there is no live plan (none, lapsed or cancelled), a granted plan of the same rank that ends sooner, or a lower granted plan that ends no later than the offer. The record becomes Active on the offer's tier: manual cadence, not permanent, no processor subscription behind it, ending exactly at the accepted end. A cancelled plan's retained term is replaced by that end, never kept.
    • The offer is covered when there is a plan that is higher, sold (a billing cadence), permanent, or of the same rank and unbounded or reaching as far, or when this site's own plan covers it. The record does not change, and the claim goes to Active with grant: Covered.
    • It is refused when a lower plan is sold, permanent, unbounded, or reaches beyond the offer. The offer would re-tier, cut short or extend that plan, so the claim needs attention.

    Pending requests and their settlements are kept by request id (requestedCouponGrants, couponGrantOutcomes). Two claims of one person therefore never overwrite each other. They are decided oldest first in the same write: the first may apply, and the second then finds the plan covering it. Each claim reads its own receipt. An applied grant is final for its claim only once the record has reconciled it, meaning it has revoked the unlocks the new tier no longer covers, as an activation does. That reconciliation is the record's own resumable step: a restart runs it again, and a sweep that fails is recorded on the receipt, so the claim needs attention. Its repair asks again, which clears the failure and retries the sweep. An ACTIVE permanent plan is never replaced, whatever date is left on it.

    A claim acts only on a receipt that is final AND whose id is no longer pending (CouponGrants.SettledReceipt), read off the record's own live stream. A repair that asks again therefore never reads the previous round's receipt as its answer. Receipts are trimmed as history only: one that still owes its reconciliation, or that failed and waits for repair, is always kept.

    The request id is applied at most once. A claim asking again (after a crash, a recycle or a repair) gets the settlement it already had, and a second claim for the same offer finds the plan covers it. This is never RedeemFree, and never an eternal entitlement.

  3. A stopped claim with a slot gives the slot back, which returns the unit to the coupon's budget, but only while the slot is still this claim's. The slot is read first. A slot that is gone, or that names another claim or holder (it was given back before and taken again), is left alone. This claim's own slot is deleted with the version that read saw as a precondition, which the owning hub checks. The claim then records slotReleased. This is the coupon budget's one give-back (CouponBudget.Release, ownership by CouponBudget.Holds), the same one a lost save and a free redemption's ticket use; a journey slot reserved before slots recorded their holder is owned by its claim key alone.

  4. Active β†’ Installing β†’ Complete: the course. The claim records the course the journey selected (course). The watcher reads the course's tier, then waits until a reading SHOWS the recipient's plan covering it. That is the same read Subscriptions.Unlock decides with, re-read because it trails the grant. A record that states it is permanent reads as live whatever date is left on it (Subscriptions.Parse). A read that fails reads as nothing, never as "no plan".

    • If this site's plan covers the course, the audience verdict (PlanAccess.Grants) must admit the recipient.
    • Otherwise the course is unlocked through the plan (Subscriptions.Unlock): an unlock record under the subscription plus the plan's own grant, and never an eternal entitlement marker, so the course goes when the plan lapses. An unlock that completes empty is a refusal, not a success.
    • Only then is the course installed (DependencyInstall.InstallAfterAcquisition), as System for the recipient, the same shape order fulfilment uses.
    • A course confirmed not covered is never unlocked: one with no tier, or one a live plan of a lower rank cannot reach. The claim completes, and its note says why.
    • An inconclusive reading, a refusal, or a failed install leaves the plan granted and the claim in NeedsAttention with failedIn: Installing. Its repair runs the install again.
    • A journey that selected no course completes straight away.

A step that faults moves the claim to NeedsAttention, with the phase it failed in (failedIn) and why (problem). The repair is an operator moving the claim back to failedIn, and the running hub runs the step again.

Store never compiles Essentials. The review page reads the link's presentation (linkId, definition) and private definition (linkId, site, invocation, validFrom, expiresAt, revokedAt, revision) as data (CouponJourneyLinkFacts) β€” a field-name contract with Essentials' ActionLinkDefinition, read tolerantly: a missing field reads as "does not serve".

One budget

A coupon has ONE redemption budget, maxRedemptions, and both ways of redeeming it reserve from the same ledger: the free redemption (CouponRedemption.RedeemFree, the coupon field on a package page) and a coupon journey's claim. Two ledgers over one budget could each see a unit left and each take it, so there is one (Store/Coupon/Source/CouponBudget.cs).

Rule What it means
A unit is a slot CouponClaims/Slots/{CODE}/{n}, n in 1…capacity. The CREATE of the slot node is the reservation; only an already-exists conflict moves on to the next slot. The slot records its holder and its claimKey (empty for a free redemption).
Reserved before anything is granted The free redemption reserves its slot first and only then issues the key, activates the plan and grants the package. An exhausted budget refuses it with "This coupon has no redemptions left." and grants nothing.
Capacity maxRedemptions less the redemptions the coupon's own ledger counted WITHOUT a slot: the larger of redeemed and the distinct redeemedBy, less the people in slottedBy. A free redemption that holds a slot is added to redeemedBy and slottedBy in the SAME write, so a snapshot of the coupon shows both or neither and the capacity is exact from that snapshot β€” never from a listing.
Counted once A person already in redeemedBy takes no slot. A free redemption's unit is one per person by a CREATE, not by a listing: its ticket CouponClaims/Holders/{CODE}/{sha256(person)} names the slot it holds. Two submissions of one person both reserve a slot; exactly one creates the ticket, and the other gives its slot back and stands on the winner's. A retry after a failed grant, or with a stale listing, stands on the ticket's slot. A ticket create that answers with a fault is reconciled against the ticket before anything is given back: if it committed, the slot is kept; if another slot is named, the person stands on that one; if it is unknown, nothing is released. A slot of the person's that has no ticket (a run stopped in between) is adopted by the retry, never left occupied while another unit is taken. When a ticket exists, any OTHER free-redemption slot of the same person (a crashed losing submission's) is given back. Every give-back is ownership-checked: the slot is read through the synced read, and deleted only while it still names that holder and claim, with the read's version as a precondition. A person's free-redemption slots are given back ONLY by their ticket's own hub (CouponTicketControlPlane), which takes the asked slots (releases on the ticket) one after the other. So no two releases of one slot are ever in flight together, and a slot re-issued between a release's read and its delete cannot happen. This also holds for a person the coupon already counted.
A lowered limit When maxRedemptions is lowered below a slot already held, every slot held above the new bound still counts, so the slots below the bound serve that many fewer (EffectiveBound). A listing that still shows a slot already given back only lowers the bound further.
Never shared A free redemption (permanent) never stands on a journey claim's slot (given back when the claim is cancelled or expires), and a claim never stands on another holder's slot. The rare double count this costs is conservative: it can refuse, never over-issue.
Every free entrypoint The paywall's free redemption (RedeemFree) and a free coupon order (OrderControlPlane, a coupon that brings the price to zero) both reserve before they grant.
Its own node type Slots and tickets are Store/CouponClaimSlot nodes, which register CouponClaimSlot as their content type. Any hub may therefore write the discriminator, including the Plugin page and an order, which never register the type themselves. Readers on every hub take the content by property name (CouponBudget.ReadSlot).
Not covered A coupon WITH a price goes through checkout and is counted at fulfilment, without a slot; it then counts as a unit without one and reduces the capacity. A coupon journey refuses a priced coupon, so a journey and a paid checkout never compete for one unit.

Pinned by CouponClaimTests in the Store/CouponClaim Tests area (the pure capacity and ticket rules; a live race in which the free redemption's reservation and a journey claim's reservation are subscribed together for the last unit and exactly one wins; one person's concurrent submissions holding one unit) and by CouponBudgetEntrypointTests in the Store/Plugin Tests area, on a hub that does not register the slot type: RedeemFree and a free coupon order refused before any grant when a journey holds the last unit, and a successful RedeemFree writing redeemedBy and slottedBy together and leaving the other unit.