Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ A request has one `DbContext`, and two queries on it must not overlap. A Blazor

Work that outlives a request, such as a bulk operation or a check run, is a job in the `Jobs` table (ADR-0010). Enqueue it with `IJobQueue.EnqueueAsync(new NewJob(type, payload) { Subject = ..., RunAt = ..., MaxAttempts = ... })` inside the unit of work of the change that needs it, so the job commits with the change or not at all; outside a unit of work it throws, as the audit log does. The job records the current initiator, and its handler runs as them, so what it changes names whoever asked for it. A handler for work no person or agent does, such as the check run a submission starts, names a `SystemInitiator` in `RunAs`, and its jobs run as that engine whoever enqueued them, while the job still records who did (ADR-0012). Core lists each such handler with its engine in `EngineJobs`; the worker fails, without running it, a job whose handler names an engine it is not listed for, and a job an engine enqueued for a handler that names no engine, so a plugin's handler never runs as an engine. `IJobQueue.GetAsync` and `ListForSubjectAsync` read a job's state (`queued`, `running`, `succeeded`, `failed` or `dead`), attempts, progress and last error.

A handler implements `IJobHandler` (an extension point) with the type name its jobs store, which never changes once released. It reads the payload from `JobContext.Job`, resolves services from `JobContext.Services`, a scope of its own, and reports progress with `ReportProgressAsync`, which also keeps its lease; when that returns false another worker has the job, and the handler stops. A returned failure ends the job as `failed`. An exception retries it after `JobQueue__RetryDelay`, doubled for each attempt up to `JobQueue__MaxRetryDelay`, and after its last attempt the job is `dead`. Work in a handler therefore has to be safe to run again.
A handler implements `IJobHandler` (an extension point) with the type name its jobs store, which never changes once released. It reads the payload from `JobContext.Job`, resolves services from `JobContext.Services`, a scope of its own, and reports progress with `ReportProgressAsync`, which also keeps its lease; when that returns false another worker has the job, and the handler stops. A returned failure ends the job as `failed`. An exception retries it after `JobQueue__RetryDelay`, doubled for each attempt up to `JobQueue__MaxRetryDelay`, and after its last attempt the job is `dead`. Work in a handler therefore has to be safe to run again. Each handler has a class derived from `JobHandlerConformanceTests` that names its type.

`JobQueue__WorkerCount` workers in the host claim due jobs every `JobQueue__PollingInterval`. A claim holds a job for `JobQueue__LeaseDuration`, which the worker renews at a third of it while the handler runs. A renewal that fails is tried again while the lease holds, and no wait or renewal lasts past the lease's end. The handler's cancellation token fires as soon as the lease is lost or its end passes with no renewal, because another worker may then claim the job; a handler should honour it. A job whose worker stopped is claimed again once the lease runs out, counting another attempt. A host that stops while a handler runs puts the job back in the queue with its attempt given back. Claims are conditional updates on the row, so with any number of workers, in one host or several, each job runs once at a time.

Expand All @@ -132,13 +132,15 @@ An administrator adds a webhook on `/webhooks` with the URL of an endpoint and t
- **Secret.** Each webhook gets a signing secret, 32 random bytes written as `whsec_` and base64, shown once in the response that adds it. It is stored encrypted with the Data Protection key ring, so losing `DataProtection__KeysDirectory` loses the secrets too. It never appears in a list, a log or the audit log (SEC-07).
- **Audit.** Adding and disabling append `webhook.added` and `webhook.disabled`, which name the subscription.

