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
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ asked for it.

| Document | Read it before |
| --- | --- |
| [docs/design/architecture.md](docs/design/architecture.md) | changing startup, service ownership, UI threading, frontend/host contracts, API authentication, single-instance activation, or policy composition. |
| [docs/design/diagnostics.md](docs/design/diagnostics.md) | catching, logging, or reporting an exception, or touching the crash dialog, the diagnostics pane, or telemetry. |
| [docs/design/versioning.md](docs/design/versioning.md) | touching `version.json`, the build number, or any code that compares versions. |
| [ADR 0001](docs/adr/0001-releases-without-update-channels.md) | touching the update check, the update feed, or the release of a version. |
| [ADR 0002](docs/adr/0002-shared-msi-upgrade-code.md) | changing `UpgradeCode`, `MajorUpgrade` or the product version in `installer/wix`. |
39 changes: 39 additions & 0 deletions docs/adr/0003-fatal-exceptions-end-the-process.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# 0003. Fatal exceptions end the process after a report

- Status: Accepted
- Date: 2026-10-20

## Context

Bravo is a thin WinForms shell around a WebView2 UI and an in-process API. An exception that escapes a thread,
the UI thread included, leaves that shell in an unknown state. The users are business analysts, who need to know
that the application failed and need something they can pass to support.

## Decision

- `GlobalExceptionHandling` sets `UnhandledExceptionMode.ThrowException` for every thread and handles
`AppDomain.UnhandledException` and `TaskScheduler.UnobservedTaskException`.
- A fatal exception is logged, tracked through telemetry when consent allows it, saved to `ErrorReport.txt` in the
application data folder and shown in a task dialog. The runtime then ends the process.
- When telemetry is disabled, the dialog offers to send that one report; nothing is sent without that action.
- An unobserved task exception is logged and tracked, without a dialog, and the process continues.
- If the handler itself fails, `Environment.FailFast` ends the process.

## Rejected alternatives

- Continue after a UI-thread exception (`CatchException` with `Application.ThreadException`): the window stays
open in a broken state, and exceptions on other threads are not covered.
- Keep the process alive at any cost, parking faulting threads: the API and the WebView2 bridge cannot be trusted
after an unknown failure.
- Report through Windows Error Reporting: it adds native interop and an approval flow, for a report that a text
file and telemetry already carry.
- Track fatal exceptions only after the user confirms: crashes would be lost whenever the user closes the dialog,
although they consented to telemetry.

## Consequences

