Frank.Alps 7.3.2

There is a newer version of this package available.
See the version list below for details.
dotnet add package Frank.Alps --version 7.3.2
                    
NuGet\Install-Package Frank.Alps -Version 7.3.2
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Frank.Alps" Version="7.3.2" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Frank.Alps" Version="7.3.2" />
                    
Directory.Packages.props
<PackageReference Include="Frank.Alps" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Frank.Alps --version 7.3.2
                    
#r "nuget: Frank.Alps, 7.3.2"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Frank.Alps@7.3.2
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Frank.Alps&version=7.3.2
                    
Install as a Cake Addin
#tool nuget:?package=Frank.Alps&version=7.3.2
                    
Install as a Cake Tool

Frank.Alps

Frank.Alps serves ALPS (Application-Level Profile Semantics, draft-07) profile documents describing what your resources mean and which transitions are available. Profiles are hand-authored F# values — never derived from CLR types or view templates — and each transition is bound to the endpoint that implements it, so the served document is filtered per principal and, optionally, per resource state.

Features

  • Hand-authored profiles: plain |> combinators, or a descriptor { } computation expression — both produce identical Descriptor values
  • Compile-checked references: rt/href/from take a Descriptor value, not a string id, so a dangling reference is a compile error, not a wrong document
  • Composite-state authoring: contains/initial/regions express substate (OR) and orthogonal (AND) decomposition, riding ext so documents stay spec-valid for readers that don't know them
  • Zero-friction binding: binds descriptor inside handler { } attaches a transition to the endpoint that implements it — no separate registry to keep in sync
  • Startup validation: a transition's authored type is checked against its bound HTTP method; a mismatch fails host startup, and an unbound transition is logged as a warning, rather than either shipping silently
  • Authorization- and state-filtered documents: reads stock IAuthorizeData/AuthorizationPolicy metadata (no Frank.Auth dependency) at every depth of the profile, and optionally filters by a CurrentStateResolver you supply
  • Two HTTP exposures: an app-wide document at /.well-known/alps.json, and a per-resource excerpt wired into negotiate { }
  • Zero NuGet dependencies: only FrameworkReference Microsoft.AspNetCore.App and a project reference to Frank; no dependency on Frank.Auth, Frank.Rdf, or Frank.OpenApi

Installation

dotnet add package Frank.Alps

Quick Start

open Frank.Builder
open Frank.Alps

module Catalog =
    let openState = semantic "open" |> def "https://tictactoe.example/states/open"
    let closedState = semantic "closed" |> def "https://tictactoe.example/states/closed"

    let viewGame = safe "viewGame"
    let makeMove = unsafe "makeMove" |> from [ openState ] |> rt closedState

    // Transitions may be authored at the top level or nested under the state they act on.
    let game = semantic "game" |> doc "A tic-tac-toe game" |> contains [ viewGame; makeMove ]

webHost args {
    useDefaults

    resource "/games/{id}" {
        // Advertises the per-resource excerpt available at this same url.
        link (fun ctx ->
            Seq.singleton
                { Target = string ctx.Request.Path
                  Rel = "profile"
                  Params = [ "type", "application/alps+json" ] })

        get (
            negotiate {
                accepts "application/json" (handler {
                    handle getGameJson
                    binds Catalog.viewGame
                })

                accepts "application/alps+json" (Alps.excerpt None)
            }
        )

        post (handler {
            handle makeMoveHandler
            binds Catalog.makeMove
        })
    }

    useAlps [ Catalog.openState; Catalog.closedState; Catalog.game ]
}

A descriptor { } computation expression offers the same vocabulary as the |> combinators above, producing an identical Descriptor:

descriptor "makeMove" { unsafe; from [ Catalog.openState ]; rt Catalog.closedState }

API Reference

