Frank.Rdf
7.3.2
See the version list below for details.
dotnet add package Frank.Rdf --version 7.3.2
NuGet\Install-Package Frank.Rdf -Version 7.3.2
<PackageReference Include="Frank.Rdf" Version="7.3.2" />
<PackageVersion Include="Frank.Rdf" Version="7.3.2" />
<PackageReference Include="Frank.Rdf" />
paket add Frank.Rdf --version 7.3.2
#r "nuget: Frank.Rdf, 7.3.2"
#:package Frank.Rdf@7.3.2
#addin nuget:?package=Frank.Rdf&version=7.3.2
#tool nuget:?package=Frank.Rdf&version=7.3.2
Frank.Rdf
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. It builds and serializes documents; it has no opinion on how a handler returns the result.
Installation
dotnet add package Frank.Rdf
Example
open System
open Frank.Rdf
let players = Node.Iri "https://example.org/games/1#players"
let anonymousReview = Node.blank () // no natural IRI -- minted fresh, GUID-backed
let gameDoc =
rdf {
prefix "schema" "https://schema.org/"
about (
describe (Node.Iri "https://example.org/games/1") {
typ "schema:Game"
propertyString "schema:name" "Tic-tac-toe"
propertyInt "schema:numberOfPlayers" 2
propertyBool "schema:isFree" true
propertyDateTime "schema:datePublished" (DateTimeOffset(1952, 1, 1, 0, 0, 0, TimeSpan.Zero))
propertyNode "schema:sameAs" (Node.Iri "http://www.wikidata.org/entity/Q210339")
propertyNode "schema:review" anonymousReview
}
)
about (describe anonymousReview { propertyString "schema:reviewBody" "A timeless classic." })
}
Doc.toJsonLd gameDoc
describe/about mirrors handler { }/get: describe subject { ... } runs to completion on its own, producing a plain Description, and about absorbs it into the surrounding rdf { } document — the same two-CE composition pattern Frank core already uses for handler { } feeding resource { }'s get. A bare triple subject predicate value operation is also available for one-off statements.
Node.blank () mints an anonymous node for values with no natural IRI (like the review above) — each call is GUID-backed, so blank nodes minted by two independently-built Docs never collide when merged via Doc.merge/includeDoc.
Available Operations
prefix "name" "uri"- Declares a CURIE namespace mappingabout (describe subject { ... })- Absorbs aDescriptionbuilt by a nesteddescribe { }blocktriple subject predicate value- Asserts a single statement directlyincludeDoc otherDoc- Merges another independently-builtDocin (same asDoc.merge)typ "prefix:Type"- Assertsrdf:typepropertyString/propertyInt/propertyBool/propertyDateTime/propertyNode- Asserts a property, picked by the value's type (five distinct operations rather than one overloadedproperty, since F#'s custom-operation overload resolution can't reliably disambiguate by argument type across calls in the same block)Node.blank ()- Mints a fresh, GUID-backed blank node for a subject/object with no natural IRI
Serializing
Doc.toGraph doc- Builds aVDS.RDF.GraphDoc.writeJsonLd doc writer- Streams expanded-form JSON-LD straight into aSystem.IO.TextWriter(e.g. wrappingHttpResponse.Body), without materializing the whole document as a string firstDoc.toJsonLd doc- Convenience wrapper returning the JSON-LD as astring
Output is always expanded-form JSON-LD: no @context, every predicate and type expanded to its absolute IRI. There is no compact-form option.
Related Packages
Has no dependency on Frank, but is designed to serve JSON-LD documents from Frank resources — see sample/Frank.Rdf.Sample for a runnable demonstration, including Doc.merge folding shared facts (a publisher record) into each per-resource document.
See the project repository for the complete guide and sample applications.
License
| 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
- dotNetRdf.Core (>= 3.5.1)
- FSharp.Core (>= 10.1.302)
-
net8.0
- dotNetRdf.Core (>= 3.5.1)
- FSharp.Core (>= 10.1.302)
-
net9.0
- dotNetRdf.Core (>= 3.5.1)
- FSharp.Core (>= 10.1.302)
NuGet packages (2)
Showing the top 2 NuGet packages that depend on Frank.Rdf:
| Package | Downloads |
|---|---|
|
Frank.Validation
Hand-authored SHACL Core validation for Frank resources, built on Frank.Rdf |
|
|
Frank.Provenance
PROV-O provenance recording and querying for Frank resources, built on Frank.Rdf |
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)