- No work survives a fatal exception; a crash is visible, never silent.
- The report file is overwritten by the next crash: it serves the dialog that shows it, not as a history.
- Exceptions raised before the handler is installed are not covered; see
[diagnostics](../design/diagnostics.md#startup-coverage-and-fatal-reporting).
- Code on the finalizer thread, where unobserved task exceptions arrive, must never show UI or wait.
38 changes: 38 additions & 0 deletions docs/adr/0004-bootstrap-services-outside-the-container.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# 0004. Bootstrap services are created outside the container

- Status: Accepted
- Date: 2026-10-20

## Context

Logging, policies, user settings and telemetry are needed before the ASP.NET Core host exists: the global
exception handler and the crash dialog use them, and a failure while building the host must still be reported.
The host has its own dependency injection container.

## Decision

- `BootstrapContextFactory` creates the logger factory, the policy service, the user settings and the telemetry
service, in that order, before the host. `BootstrapContext` owns them and disposes telemetry before the logger
factory.
- The host container receives them as existing instances, so it resolves them but never disposes them. Its default
`ILoggerFactory` is replaced by the bootstrap one: bootstrap and host share one logging pipeline.
- No other container exists. Services that only the host needs are registered in the host container.

## Rejected alternatives

- A second `ServiceProvider` for the bootstrap: two root containers with separate lifetimes, singletons duplicated
when a type rather than an instance is registered twice, and an ambiguous disposal order. It also turns bootstrap
code into a service locator.
- Lazily created static services that construct one another: their creation order depends on first use, which
caused re-entrancy, and their failures surface at an arbitrary caller.
- Registering factories in the host container: the container would dispose services that the crash path still
needs after the host is gone.

## Consequences

- A registration in `BravoServiceCollectionExtensions` that turns one of these instances into a container-created
service changes who disposes it and when; see
[architecture](../design/architecture.md#composition-and-resource-ownership).
- The bootstrap order is explicit code, and each step can degrade independently; see
[ADR 0005](0005-policy-failures-stop-startup.md).
- Tests build a `BootstrapContext` from fakes without starting the host.
33 changes: 33 additions & 0 deletions docs/adr/0005-policy-failures-stop-startup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# 0005. A policy read failure stops the startup

- Status: Accepted
- Date: 2026-10-20

## Context

Administrators configure Bravo through group policies in the registry: they can disable telemetry, update checks,
built-in or custom templates, or force the system browser for sign-in. User settings and telemetry are created in the
same bootstrap step, and each can fail: a corrupt settings file, an unreachable telemetry configuration, a registry
key that cannot be read.

## Decision

- `BootstrapContextFactory` reads the policies eagerly. A failure propagates and the application does not start.
- A user settings failure is logged and the defaults are used.
- A telemetry failure is logged and `NullTelemetryService` is used.

## Rejected alternatives

- Treat unreadable policies as not configured: Bravo would start with the restrictions an administrator imposed
silently removed, for example sending telemetry where a policy forbids it.
- Stop the startup on any bootstrap failure: a corrupt settings file or a telemetry problem would make Bravo
unusable, although neither puts an administrative restriction at risk.

## Consequences

- A policy registry key that cannot be read ends the process before the global exception handler exists: there is
no report and no dialog, only the runtime's Event Log entry. See
[diagnostics](../design/diagnostics.md#startup-coverage-and-fatal-reporting).
- Defaults replace user settings only for the current session; how a corrupt settings file is kept and reported to
the user is outside this decision.
- A new policy that restricts behavior is safe by construction: it cannot be bypassed by a read failure.
34 changes: 34 additions & 0 deletions docs/adr/0006-persistent-telemetry-channel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# 0006. Telemetry is sent through a persistent channel

- Status: Accepted
- Date: 2026-10-20

## Context

Host telemetry carries exceptions, and the most valuable ones are tracked right before the process ends. With the
in-memory channel a transmission that fails or is still pending at exit is lost, and `Flush` gives no confirmation
of delivery. Consent comes from a policy or from the user preference and can change while Bravo runs.

## Decision

- `TelemetryService` uses `ServerTelemetryChannel` with a local store under `.cache\.telemetry` in the application
data folder. Transmissions that cannot be sent are persisted and retried at a later start.
- Every path that ends the process or confirms a report awaits `FlushAsync` with a timeout, because the
synchronous `Flush` returns before anything is sent.
- `TelemetryConsent.Resolve` decides whether telemetry is enabled: a configured policy wins over the user
preference. Every caller that enables or disables telemetry resolves the consent first.

## Rejected alternatives

- Keep the in-memory channel and call `Flush` after each exception: the call blocks on a network request, it
cannot report a failure, and anything still pending at exit is lost.
- Send crash reports through a separate endpoint or file upload: a second transport, with its own consent handling,
for data that the telemetry pipeline already carries.

## Consequences

- An exception tracked offline reaches the service at a later start instead of being lost.
- `FlushAsync` reports that data left the process, sent or persisted, not that it was ingested.
- Transmissions in the store are sent even after telemetry is disabled: they were generated while telemetry was
enabled and consent was given.
- The store folder must exist before the channel starts; `BravoApplicationConfiguration` creates it.
92 changes: 92 additions & 0 deletions docs/design/architecture.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# Architecture and runtime boundaries

## Composition and resource ownership

[BravoApplication.Run](../../src/Host/BravoApplication.cs) resolves the single-instance role before starting
Kestrel: a secondary process redirects its activation and exits without starting a server or a window. It does so
after the bootstrap services and the host are built, so a secondary process still pays for both.

| Resource | Lifetime owner | Constraint for changes |
| --- | --- | --- |
| Bootstrap telemetry and logger factory | [BootstrapContext](../../src/Host/BootstrapContext.cs) | Keep them available until host disposal completes; telemetry is disposed before the logger factory |
| Bootstrap policy service and user settings | Bootstrap creates or obtains them; the container receives those instances | Preserve the shared instances so consumers do not silently use different configuration snapshots |
| Application instance and its pipe server | The host container owns `IBravoApplicationInstance`; that instance owns the pipe server | Releasing the pipe ends ownership of the application instance |
| Main window | `BravoApplication.Run` | The window is created through DI but is not registered as a container-owned service |

[BravoServiceCollectionExtensions](../../src/Host/BravoServiceCollectionExtensions.cs) registers the bootstrap
services as existing instances, which the container does not dispose. Replacing them with container-owned factories
moves disposal to the container and must keep the order above. Global exception handling uses the bootstrap
services, not the container; its coverage is in [diagnostics](diagnostics.md#startup-coverage-and-fatal-reporting).
Why the bootstrap services live outside the container:
[ADR 0004](../adr/0004-bootstrap-services-outside-the-container.md).

## UI and server threads

[AppWindow](../../src/Infrastructure/AppWindow.cs) is created on the STA entry thread, which runs the WinForms
message loop. API work and single-instance activation callbacks do not have that UI-thread guarantee.

- Marshal operations on the window or WebView2 to the UI thread. Activation handlers use
`ProcessHelper.InvokeOnUIThread` with the window as the control.
- Setting `SynchronizationContext.Current` is not thread dispatch.
[ProcessHelper.RunWithUISynchronizationContext](../../src/Infrastructure/Helpers/ProcessHelper.cs) changes the
ambient context only during a synchronous callback; do not treat it as permission to access controls from a
server thread.
- Keep window activation and document delivery distinct: the window can exist before WebView2 is ready to receive
a startup message.

Violating these boundaries can cause cross-thread UI failures or lost activation requests. The
[single-instance contract](#single-instance-contract) describes the current startup limitation.

## Frontend and API contract

[AppWindow](../../src/Infrastructure/AppWindow.cs) injects `CONFIG` with the API address, token, options, policies
and environment information. The frontend uses [Host](../../src/Scripts/controllers/host.ts) for API calls and
WebView messages for desktop integration. The C# serializer and TypeScript consumers must agree on field names,
nullability and enum values; changing only one side can compile in C# while breaking the UI. Follow the full-build
requirement in [AGENTS.md](../../AGENTS.md#build-and-test) for changes to this contract.

[BravoApplicationBuilder](../../src/Host/BravoApplicationBuilder.cs) configures Kestrel and endpoint authorization:

| Build | Listening address | Controller authorization |
| --- | --- | --- |
| Debug | IPv4 loopback, port 5000 | No blanket authorization requirement; Swagger enabled |
| Release | IPv4 loopback, dynamically assigned port | Controllers require authorization |

[AppAuthenticationHandler](../../src/Infrastructure/Authentication/AppAuthenticationHandler.cs) requires both a
loopback remote address and the application token in the `Authorization` header. The template-development token is
accepted only under the template-development controller path. Loopback binding and token validation are separate
checks; neither should be removed on the assumption that the other is sufficient.

[HostingExtensions.AddAndConfigureProblemDetails](../../src/Infrastructure/Extensions/HostingExtensions.cs) maps
exceptions to responses in order, first match wins. The `SocketException` mapping also matches other exception types
that wrap a socket failure, such as `AMO.ConnectionException`, so it stays after the specific mappings and before the
catch-all. Reporting at the API boundary is in [diagnostics](diagnostics.md#api-boundary).

## Single-instance contract

[InstancePipeName](../../src/Host/InstancePipeName.cs) identifies one instance per Windows session and user SID,
independent of installation mode and elevation. Keep its application identifier, scope identifier and naming format
stable across releases: changing them allows versions that share user data to run as independent owners.

[SingleInstanceServer](../../src/Infrastructure/SingleInstance/SingleInstanceServer.cs) arbitrates ownership
through a named pipe with one server instance. It disconnects between clients without releasing the pipe name. Do
not replace that with disposal and recreation between requests: another process could claim ownership in the gap.

Current activation limits in [BravoApplicationInstance](../../src/Host/BravoApplicationInstance.cs):

- A delivered payload does not guarantee that the requested document opened. Requests arriving before the window
or WebView subscriptions are ready are not buffered.
- Activation callbacks can run concurrently and in a different order from arrival.
- Different elevation levels still share an instance name, but pipe access can prevent activation redirection. A
redirect failure is logged and tracked without a user-facing dialog.

## Configuration boundaries

[PolicyService](../../src/Infrastructure/Policies/PolicyService.cs) exposes a snapshot initialized once.
[RegistryPolicyReader](../../src/Infrastructure/Policies/RegistryPolicyReader.cs) merges machine and user policies,
with configured machine values taking precedence. A null policy value means not configured; `false` is an explicit
policy, not a request to use a default.

User preferences remain mutable through [UserPreferences](../../src/Infrastructure/Configuration/UserPreferences.cs),
and saving them does not reload policies. A consumer that combines a policy with a preference applies the precedence
itself, as [TelemetryConsent](../../src/Infrastructure/Telemetry/TelemetryConsent.cs) does for telemetry.
Loading
Loading