- **Delivery.** Publishing a change set queues one `webhook.deliver` job per enabled subscription that wants the event, in the publish's unit of work (`IChangeSetEvents`), so publishing never waits for an endpoint. A revert's publish sends `change_set.reverted` alone. The job POSTs JSON in the Standard Webhooks shape: `type`, `id` (the same as `webhook-id`), `timestamp` and `data`, with the set's id, rationale, sources and a `link` to it in the admin, its items (kind, id, operation, version) and, for a revert, the set it reverted. Set `Webhooks__AdminUrl` to make the link absolute. A 2xx answer delivers it. Anything else, a timeout (`Webhooks__Timeout`, ten seconds by default) or a network error is retried with the job queue's growing delay, up to `Webhooks__MaxAttempts` attempts in all. A redirect is never followed. A host taken off the allow-list is refused at sending and not retried. Every attempt is in `WebhookDeliveries`, and `/webhooks` shows the latest beside each subscription, with a button that sends a `webhook.test` delivery.
- **Delivery.** Publishing a change set queues one `webhook.deliver` job per enabled subscription that wants the event, in the publish's unit of work (`IChangeSetEvents`), so publishing never waits for an endpoint. A revert's publish sends `change_set.reverted` alone. The job POSTs JSON in the Standard Webhooks shape: `type`, `id` (the same as `webhook-id`), `timestamp` and `data`, with the set's id, rationale, sources and a `link` to it in the admin, its items (kind, id, operation, version) and, for a revert, the set it reverted. Set `Webhooks__AdminUrl` to make the link absolute. A 2xx answer delivers it. Anything else, a timeout (`Webhooks__Timeout`, ten seconds by default) or a network error is retried with the job queue's growing delay, up to `Webhooks__MaxAttempts` attempts in all. A redirect is never followed. A host taken off the allow-list is refused at sending and not retried. Every attempt is in `WebhookDeliveries`, and `/webhooks` shows the latest beside each subscription, with a button that sends a `webhook.test` delivery. A publish listener implements `IChangeSetEvents` (an extension point) and passes `ChangeSetEventsConformanceTests` on both databases.
- **Verifying.** Each request carries `webhook-id`, `webhook-timestamp` (Unix seconds) and `webhook-signature`: `v1,` and the base64 HMAC-SHA256 of `{webhook-id}.{webhook-timestamp}.{body}`, keyed with the bytes of the secret after `whsec_`. A Standard Webhooks library checks it directly. A receiver should reject a timestamp more than a few minutes old, and should treat a repeated `webhook-id` as a retry of the same event.

## Extension points

Every pluggable part of the product is an interface in `FluentCMS.Core` marked `[ExtensionPoint]`. Implementations register through dependency injection, either explicitly with `AddFluentCmsExtension<TContract, TImplementation>()` or by discovery with `AddFluentCmsExtensions(assembly)`, which picks up every concrete class implementing an extension-point interface in that assembly. Both paths produce the same `IExtensionCatalog` entry and the same `IEnumerable<TContract>` resolution, so built-ins under `FluentCMS.Core.Builtin` and future plugins look identical to the rest of the product. Each interface is documented where it is defined, together with the conformance tests an implementation must pass.

Each extension point has a conformance class, an abstract test class that every implementation derives from, built in or not, marked `[ConformanceFor(typeof(...))]`. A test fails for an extension point without one, so a new extension point comes with its class. `tests/FluentCMS.Tests/Extensions/extension-points.txt` lists every extension point's members. A change to any of them fails a test until that file is updated in the same pull request: run the tests with `FLUENTCMS_UPDATE_EXTENSION_BASELINE=1` to rewrite it, and say in the pull request whether the change is additive or breaking. ADR-0013 sets the rules: before the plugin SDK ships in M3 any change is allowed, and from then on breaking changes wait for a major version. Names an extension point stores, such as a field type's or a job handler's, never change once released.

Field types (`IFieldType` in `FluentCMS.Core.Schema`) are the first. A field type has a name that definitions store, so it never changes once released, and it checks the settings a definition gives a field of its type, returning errors for missing, invalid and unknown settings instead of throwing. `FieldSettingsReader` does that bookkeeping for built-ins and plugins alike. A type whose values point at entries of another content type, or at one field of an entity type such as `product.price`, also implements `ReferenceTargets`; `IContentTypeDirectory` refuses a definition whose targets do not exist in the site, or whose field targets are not fields of an entity type, so a plugin type gets the same checks as the built-in `reference`. Such a type also lists the references each stored value makes (`References`), with a JSON Pointer to each one's place; entries write them to the reference index that where used (`IWhereUsed`, CNT-05) reads, and refuse a reference to an entry that is missing or of another type. Every type also describes its values for the schema document agents read (`DescribeValue`, SCH-06): a JSON Schema for one value, an example that satisfies it and one sentence about it. It checks each value an entry stores in a field of its type (`ValidateValue`), accepting what it describes and returning `value_invalid` errors for anything else; `IEntryDirectory` stores no version with a value its type refuses. To add one, implement the interface, then derive a test class from `FieldTypeConformanceTests` in `tests/FluentCMS.Tests/Schema` with settings the type must accept and settings it must reject, and values it must accept and reject; a type outside Core also overrides `AddFieldTypes` to register its assembly.

