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:
- 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(elsePublicBaseUrl) and the optionalActionLinks: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
Validatemust authorize the publisher. For a rule-bound handler that means the rule'sPublishPermissionon its handler node. - both answers are required. When the chain or
Validatecompletes without one,Publishfails with anActionLinkRefusedExceptionthat says so (OrRefuse). It never completes with neither a link nor an error.
- As the platform. The
ActionLinksandapartition 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 atActionLinks/{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. - 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.
Following a link
Not signed in. The
apartition grants nothing toAnonymous, so the portal's anonymous navigation gate sends the visitor to sign in withreturnUrl=/a/{linkId}. That return address is no longer what brings them back: a server-side continuation does (next section), so a sign-up, an e-mail verification or an onboarding that drops the return address still ends on the link.The link page (
a/{linkId},ActionLinkRecipient.For) is one template, bound live. It checks the following, in order:- The link serves. A revoked link wins over every other state, then an expired one, then one whose start is still ahead. Every unavailable state renders the same line, so the page never says which one applies. A one-shot timer closes the page at the expiry.
- The visitor is admitted, in the core contract's order: not signed in first, so an anonymous visitor learns nothing; then a link bound to another site.
- The handler's read-only
Preparenames the page Continue opens. Visiting, refreshing, previewing or returning from sign-in changes nothing.
The private definition is read as the platform. The visitor was admitted by reading the page and is never granted the definition.
The open-page handler page (
/Essentials/ActionLinkHandlers/OpenPage?link={linkId}) trusts nothing it was handed. It checks, as the viewer, that they may read the link's page. Then it re-decides everything as the platform: the link serves, it is bound to this site, the viewer is signed in, and the action isessentials.open-pageto a local page. Only then does it redirect. Like the link page it is a template emitted at once; its redirect is bound and navigates the moment the projection names the page.A fault is not a verdict. When a page cannot read the link right now, it shows a load-error line that asks the visitor to try again. It never shows the unavailable line for a fault, so an outage does not read as a revoked or expired link. The same holds for the open-page handler's read check: it is tri-state (
CheckPermissionOutcome), and an undetermined answer — a permission fold that faulted or produced nothing — gets the load-error line, never the unavailable one.
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:
- iPhone or iPad: Share → Add to Home Screen, with the card's title as the suggested name. Safari has no programmatic prompt, and none is promised.
- Android: the browser's own install prompt, raised by the person's click and confirmed in the browser. Where the browser offered no prompt, the browser-menu steps instead.
- In-app browser (a messenger's or a social app's): open the page in Safari or Chrome first. The address is shown to copy.
- Opened from the home screen: Installed, and nothing else is pitched.
- Desktop: the address to open on the phone.
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/):
idandstart_urlare the card itself (/a/{linkId}/Card), andscopeis/a/{linkId}/. Each card is therefore its own home-screen entry, and installing one never replaces another card's entry or the portal's (the portal declares no manifest of its own).- The icons are 192 and 512 px PNG,
anyandmaskable, rendered by the platform'sIconRasterizerfrom one drawing: the action-link mark on an edge-to-edge indigo ground. - The manifest is derived from the link id's SHAPE alone. It names no title, publisher or offer, so it is served anonymously, which is how a browser fetches a manifest, and a malformed id is 404.
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.
- A visitor who is not signed in opens
/a/{linkId}. If the link EXISTS (a System listing ofa/{linkId}; a guessed id writes nothing), the portal records a continuation as the platform atActionLinks/continuation-{fingerprint}— in the private partition nobody is granted anything on — and sets the handle in an HttpOnly,SameSite=Laxcookie (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. - 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.
- 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:
- alice holds Update on the handler page, through an ordinary
Editorrole assignment scoped to that page and nothing else.Publish, run as her, passes the site check, the live handler chain and the rule's permission seam, and the platform writes both records. A retried request returns the same link. A signed-in recipient is continued to the page the link opens. Only she can revoke it, and after she does, the handler refuses the link. - bob holds nothing on the handler page. The same
Publishrefuses him (you may not distribute 'essentials.open-page') and writes nothing, and his attempt to revoke alice's link is refused.
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
- Provision signed-in read on
a. Propose a governedaccess.grant-broadforPublic(every signed-in user, neverAnonymous) with theViewerrole on theapartition. Until that grant exists, a link page is readable by nobody but the platform, so recipients cannot open it. - Name the publishers. Grant Update on the handler page of each action that may be published
(for open-page:
Essentials/ActionLinkHandlers/OpenPage). - Configure the site.
Portal:BaseUrlmust be the portal's canonicalhttpsorigin.
Not built yet (remaining slices of #2060)
- Cross-site publication and handoff between registered sites.
- Redaction of the two places above that the route cannot reach: the hosting log scope's
RequestPathand the ingress access log. - Throttling of the in-circuit navigation and the API reads of
a/{linkId}(the throttle covers the HTTP route; a signed-in client reading the node over the API or a Blazor navigation is bounded by the 122-bit id space, not by a limiter). - Scanning the card and installing it need verification on real devices: iPhone Safari and Android Chrome, in the browser and installed (MeshWeaver.Plugins#2062).