Frank.Alps
7.3.2
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
<PackageReference Include="Frank.Alps" Version="7.3.2" />
<PackageVersion Include="Frank.Alps" Version="7.3.2" />
<PackageReference Include="Frank.Alps" />
paket add Frank.Alps --version 7.3.2
#r "nuget: Frank.Alps, 7.3.2"
#:package Frank.Alps@7.3.2
#addin nuget:?package=Frank.Alps&version=7.3.2
#tool nuget:?package=Frank.Alps&version=7.3.2
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 adescriptor { }computation expression — both produce identicalDescriptorvalues - Compile-checked references:
rt/href/fromtake aDescriptorvalue, not a string id, so a dangling reference is a compile error, not a wrong document - Composite-state authoring:
contains/initial/regionsexpress substate (OR) and orthogonal (AND) decomposition, ridingextso documents stay spec-valid for readers that don't know them - Zero-friction binding:
binds descriptorinsidehandler { }attaches a transition to the endpoint that implements it — no separate registry to keep in sync - Startup validation: a transition's authored
typeis 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/AuthorizationPolicymetadata (noFrank.Authdependency) at every depth of the profile, and optionally filters by aCurrentStateResolveryou supply - Two HTTP exposures: an app-wide document at
/.well-known/alps.json, and a per-resource excerpt wired intonegotiate { } - Zero NuGet dependencies: only
FrameworkReference Microsoft.AspNetCore.Appand a project reference toFrank; no dependency onFrank.Auth,Frank.Rdf, orFrank.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 thattype(semanticis the spec's default). Also available as zero-argument custom operations insidedescriptor { }doc "text"/docWith { Value; Href; Format; ContentType; Tag }— Human-readable documentationdef "iri"— The descriptor's source-definition IRInamed/rel/tag—name,rel, andtagfrom draft-07 §2.2ext "id" "value"/extWith { Id; Href; Value; Tag }— Author-specific extension datalink "href" "rel"/linkWith { Href; Rel; Title; Tag }— An RFC 8288 web link, distinct from descriptor inheritancecontains [ children ]— Nests descriptors (draft-07 §2.2.4), deliberately unrestricted by child typert 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 owninitial/regions [ children ]— Composite-state structure: the default child of acontainslist, and orthogonal (AND) rather than substate (OR) decomposition. Both rideextunderhttps://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
typeis 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/AuthorizationPolicyendpoint metadata, so it works withFrank.Authwithout referencing it and equally with a plainAuthorizeAttribute. 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 document —
useAlps [ ... ]serves the whole profile at/.well-known/alps.json(configurable) and advertises it with aLink: rel="profile"response header. Filtered by authorization only; there is no resource instance in scope to have a state. - Per-resource excerpt —
Alps.excerpt resolver, wired into anegotiate { }block'saccepts "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, whenresolverisSome, 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:
- Frank: Provides the computation expression framework for defining HTTP resources and the
HandlerDefinition/endpoint-metadata mechanismbindswrites into - ASP.NET Core:
Endpoint/EndpointDataSourceare read directly (EndpointSurface) rather than through an ApiExplorer dependency
There is no dependency on Frank.Rdf, Frank.JsonHome, Frank.Provenance, or Frank.Auth — AuthorizationFilter 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.
Related Projects
- 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 | Versions 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. |
-
net10.0
- Frank (>= 7.3.2)
- FSharp.Core (>= 10.1.302)
-
net8.0
- Frank (>= 7.3.2)
- FSharp.Core (>= 10.1.302)
-
net9.0
- Frank (>= 7.3.2)
- FSharp.Core (>= 10.1.302)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
### 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)