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 CouponJourney/Publishers may publish (publishScope, D5). Store is synced one-way, so no write right can be granted on Store/CouponJourney/Review itself; Update there admits nobody. 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 campaign terms are decided (see "Launch terms (decided)" below); the rule stays disabled until the conditions of D15 are met and the owner approves the enable. |
Review |
The review page (/Store/CouponJourney/Review?link={linkId}). |
Essentials/CouponJourneyPublishers |
The node type of CouponJourney/Publishers (Essentials, which composes the link; Store never depends on Essentials): the Publish invitation form, see "Launch" below. |
Launch terms (decided)
The owner decided the launch terms of the first campaign on MeshWeaver.Plugins#2066 (the proposal and the decision record are comments there). The launch is a free claim only: no payment method, no renewal.
| # | Term | Decided | Carried by |
|---|---|---|---|
| D1 | Destination site | The public instance (memex.meshweaver.cloud). |
The serving portal's Portal:BaseUrl and ActionLinks:SiteId; ForSite(ActionLinkPublisher.ServingSite(hub)). |
| D2 | Course | Agentic Engineering, landing on it after the claim. | Coupons.Journey(code).OpenCourse("AgenticEngineering").AfterClaim(a => a.OpenSelectedCourse()). |
| D3 | Coupon | A NEW dedicated journey coupon, capped. COURSES3M is not changed. The code, window and cap are set by the owner when the coupon is created. |
Admin/Coupons/{CODE}: tier: personal, tierDays: 90, validFrom, validUntil, maxRedemptions, no price. |
| D4 | Link validity | Until the journey coupon ends. | Actions.Link(β¦).ValidUntil(<the coupon's validUntil>) (capped at 366 days). |
| D5 | Publishers | A scoped publisher group: Editor on CouponJourney/Publishers, a node in a writable space of its own β Store is synced one-way, so no write right can be granted on the handler page. |
ReviewRule.publishPermission: "Update" + publishScope: "CouponJourney/Publishers" (core ActionHandlerRule.PublishScope); the group by group.scoped-create; coupon records stay platform-admin only. |
| D6 | Payment-method setup | None at launch. | No .RequirePaymentSetup(β¦); a claim goes Prepared β GrantPending. |
| D7 | Renewal | None at launch: the plan lapses at its end. | No OptionalSubscriptionRenewal. |
| D8βD10, D13 | Renewal price and tax, cadence and reminders, cancellation, consent text | Not applicable now; they return with phase 2 (setup and renewal). | β |
| D11 | Cross-site publication | One site per deployment. | ActionLinkPublisher.ServingSite(hub). |
| D12 | Listing on a |
Accepted: Public β Viewer on partition a, never Anonymous. |
The governed access.grant-broad. |
| D14 | Retention | Tiered β see "Retention" below. | CouponClaims:* and ActionLinks:Retention* configuration; JourneyRetentionSweeper. |
| D15 | Enabling ReviewRule |
After the D3 coupon exists and the D5 and D12 grants are in place, while the D5 group holds only the tester. The isolated end-to-end run on the destination (#2065) follows the enable, and the real publishers join the group only after it passed. The owner approves the enable. The order is the owner's decision recorded on #2066. | ReviewRule.content.enabled; the group's GroupMembership children. |
Launch: what is left (phase 1)
Every line of phase-1 code is on main: the journey, the claim, the bounded plan, the course install
through the plan, the retention sweep, the publisher scope and both acceptance suites (above and
below). What remains is the owner's, in this order. Each step names the record that proves it done,
so the state can be read rather than remembered.
| # | Step | Done when this reads so on the destination (D1) |
|---|---|---|
| 1 | Create the dedicated journey coupon (D3): tier: personal, tierDays: 90, validFrom, validUntil, maxRedemptions, no price. The code, window and cap are the owner's. |
Admin/Coupons/{CODE} exists. |
| 2 | Create the space CouponJourney and the node CouponJourney/Publishers of node type Essentials/CouponJourneyPublishers β its page is the Publish invitation form (below). The creator becomes the space's Admin. |
CouponJourney/Publishers exists and shows the form. |
| 3 | File a group.scoped-create proposal: scope: CouponJourney/Publishers, role: Editor, the group's name. The intake ends at Filed and names the activity in its activityPath; sign THAT activity. The proposer and the signer both administer CouponJourney. The executor mints a fresh group id (the requested name plus 16 random hex digits), so take the group's path from the activity's execution result (scoped group ScopedGroups/<name>-<hex> holds Editor on β¦), never the bare name. Add only the tester as a GroupMembership child of that path. |
The activity at the intake's activityPath reads Done, and the group at the path its result names has exactly one member: the tester. |
| 4 | Sign the D12 grant: Public β Viewer on a, never Anonymous. It is already filed as Governance/Activities/coupon-journeys-a-public-viewer. |
That activity reads Done. |
| 5 | Enable the rule (D15) while the group still holds only the tester. | Store/CouponJourney/ReviewRule reads enabled: true. |
| 6 | The tester runs one end-to-end run on the destination (#2065) against a separate test coupon (created like step 1, with maxRedemptions of 1 or 2 and a validity window of a day or less): publish one journey for it through the Publish invitation form and claim it as a second account. |
The run's evidence on #2065; the test coupon has expired or is used up. |
| 7 | Real-device evidence: iPhone Safari and Android Chrome, in the browser and installed as the phone card. | The evidence table on #2065. |
| 8 | Add the real publishers as GroupMembership children of the step-3 group path (ScopedGroups/<name>-<hex>). |
The group lists them. |
π¨ Why the enable comes before the run. A disabled rule refuses Publish and stops the review
page (acceptance scenario 7), so no journey can exist on the destination before the enable, and the
run has to happen with the rule enabled. What the publish scope isolates is the PUBLISHER: only
holders of Update on CouponJourney/Publishers can publish β the step-3 group's members and the
administrators of the CouponJourney space (step 2's creator and the step-3 signer), who hold Update
there by their role. While the group holds only the tester, nobody outside those can publish a
journey. It does NOT hide the tester's journey: D12 is a listing grant (Public β Viewer
on a), so after step 4 any signed-in user can list the links under a and follow the test link
without being handed it, and claim the coupon within its cap and window. Steps 4 to 6 therefore expose
the test coupon to every signed-in user. The owner accepted that exposure for the run (decision
recorded on #2066) and bounds it on the coupon instead: the test coupon carries a redemption cap of 1
or 2 and a validity window of a day or less, so at most that many people can claim it, and only while
it runs. This page claims no isolation beyond the publisher's and that bound. The real publishers are
added (step 8) only after the run and the device checks passed.
Publishing is a form: Publish invitation. CouponJourney/Publishers (node type
Essentials/CouponJourneyPublishers) shows a framework Edit form over CouponInvitationInput β the
coupon code (D3), the course the recipient lands on (D2, AgenticEngineering by default; empty for
none), the card's title and text, and the last day the link serves (D4: the coupon's own end; the link
stops at the end of that day, UTC) β and a Publish button. The button runs
CouponInvitation.Publish, which declares exactly the journey of the launch terms
(Actions.Link(β¦).Do(Coupons.Journey(code).Benefit(b => b.UseCouponTierAndExactExpiry()).OpenCourse(course).AfterClaim(a => a.OpenSelectedCourse()).BuildAction()).ForSite(ActionLinkPublisher.ServingSite(hub)).WithCard(β¦).ValidUntil(β¦)))
and publishes it through the one publish path, Publish(hub) (see
Action links), as the signed-in person who clicked β captured at the click
and re-established for the publish. Nothing in the form authorizes: the publish path checks the rule's
Update on CouponJourney/Publishers (the group's Editor), writes the link as the platform, and
refuses everyone else by name, and the form shows that refusal. On success it shows the link and its
π± phone card (the QR code). The request id is derived from the inputs, so publishing the same
invitation twice returns the first link, and changing any value publishes a new one. The suite case
ThePublishInvitationForm_PublishesForAGroupMember_AndRefusesANonMember pins both answers.
The form keeps what the publisher typed: the input model is seeded once, only when absent, and the
editor binds to it by type, while the click's progress and outcome render in their own view β so a
"Publishingβ¦" or a refusal never puts a blank model over the fields, and the click reads the input
before it writes any status. A course the journey rules refuse (a pasted web address) is the form's
refusal naming it, never a fault. A fault after the click says the publication could not be
confirmed, not that nothing was published β the definition may already exist β and asks to publish
the same invitation again unchanged, which returns the existing link (same request id) or publishes
it. The node type's Tests area runs the drafting cases and two live cases against the rendered page
(an outcome redraw keeps the typed input; the real Publish click submits it), and
CouponInvitationFormLiveTest runs the same live cases on a disposable mesh.
Phase 2 (the renewal, the deferred subscription, the required payment setup and the wallet payments) continues in MeshWeaver.Plugins#4844, which carries the terms still undecided. #2063 and #2064 closed as phase 1 delivered; #2065 and #2066 closed with the steps above recorded, and reopen if the launch reveals a defect.
Acceptance suite (phase 1)
The free claim, sender β recipient, is pinned end to end by CouponJourneyPhaseOneAcceptanceTest
(src/Memex.Portal.Gui.Test, MeshWeaver.Plugins#2065) on a DISPOSABLE mesh β the only kind on which
the publisher group may be granted and this rule enabled. It compiles the Essentials action-link
sources and the Store sources of Store/CouponClaim unchanged, writes the SHIPPED ReviewRule file
through the mesh, builds the publisher group in the exact shape group.scoped-create writes, grants
D12 on a, and runs the portal's own retention sweep. Every refusal has a negative control.
The course a journey lands on carries the access model Store/Plugin declares: the fixture registers
the real AddPluginGating hook on the plugin type (the Store/Publishing sources are already compiled
into the suite, so this pulls nothing new in), and settles the gating with the explicit
PluginGate.SeedGating pass before anything reads through it β the same idempotent reconcile the
hook's watcher runs, never a race with that watcher's delayed start (the pattern of
Store/Licensing/Test/EntitlementPrivacyTests). Course access is then asserted AS THE CLAIMANT, by a
direct read of the course's own Exercises through the exact-node stream: denied before the claim,
readable after it; a signed-in recipient who never claimed stays denied, while the cover stays
readable to both (the positive control that the gating ran). MeshWeaver.Plugins#4521.
| # | Scenario | Case |
|---|---|---|
| 1, 6 | A member of the scoped publisher group publishes a journey for a dedicated coupon; a signed-in non-member is refused by the same entry point | APublisherInTheScopedGroup_Publishes_AndANonPublisherIsRefused |
| 1, 6 | The same, through the Publish invitation form's action: the member publishes (and a repeat returns the same link), the non-member is refused naming the publish scope, an incomplete form is refused | ThePublishInvitationForm_PublishesForAGroupMember_AndRefusesANonMember |
| 2, 3 | An anonymous visitor opens the link (nothing readable, a continuation, "sign in"), signs in, sees the journey, claims, and the course is installed through the plan and readable to the claimant under the production gating (denied before the claim; an unclaimed signed-in recipient stays denied); a second claim β same link or a second link to the offer β returns the same claim | AnAnonymousRecipient_SignsIn_Claims_AndGetsTheCourse_AndASecondClaimDoesNothing |
| 4 | The claim after the cap is refused "the coupon has no redemptions left" | TheClaimAfterTheCap_IsRefused |
| 5 | A claim before or after the coupon's window is refused; moving the window makes the same claim succeed | AClaimOutsideTheWindow_IsRefused |
| 7 | While ReviewRule is disabled nothing publishes and a published journey is not followed; enabling it is the control |
WhileTheReviewRuleIsDisabled_NothingPublishesAndNothingIsFollowed |
| 8 | The retention sweep keeps a free claim inside its period, removes it after, retires its slot anonymized, and never hands the unit out again | TheRetentionSweep_RemovesAnExpiredClaim_AndNeverReissuesItsUnit |
What the suite found when it was written (each case asserts the intended behaviour, so it stays red until the defect is fixed):
- Fixed: the review could not read a published link.
CouponClaimStoreread the link's page and definition withContentAs<CouponJourneyLinkPage>/ContentAs<CouponJourneyLinkFacts>, a field-name contract with Essentials' records. The owner's node stream hands that content over TYPED asActionLinkPresentation/ActionLinkDefinition, andContentAsreturns null for a differently named type by design, so every journey reviewed as Unavailable.Saveand the claim control plane's re-check go through the same decision, so they refused as well (scenarios 2β5 and 8). Store cannot read with Essentials' own types: Essentials requires Store, so compiling its records into a Store type would make the two packages a cycle. The reader (CouponClaimStore.LinkRecord) therefore takes the content as JSON first (NodeElement.Of, which round-trips a typed record without adopting its type into the reader's registry). It then reads that JSON with the canonicalAs<T>accessor. See "The link facts it reads" below. enabled: falsereads back astrue(scenario 7): core'sActionHandlerRule.Enableddefaults totrueand is not markedJsonIgnoreCondition.Never, so the hub'sWhenWritingDefaultdrops the explicitfalseon a typed write and the reader re-applies the initializer. Measured on the disposable mesh (the shipped file deserialized with the mesh's options and created); whether a portal's package import stores the rule through the same typed write was not established there.
D15's "one isolated end-to-end run on the destination" is still owed: this suite is the disposable-mesh half.
Acceptance suite (payment setup, Stripe test mode)
Phase 2 turns on .RequirePaymentSetup(β¦). CouponJourneyPaymentSetupAcceptanceTest
(src/Memex.Portal.Gui.Test, MeshWeaver.Plugins#2065 and #2064) pins that journey end to end on a
disposable mesh, with Stripe faked at the HTTP boundary and nothing else. The real Stripe provider is
mounted through the module's own registration (AddStripePaymentServices). Only its named HttpClient
(store-stripe) reaches a fake that answers in Stripe's wire shapes, and every key is a test-mode
placeholder. The webhook goes through the portal's own inbox (WebhookInbox.Deliver, which is what
POST /api/hooks/Store/Payments runs) and the Store's PaymentInboxWatcher. The provider verifies the
Stripe-Signature against the arrival instant (#3544).
| Step | What is asserted |
|---|---|
| Publish | A setup journey is published only where ReviewRule declares PaymentMethodSetup. The rule ships without it (D6). Negative control: the owner declares it, and the same journey publishes. |
| Review and consent | Nothing is due, and the consent text is shown. A Confirm without the method-storage consent is refused and writes nothing. |
| Setup | The claim's control plane opens one client-rendered SetupIntent (usage=on_session, card methods only, under an idempotency key, stamped with buyer, claim and site), then waits. |
| The form | The review page's embedded form (PaymentFormOf) is shown to the claimant only, and the provider reveals the client secret to the claimant only. The secret is never on the claim. |
| Delivery | A delivery with a forged signature is retained at Store/Payments/_Rejected and wakes nothing, so the claim still waits. The authentic setup_intent.succeeded wakes the claim. The claim then reads the intent back and moves on only on that reading: SetupVerified with the saved method (Google Pay), the bounded plan, and the course installed through the plan. |
| Replay | A duplicate delivery changes nothing. |
| No charge | The fake recorded every request the site sent: a customer, one SetupIntent, and reads of it. There was no PaymentIntent, charge, invoice, subscription or Checkout Session, and nothing off-session. |
Turning phase 2 on therefore needs, besides the owner's Stripe acts on MeshWeaver.Plugins#2064 (the
test-mode keys and webhook secret through the write-only secrets screen, the publishable key, wallet
domain registration, and Apple Pay and Google Pay enabled on the account), one edit to the rule:
capabilities: ["PaymentMethodSetup"] on Store/CouponJourney/ReviewRule. That edit is the site's
declaration that it can save a method (policy declared-not-derived).
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 page follows the viewer's own claim through the claim's node stream
(CouponClaimStore.FollowClaim, read as the platform, which owns the claims partition, at the one
path derived from the viewer and the offer). Existence is observed live through the query transport,
so a first visit that saves its claim sees the new claim at once. The claim's node stream is opened
only once it exists, so an absent path is never point-read. "No claim" is read only from the
existence query's own ANSWER (MeshQueries.ObserveNodeAnswered), never from a seed placed before
it: "no claim" is what offers the consent and Confirm, so until the query answers the page stays
Loading. A returning claimant therefore never sees the decision to claim an offer they already
hold, or a refusal of it, while the query is slow. The page therefore moves to the
claim's next state as its control plane advances it, without a reload. While the claim waits for its
payment method (SetupPending, with an open client-rendered setup and no verified method), the page
shows the embedded payment-method form (PaymentFormOf β core PaymentSetupFormControl), and
only for the claim's own buyer. The control names only the setup. The form's view obtains the one-time
secret in the buyer's own session (MeshWeaver.Plugins PaymentSetupFormView), and the provider
answers only that buyer. Once the method is verified the form is gone, because the claim has moved on.
A claim waiting on a hosted setup gets a link to the provider's own https page instead
(HostedSetupLinkOf, opens in a new tab), under the same conditions.
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 }:
methodStorageConsentβ present exactly when the journey requires a payment setup, over the journey's own consent text.recurringConsentβ only for a selected renewal whose terms were quoted. This build quotes no renewal terms, so a selected renewal or a recurring consent is refused; the two are never merged into one record.
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:
- A slot is taken before the claim is created. A run that loses the claim create gives its slot back; a process that dies between the two leaves a slot held by no claim. Both failures can only issue FEWER redemptions, never more.
- A stopped claim (expired, revoked or cancelled) gives its slot back (see below). A claim that became active used its unit, and that unit is never given back.
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.
Prepared β SetupPending β SetupVerified, when the journey requires a payment setup (MeshWeaver.Plugins#2064). Nothing is charged at any point of it.
- Prepared: the recipient's authority and the coupon are checked again, exactly as in step 1
below, before anything is asked of a payment provider on the recipient's behalf. Then
SetupPending. - SetupPending, nothing frozen yet: the attempt's request inputs are frozen on the claim
before anything is created (
setupIntendedUse,setupCurrency,setupReturnUrl,setupHandoff). Every creation retry and every verification composes the request from them alone (CouponClaimSetupRules.RequestOf), so a configuration or coupon change after the freeze never sends different parameters under the same idempotency key. - SetupPending, frozen, nothing opened: the recipient and the offer are re-checked again
before each create, so a resumed or repaired claim whose invitation was revoked or whose
coupon ended stops instead of creating a provider object. The site's
IPaymentMethodSetupProvideris asked what it can save for the journey's use. The claim froze the use when it was saved (setupPurpose);individually-approved-purchasesis customer-present only, and never authorizes an off-session charge. The setup opens on the handoff the provider offers for that use, preferring the site's embedded form, under the claim's own idempotency key (coupon-claim-setup/1, the claim key,setupAttempt). The claim recordssetupId,setupHandoff,setupHostedUrlandsetupCustomerId, and never the client secret. A site with no provider, an unavailable one, or one that offers no handoff for the use needs attention. It never charges and never skips the setup. - SetupPending, opened: on every emission of the claim (an activation, a restart, a recycle,
a repair), the setup is read back from the provider (
RetrieveSetup). The verdict (CouponClaimSetupRules.Decide) is taken over that reading, matched to the claim's own recomposed request with the platform'sSatisfies. A browser return is never the verdict. A method saved for this claim moves it to SetupVerified and recordspaymentMethodId. An unfinished setup waits. A failed, cancelled, expired or mismatched one needs attention: an off-session permission the claim never asked for, or another buyer, counts as a mismatch, never as a bonus. The setup is then forgotten andsetupAttemptcounted, so the repair opens a fresh setup. A claim that failed in its setup may still stop, because nothing was granted. - The step itself is
CouponClaimSetupStep.Run, over the provider it is given; the suite executes it over a contract fake (create once, restart without a duplicate, repair with a fresh key, provider unavailable or failing, re-check before create), and pins the dispatch (StepOf). - SetupVerified continues exactly as
Preparedwithout a setup does (step 1).
What a site still needs before a recipient can complete the setup: the review page draws the embedded form (see What the review page does). The Stripe provider offers the embedded (client-rendered) handoff for a customer-present use only when the site's Stripe publishable key is set (
Commerce:Stripe:PublishableKey). The wallet buttons also need every portal domain that shows them registered as a Stripe payment-method domain, with Apple Pay and Google Pay enabled on the account. Both are owner acts on MeshWeaver.Plugins#2064. Until the publishable key is set, a claim that needs a setup finds no handoff and needs attention; it is never charged and never skips the setup.- An unfinished setup is cancelled when it can no longer be used. This covers a claim that
stops (expired, revoked, cancelled) before any method was verified, and a setup the claim
abandoned (failed or mismatched;
setupToCancel). In both cases the setup is cancelled at the provider throughCancelSetup. That call reads first, writes only for a setup that can still stop, and returns an already-stopped one as it is. The claim then settles the debt (setupCancelled/ a clearedsetupToCancel, plussetupCancelOutcome). A settled cancellation is never sent again. A verified method is never cancelled, because the recipient consented to saving it. A site without a provider keeps the debt visible on the claim and does not drop it (CouponClaimSetupStep.Cancel). - An open setup is re-checked on every read, so an invitation revoked or a coupon ended while a setup is pending stops the claim, and the stop cancels the setup.
- "A create was sent" is persisted before the create (
setupCreateRequested). A create whose answer was lost (a lost response, or a restart before the setup was recorded) is therefore never forgotten. If the claim then stops, it re-sends the frozen request under the same idempotency key, which returns the setup that create made, and cancels it (OwesCreateReconcile). - A verified setup delivery wakes its claim (
CouponClaimWake, shared by the payment inbox and the claim). The inbox writesrequestedSetupCheckAtandrequestedSetupCheckEventonto the claim the delivery names, through the claim's own stream, as System. The write lowers to a merge patch of those two fields and goes to the claim's owning hub. That emission makes the claim's control plane read the setup back from the provider now, not on its next unrelated update. A wake never decides anything; the provider's reading does. Only an authentic delivery naming a claim directly underCouponClaims/wakes, and only a claim whosesetupIdis the setup the delivery reports on, so a late delivery about an abandoned setup changes nothing.
- Prepared: the recipient's authority and the coupon are checked again, exactly as in step 1
below, before anything is asked of a payment provider on the recipient's behalf. Then
Prepared, when the journey needs no payment setup, or SetupVerified. 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, withcoupon-claim:{claimKey}as its request id.
- The recipient's authority is checked again (
GrantPending: the claim's ONE bounded plan is requested on the recipient's subscription record (
CouponGrants.Request). The request writes onlyrequestedCouponGrant: 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
Activewithgrant: 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.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 byCouponBudget.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.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 readSubscriptions.Unlockdecides 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
NeedsAttentionwithfailedIn: Installing. Its repair runs the install again. - A journey that selected no course completes straight away.
- If this site's plan covers the course, the audience verdict (
A first re-check that answers nothing never parks a claim silently. When the recipient-and-offer
re-check completes without a verdict (for example, the handler resolution answered nothing), Prepared
and SetupVerified record a named reason in place (undecided), with no phase change. The claim's
next update (a wake, a recycle, a repair) therefore runs the step again. Writing the same reason again
changes nothing, so an unchanged re-check never loops, and a step that decides clears the reason.
Both steps share one rule: CouponClaimGrantRules.FirstRecheck for BeginSetup and
CouponClaimGrantRules.FirstReview for Fulfil write the same NoVerdict transition, and the
suite executes each one.
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.
The link facts it reads
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".
Both records cross a package boundary. Essentials requires Store, so Store cannot compile them
without a package cycle. They therefore reach a Store hub in whichever shape that hub resolved:
stored JSON, or Essentials' typed record when the type is known there. ContentAs over the typed
record is null by design. So the one reader, CouponClaimStore.LinkRecord, goes through
NodeElement.Of and then As<T>, and the review, Save and the control plane's re-check all use it.
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. A unit whose holder the retention sweep forgot stays on its slot, anonymized in place β the sweep never frees a slot number (see "Retention" below). |
| 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.
Retention
Claims, the slots they used and the links that led to them are removed once their period is over
(the owner's decision D14 on MeshWeaver.Plugins#2066), and so are free redemptions (a later owner
decision on the same issue). Before this, nothing removed them. The portal's JourneyRetentionSweeper
(src/Memex.Portal.Gui/ActionLinks/JourneyRetention.cs) runs the clock on every replica: the first
pass 15 minutes after start, then every ActionLinks:RetentionSweepMinutes (360 in the shipped
appsettings.json; 0 turns it off).
π¨ One replica per pass. A pass sweeps only on the replica that takes the pass lease
(Admin/RetentionLease/journey, node type RetentionLease). Taking it is a fold on that one node,
decided by its owning hub. The lease is held for 90% of the interval and never less than an hour; the
pass is cut at half the hold, so everything it issued has settled before the lease can lapse; and a
finished pass releases the lease (only its own). So two sweeps never overlap.
| Record | Removed | Configuration (shipped value) |
|---|---|---|
A claim that touched no payment (CouponClaims/{claimKey}) |
FreeRetentionMonths after its benefit ended (quote.benefitEndsAt). |
CouponClaims:FreeRetentionMonths (24) |
| A claim that touched a payment method or a payment (either consent, a payment method, a provider setup or customer, a setup create sent) | PaymentRetentionYears after the LAST event the claim records (saved, last moved, benefit end, a consent, a setup check). |
CouponClaims:PaymentRetentionYears (10) |
A budget slot a removed claim used (CouponClaims/Slots/{CODE}/{n}) |
NOT removed: its holder is forgotten in place (below), and it keeps counting its unit. | β |
An action link's definition (ActionLinks/{key}) and page (a/{linkId}) |
RetentionDays after it stopped serving: the earlier of expiresAt and revokedAt. |
ActionLinks:RetentionDays (90) |
A free redemption β the person's ticket (CouponClaims/Holders/{CODE}/{holder key}) is removed; their name on their free-redemption slots and in the coupon's redeemedBy and slottedBy is anonymized in place (below) |
FreeRedemptionRetentionMonths after the COUPON's window ended (validUntil). The window bounds every benefit the coupon granted β the plan and the key are capped by it, and a person who redeems again refreshes their benefit without a new ticket or audit entry, so no per-person date bounds it. Kept: an open-ended coupon (no validUntil), a permanent plan (tierDays: 0), no plan (an eternal entitlement). |
CouponClaims:FreeRedemptionRetentionMonths (24) |
A continuation (ActionLinks/continuation-*) |
Unchanged: its own sweep, 30 minutes after it started. | ActionLinks:ContinuationMinutes, ActionLinks:ContinuationSweepMinutes |
π¨ Declared, never defaulted. A period that is missing from configuration, or out of range, keeps every record of that kind: nothing is removed on a value the code made up.
π¨ The budget stays whole. A claim whose plan became active USED one unit of the coupon's
maxRedemptions, and its slot is what counts that unit. The slot is therefore never removed: on the
slot's own hub its holder is emptied and its claimKey set to retired β it names nobody, keeps
counting, and is never given back, adopted or released (no claim key matches it). Then the claim is
removed. Removing the slot, even after moving its unit into the coupon's count, would let a
reservation that had read the coupon BEFORE the count moved take the unit again.
π¨ A free redemption stays counted β on its own slot. The TICKET is the evidence of a free unit β
only the free redemption mints one, and only for a person the ledger had not counted otherwise. The
sweep moves NO count and frees NO slot number. For each ticket, ONE coupon write replaces every entry of
the holder in redeemedBy and slottedBy with an anonymous entry retired:{slot}@{reservedAt} (one
per ticket, naming nobody, never a person's id); redeemed is left as it is. So the distinct counted
people, the counted people holding a slot and redeemed all read the same afterwards: the capacity and
the redeemed >= maxRedemptions refusals are exactly what they were. Then, on each slot's own hub, every
free-redemption slot of the holder is anonymized in place like a journey slot β holder emptied,
claimKey set to retired β so it names nobody, keeps counting its unit, and is never given back,
adopted or released. Then the ticket is removed (last, so a pass cut short resumes from it). A holder
the ledger never counted (the free redemption tolerates a failed count write, because the slot preserves
the budget) is named nowhere on the coupon, and their slot alone keeps the unit. A retry finds no holder
named and writes nothing. Kept: a person the coupon counted WITHOUT a ticket (a paid checkout counts
buyers the same way, or a redemption before the ledger), and the redemptions audit entries β nothing
records whether an entry was free or paid.
Why in place, never deleted (Plugins#4398): a reservation computes its capacity from a coupon snapshot
it read earlier and probes slot creates, where only an already-exists conflict stops a probe. Deleting a
free-redemption slot after moving its unit into redeemed (the first version of this sweep) let a
reservation that had read the coupon before that write take the deleted number again β one unit over
maxRedemptions once an administrator extends validUntil. An anonymized slot keeps its number taken.
Pinned by AReservationHoldingASnapshotFromBeforeTheSweepCannotTakeASweptSlot.
Kept, whatever the period:
- a claim that changed after it was selected: it is read again and re-decided, and deleted only at the version that re-read saw (a wake that stamps a newer event in between keeps it);
- everything, when the
CouponClaimspartition root is not the Space the platform created β and any claim, slot or ticket the platform did not create itself (itscreatedBy), so a record someone else wrote never drives a change to a coupon or a slot; - a claim that is still in progress or needs an operator (only
Active,Complete,Expired,RevokedandCancelledare removed); - a claim that still owes its payment provider a cleanup β the states the claim's control plane acts
on:
setupToCancel, a stopped claim'ssetupIdwith no verified method and notsetupCancelled, or a stopped claim that sent a setup create (setupCreateRequested) whose answer it never recorded; - a claim that stopped before its plan was active and has not given its slot back yet β its slot counts nothing, and its own control plane gives it back;
- a record written under a contract version this build does not read β for a link, the definition's
OR its page's
contractVersion; - a record with a date the decision reads that is present but cannot be read (it might be the latest
event), and a claim without a readable
createdAt(it keys the slot's retirement).
The sweep reads every record BY PROPERTY NAME: a field-name contract with CouponClaim,
CouponClaimSlot, CouponContent, ActionLinkDefinition and ActionLinkPresentation, which the
portal never compiles. Renaming one of the fields it reads (phase, quote.benefitEndsAt, slot,
slotReleased, claimKey, recipient, code, createdAt, the payment fields, redeemed,
redeemedBy, slottedBy, redemptions[].viewer/at, holder, reservedAt, tier, tierDays,
validUntil, grantsAll, scope, keyDays, linkId, expiresAt, revokedAt, definition,
contractVersion) breaks it silently: it then keeps the record. Pinned by JourneyRetentionTest (src/Memex.Portal.Gui.Test).
Not covered yet. What a redemption granted OUTSIDE these records β the plan's subscription record, an eternal entitlement marker, an issued key β is not touched here. "The last transaction" of a claim that touched a payment is the last event the CLAIM records β payments recorded elsewhere (orders, subscriptions) are not read. No renewal exists at launch (D7), so no later charge can be missed today; the renewal must stamp its claim before phase 2 is enabled.