Frank 7.3.2

There is a newer version of this package available.
See the version list below for details.
dotnet add package Frank --version 7.3.2
                    
NuGet\Install-Package Frank -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" Version="7.3.2" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Frank" Version="7.3.2" />
                    
Directory.Packages.props
<PackageReference Include="Frank" />
                    
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 --version 7.3.2
                    
#r "nuget: Frank, 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@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&version=7.3.2
                    
Install as a Cake Addin
#tool nuget:?package=Frank&version=7.3.2
                    
Install as a Cake Tool

Frank

NuGet Version GitHub Release Date Build status

F# computation expressions, or builders, for configuring the Microsoft.AspNetCore.Hosting.IWebHostBuilder and defining routes for HTTP resources using Microsoft.AspNetCore.Routing.

This project was inspired by @filipw's Building Microservices with ASP.NET Core (without MVC).

Installation

dotnet add package Frank

Features

  • WebHostBuilder - computation expression for configuring WebHost
  • ResourceBuilder - computation expression for configuring resources (routing)
  • No pre-defined view engine - use your preferred view engine implementation, e.g. Falco.Markup, Oxpecker.ViewEngine, or Hox
  • Easy extensibility - just extend the Builder with your own methods!

Basic Example

module Program

open System.IO
open Microsoft.AspNetCore.Builder
open Microsoft.AspNetCore.Http
open Microsoft.AspNetCore.Routing
open Microsoft.AspNetCore.Routing.Internal
open Microsoft.Extensions.DependencyInjection
open Microsoft.Extensions.Logging
open Frank
open Frank.Builder

let home =
    resource "/" {
        name "Home"

        get (fun (ctx:HttpContext) ->
            ctx.Response.WriteAsync("Welcome!"))
    }

[<EntryPoint>]
let main args =
    webHost args {
        useDefaults

        logging (fun options-> options.AddConsole().AddDebug())

        plugWhen isDevelopment DeveloperExceptionPageExtensions.UseDeveloperExceptionPage
        plugWhenNot isDevelopment HstsBuilderExtensions.UseHsts

        plugBeforeRouting HttpsPolicyBuilderExtensions.UseHttpsRedirection
        plugBeforeRouting StaticFileExtensions.UseStaticFiles

        resource home
    }

    0

Middleware Pipeline

Frank provides two middleware operations with different positions in the ASP.NET Core pipeline:

Request → plugBeforeRouting → UseRouting → plug → Endpoints → Response

plugBeforeRouting

Use for middleware that must run before routing decisions are made:

  • HttpsRedirection - redirect before routing
  • StaticFiles - serve static files without routing overhead
  • ResponseCompression - compress all responses
  • ResponseCaching - cache before routing
webHost args {
    plugBeforeRouting HttpsPolicyBuilderExtensions.UseHttpsRedirection
    plugBeforeRouting StaticFileExtensions.UseStaticFiles
    resource myResource
}

plug

Use for middleware that needs routing information (e.g., the matched endpoint):

  • Authentication - may need endpoint metadata
  • Authorization - requires endpoint to check policies
  • CORS - may use endpoint-specific policies
webHost args {
    plug AuthenticationBuilderExtensions.UseAuthentication
    plug AuthorizationAppBuilderExtensions.UseAuthorization
    resource protectedResource
}

Conditional Middleware

Both plugWhen and plugWhenNot run in the plug position (after routing):

webHost args {
    plugWhen isDevelopment DeveloperExceptionPageExtensions.UseDeveloperExceptionPage
    plugWhenNot isDevelopment HstsBuilderExtensions.UseHsts
    resource myResource
}

Conditional Before-Routing Middleware

Both plugBeforeRoutingWhen and plugBeforeRoutingWhenNot run in the plugBeforeRouting position (before routing):

let isDevelopment (app: IApplicationBuilder) =
    app.ApplicationServices
        .GetService<IWebHostEnvironment>()
        .IsDevelopment()

webHost args {
    // Only redirect to HTTPS in production
    plugBeforeRoutingWhenNot isDevelopment HttpsPolicyBuilderExtensions.UseHttpsRedirection

    // Only serve static files locally in development (CDN in production)
    plugBeforeRoutingWhen isDevelopment StaticFileExtensions.UseStaticFiles

    resource myResource
}

Content Negotiation

The negotiate { } computation expression performs real per-media-type dispatch: each accepts registers an independent representation, and the one matching the request's Accept header (by RFC 9110 quality and specificity rules) is the only one whose handler runs. Nothing matching means 406 Not Acceptable, and every response carries Vary: Accept.

resource "/products/{id}" {
    get (negotiate {
        accepts "application/json" (fun ctx -> task {
            do! ctx.Response.WriteAsJsonAsync(product)
        })
        accepts "text/html" (fun ctx -> task {
            do! ctx.Response.WriteAsync($"<h1>{product.Name}</h1>")
        })
    })
}

Frank has several companion packages that build on this core: Frank.Auth (authorization), Frank.OpenApi (OpenAPI generation), Frank.JsonHome (hypermedia discovery), Frank.Datastar (reactive SSE), Frank.Rdf (linked data), and Frank.Analyzers (compile-time checks).

See the project repository for the complete guide and sample applications.

License

MIT

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 (6)

Showing the top 5 NuGet packages that depend on Frank:

Package Downloads
Frank.Datastar

Datastar SSE integration for Frank web framework with F# computation expression support. Native SSE implementation with no external dependencies, supports .NET 8.0/9.0/10.0.

Frank.Auth

Resource- and handler-level authorization extensions for Frank web framework

Frank.OpenApi

OpenAPI document generation extensions for Frank web framework

Frank.Validation

Hand-authored SHACL Core validation for Frank resources, built on Frank.Rdf

Frank.Alps

Hand-authored ALPS profile documents for Frank resources

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
7.3.3 186 8/11/2026
7.3.2 166 8/10/2026
7.2.1 194 6/22/2026
7.2.0 243 2/10/2026
7.1.0 158 2/8/2026
7.0.0-build.0 83 2/6/2026
6.5.0 215 2/5/2026
6.4.1 155 2/4/2026
6.4.1-build.0 83 2/4/2026
6.4.0 140 2/2/2026
6.4.0-build.0 84 2/2/2026
6.3.0 544 3/15/2025
6.2.0 5,527 11/18/2020
6.1.0 1,917 6/11/2020
6.0.0 947 6/2/2020
5.0.5 1,115 1/7/2019
Loading failed

### 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)