Authoring Operations

  • semantic / safe / unsafe / idempotent "id" — Constructs a descriptor of that type (semantic is the spec's default). Also available as zero-argument custom operations inside descriptor { }
  • doc "text" / docWith { Value; Href; Format; ContentType; Tag } — Human-readable documentation
  • def "iri" — The descriptor's source-definition IRI
  • named / rel / tagname, rel, and tag from draft-07 §2.2
  • ext "id" "value" / extWith { Id; Href; Value; Tag } — Author-specific extension data
  • link "href" "rel" / linkWith { Href; Rel; Title; Tag } — An RFC 8288 web link, distinct from descriptor inheritance
  • contains [ children ] — Nests descriptors (draft-07 §2.2.4), deliberately unrestricted by child type
  • rt target / href target / from [ sources ] — Descriptor-typed references: a dangling one is a compile error, not a wrong document. hrefExternal "uri" is the escape hatch for a document this codebase doesn't own
  • initial / regions [ children ] — Composite-state structure: the default child of a contains list, and orthogonal (AND) rather than substate (OR) decomposition. Both ride ext under https://frank-fs.github.io/alps-ext/, so documents stay spec-valid for readers that don't know them

Binding Transitions to Resources

binds descriptor, inside a handler { } block, records which transition an endpoint implements. That binding is what makes the rest work:

  • Startup validation: a transition's authored type is checked against its bound HTTP method (safe → GET/HEAD, idempotent → PUT/DELETE, unsafe → POST). A mismatch fails host startup rather than serving a wrong document. A transition in the profile that nothing binds is logged as a startup warning and omitted from the document.
  • Authorization filtering: reads stock IAuthorizeData/AuthorizationPolicy endpoint metadata, so it works with Frank.Auth without referencing it and equally with a plain AuthorizeAttribute. Evaluation failures deny. Filtering applies at every depth of the profile, so a guarded transition nested under a semantic state is hidden too. Semantic descriptors are never filtered — vocabulary, not capability.

Two HTTP Exposures

  • App-wide documentuseAlps [ ... ] serves the whole profile at /.well-known/alps.json (configurable) and advertises it with a Link: rel="profile" response header. Filtered by authorization only; there is no resource instance in scope to have a state.
  • Per-resource excerptAlps.excerpt resolver, wired into a negotiate { } block's accepts "application/alps+json" case, serves just the transitions bound to this resource's route (every HTTP method's, not only the one it runs under). Filtered by authorization and, when resolver is Some, by state. When a served (filtered) document references a descriptor absent from that filtered view, the reference resolves to the full document's URI (rootUri#id) rather than a dangling same-document fragment.

Both emit Cache-Control: private, no-cache and Vary: Authorization whenever any bound endpoint is guarded.

State-Based Filtering

from [ states ] marks a transition valid only from the given source state(s); a transition with no from is never state-filtered. CurrentStateResolver — a plain string -> Uri list, wired at composition time — answers "what states is this specific resource concurrently in" (one element per active orthogonal region):

let resolver: CurrentStateResolver =
    fun resourceIri -> if isFinished resourceIri then [ closedIri ] else [ openIri ]

accepts "application/alps+json" (Alps.excerpt (Some resolver))

No dependency on any store: the natural implementation queries a provenance or event store, and an absent resolver (or one returning []) simply means state filtering does not apply. Matching walks contains ancestry rather than requiring exact equality, so being in a substate satisfies a transition declared from any of its ancestors. A transition's from candidate is satisfied if any element of the resolved list satisfies it (existential/OR match across concurrently-active regions) — conjunctive AND-guards and multi-region fan-out targets are out of scope here (frank-fs/frank#489).

ProtocolGraph.ofProfile derives the read-only { FromGuard: StateGuard option; Transition: Descriptor; ToTargets: TransitionTarget list } edge set from the authored profile. FromGuard comes from Guard (set via guardedBy) if present, else from From: empty yields None (unconditional — the edge fires regardless of prior state), one state yields Some (State s), and multiple states now collapse into a single Some (Any [State s1; State s2; ...]) edge rather than one edge per source state as before. ToTargets comes from Targets (set via entersRegions) if non-empty, else from Rt: Some yields one [EnterState t], None yields []. An edge is emitted iff the resulting ToTargets is non-empty — FromGuard is independently optional, so a transition declaring only rt (no from/guardedBy) now yields an unconditional edge, where previously it was excluded. Nothing in this package executes a transition or owns what state a resource is actually in. See docs/superpowers/specs/2026-08-08-frank-alps-compound-transitions-design.md for the full design rationale behind this shape and both behavior changes.

Architecture

Frank.Alps is built on:

  1. Frank: Provides the computation expression framework for defining HTTP resources and the HandlerDefinition/endpoint-metadata mechanism binds writes into
  2. ASP.NET Core: Endpoint/EndpointDataSource are read directly (EndpointSurface) rather than through an ApiExplorer dependency

There is no dependency on Frank.Rdf, Frank.JsonHome, Frank.Provenance, or Frank.AuthAuthorizationFilter reads stock ASP.NET Core authorization metadata, so it composes with Frank.Auth without referencing it.

See sample/Frank.Alps.Sample for a runnable demonstration of both HTTP exposures and both Link headers.

  • Frank - F# web framework
  • ALPS - Application-Level Profile Semantics (draft-07)
  • RFC 6906 - The 'profile' Link Relation Type

License

MIT License - see LICENSE file for details

Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 is compatible.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
7.3.3 92 8/11/2026
7.3.2 101 8/10/2026

### New in 7.3.2 (Released 2026-08-09)
**Frank Core - HandlerDefinition Metadata Refactor**
- **`HandlerDefinition` is now an open metadata list:** `{ Handler: RequestDelegate; Metadata: obj list }`, replacing the six fixed fields (`Name`, `Summary`, `Description`, `Tags`, `Produces`, `Accepts`). The `handler { }` computation expression's operations (`handle`, `name`, `summary`, `description`, `tags`, `produces`, `producesEmpty`, `accepts`) are unchanged — this is an internal representation change, not an authoring change.
- **`ProducesInfo` and `AcceptsInfo` removed** — these staging record types are no longer needed since metadata is built directly. Use `HandlerDefinition.tryFind<'T>`/`findAll<'T>` to inspect metadata (e.g. `HandlerDefinition.findAll<IProducesResponseTypeMetadata>`) if you referenced these types directly rather than through the `handler { }` CE.
- **Breaking namespace move:** the `get`/`post`/`put`/`delete`/`patch`/`head`/`options` overloads that accept a `HandlerDefinition` moved from `Frank.OpenApi` into Frank core (`Frank.Builder`). If your project only had `open Frank.OpenApi` and not `open Frank.Builder`, add `open Frank.Builder` — the `resource { }` CE already requires it, so most consumers are unaffected.
- **New: `ResourceBuilder.AddMethodMetadata`** — scopes an endpoint-metadata convention to a single HTTP method within a resource, promoted from a private `Frank.OpenApi` helper to a public core operation.
**Frank Core - Shared Response Link Provider**
- **New: `link` operation, on both `webHost { }` and `resource { }`** — any code can now contribute an RFC 8288 `Link` response header entry through one shared mechanism (`WebLink`, `WebHostSpec.LinkProviders`), replacing the ad-hoc, independently-duplicated `Link`-header middleware `Frank.JsonHome` and `Frank.OpenApi` previously each rolled themselves. Two overloads either way: `link "https://example.com/license" "license"` for a static target/relation, or `link (fun ctx -> ...)` for a value computed per request. Contributions from multiple sources combine into one multi-value `Link` header rather than one overwriting another.
- **App-wide (`webHost { link ... }`):** applies to every response, including unmatched routes (404s) and responses regenerated by `UseExceptionHandler` — the same guarantee `Frank.JsonHome`'s and `Frank.OpenApi`'s existing Link headers already had, now backed by one shared implementation instead of two.
- **New: resource-scoped (`resource { link ... }`)** — a `Link` entry that appears only on that specific resource's own responses, not app-wide. No prior mechanism supported this. Enables cases like advertising an alternate representation available at the same URL via content negotiation (e.g. a JSON-LD document) without leaking that link onto every other resource in the app.
**Frank Core - `negotiate { }` Content Negotiation** ([#482](https://github.com/frank-fs/frank/issues/482))
- **New: the `negotiate { }` computation expression** (`Frank.Builder`, alongside `handler { }`) — genuine per-media-type content negotiation, producing a `HandlerDefinition list` (one per representation) that drops into any `get`/`post`/… slot on a resource, just as a single `handler { }` does. Each `accepts "<media-type>" <handler>` registers one *independent* representation; the one the request's `Accept` header selects is the only one whose handler ever runs. Selection follows RFC 9110 §12.5.1 — a representation's effective quality is taken from the **most specific** matching `Accept` entry, not the best-quality one, so `text/html;q=0` correctly overrides a broader `*/*;q=0.5` and vice versa, and `q=0` excludes a representation outright rather than merely deprioritizing it. Ties break by registration order; no match at all is `406 Not Acceptable` with no body; an absent, empty, or unparseable `Accept` is treated as an implicit `*/*` rather than a separate "default representation" concept. Every response — including the 406 — carries `Vary: Accept` (RFC 9110 §12.5.5).
- **`accepts` overloads:** a `RequestDelegate`, an `HttpContext -> unit`, a `handler { }`-built `HandlerDefinition` (whose `produces` metadata flows through to the generated OpenAPI document), or an `HttpContext -> Task<'a>`/`HttpContext -> Async<'a>` whose returned value is auto-formatted via `viaOutputFormatter` rather than silently discarded. A `mediaTypes: string list` batch form registers one representation per media type from a single shared handler: `accepts [ "application/json"; "application/xml" ] getProduct`.
- **Wildcard catch-all representations:** `accepts "*/*" ...` or `accepts "type/*" ...` registers a representation matching any client entry within that pattern — an ordinary entry in the same list, not a separate fallback mechanism. Because registration order breaks ties, **wildcards must be registered last** or they shadow every more specific representation after them. Frank never auto-sets `Content-Type` for a wildcard representation (a wildcard is not a valid `Content-Type`), which is exactly what lets one compose with the existing `ctx.Negotiate` for a full "delegate whatever's left to MVC's formatters" fallback. Pairing a wildcard with a *value-returning* handler is rejected at registration time with a clear exception — there is no concrete type to hand the formatter selector.
- **Media-type matching is exact for concrete types.** `MediaTypeHeaderValue.MatchesMediaType` is lenient about RFC 6839 structured-syntax suffixes in **both** directions; that leniency is now gated on the pattern side actually being a wildcard. Concrete-vs-concrete requires exact (case-insensitive) equality, so an `Accept: application/json` never silently receives a registered `application/ld+json` representation (nor the reverse), and a client's explicit ranking of `application/json` above `application/ld+json` is never inverted.
- **New: `Frank.ContentNegotiation.viaOutputFormatter mediaType body ctx`** — bridges a single representation to ASP.NET Core MVC's registered `IOutputFormatter` registry (`AddMvcCore()`, `AddXmlSerializerFormatters()`, …), writing `body` as exactly `mediaType` instead of requiring a hand-written producer. Unlike the existing `negotiate` function, the caller names the target media type rather than having it derived from `Accept`. Throws if no formatter is registered for that type (a server misconfiguration, not a client error, by the time it's called).
- **Naming caveat:** `Frank.Builder.negotiate` (the CE) and the pre-existing `Frank.ContentNegotiation.negotiate` function share an identifier. With both modules opened, F#'s ordinary shadowing rules apply and the last `open` wins — qualify one, or use the non-colliding `ctx.Negotiate(statusCode, body)` extension member. Documented in both signature files.
- **Sample:** `sample/Frank.OpenApi.Sample` demonstrates both a hand-written-producer `negotiate { }` block and one bridged to MVC's JSON/XML formatters via `viaOutputFormatter`.
**Frank Core - `negotiate { }` Dispatches at the Routing Layer**
- **`NegotiateBuilder.Run` now returns `HandlerDefinition list`** (was a single `HandlerDefinition`) — one per registered representation, each becoming its own `RouteEndpoint` at the same route and HTTP method, instead of one endpoint whose handler dispatched internally. Which endpoint serves a request is decided during endpoint selection by the new `FrankProducesMatcherPolicy`, the same stage at which ASP.NET Core's own `Accept-Encoding` negotiation runs — so the selected representation's endpoint is what authorization, `IEndpointFilter`s, `Frank.JsonHome`, `Frank.Alps` and the OpenAPI document all see, rather than a single opaque wrapper. Behavior a client observes (RFC 9110 §12.5.1 selection, bodyless 406, `Vary: Accept`, `Content-Type` from the winning representation) is unchanged.
- **Binary-breaking, source-compatible for the standard authoring pattern.** `negotiate { accepts "…" … }` passed straight into `get`/`post`/`put`/`delete`/`patch`/`head`/`options` still compiles unchanged — those operations gained `HandlerDefinition list` overloads alongside their existing `HandlerDefinition` ones. It is NOT source-compatible for code that reached into a `negotiate { }` result directly (`.Handler`/`.Metadata` on what is now a list), nor for code that built a `ResourceSpec.Handlers` list literal by hand: that tuple changed from `(string * RequestDelegate)` to `(string * RequestDelegate * obj list)`, so each handler entry carries its own metadata instead of every handler sharing one per-method convention list.
- **Metadata is per representation, not pooled.** Each representation's endpoint carries its own metadata plus the shared, merged `produces` metadata (broadcast so the generated OpenAPI document lists the full content-type union on every one). Non-`produces` metadata — an ALPS `Descriptor` from `binds`, a `Frank.Auth` requirement, anything an extension library attaches — stays on the representation that declared it and is never copied onto its siblings.
- **New public types in `Frank.Builder`:** `MediaTypeNegotiation` (the RFC 9110 §12.5.1 media-type matching and quality-value functions, now a shared module rather than private helpers inside `NegotiateBuilder`), `ProducesMediaTypeMetadata` (the per-representation marker the policy reads), and `FrankProducesMatcherPolicy` (the `MatcherPolicy` itself). `webHost { }` registers the policy automatically — no configuration needed.
- **Caveat — hand-rolled hosts must register the policy.** An app that does NOT build its host through `webHost { }` (a hand-written `WebApplication`/`IHost` wiring Frank resources into `UseEndpoints` itself) must register the policy explicitly:
```fsharp
services.AddSingleton<MatcherPolicy, FrankProducesMatcherPolicy>()
```
Without it, any `negotiate { }` block with more than one representation throws `AmbiguousMatchException` at request time, because the representations are now genuinely several endpoints on one route and nothing is left to choose between them. This is a regression from earlier releases, where a single endpoint dispatched internally and therefore worked regardless of host setup.
- **`HandlerDefinitionMetadata.toConventions` removed** — it projected a `HandlerDefinition`'s metadata into `EndpointBuilder` conventions, which nothing needs now that handler entries carry their own metadata; it had no remaining callers.
**Frank.Analyzers - FRANK002 Duplicate Accepts Media Type** ([#482](https://github.com/frank-fs/frank/issues/482))
- **New rule `FRANK002`:** warns when the same media type is registered by more than one `accepts` operation inside a single `negotiate { }` block. The earlier registration always wins on the tie-break, making the later one dead code. Emitted by the existing `DuplicateHandlerAnalyzer` (which now covers both duplicate HTTP-method handlers and duplicate `accepts` media types) — no new analyzer assembly, no new package reference.
**Frank.OpenApi - service-desc Link Header**
- **Responses advertise the OpenAPI document:** apps with `useOpenApi` enabled carry a `Link: <path>; rel="service-desc"; type="application/json"` response header (RFC 8631), so clients can discover the machine-readable OpenAPI document without prior knowledge of its route. Delivered through Frank core's shared `link` mechanism (see Frank Core above) rather than a private implementation — no change in the header a client sees.
**Frank.JsonHome - JSON Home Document Support**
- **New Library:** Frank.JsonHome extension library serving a [JSON Home](https://datatracker.ietf.org/doc/html/draft-nottingham-json-home-06) document describing an application's entry-point resources. Zero NuGet dependencies — only `FrameworkReference Microsoft.AspNetCore.App` and a project reference to `Frank`; no dependency on `Frank.Auth` or `Frank.OpenApi`.
- **WebHostBuilder Integration:** `useJsonHome` operation serves the document at `/.well-known/home.json` (configurable) and advertises it with a `Link: rel="home"` response header, delivered through Frank core's shared `link` mechanism (see Frank Core above) — same `plugBeforeRouting` ordering caveat as `Frank.OpenApi`'s `service-desc` header above.
- **ResourceBuilder Extensions:** `rel`, `hrefVar`, `docs`, `deprecated`, `gone`, `acceptRanges`, `acceptPrefer`, `preconditionRequired`, and `authScheme` declare resource-level discovery metadata. Resources without a `rel` are omitted from the document — JSON Home is a curated entry point, not a sitemap.
- **draft-06 Compliant:** all eleven resource hints implemented (`allow`, `formats`, `acceptPatch`, `acceptPost`, `acceptPut`, `acceptRanges`, `acceptPrefer`, `docs`, `preconditionRequired`, `authSchemes`, `status`), camelCase hint names throughout, and `hrefVars` always present alongside `hrefTemplate` per the spec's MUST.
- **Authorization Filtering:** reads stock `IAuthorizeData`/`AuthorizationPolicy` endpoint metadata, so it works with `Frank.Auth` without referencing it and equally with a plain `AuthorizeAttribute`. Evaluation failures deny. The document is dispatched as a real endpoint through the same routing stage as every other resource — after any `useAuthentication`/`useAuthorization` middleware, regardless of where `useJsonHome` appears in the `webHost { }` block — so filtering sees the real principal rather than an anonymous one. Emits `Cache-Control: private, no-cache` and `Vary: Authorization` whenever any resource is guarded. Filtering is per HTTP method, not per whole resource: a resource whose `DELETE` requires a role it doesn't have but whose `GET` is public now shows `["GET"]` in `hints.allow` rather than disappearing entirely (previously any guarded method hid the whole resource). A resource left with no visible methods for the current principal is omitted entirely, as before.
- **Route Template Translation:** ASP.NET route templates (`{id:guid}`, `{id?}`, `{*rest}`) are translated to RFC 6570 URI Templates for `hrefTemplate`.
- **Duplicate `rel` fails startup** ([#475](https://github.com/frank-fs/frank/issues/475)): `useJsonHome` registers an `IStartupFilter` that throws `OptionsValidationException` when two or more resources declare the same `rel`, naming every colliding route template. `resources` in the served document is a JSON object keyed by `rel`, so a collision would otherwise silently drop one resource's *entry from the discovery document* (the resource itself stays reachable over HTTP) with no diagnostic. No opt-in: every `useJsonHome` call gets the check. It runs after the endpoint pipeline is built but before the server accepts a request — `IApiDescriptionGroupCollectionProvider` does not reflect Frank's resources any earlier than that, so `AddOptionsWithValidateOnStart` is deliberately *not* the mechanism.
- **Mismatched `hrefVar` fails startup, and at compile time too** ([#474](https://github.com/frank-fs/frank/issues/474)): two independent checks catch a `hrefVar` that doesn't match its resource's route template variables — the motivating case being a typo like `hrefVar "prodId" ...` on `/products/{id}`, which previously produced a `hrefVars` entry that resolved nothing, silently. At compile time, `Frank.Analyzers`' new `FRANK003` rule reports the mismatch at the `hrefVar`/`resource` call site (`Severity.Error`), in both directions — an undeclared template variable and a declared-but-unmatched `hrefVar`. At startup, `useJsonHome` registers a second `IStartupFilter` (`HrefVarStartupFilter`, alongside #475's `DuplicateRelStartupFilter`) that throws `HrefVarValidationException` listing every mismatched resource, same `next.Invoke(app)`-then-check timing as #475 for the same reason. Both mechanisms share one diff function (`HrefVarValidation.diff`) — the analyzer links its source directly rather than taking a `ProjectReference`, so `Frank.Analyzers` still has no dependency on `Microsoft.AspNetCore.App`.
- **No Breaking Changes:** entirely additive — no changes to `Frank` core.
- **Sample:** `sample/Frank.JsonHome.Sample` demonstrates the discovery metadata and, curling the same `/.well-known/home.json` as anonymous vs. an authenticated admin, the authorization filtering actually changing what the document lists.
**Example Usage:**
```fsharp
webHost {
useJsonHome
resource "/products" {
rel "tag:example.com,2026:products"
get listProducts
}
resource "/products/{id}" {
rel "tag:example.com,2026:product"
hrefVar "id" "https://example.com/param/product-id"
requireRole "admin"  // Frank.Auth, filtered from the document for other principals
get getProduct
}
}
```
**Frank.Auth - Handler-Level Authorization**
- **New: handler-level `requireAuth`/`requireClaim`/`requireRole`/`requirePolicy`/`allowAnonymous`** — the same four authorization operations already available on `ResourceBuilder`, now also usable inside a `handler { }` block, so a single HTTP method can carry its own requirement independent of the resource (e.g. `GET` public, `DELETE` admin-only on the same resource). Composes additively (AND) with any resource-level requirement on the same endpoint via ASP.NET Core's own multi-`IAuthorizeData` combination — no new mechanism, no Frank core change.
- **New: `allowAnonymous`** — bypasses ALL authorization on that one handler, resource-level and handler-level alike, via stock ASP.NET Core `AllowAnonymousAttribute`/`IAllowAnonymous` semantics. This is a binary bypass, not a policy downgrade: co-declaring `allowAnonymous` with a handler-level requirement on the same handler means the requirement is never evaluated, matching `[AllowAnonymous]`'s real behavior in ASP.NET Core MVC.
**Frank.Rdf - Hand-Authored RDF/JSON-LD**
- **New Library:** Frank.Rdf provides an `rdf { }` computation expression for hand-authoring RDF triples across one or more resources, serialized to JSON-LD in expanded form. Zero ASP.NET Core dependency — no `ProjectReference` to `Frank`, no `FrameworkReference` to `Microsoft.AspNetCore.App`; the only NuGet dependency is `dotNetRdf.Core 3.5.1`. It builds and serializes documents; it has no opinion on how a handler returns the result.
- **`describe`/`about` mirrors `handler { }`/`get`:** `describe (Node.Iri "https://example.org/g1") { typ "schema:Game"; propertyString "schema:name" "Tic-tac-toe" }` runs to completion as a self-contained `DescribeBuilder`, producing a plain `Description`; `rdf { }`'s `about` operation consumes that value directly, the same two-CE composition pattern Frank core already uses for `handler { }` feeding `resource { }`'s `get`. No `Combine`/`Delay` on either builder. `rdf { }` also has a bare `triple subject predicate value` operation for one-off statements, and `prefix` to declare CURIE namespace mappings. Properties are five distinct operations — `propertyString`/`propertyInt`/`propertyBool`/`propertyDateTime`/`propertyNode`, chosen by the value's type — rather than one overloaded `property`: F#'s custom-operation overload resolution commits to a single resolved parameter type for the whole CE block once the first call type-checks, so an overloaded `property` failed to compile reliably across calls with different value types in the same block.
- **CURIE resolution is declared-prefix-first:** a string is resolved against a declared `prefix` before falling back to "is this already a well-formed absolute URI" — the other order is a real bug, not a style choice, since `.NET`'s `Uri` parser accepts unrecognized schemes (`"schema:Game"` parses as well-formed with scheme `"schema"`), which would otherwise let the single most common kind of CURIE this CE exists to construct silently pass through unresolved. This ordering fixes that specific bug — a declared prefix always wins over accidental well-formedness.
- **Undeclared-prefix fallback tightened** ([#484](https://github.com/frank-fs/frank/issues/484)): passing through an undeclared prefix as an absolute IRI now additionally requires the string to *look* genuinely absolute — the text immediately after the parsed scheme's colon must start with `//` (anchored, not merely `://` appearing anywhere later in the string), or the string must start with an allow-listed non-hierarchical scheme (`urn:`, `mailto:`, `tel:`, matched case-insensitively per RFC 3986 §3.1) — before `Uri.IsWellFormedUriString` is even consulted. Previously, any `word:word`-shaped string satisfied .NET's lax well-formedness rules regardless of scheme, so a typo like `foaf:name` with no `foaf` prefix declared silently became the literal (wrong) IRI `<foaf:name>` instead of raising, and a stray leftover prefix like `schema:http://weird` would have slipped through too. This is a deliberate behavior tightening: such typos now raise `Frank.Rdf: undeclared prefix '...' in '...'` instead of silently passing through. Legitimate absolute-IRI passthrough (`http://...`, `https://...`, and the allow-listed schemes above, in any case) is unaffected.
- **`Doc.toGraph`/`Doc.writeJsonLd`/`Doc.toJsonLd`:** wrap dotNetRDF for graph construction and JSON-LD writing. `writeJsonLd` takes the caller's own `TextWriter` (never closing or disposing it) so a response handler can stream straight into `HttpResponse.Body` without materializing the whole document as a string first; `toJsonLd` is a thin `string`-returning convenience wrapper for callers that need one (tests, mainly).
- **Output is expanded-form JSON-LD only:** no `@context`, no compaction, every predicate and type expanded to its absolute IRI. Deliberate, not a placeholder — see the design doc's *Serialization* section.
- **`Node.blank ()` mints a GUID, not a per-`Doc` counter** — the specific choice that makes `Doc.merge`/`includeDoc` (combining two independently-built documents) safe: two docs that each mint their own blank nodes can never collide when merged, which a counter-based scheme could not guarantee.
- **No breaking changes:** entirely additive, new package.
- **Sample:** `sample/Frank.Rdf.Sample` demonstrates the CE across two subjects (a game and its `numberOfPlayers` value), `Doc.merge` folding in facts shared across every resource (a publisher record merged into each game's document), and `Doc.writeJsonLd` streaming straight into the response body. `GET /games/{id}` now demonstrates real `Accept`-based content negotiation: a `negotiate { }` block serves a plain-JSON DTO representation (`application/json`, also the default with no `Accept` header) and the JSON-LD representation (`application/ld+json`) at the same url, plus a resource-scoped `link` operation advertising the JSON-LD alternate via a `Link: </games/{id}>; rel="alternate"; type="application/ld+json"` header on every response from that resource, found or not.
**Frank.Provenance - PROV-O Provenance**
- **New Library:** Frank.Provenance records and queries [PROV-O](https://www.w3.org/TR/prov-o/) provenance -- an `Agent` performing an `Activity` that generated a `Resource` (Entity), with `startedAtTime`/`endedAtTime` and optional domain-specific `ActivityType`/`Properties` -- via the `ProvenanceRecord` type and `IProvenanceStore` interface, built directly on `Frank.Rdf`'s `Doc`/`Description` model rather than a parallel triple representation. Zero ASP.NET Core dependency, same as `Frank.Rdf` itself -- no `ProjectReference` to `Frank`, no `FrameworkReference` to `Microsoft.AspNetCore.App`; the only new NuGet dependency beyond `Frank.Rdf`'s own is `Microsoft.Extensions.Logging.Abstractions`.
- **Closed query vocabulary:** `ProvenanceQuery` (`ByResource`/`ByAgent`/`ByActivityId`) is the *only* way a caller queries a store -- there is no public API accepting a raw `SparqlQuery` or query string. Each case compiles to a pre-built, parameterized SPARQL `CONSTRUCT`/`DESCRIBE` query internally; adding a new provenance-meaningful query shape means adding a case to this closed vocabulary, not widening the surface to open query text.
- **`MailboxProcessorProvenanceStore`:** an in-memory `IProvenanceStore` backing every appended record with its own named graph in a single dotNetRDF `TripleStore`, queried over the store's union graph. A `MailboxProcessor` serializes all `Append`/`Query` access, so concurrent callers never race; a malformed record (e.g. a relative-IRI `Activity`) is caught, logged, and dropped rather than killing the mailbox loop and silently hanging every later `Query`. `ProvenanceStoreConfig` bounds the store (`MaxRecords`, `EvictionBatchSize`), evicting the oldest records once the bound is exceeded; eviction is clamped so the record just appended is never evicted in the same call, regardless of how those two values are configured.
- **Opt-in durability via `IProvenanceJournal`:** a new three-member interface (`Append`/`Snapshot`/`Recover`) passed as an optional third constructor argument to `MailboxProcessorProvenanceStore`. With one attached, every `Append` is durably logged, every `ProvenanceStoreConfig.SnapshotEvery` appends (new field, default 100) the store's current state is compacted into a snapshot, and construction replays the latest snapshot plus everything journaled since it -- so a restarted process with the same journal picks up where the last one left off. Omit the argument and behavior is bit-for-bit the v1 in-memory store: no files, no cost. Journal writes are fire-and-forget and best-effort -- a failing journal is logged and swallowed, never blocking, failing, or un-recording a caller's `Append`.
- **`FileProvenanceJournal`:** the in-box implementation, writing N-Quads segment/snapshot files (`{actorId}.journal.{seq}.nq` / `{actorId}.snapshot.{seq}.nq`) under a caller-supplied directory, tracked by an `{actorId}.manifest.json` pointer file written atomically (temp-file-then-rename). Segment and snapshot files are immutable and versioned -- never overwritten, never deleted, not even once a snapshot supersedes them. Its own writes are serialized by a `MailboxProcessor`, and a write failure (full disk, denied permission, corrupt manifest) is logged through an optional `ILogger` and absorbed rather than killing that loop.
- **`ProvBuilder`:** two authoring surfaces, one value -- plain `|>` combinators over `Prov`'s functions, or an equivalent `activity`/`entity`/`agent` computation expression (`activity a { wasAssociatedWith agentNode; startedAtTime t0; endedAtTime t1 }`), produce structurally identical `Description`s. Mirrors `Frank.Rdf`'s `describe`/`DescribeBuilder` and `Frank.Alps`'s `descriptor`/`DescriptorBuilder` exactly.
- **No breaking changes:** entirely additive, new package.
- **Sample:** `sample/Frank.Provenance.Sample` demonstrates recording a `ProvenanceRecord` on every real request to `/games/{id}` and querying it back via `GET /provenance?resource=`, plus `GET /provenance/lineage`, which hand-authors a `wasDerivedFrom` relationship via `ProvBuilder` -- a PROV-O shape `ProvenanceRecord`/`IProvenanceStore.Append` can't express.
**Frank.Alps - Hand-Authored ALPS Profiles**
- **New Library:** Frank.Alps serves [ALPS](https://datatracker.ietf.org/doc/draft-amundsen-richardson-foster-alps/) (draft-07) profile documents describing what an application's resources mean and which transitions are available from them. Zero NuGet dependencies — only `FrameworkReference Microsoft.AspNetCore.App` and a project reference to `Frank`; no dependency on `Frank.Auth`, `Frank.Rdf`, or `Frank.OpenApi`. Profiles are hand-authored F# `Descriptor` values, never derived from CLR types or view templates.
- **Two authoring surfaces, one value:** plain `|>` combinators (`semantic "game" |> doc "A tic-tac-toe game" |> contains [ viewGame; makeMove ]`) and an equivalent `descriptor { }` computation expression (`descriptor "makeMove" { unsafe; from [ openState ]; rt closedState }`) produce structurally identical `Descriptor`s. `semantic`/`safe`/`unsafe`/`idempotent` do double duty as plain constructors outside a block and as zero-argument custom operations inside one, the same trick F#'s own `query { }` uses for `distinct`. Full draft-07 field coverage: `id`, `name`, `type`, `def`, `doc` (with `href`/`format`/`contentType`), `ext`, `href`, `link`, `rel`, `rt`, `tag`, and nested `descriptor`.
- **Compile-checked references:** `rt`, `from`, `href`, and `contains` take `Descriptor` values, so a dangling reference is a compile error rather than a wrong document. Plain strings only where there is genuinely nothing to check against — `hrefExternal`, `def`, and a descriptor's own id.
- **`binds` ties a transition to the endpoint that implements it,** inside a `handler { }` block, via Frank core's open `HandlerDefinition.Metadata` list. That binding drives everything else: a transition's authored `type` is validated at startup against its bound HTTP method (`safe` → GET/HEAD, `idempotent` → PUT/DELETE, `unsafe` → POST), failing host startup on a mismatch rather than serving a wrong document; and authorization filtering reads that endpoint's own metadata. A transition the profile names but nothing binds is logged as a startup warning and omitted, rather than vanishing silently.
- **Authorization Filtering:** reads stock `IAuthorizeData`/`AuthorizationPolicy` endpoint metadata, so it works with `Frank.Auth` without referencing it and equally with a plain `AuthorizeAttribute`. Evaluation failures deny. Filtering applies at every depth of the authored tree, not just its top level — a guarded transition nested under a semantic state via `contains` is hidden from principals who may not invoke it, while the semantic parent itself is still served. Semantic descriptors are never filtered by either mechanism: vocabulary, not capability.
- **Two HTTP exposures.** `useAlps [ ... ]` serves the whole profile at `/.well-known/alps.json` (configurable) with a `Link: rel="profile"` header, filtered by authorization only — there is no resource instance in scope to have a state. `Alps.excerpt resolver`, wired into a `negotiate { }` block's `accepts "application/alps+json"` case, serves just the transitions bound to that resource's route (every HTTP method's, not only the one it runs under), filte

...(truncated -- full release notes: https://github.com/frank-fs/frank/blob/master/RELEASE_NOTES.md)