Media storage (`IMediaStorage` in `FluentCMS.Core.Media`, MED-04) holds the bytes of media assets. A storage has a name that every asset stores, so it never changes once released; it saves a stream and hands back an opaque key with the length and SHA-256, opens a key for reading and deletes one, and treats a key it did not hand out as not found, never as a path. The local disk storage, `local`, ships in Infrastructure. To add one, implement the interface, register it with `AddFluentCmsExtension<IMediaStorage, T>()` and derive a test class from `MediaStorageConformanceTests` in `tests/FluentCMS.IntegrationTests/Media`; `Media__Storage` then names it, and the host refuses to start with a name nothing registered.
Expand All @@ -155,7 +157,7 @@ A submitted change set is checked as the check engine (`SystemInitiator.Checks`,

The rule book (`IRuleDirectory` in `FluentCMS.Core.Rules`, RUL-01 to RUL-03) holds the team's rules as versioned content. A rule has a slug and a definition: its kind (voice and style, factual, structural, or permission and routing), its text, a severity (block or warn) and a scope of content types, fields and locales; nothing reads the locales in M1. Each version is one JSON document. Every write goes into a `ChangeSetTarget`, as content type writes do: creating and revising store a version, and retiring proposes taking the active version out, each recorded as an operation of the set with its audit event in one unit of work. A rule is a draft until a published set makes one of its versions active, and retired once a published set retires it; reverting that set makes it active again, and reverting the set that created it makes it a draft again. The change set diff shows a rule's kind, text, severity and scope before and after. Only a person writes rules; an agent or an engine is refused.

Search (`ISearchIndex` in `FluentCMS.Core.Search`, SRC-01, ADR-0010) indexes each entry's latest version in the same unit of work as the write, as the lowercase words of every string and number in its fields (`SearchText`), so a result appears the moment its write commits. PostgreSQL keeps them in a `tsvector` with a GIN index and SQLite in an FTS5 table, both outside the EF model and created by their migrations; an external engine implements the same interface, registered with `AddFluentCmsExtension<ISearchIndex, T>(ServiceLifetime.Scoped)` when it needs the request's scope. `ISearch` combines the matches with the entry rows, so deleting, restoring and publishing change results without touching the index, follows a match to the entries that reference it, and returns only types the caller may read. It reads the matches a page at a time, `Search__PageSize` (1,000 by default), and drops what the scope, deletions and filters leave out, until enough are left, the index has no more, or it has read `Search__MaxMatches` (10,000). A result that stopped there says it is not complete. Each hit has the state the entry tools show, in review included. A string that is a GUID, such as a reference, is not indexed. Field filters (`SearchQuery.Fields`) compare one type's latest field values, an entity's by its fact's value, and `SearchQuery.References` keeps entries whose latest version references an id; both are checked against the type before anything is read and applied to each page's candidates. With one type and either filter the text can be left out, and the candidates are then that type's entries.
Search (`ISearchIndex` in `FluentCMS.Core.Search`, SRC-01, ADR-0010) indexes each entry's latest version in the same unit of work as the write, as the lowercase words of every string and number in its fields (`SearchText`), so a result appears the moment its write commits. PostgreSQL keeps them in a `tsvector` with a GIN index and SQLite in an FTS5 table, both outside the EF model and created by their migrations; an external engine implements the same interface, registered with `AddFluentCmsExtension<ISearchIndex, T>(ServiceLifetime.Scoped)` when it needs the request's scope, and passes `SearchIndexConformanceTests` on both databases. `ISearch` combines the matches with the entry rows, so deleting, restoring and publishing change results without touching the index, follows a match to the entries that reference it, and returns only types the caller may read. It reads the matches a page at a time, `Search__PageSize` (1,000 by default), and drops what the scope, deletions and filters leave out, until enough are left, the index has no more, or it has read `Search__MaxMatches` (10,000). A result that stopped there says it is not complete. Each hit has the state the entry tools show, in review included. A string that is a GUID, such as a reference, is not indexed. Field filters (`SearchQuery.Fields`) compare one type's latest field values, an entity's by its fact's value, and `SearchQuery.References` keeps entries whose latest version references an id; both are checked against the type before anything is read and applied to each page's candidates. With one type and either filter the text can be left out, and the candidates are then that type's entries.

## Code style

Expand Down
51 changes: 51 additions & 0 deletions docs/adr/0013-extension-interface-versioning.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# ADR-0013: Versioning the extension interfaces

- Status: proposed
- Date: 2026-10-19
- Related: EXT-01, EXT-02, EXT-03, EXT-N1, EXT-N2, ADR-0004

## Context

Every pluggable part of the product is an `[ExtensionPoint]` interface in `FluentCMS.Core`, and the built-in features implement them the way a plugin will (EXT-01, EXT-02). In M1 and M2 only code in this repository implements them. The public plugin SDK comes in M3 (EXT-03), and from then on other people's assemblies compile against these interfaces. EXT-N2 asks that the interfaces be versioned, with breaking changes only at major versions.

Some of what an implementation relies on is not in the interface's signature: what its conformance suite checks, and the names it stores in data, such as a field type's name in a content type definition or a job handler's type in every job.

## Decision

- **The contract of an extension point** is three things:
- its `[ExtensionPoint]` interface and the public types in its members;
- the behaviour its conformance suite checks (EXT-N1);
- the names it stores, which never change once released.
- **Additive changes** keep every implementation compiling and passing its suite:
- a new extension point;
- a new member with a default implementation;
- a new optional argument on a record or options type that a caller fills in;
- a new type that existing members don't return.
- **Breaking changes**:
- removing or renaming a member;
- changing a member's signature, or the nullability of its types;
- a new member without a default;
- a stricter conformance check;
- a new value an implementation must handle in a closed set it switches on, such as an enum, unless the contract says unknown values are ignored;
- renaming a stored name.
- **Before the SDK ships (M1 and M2)**, any change is allowed, because every implementation is in this repository and is changed in the same pull request. Each change still updates the baseline below and says in the pull request whether it is additive or breaking, so the M3 SDK starts from a known surface.
- **From the SDK release**:
- The interfaces follow the product's semantic version. A minor release takes only additive changes. A breaking change waits for the next major.
- A member to be removed is first marked `[Obsolete]` with its replacement, for at least one minor release, and goes at the next major.
- **A surface baseline** in `tests/FluentCMS.Tests/Extensions/extension-points.txt` lists every extension point and its members, with their types and nullability.
- A test compares the interfaces with it and names each member that differs.
- A pull request that changes an extension point updates the baseline, and its reviewer judges the change against the rules above.
- **Every extension point has a conformance class**: an abstract test class that every implementation derives from, built in or not, marked `[ConformanceFor(typeof(...))]`. A test fails for an extension point that none covers.

## Consequences

- A change to an extension point can't slip through review unseen. The baseline diff shows it, and the rule test names it.
- A new extension point lands with its conformance class, so a plugin author has the contract to test against from the start.
- The baseline covers the interfaces' own members. A change to a type that only appears in a signature, such as `JobContext`, shows up in that type's own review, not in the baseline.
- Stored names are part of the contract, so renaming a built-in field type, check type, tool or job type is breaking even when no signature changes.

## Alternatives considered

- **`Microsoft.CodeAnalysis.PublicApiAnalyzers`** with `PublicAPI.Shipped.txt`: it tracks the whole public surface of `FluentCMS.Core`, not just the extension points. It adds a package and is noisy for a library that isn't published yet. The baseline test tracks only what plugins implement.
- **A separate contracts assembly**: the cleanest way to version the interfaces apart from the rest of Core, but ADR-0004 keeps M1 to a few projects, and nothing is published until M3. M3 can still move the interfaces without breaking source compatibility.
- **No rule before M3**: simplest, but the SDK would start from whatever the interfaces drifted to, with no record of which changes were breaking.
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,6 @@ Technical decisions for FluentCMS v2. Product decisions live in `docs/requiremen
| [0010](0010-in-database-search-and-job-queue.md) | In-database full-text search and a durable job queue | accepted |
| [0011](0011-admin-component-set-and-styling.md) | Admin component set and styling | accepted |
| [0012](0012-system-initiator-for-engine-driven-actions.md) | A system initiator for actions the product takes on its own | accepted |
| [0013](0013-extension-interface-versioning.md) | Versioning the extension interfaces | proposed |

Deferred to their own ADR when the work starts: CI and release pipeline, container image layout.
Loading
Loading