Adopt Durable operational assessments
Source of truth
For the historical schema-11 runtime heartbeat cleanup rollout, use the heartbeat retention operations guide. The current role recipe requires schema 12 and must not be run on an older store; use the recipe matching that historical package until the coordinated upgrade. The schema 9→10 transcript below remains the historical runtime-health adoption proof. For retry-policy choices, start with the execution-policy guide and its migration checklist.
This is the task guide for upgrading an existing PostgreSQL worker or external activator to the additive Durable operational-assessment contract. Start here if you need to decide whether activation is currently authorized, whether one pump invocation actually entered application execution, or how to roll the package back safely. The package reference remains in the Durable, Provider, and PostgreSQL READMEs.
Audience and boundary
For shared-store deployments with separately identified worker lanes, this guide is the operator start page. The PostgreSQL provider reference defines the version-1 manifest and exact grant profiles; the local two-pair walkthrough shows a disposable database proof. The boundary is deployed credentials and operational identity on one store, not PostgreSQL-enforced row isolation by lane.
| You are | Start with | The provider decides | Your application still owns |
|---|---|---|---|
| Existing worker host | existing-host recipe | Store observation, schema/epoch compatibility, admission, pass bookkeeping | Process lifetime, traffic readiness, retry policy, authorization |
| External activator | authoritative admission | Whether this invocation entered RunPassAsync |
Wake transport, endpoint policy, retry schedule |
| Diagnostics-only consumer | health predicates | Current provider control-plane assessment | Application liveness and user-facing status mapping |
| Schema owner | migration and roles | Required schema and role boundary | Review, apply, and audit credentials |
| Custom pump implementer | custom composition | The four-kind public algebra | One shared singleton under both pump interfaces |
| Rollback operator | rollback | Reader/writer compatibility range | Stop/disable activation and deploy the selected binary |
For an external wake endpoint that needs a separate cooperative request deadline and common outcome/telemetry mapping, use the canonical external activation reference and its authenticated reference host. The direct-admission recipe below remains useful when a caller intentionally owns the provider's four-kind attempt contract.
This case is provider-first. Apply only migrations embedded in the exact package you are deploying, and verify
PostgreSqlDurableRuntimeSchemaManager.RequiredVersion before rollout. The #794 package embeds
0010_runtime_health_observation.sql; registration remains passive and never applies it.
Health predicates
DurableRuntimeHealthSnapshot exposes four computed predicates. Use their names instead of reconstructing policy from
SchemaCompatible, EpochCompatible, timestamps, or exception text:
var health = await healthClient.GetAsync(cancellationToken);
Console.WriteLine(
$"state={health.State}; observed={health.WasStoreObserved}; " +
$"can-enable={health.CanEnableActivation}; " +
$"can-attempt={health.CanAttemptPump}; ready={health.IsReady}");
| Predicate | Meaning | Important non-meaning |
|---|---|---|
WasStoreObserved |
The provider published a complete authoritative-store assessment | false does not mean the store is incompatible; it means it was unavailable to observe |
CanEnableActivation |
This assessment authorizes activation under schema and epoch rules | Not a permanent deployment toggle or application-liveness result |
CanAttemptPump |
A host precheck says a new pass is appropriate now | Advisory only; it must not be a check-then-act gate |
IsReady |
The runtime control plane is healthy and compatible | Does not prove HTTP readiness, dependency liveness, or item success |
NotStarted and Stale may enable activation and may attempt a pass so the provider can establish or take over a
heartbeat. Draining may remain activation-compatible while refusing a new pass. Unavailable fails closed for
activation because compatibility was not established. Healthy with either compatibility flag false is also not
activatable; synthetic snapshots must fail closed through the computed predicates.
Before the first heartbeat, the PostgreSQL provider can return State=NotStarted with ProblemCode=ASDUR404 because
worker identity, heartbeat time, or active heartbeat epoch has not yet been observed. That is a valid initial health
assessment, not a reason to rewrite provider state: CanEnableActivation may be true while IsReady remains false.
Host-owned assessment responses preserve the observed state and code. External activation results retain ASDUR404 or
ASDUR405 only for Stale; a successful NotStarted precheck omits the initial ASDUR404 from the activation result.
See the canonical result validation matrix.
ObservedAtUtc is a timestamp for the assessment: database statement time for an observed store, process time for an
Unavailable assessment. Use WasStoreObserved as the provenance signal.
Replace handwritten readiness
Delete component-level expressions such as:
// Old: duplicates provider policy and misclassifies an unobserved store.
var canEnable = health.SchemaCompatible && health.EpochCompatible;
var ready = health.State == DurableRuntimeHealthState.Healthy
&& health.SchemaCompatible
&& health.EpochCompatible;
Use the computed contract instead:
// New: the provider owns state, compatibility, and observation semantics.
var canEnable = health.CanEnableActivation;
var canAttempt = health.CanAttemptPump; // Display/precheck context only.
var ready = health.IsReady;
var observed = health.WasStoreObserved;
Search the host for other uses of SchemaCompatible, EpochCompatible, and DurableRuntimeHealthState. Keep the raw
fields only where they are displayed as diagnostic evidence; do not use them to recreate activation or readiness
policy. Add Unavailable = 5 to exhaustive enum/serialization handling.
Existing-host recipe
Authoritative admission
Update ForgeTrust.AppSurface.Durable, ForgeTrust.AppSurface.Durable.Provider, and
ForgeTrust.AppSurface.Durable.PostgreSql to the same #794 package version. Keep the existing PostgreSQL registration,
separate dispatcher/runtime data sources, and explicit AddWorkerHost() choice. The new consumer resolves the
admission-aware interface alongside the legacy interface, but calls admission directly. It does not read health and
then separately gate the call:
using ForgeTrust.AppSurface.Durable;
using ForgeTrust.AppSurface.Durable.Provider;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
static async Task RunOnePassAsync(
IServiceProvider services,
ILogger logger,
CancellationToken cancellationToken)
{
var request = new DurableRuntimePumpRequest(
maximumItems: 32,
timeBudget: TimeSpan.FromSeconds(10),
surfaces: DurableRuntimeSurface.All);
// Authoritative admission owns the check and the transition into one bounded pass.
var attempt = await services
.GetRequiredService<IDurableRuntimePumpAdmission>()
.TryRunOnceAsync(request, cancellationToken);
switch (attempt.Kind)
{
case DurableRuntimePumpAttemptKind.Completed:
var result = attempt.Result!;
logger.LogInformation(
"Durable pass completed: discovered={Discovered}, processed={Processed}, failed={Failed}",
result.Discovered, result.Processed, result.Failed);
// Completed means terminal pass bookkeeping completed, not that every item succeeded.
break;
case DurableRuntimePumpAttemptKind.Refused:
logger.LogDebug("Durable pass was refused before RunPassAsync.");
break;
case DurableRuntimePumpAttemptKind.Unavailable:
logger.LogWarning("Durable store was not observed; problem={ProblemCode}.", attempt.ProblemCode);
break;
case DurableRuntimePumpAttemptKind.Incompatible:
logger.LogError("Durable store rejected this runtime; problem={ProblemCode}.", attempt.ProblemCode);
break;
}
}
The four-kind switch is intentionally exhaustive. Completed always has a non-null result, including a zero-valued
empty pass. Refused, Unavailable, and Incompatible have no result and prove only that this invocation did not
enter RunPassAsync; they say nothing about a previous invocation whose response was lost, another process, or
item-level external effects. Caller cancellation, exceptions escaping application execution, malformed provider
state, and finalization failures propagate. Try does not mean “never throws.”
Existing hosts may continue using IDurableRuntimePump.RunOnceAsync; its source and binary shape remain supported.
External activators may keep direct IDurableRuntimePumpAdmission calls when they intentionally own the four-kind
result mapping. A host adopting the standard health/deadline/outcome/telemetry lifecycle should use
IDurableExternalActivationService.
Expected output is one and only one attempt classification:
Durable pass completed: discovered=1, processed=1, failed=0
An empty admitted pass is still Completed with zero counts. A refusal prints only Refused; an observation failure
prints Unavailable with ASDUR103; a schema/epoch rejection prints Incompatible with the corresponding stable
problem code. Exceptions and cancellation do not become an output kind.
Custom composition
The PostgreSQL implementation must be one singleton behind both public interfaces so local pass serialization cannot be bypassed. Pre-register a complete replacement before provider registration, then assert the resolved identities:
var customPump = new MyDurablePump(/* custom provider dependencies */);
services.AddSingleton(customPump);
services.AddSingleton<IDurableRuntimePump>(customPump);
services.AddSingleton<IDurableRuntimePumpAdmission>(customPump);
services.AddAppSurfaceDurablePostgreSql(
dispatcherDataSource,
runtimeDataSource,
workOptions,
scheduleOptions);
using var provider = services.BuildServiceProvider();
if (!ReferenceEquals(
provider.GetRequiredService<IDurableRuntimePump>(),
provider.GetRequiredService<IDurableRuntimePumpAdmission>()))
{
throw new InvalidOperationException(
"The legacy and admission-aware pump registrations must share one singleton.");
}
Assert reference equality in composition tests. Replacing only the legacy interface or only the admission interface
is unsupported for concurrent use because it can create independent process-local slots. A custom provider may use
an in-process fake for contract tests, but a fake must not be described as PostgreSQL admission or used to certify a
real store. MyDurablePump must implement both interfaces over one process slot; its admission method must perform the
authoritative pre-execution decision rather than delegating through a separate check. The
packed PostgreSQL consumer compiles this registration pattern,
exercises all four legal outcomes, and records zero execution calls for each returned non-completed outcome.
Diagnostics and recovery
The public contract deliberately keeps outage detail coarse. PostgreSQL diagnostics should attach only low-cardinality operation, phase, cause, and code fields; never SQL, exception text, connection strings, role names, payload ids, scope ids, or aggregate ids.
| Public evidence | Cause | Safe fix | Retry safety | Execution certainty |
|---|---|---|---|---|
Health or admission Unavailable / ASDUR103 |
Transport, pool exhaustion, or connectivity failure | Check the selected data source, network, pool capacity, and database availability | Retry by host policy after the cause clears | This invocation did not enter RunPassAsync |
Health or admission Unavailable / ASDUR103 |
Provider deadline or query/backlog pressure | Inspect the full health query plan, exact due-row aggregation, pool wait, and command bounds before increasing budgets | Retry by host policy only after diagnosing the elapsed bound; do not infer incompatibility | This invocation did not enter RunPassAsync |
Health or admission Unavailable / ASDUR103 |
Permission denied (42501) |
Rerun and verify the canonical role recipe | Do not retry blindly; reconcile grants first | This invocation did not enter RunPassAsync |
Incompatible / ASDUR400–ASDUR403 |
Observed schema is missing, old, too new, or inconsistent | Review status, generate a forward-only script, apply it as migration owner, rerun roles | Do not retry until schema and role reconciliation completes | This invocation did not enter RunPassAsync |
Incompatible / ASDUR108 |
Observed runtime epoch does not authorize this process | Follow the authorized epoch-rotation/recovery procedure | Do not retry until the configured epoch is authorized | This invocation did not enter RunPassAsync |
Refused |
Local overlap, closed gate, draining, active store pass, or lost worker generation | Wait for the current pass, finish shutdown, or replace/reconcile the stale worker as appropriate | A new attempt is policy-controlled after the refusal cause clears | This invocation did not enter RunPassAsync; the prior/concurrent invocation may have |
| Exception before execution | Malformed data, undefined object, unclassified provider failure, or caller cancellation | Preserve and diagnose the original exception; fix the provider/store contract or cancellation owner | Do not classify or silently retry from exception text | Application execution did not begin |
| Exception after execution begins | Application failure, finalization failure, or cleanup failure | Inspect durable state and the original exception; cleanup is bounded and secondary | Do not retry automatically | Execution may have begun; do not treat it as a safe refusal |
Shared-store role-pair choice
| Choice | What it provides | Operational cost and boundary |
|---|---|---|
| One shared pair | Lowest credential and review overhead | Both lanes share deployed identity; it does not meet a separate-lane credential/audit requirement. |
| Two pairs on one store | Distinct dispatcher/runtime credentials and secret rotation per deployed lane, with one StoreId and active epoch | One shared failure domain and database. Existing RLS scopes and Work registration rules retain their meaning; neither role pair nor a Work selector partitions rows by lane. |
| Separate stores | Independent database failure domains and store-level access administration | Requires separate StoreIds/epochs and explicit cross-store coordination; it changes the shared-store semantics. Use it when that tradeoff is desired. |
Require a separately designed PostgreSQL row partition or separate store when the security requirement is independent
database-enforced row authorization or failure isolation. Transaction-local scope_id is caller-set execution context,
and Work discovery contract arrays are routing selectors; neither authenticates a lane. The work_only profile removes
direct Durable SQL access from its dispatcher, but it does not narrow the paired runtime's reviewed SQL grant set.
Migration and role reconciliation
The following is the historical #794 schema-10 rollout sequence. The role recipe matching the #794 release was paired with migration 0010; the current packaged recipe references the heartbeat-pruning function introduced by migration 0011 and schema-12 execution-policy functions introduced by migration 0012. For current package deployments, apply all migrations through 0012 before running the matching package recipe. The current recipe cannot reconcile a schema-11-or-older store; use the matching historical package recipe until migration 0012 is applied.
That historical #794 rollout was migration-first and forward-only:
- Drain/stop every durable worker and Schedule writer, disable external activators, and hold the reviewed maintenance
window through migration 10. Its partial lease-expiry index uses transactional
CREATE INDEX, so Schedule writes can block for the duration of the index build. The migration acquires its Schedule lock withNOWAITand bounds the index statement at five minutes; lock contention or a longer build rolls the transaction back for diagnosis and a later retry. - Generate and review the exact script from the installed version.
- Verify that the configured migration owner already owns the schema-9
runtime_due_dispatch_health(integer)function. If ownership drift requires correction before migration, stop and use a separately reviewed repair invocation against the currently installed schema; this is not the normal post-migration pair-enrollment run. Migration 0010 deliberately fails before DDL rather than replacing a function owned by another principal. - Apply migration
0010_runtime_health_observationwith that migration owner through the generated script or the explicit CLI apply command, taking schema 9 to schema 10. The embedded.sqlresource is a checksum-bound migration fragment whoseSET LOCALrelies on the package-generated transaction wrapper; do not pass the fragment directly topsql. - For the historical schema-10 rollout, use the schema-10 single-pair role recipe
from the matching source commit with its single-pair role arguments to reconcile object ownership,
PUBLIC EXECUTE, and the restricted dispatcher/runtime roles. That source snapshot's provider package did not include the recipe incontentFiles; that recipe predates version-1role_pairs_json, so do not pass it a current manifest. Keep the exact source commit and role inputs with the release record. - Run schema
statusandpreflight, verify the active epoch and StoreId, then smoke-test the old supported reader. - Deploy the #794 binary, exercise health and both pump interfaces, and re-enable activation.
For current package deployments, apply migration 0012 before running the matching released package's recipe with the
complete reviewed version-1 role_pairs_json manifest. The #794 historical schema-10 reconciliation above is
specific to that release and its single-pair recipe. The role recipe remains required after the applicable migration
even when the function signature is unchanged. It must leave the
migration owner as the owner of package objects/functions, revoke package-schema PUBLIC privileges, grant each
runtime only its reviewed scoped runtime capabilities (including documented heartbeat rights), and keep every
dispatcher/runtime pair plus the retention role as distinct restricted login leaves. Registration stays passive: it
never applies this migration or repairs grants at startup.
The version-1 manifest has exactly version: 1 and pairs (1–32 entries); each pair has exactly dispatcher,
runtime, and required dispatcher_profile (full or work_only). The profile matrix and full schema are in the
provider reference. A typical conversion
keeps the existing forwarding pair as full and adds the Source pair as work_only. No profile is implicit. Every
role recipe rerun uses the same complete reviewed manifest; an old one-pair rerun after enrollment must refuse before
mutation. An identical rerun preserves policy OIDs, policy definitions/targets, ACLs, owners, and effective privileges.
The recipe can reject omitted principals visible in managed policy targets and package ACLs, but catalog inference
cannot detect a prior pair after a privileged actor has erased all its catalog traces. Compare the exact file against
the prior release record for that reason.
For certificate evidence, hash the exact UTF-8 bytes of the reviewed manifest passed to the recipe and record the file path, SHA-256 digest, pair role names/profiles, released package version, schema version, both lane surface settings, and confirmation that both lanes use the same authoritative StoreId and active epoch. A whitespace or ordering edit changes the digest and needs re-review. State the claim as a deployed-lane credential boundary; do not claim PostgreSQL row isolation. The AppSurface release owner supplies package/recipe evidence; the deployment/certificate owner confirms the live role slots, host configuration, StoreId/epoch, and activation evidence. The certificate must use the words “deployed-lane boundary” and explicitly disclaim PostgreSQL-enforced per-lane row isolation.
For the Source lane, set HostedSurfaces = DurableRuntimeSurface.Work and pass DurableRuntimeSurface.Work to every
direct pump and recovery request. The provider default is All; a Work-only dispatcher cannot make a mistakenly
selected Flow or Schedule pass succeed. Prove Work discovery/pump and recovery, and verify direct Durable table,
column, sequence, Flow, and Schedule access is denied. The local example checks source dispatcher grants while its
existing end-to-end workload remains on the forwarding connection; use the deployment's real Source worker for
certificate pump evidence.
Keep Source processing closed until migration, complete-manifest reconciliation, exact preflight, the before/after forwarding proof, Source Work-only privilege and pump proof, and certificate review pass. The recipe is atomic, but policy DDL and its advisory lock can briefly wait on work; drain within the bounded maintenance procedure and do not promise zero blocking. On any error keep Source closed and repair/roll forward with the full manifest. Never use a one-pair manifest, broad grant, or destructive schema rollback as recovery. Pair retirement and profile narrowing require a separately reviewed procedure.
The historical schema-11 role recipe granted heartbeat pruning to every authorized runtime and no dispatcher. The
current recipe retains that grant and additionally requires schema 12 for execution-policy functions; do not run it
against a schema-11 store. The CLI
preflight requires both --role-pairs-file and --migration-owner-role, including for one pair, and checks every
manifest runtime against exact policy, ACL, role, function, and index evidence. The complete-manifest walkthrough
shows a one-pair and full-plus-work_only invocation using explicit per-command environment-variable names. Keep
activation closed until the canonical four-stage checklist
has actual matching candidate and public artifacts, distinct runtime credential results, both lane proofs, the
continuous deployment-owner guard, and matching StoreId/nonempty epoch. Local/source proof alone is insufficient.
For the historical #794 rollout, run this sequence from the
schema-10 source snapshot, with
APPSURFACE_DURABLE_MIGRATION_CONNECTION naming the migration-owner connection. The current checkout's CLI applies
through 0012 and does not produce this 9-to-10-only script.
$ dotnet run --project Cli/ForgeTrust.AppSurface.Cli -- \
durable schema status \
--connection-env APPSURFACE_DURABLE_MIGRATION_CONNECTION
# Review: installed 9; required 10; upgrade required.
$ dotnet run --project Cli/ForgeTrust.AppSurface.Cli -- \
durable schema script --from-version 9 \
--output /tmp/appsurface-durable-9-to-10.sql
# Review the generated file; for the historical #794 upgrade it must contain only migration 0010.
# Historical #794 only: if the schema-9 function is not already owned by appsurface_durable_owner, run that
# release's matching historical role recipe here before applying the migration. Never use the current recipe before
# migration 0012; it cannot reconcile a schema-9 store.
# Healthy installations skip this repair-only invocation.
$ dotnet run --project Cli/ForgeTrust.AppSurface.Cli -- \
durable schema apply \
--connection-env APPSURFACE_DURABLE_MIGRATION_CONNECTION \
--apply
# Expected: Durable schema: 9 -> 10; applied: 0010.
$ psql --host <database-host> --dbname <database-name> --username appsurface_durable_owner \
-v migration_owner_role=appsurface_durable_owner \
-v dispatcher_role=appsurface_durable_dispatcher \
-v runtime_role=appsurface_durable_runtime \
-v retention_operator_role=appsurface_durable_retention \
-f Durable/configure-postgresql-roles.sql
$ dotnet run --project Cli/ForgeTrust.AppSurface.Cli -- \
durable schema preflight \
--connection-env APPSURFACE_DURABLE_MIGRATION_CONNECTION
Treat the comments as expected checkpoints, not captured output from this working tree. Command wording is canonical in
the durable schema CLI guide;
operators must review actual output and the generated SQL before applying it.
Compatibility matrix
| Runtime | Schema 9 | Schema 10 with reconciled roles |
|---|---|---|
| #794 package | Upgrade required; do not activate | Supported |
v0.2.0-preview.8 |
Supported | Supported rollback Work reader/writer; mandatory exact-package smoke test |
Pre-0009 package |
Stop before role reconciliation | Unsupported |
Rollback to v0.2.0-preview.8
The named supported binary rollback target is v0.2.0-preview.8, after the migration owner has completed the role
recipe. The additive migration remains in place; do not delete migration rows or run a destructive down-migration.
| Binary | Schema 9 | Schema 10 | Action |
|---|---|---|---|
| #794 binary | Upgrade required | Current/compatible | Apply migration, reconcile the complete manifest from the matching package, and preflight before activation |
v0.2.0-preview.8 |
Current/compatible | Compatible Work reader/writer: its published range includes 10 and migration 0010 preserves the Work discovery function signature | Stop activation, deploy this binary, smoke-test status/health/heartbeat/real Work, then decide whether to continue; this exact-package proof makes no Flow or Schedule rollback claim |
Pre-0009 binary |
Not a supported post-role-recipe rollback | Not supported | Keep stopped; repair forward or restore the reviewed role posture |
If a schema-10 function defect is found, stop activation and use a reviewed corrective forward migration. Never edit
0005 or 0010 in place and never restore broad dispatcher access as a rollback shortcut.
Cancellation and shutdown
Before and during RunPassAsync, caller cancellation propagates. Immediately after RunPassAsync returns, provider
finalization owns a fresh reserve token that is not linked to caller cancellation. This prevents a caller disconnect
from rewriting completed application work as a canceled pass. A finalization deadline still propagates as a pump-level
failure because execution may have begun; failed-pass cleanup gets a separate bounded reserve and cannot mask the
original exception.
For a hosted loop, let H = HostOptions.ShutdownTimeout, T = TimeBudgetPerPass, and R = ShutdownReserve. Validate
T + (2 * R) <= H without overflow and cancel an active pass no later than shutdownStart + H - (2 * R). A
finalization cancellation must not be swallowed merely because the earlier pass token was canceled.
First-run proof and evidence
The one-command local proof is:
bash examples/durable-postgresql/run-local-proof.sh
It creates a disposable loopback PostgreSQL 16.5 container, applies the current checked-in migrations through schema 12 using the explicit CLI path, runs the current canonical role recipe, initializes a development epoch, runs the real Work/Flow/Schedule example, resolves both pump interfaces to the same PostgreSQL singleton, calls authoritative admission directly, and checks that worker startup performs no DDL.
The measurement harness is
Durable/evidence/measure-issue-794-adoption.sh.
It records command boundaries and real elapsed times only when invoked. The checked-in evidence file deliberately
marks unrun five-journey targets as not-run; it contains no fabricated timing measurements.