Action links

Design of record: QR Coupon Journeys (MeshWeaver.Plugins#2066). This page describes what is built (stage 2a, MeshWeaver.Plugins#2060), and what is not built yet. The platform contract it consumes is core's Action-Link Contracts.

An action link is an opaque URL, https://{site}/a/{linkId}, that a person can scan or open. After they sign in or sign up, it resumes an action that a handler on this portal offers. The link is an address, never a grant. Holding it entitles nobody to anything. The handler decides what the signed-in recipient may do, afresh, every time.

Four records, four node types

Record Path Node type Who reads it
Definition — site, action (handler key, version, payload), card, shared prefill, validity, publisher, revocation ActionLinks/{publicationKey} Essentials/ActionLinkDefinition the platform only
Presentation — the card, the site's origin, the validity window and the revocation stamp. Nothing else. a/{linkId}, which is the URL Essentials/ActionLink every signed-in user, once the a partition is provisioned (below)
Handler page — where a rule sends the recipient Essentials/ActionLinkHandlers/OpenPage Essentials/ActionLinkHandler every signed-in user (Essentials is pre-installed)
Continuation — the server-side handle that carries a browser back to the link through sign-in (below); stores the handle's fingerprint, never the handle ActionLinks/continuation-{fingerprint} ActionContinuation the platform only

The presentation never carries the publisher, the action, its payload or the prefill (ActionLinkRules.Present, pinned by The presentation is sanitized).

Declaring and publishing

Actions.Link("launch-2026")                    // the publisher's own request id
    .ForSite(site)                             // ActionLinkPublisher.ServingSite(hub): this portal's site
    .Do(Actions.OpenPage("/Doc/Welcome"))      // or a domain action: Coupons.Journey(…).BuildAction()
    .Prefill(p => p.Language("en").Campaign("launch"))
    .WithCard(c => c.Title("Welcome").Description("Start here."))
    .ValidUntil(expiresAt)                     // required, at most 366 days
    .Publish(hub, activityPath)                // cold; emits one ActionLinkPublication

Constructing the declaration executes nothing. The action is data: a handler key, a version and an opaque payload, never a delegate, a script or a method name. ActionLinkDraft.Refusal rejects an incomplete or contradictory declaration before anything is written. That covers a missing request id, a site that is not canonical, a missing action, title or expiry, an expiry in the past or more than 366 days away, a prefill key outside the allowlist, and an open-page target that is not a page on this site.

Publish follows the control-plane rule authorize as the caller, execute as the system:

  1. As the publisher. The signed-in person the request runs under is read once, at the call. Then:
    • the declaration must be complete;
    • the link must be bound to the site this portal serves. The site comes from configuration: Portal:BaseUrl (else PublicBaseUrl) and the optional ActionLinks:SiteId. It never comes from a request header. Cross-site publication is not built.
    • the handler chain must resolve the action;
    • the handler's own Validate must authorize the publisher. For a rule-bound handler that means the rule's PublishPermission on its handler node.
    • both answers are required. When the chain or Validate completes without one, Publish fails with an ActionLinkRefusedException that says so (OrRefuse). It never completes with neither a link nor an error.
  2. As the platform. The ActionLinks and a partition roots are created when they are absent. An existing root is adopted only when the platform created it and it is a Space. Then the definition is created at ActionLinks/{publicationKey}. Its id is the publication's idempotency key (site, publisher, request id), so the create is the atomic arbiter: a retried request finds the link the first one created and returns it (Existing: true). It never mints a second link, and no query decides whether to create. The stored link is returned only when it is this publisher's, for this site and request, and under a contract version this portal understands (1..CurrentContractVersion). A link written under a newer contract is refused, not adopted. After that the presentation is written.
  3. As the publisher again. An Open link action (ActivityActionReference) is recorded on the publishing activity, so its history keeps offering the link after the run (see the Activity page).

ActionLinkPublisher.Revoke(hub, linkId) lets only the publisher revoke a link. It stamps both records. The link page then closes, including a page that is already open, and the handler page refuses the link. Revoking twice converges.

The phone card

/a/{linkId}/Card is the sender's phone card (MeshWeaver.Plugins#2062): a layout area of the link's own page node (ActionLinkPhoneCard), so it needs no partition, grant or route of its own. Whoever may read the link page may open its card, and an anonymous visitor is sent to sign in by the same gate. The link page offers it as 📱 Phone card next to Continue.

Shows Where it comes from
Title and offer text the card the publisher declared (WithCard)
A QR code QrCodes.Encode on the SERVER, of exactly {origin}/a/{linkId}; rendered by the platform's QrCodeControl (black on white, four-module quiet zone, 240 px, nothing drawn over it)
The same address as text so a person without a camera can use it
On — the verified host the site THIS portal serves (Portal:BaseUrl), which the link must be bound to
Valid until the link's expiry, in the viewer's zone
Copy link and Share the platform's ShareLinkControl; Share appears only where the browser has a share sheet
Install on iPhone / Android the platform's InstallAppControl, over the card's own manifest (below)

🚨 The QR carries only the public address the link already is. It contains no publisher, no prefill, no payload, no coupon administration, no credential and no single-use token. A revoked, expired, not-yet-open, unreadable or foreign link, or an anonymous viewer, gets ONE line and no code and no address, so nothing on the page can be scanned, copied or shared. Like the link page, the card is live over the link node (a revocation closes an open card) and over its validity (one-shot timers). It is live over the PRIVATE definition too, read as the platform (ActionLinkRecipient.ObserveAsPlatform): a revocation whose second write (the public presentation) failed, or a definition deleted under the card, still clears the code, the address, the card address and the manifest of an OPEN card, and only the definition's verdict reaches the viewer. Pinned by the live case An open phone card closes when only its definition is revoked, which runs in the Tests area and in Memex.Portal.Gui.Test (checked against the one-shot read it replaced: the open card then kept its code), and by its twin An open phone card closes when only its definition is deleted, which also requires the closed card to show the unavailable line, not the load-error one: a deleted definition is a verdict, not a fault (checked against a reader that treats the delete as a fault: the card then showed the load-error line).

Installing the card on a home screen

Install on iPhone / Android adds nothing by itself. The person opens it, and it then shows the step that fits the browser:

The person can switch iPhone / Android by hand, because a user agent can be wrong. No permission is requested: no notifications, camera, contacts or location.

While the card is shown, the page's manifest is the card's own, /a/{linkId}/manifest.webmanifest. The portal serves it (ActionLinkCardManifest, src/Memex.Portal.Gui/ActionLinks/):

When the card closes (revoked or expired), the manifest is cleared with the code, so nothing is left to install. The previous manifest returns when the card goes away.

Offline. No service worker is registered. Nothing the card shows (offer, claim or payment data) is cached, so an installed card opened offline gets the browser's own offline page and never a QR that claims a closed offer is active.

Pinned by The phone card encodes only the public address, A phone card that does not serve has no code and no address, and The link page offers the phone card with Continue.

The portal's half of `/a/

The link page is in-mesh content; the HTTP request for it is the portal's. These are wired in the portal host (src/Memex.Portal.Gui/ActionLinks/, MeshWeaver.Plugins#2060 slices 2 and 3):

What How Where
Headers. Every /a/{linkId} response — the page, the sign-in redirect, the onboarding redirect, a 429 — carries X-Robots-Tag: noindex, nofollow, noarchive, nosnippet, Referrer-Policy: no-referrer and Cache-Control: no-store. Set on response start, before the identity middlewares run. ActionLinkRoute.ApplyHeaders
Throttling. Lookups of /a/{linkId} are limited per client (default 30 per minute; ActionLinks:Throttle:PermitLimit, ActionLinks:Throttle:WindowSeconds). An excess request gets 429 with Retry-After and one sentence in the visitor's language. ASP.NET Core's own rate limiter — a PartitionedRateLimiter as the global limiter, a fixed window per client address, the no-op partition for every other route. No hand-rolled counter, no static state: the limiter belongs to the host's container. It runs before identity, so a flood never mints a guest identity. ActionLinkThrottle
Redacted telemetry. A request's trace names the route's shape, /a/{linkId}, never the link: the path tags the ASP.NET Core instrumentation set when the request started (url.path, url.full, http.target, http.url) and a display name naming the path are rewritten; a tag that was never set is not added. Log lines name a link by a one-way reference, a/~ and 8 hex digits of a SHA-256 over the id — the same reference on the route and in the link page's own lines, so one link's lines still correlate. A fault is logged as redacted TEXT — every exception in the chain as type and message, then the stack trace, each link id replaced by its reference — and never as the exception object, whose message or inner exception may name a/{linkId}. In the guards, the first middleware that runs after the trace started. ActionLinkRoute.RedactActivity, ActionLinkRoute.LogRef / RedactFault, Essentials ActionLinkRules.LogRef / RedactFault
Continuation. The server-side record that carries a browser back to the link through sign-in. Below. ActionContinuation, ActionLinkRouteMiddleware
Sweep. Continuation records whose hand-off window is over are removed. Below. ActionContinuationSweeper
Frontend selection. /a/ is never redirected to the React app, whatever the viewer's frontend is. That redirect goes to the bare app URL and would drop the link. /a/ is one of the excluded prefixes, beside the sign-in routes. FrontendSelection (MeshWeaver.Blazor.Portal)

The continuation

The design's ActionContinuation: an expiring opaque handle bound to the site and the authentication transaction, then to the recipient.

  1. A visitor who is not signed in opens /a/{linkId}. If the link EXISTS (a System listing of a/{linkId}; a guessed id writes nothing), the portal records a continuation as the platform at ActionLinks/continuation-{fingerprint} — in the private partition nobody is granted anything on — and sets the handle in an HttpOnly, SameSite=Lax cookie (mw-action-continuation). The record stores the SHA-256 fingerprint of the handle, never the handle. A refresh of the same link records nothing new. The page then sends the visitor to sign in, as before.
  2. They sign in, sign up, verify their e-mail and onboard — in any order and over any number of pages. The continuation waits: the route resumes only a signed-in, onboarded person (it runs after the onboarding middleware) on a top-level page navigation, and never on sign-in, onboarding, authentication callbacks, the framework's transport, assets or APIs.
  3. The first page they land on spends the continuation and redirects them to /a/{linkId}. Arriving on the link itself through the return address spends it the same way, without a redirect. Only the link the continuation names spends it: a signed-in person who opens a different link keeps theirs, so it is not lost. The redirect is rebuilt from the link id, never read from a stored field, so a resume can only land on a link page of this site.

An installed phone card is carried the same way (MeshWeaver.Plugins#2062). A card on a home screen launches at /a/{linkId}/Card, and on some platforms (iOS above all) a home-screen web app has a cookie jar of its own, so it starts signed out even when the browser is signed in. The route treats the card as a page of its link (ActionLinkRoute.LinkOf). A signed-out launch records a continuation whose target is the card, and after sign-in the person is resumed onto the CARD, not the link page. The cookie names the page too ({linkId}.{handle}.Card), so a browser that started on the link page and then opens the card replaces its continuation with one for the card, while a refresh of the same page records nothing new. Only the allow-listed card target is honoured (ActionLinkRoute.ReturnPathOf): anything else stored there resumes onto the link page. The card's responses carry the link headers and count against the link throttle. Signing out clears the cookie jar's private state and leaves the home-screen icon alone. Pinned by ActionLinkRouteTest.TheCardIsAPageOfItsLink… and ActionContinuationTest.ACardLaunch_ResumesOntoTheCard.

Spending is atomic and binds one person. It is a fold on the record through GetMeshNodeStream(path).Update(…), decided by the owning hub — the SetupLinkClaim pattern: two racers with the same base, the second's stamp is refused as changed-since-base, and its fold re-runs on the winner's state. The first signed-in person binds it. A changed account — someone else signed in to the same browser — reads OtherAccount and is sent nowhere: nothing the first person started is attached to the second. Expiry (30 minutes by default, ActionLinks:ContinuationMinutes) bounds the hand-off, not the link: the person opens the link again, which is safe because the link page re-decides everything. Every outcome clears the cookie.

The sweep. Every 15 minutes (ActionLinks:ContinuationSweepMinutes, 1–1440; 0 turns it off) each replica walks the continuation records as the platform, in a stable path order (sort:path + path:>"{cursor}", page by page), and removes those past their expiry, at most 200 per pass. Eligibility is decided over the whole set, so a page of live records never hides an expired one behind it. A spent record is kept until its expiry too: the binding it carries is what tells a second account presenting the same cookie that it was spent for someone else. A record written under a newer contract is never removed — a later build owns it. Removal is idempotent: a record another replica removed first counts as removed, so no replica is elected. A failed sweep is logged and the next one runs on schedule.

Pinned by ActionLinkRouteTest (the step table, the exact link shape, the headers, the cookie, the throttle's partitions, the 429 texts), ActionLinkRouteGuardsTest (the guards through a real HTTP pipeline: an exhausted client gets 429 with Retry-After and the link headers, and never reaches what is registered after the guards) and ActionContinuationTest (a real mesh: a guessed link records nothing; the first person resumes; a second account gets nothing; two accounts racing bind exactly one; an expired or another site's hand-off resumes nothing; the sweep removes finished hand-offs, spent or not, and keeps a live one that still resumes). The trace redaction is pinned through a real HTTP pipeline by ActionLinkRouteGuardsTest (an ordinary page's trace keeps its path, as the negative control).

What the redaction does not reach. Two places outside this route still see the path. The ASP.NET Core hosting layer opens a log scope carrying RequestPath before any middleware runs; a log line written during an /a/ request carries it wherever a host exports scopes (core's service defaults turn scope export on for the OpenTelemetry log exporter), and nothing in the request pipeline can change it. The ingress controller's access log records every request line. Both are the platform's and the cluster's configuration, not this route's.

The live publication test

ActionLinkLiveTests runs in Essentials/ActionLink's Tests area, on the mesh the suite runs in: it publishes a link, resolves it (the page serves, admission refuses an anonymous visitor and another site), continues it (the open-page handler sends a signed-in recipient to the page and nowhere else), revokes it (a stranger may not; the publisher may; the page and the handler then refuse it), and removes both records on every path. A removal that fails FAILS the case, so a green run never leaves a live synthetic link behind; when the case had already failed, both failures are reported. "Every path" includes the one with no notification: when the runner's budget lapses and it disposes the case, the removal starts detached under its own budget (CleanupBudget), and a failure is logged on the case's row (ActionLinkLiveTests.WithCleanup). 🚨 It does not grant anyone the right to publish: it proves the live handler chain's verdict on its synthetic publisher is the mesh's own permission verdict (CheckPermissionOutcome for Update on the handler page), then drives the platform half of publication the way Publish does after an authorization succeeds. The synthetic publisher is a signed-in person, so it holds whatever Public holds: where the mesh grants that nothing the chain must REFUSE it (fail closed); on a plugin gate, which grants Public root Admin, the chain must authorize it, and the case logs that the refusal half was not measurable there. The authorization of a publisher who does hold the right is therefore not exercised there — the next section's suite does that.

A publisher who holds the right, on a disposable mesh

ActionLinkPublisherWithTheRightTest (Memex.Portal.Gui.Test) runs the real Publish and Revoke end to end on a throwaway monolith mesh — the only kind of mesh on which granting the publish right is acceptable. The Essentials sources are compiled in unchanged, and the open-page rule is bound from its shipped file, so the right under test is the one the package declares (publishPermission: Update on Essentials/ActionLinkHandlers/OpenPage). The mesh declares Essentials as a rule namespace and configures Portal:BaseUrl. Two signed-in people:

The case was checked against a weakened fixture: with alice given Viewer instead of Editor, her Publish is refused with the publisher lacks Update on Essentials/ActionLinkHandlers/OpenPage. Not covered: recording the Open link action on a publishing activity, which needs an activity record the publisher may update.

The handler rule Essentials declares

Essentials/ActionLinkHandlers/OpenPageRule is an ActionHandlerRule in Essentials' own namespace. That namespace is declared because installing the package declares it. The rule claims essentials.open-page v1 for the OpenPage handler page, with publishPermission: "Update". Publishing an open-page link therefore needs Update on Essentials/ActionLinkHandlers/OpenPage, which is granted to nobody by default. A deployment that wants publishers grants that right to a group: who may distribute links is the owner's decision. A domain package declares its own rule and PublishPermission beside its own handler page, as the Store's coupon journey will.

Owner actions needed before anyone can use it

Not built yet (remaining slices of #2060)