fix(cache): a sub-second TTL no longer becomes a permanent entry on the Dapr provider - #105
Conversation
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rever DaprDistributedCacheService truncated the requested expiry to whole seconds and dropped the ttlInSeconds metadata when the result was zero, which Dapr reads as "no expiry" — the shortest lifetime a caller can ask for became the longest one. The absolute and sliding branches also disagreed on what a sub-second request meant. Both branches now share one computation that rounds up and never falls below the store's one-second granularity. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…over An absolute expiry in the past, or a non-positive sliding expiration, means the entry is already dead. Storing it was writing a permanent entry. The write is now skipped and the span carries cache.skipped=true. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…gative DateTimeOffset.MaxValue as an absolute expiry wrapped the unchecked int cast to int.MinValue, sending a negative ttlInSeconds to the state store. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…xample Documents Dapr's one-second floor and the round-up behaviour, and replaces the AbsoluteExpirationRelativeToNow example — that property does not exist on DistributedCacheEntryOptions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Basic Get/Set sample used the same non-existent property that the Expiration Options sample did, so a reader copying it still got a compile error. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…he skip The span tags the design cited as a substitute for logging only exist under the Verbose tracing profile, so a skipped write is silent by default; the skip is also reachable from GetOrSetAsync whenever the fetch outlives a short absolute expiry, which the design wrongly called a caller bug. Both are now recorded, along with the upgrade note for entries already stored without a TTL, the per-provider meaning of cache.ttl_seconds, and the unspecified out-of-range cast behaviour that made the overflow test unreproducible on ARM64. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reviewer's GuideThe PR fixes Dapr cache expiration by converting absolute and sliding lifetimes through one shared, upward-rounding and overflow-safe path, skipping writes whose requested lifetime has elapsed, and adding targeted provider-level tests plus documentation and design records for the resulting behavior. Sequence diagram for Dapr cache writes with safe TTL handlingsequenceDiagram
participant Caller
participant DaprCache as DaprDistributedCacheService
participant DaprStore as Dapr state store
Caller->>DaprCache: SetAsync(key, value, options)
DaprCache->>DaprCache: Compute requestedTtl
alt requestedTtl <= TimeSpan.Zero
DaprCache->>DaprCache: SetTag(cache.skipped, true)
DaprCache-->>Caller: Return without writing
else positive requestedTtl
DaprCache->>DaprCache: ToStoreTtlSeconds(ttl)
DaprCache->>DaprCache: Round up and clamp to int.MaxValue
DaprCache->>DaprStore: SaveStateAsync(key, value, ttlInSeconds)
DaprStore-->>DaprCache: Save completed
DaprCache-->>Caller: Return
else no expiration options
DaprCache->>DaprStore: SaveStateAsync(key, value)
DaprStore-->>DaprCache: Save completed
DaprCache-->>Caller: Return
end
Flow diagram for Dapr TTL conversionflowchart TD
A[Requested cache options] --> B{AbsoluteExpiration set?}
B -->|Yes| C[absolute - UtcNow]
B -->|No| D{SlidingExpiration set?}
D -->|Yes| E[Use sliding duration]
D -->|No| F[No TTL metadata]
C --> G{requestedTtl <= zero?}
E --> G
G -->|Yes| H[Skip SaveStateAsync]
G -->|No| I[ToStoreTtlSeconds]
I --> J[Ceiling to whole seconds]
J --> K[Minimum 1 second]
K --> L[Clamp to int.MaxValue]
L --> M[Write ttlInSeconds with invariant formatting]
F --> N[SaveStateAsync without TTL]
M --> O[SaveStateAsync]
File-Level Changes
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars. ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Advanced Run ID: Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Hey - I've found 1 issue
Prompt for AI Agents
Please address the comments from this code review:
## Individual Comments
### Comment 1
<location path="framework/docs/distributed-cache/README.md" line_range="117-120" />
<code_context>
+non-positive `SlidingExpiration`), the Dapr provider skips the write rather than storing an entry
+that would never expire. Any previous value under that key is left in place.
+
+An `AbsoluteExpiration` computed before a slow fetch can elapse before the write happens, and the
+write is then skipped — this is exactly what `GetOrSetAsync` does, since it builds `options` before
+awaiting `fetchFunc` and only calls `SetAsync` afterwards. Prefer `SlidingExpiration` for short
+lifetimes: it is measured at write time rather than against a timestamp fixed earlier, so a slow
+fetch cannot make it negative.
+
</code_context>
<issue_to_address>
**nitpick:** The documentation states that `GetOrSetAsync` builds `options` before awaiting `fetchFunc`, but `DistributedCacheBase.GetOrSetAsync` assigns default options only after the fetch completes. This makes the documented explanation inaccurate for calls that omit options, where the default sliding expiration is not created until after the fetch.
**Triggers:** When a reader relies on the documentation to reason about the timing of default expiration options.
**Suggested fix:** Explain that caller-supplied absolute options are created before the `GetOrSetAsync` call, while the method's default sliding options are assigned after `fetchFunc` completes.
```suggestion
A caller-supplied `AbsoluteExpiration` computed before a slow fetch can elapse before the write happens, and the
write is then skipped. Caller-supplied absolute options are created before the `GetOrSetAsync` call, while the method's
default sliding options are assigned only after `fetchFunc` completes. Prefer `SlidingExpiration` for short
lifetimes: it is measured at write time rather than against a timestamp fixed earlier, so a slow
```
</issue_to_address>Sourcery assessment
Needs a human reviewer. The change alters how cache entries are persisted in Dapr: a miscomputed TTL could keep stale values briefly or skip a write and leave an older value in place. Reverting stops the new behavior, and affected cache entries are bounded and repairable by expiration or clearing the cache.
| An `AbsoluteExpiration` computed before a slow fetch can elapse before the write happens, and the | ||
| write is then skipped — this is exactly what `GetOrSetAsync` does, since it builds `options` before | ||
| awaiting `fetchFunc` and only calls `SetAsync` afterwards. Prefer `SlidingExpiration` for short | ||
| lifetimes: it is measured at write time rather than against a timestamp fixed earlier, so a slow |
There was a problem hiding this comment.
nitpick: The documentation states that GetOrSetAsync builds options before awaiting fetchFunc, but DistributedCacheBase.GetOrSetAsync assigns default options only after the fetch completes. This makes the documented explanation inaccurate for calls that omit options, where the default sliding expiration is not created until after the fetch.
Triggers: When a reader relies on the documentation to reason about the timing of default expiration options.
Suggested fix: Explain that caller-supplied absolute options are created before the GetOrSetAsync call, while the method's default sliding options are assigned after fetchFunc completes.
| An `AbsoluteExpiration` computed before a slow fetch can elapse before the write happens, and the | |
| write is then skipped — this is exactly what `GetOrSetAsync` does, since it builds `options` before | |
| awaiting `fetchFunc` and only calls `SetAsync` afterwards. Prefer `SlidingExpiration` for short | |
| lifetimes: it is measured at write time rather than against a timestamp fixed earlier, so a slow | |
| A caller-supplied `AbsoluteExpiration` computed before a slow fetch can elapse before the write happens, and the | |
| write is then skipped. Caller-supplied absolute options are created before the `GetOrSetAsync` call, while the method's | |
| default sliding options are assigned only after `fetchFunc` completes. Prefer `SlidingExpiration` for short | |
| lifetimes: it is measured at write time rather than against a timestamp fixed earlier, so a slow |
Up to standards ✅🟢 Issues
|
| Metric | Results |
|---|---|
| Complexity | 14 |
| Duplication | 0 |
NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.
|
1.0.40 carries burgan-tech/aether#105: the Dapr cache provider no longer truncates a sub-second TTL to zero seconds and silently drops the ttlInSeconds metadata, which turned the shortest-lived entry a caller can ask for into a permanent one. It now rounds up, never below one second, and skips the write entirely for an already-expired request. Exactly one call site in this repo asked for a sub-second TTL — the state-function active-subflow snapshot at 500 ms — so exactly one changes behaviour: that entry now expires after ~1 s instead of living until something overwrote it. Verified in the store on the local stack: a snapshot written by this build carries PTTL 900 ms, while entries left by the previous build still show TTL -1 (no expiry). Every other TTL in the repo is a whole number of seconds (30 s and up), where ceiling is a no-op; the 60 s non-subflow path was confirmed unchanged at TTL 60. Correctness no longer depends on that TTL either way — the fingerprint carries the client-visible status since a136255 — so this is a cost change, not a behaviour fix: with the TTL finally applied, a poller slower than roughly 1 Hz rebuilds on every poll. Whether to raise ActiveSubflowTtlMilliseconds now that the value is actually honoured is a separate, measured decision. Integration: SubflowOrchestration 22/22 and ChainBusy 14/14 green against a runtime built on 1.0.40. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…polling clients (#983) * feat(state-function): make the chain reserve visible to the state fingerprint An async transition on a parent that sits in an active SubFlow answers 202 and then the client's next state poll still reports the pre-transition body: status Active, the old state, and the transition that was just accepted still listed. The client follows it and asks for a view the flow has already left. The accept is not at fault — it reserves the chain correctly, flipping the LEAF Active -> Busy under the status lock before the 202 commits. The problem is that nothing it writes is visible to the poller's validation: the parent's own status does not move (it is Busy for the subflow's whole lifetime by design), its correlation rows are untouched, and SubFlowStateChangedAt only moves when the child reports a state change. So the parent's fingerprint is bit-identical before and after the accept, and the active-subflow snapshot cached against it stays valid across exactly the transition it must not survive. On the Dapr state store that snapshot's sub-second TTL is never applied either (Aether truncates it to zero seconds and omits the metadata, leaving the entry permanent), so the stale body is served until something else happens to move the fingerprint — measured at 142 seconds in preprod. Add Instance.EffectiveStatus, the status counterpart of EffectiveState: the status a client polling THIS instance would observe — the deepest active SubFlow's status, else its own — and fold it into InstanceStateFingerprint and the state-function ETag material. The busy walk is what maintains it. It already visits every level of the chain synchronously, locally and cross-domain, so it now carries the leaf's resulting status back up the call stack and stamps each ancestor with it; the bottom of the chain owns its own visible status, so there the projection rides along in the same CAS instead of costing a second statement. The gateway answers with MarkBusyOutput for that reason, and a far side that does not report one is read as "unknown, write nothing" rather than assumed Busy. Authority is deliberately narrow: EffectiveStatus is fingerprint material only. It is never served and never read by a decision — the response still comes from the live subflow descent — so a missed propagation stays fail-stale (a cached body survives a moment too long) instead of fail-wrong. The full-build path logs the drift between the projection and the live value, which costs nothing there and is the evidence needed before anyone promotes it to a served value. This is the downward edge. The upward one — a leaf leaving Busy at its own rest point, which reaches ancestors as an event rather than a walk — follows, together with the ordering fix its out-of-order guard needs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(subflow): report a sub-item's status upward, and order notifications by sequence The downward edge made the chain reserve visible to the state fingerprint. This is the edge back: a sub-item leaving Busy at its own rest point runs in its own process, with no walk in flight, so the ancestors learn about it only through the sub:state-changed notification — and today that notification does not carry a status and, worse, is not always sent. It is not sent when an episode ends in the state it started in: a $self shared transition, a retry landing back in the same state. ChangeState arms nothing when previous == new, so the rest point publishes nothing, and every ancestor the accept stamped Busy on the way down stays Busy. Nothing later moves them — the client is long-polling a chain that has already finished, and no subsequent event exists to correct it. Unlike a dropped state change, this one does not heal. PublishPendingSubStateChange now takes the settlement's own CAS outcome as a second reason to publish, and Instance.PropagateEffectiveStateToParent no longer returns early when only the status moved, so the release walks all the way up to the level the client actually polls. Carrying the status upward also exposed the ordering guard for what it is. The receiver rejects a notification whose ChangedAt is older than the stored stamp, and ChangedAt is a wall clock from whichever pod ran the child; two consecutive episodes of the same sub-item can run on different pods. Under a skewed clock a legitimate notification is discarded, and if it was the one taking an ancestor out of Busy the deadlock above is exactly what is left. Add a per-instance notification counter, incremented inside the transaction that publishes the event and matched by a watermark on the correlation: no clock, no skew. Equal is still a duplicate and re-applied idempotently. The timestamp comparison stays as the fallback for events whose publisher predates the counter (seq 0), for the rollout window only. A notification that reports no status leaves the parent's projection untouched rather than guessing one, the same rule the downward walk uses for a cross-domain answer it cannot read. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(subflow): compare notification sequences only when both sides carry one The sequence guard measured every delivery against the correlation's watermark, including deliveries that carry no sequence at all. Once any sequenced notification had raised that watermark, every seq-less delivery for that correlation was silently dropped — a publisher that predates the counter, which is what the other half of a rolling deploy is, and a hand-driven sub/state call. Found by vnext-example's AFreshSubStateDelivery_IsApplied going red against the locally built runtime. Sequences now order a delivery only when the delivery and the correlation both have one; anything else falls back to the timestamp guard, which for that traffic is exactly the guard that was there before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(deps): move to Aether 1.0.40 for the honoured sub-second cache TTL 1.0.40 carries burgan-tech/aether#105: the Dapr cache provider no longer truncates a sub-second TTL to zero seconds and silently drops the ttlInSeconds metadata, which turned the shortest-lived entry a caller can ask for into a permanent one. It now rounds up, never below one second, and skips the write entirely for an already-expired request. Exactly one call site in this repo asked for a sub-second TTL — the state-function active-subflow snapshot at 500 ms — so exactly one changes behaviour: that entry now expires after ~1 s instead of living until something overwrote it. Verified in the store on the local stack: a snapshot written by this build carries PTTL 900 ms, while entries left by the previous build still show TTL -1 (no expiry). Every other TTL in the repo is a whole number of seconds (30 s and up), where ceiling is a no-op; the 60 s non-subflow path was confirmed unchanged at TTL 60. Correctness no longer depends on that TTL either way — the fingerprint carries the client-visible status since a136255 — so this is a cost change, not a behaviour fix: with the TTL finally applied, a poller slower than roughly 1 Hz rebuilds on every poll. Whether to raise ActiveSubflowTtlMilliseconds now that the value is actually honoured is a separate, measured decision. Integration: SubflowOrchestration 22/22 and ChainBusy 14/14 green against a runtime built on 1.0.40. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* feat(instances): move incidents to their own table and fix incident/retry defects (#972)
* feat(instances): move incidents to their own table and fix incident/retry defects
Incidents lived in the Instances.Incidents jsonb column, pruned to the
newest five, with no way for a client to read history or explain why an
instance faulted. They now live in an InstanceIncidents table with
unbounded history, cascade-deleted with the instance, alongside a
denormalized Instances.HasActiveIncident column with a partial index.
A second migration backfills the existing jsonb rows idempotently. The
legacy column stays in the database, unmapped, and is dropped by a later
release.
Two new read surfaces: the state function body always carries an
incident block (hasActiveIncident, a client-safe active summary with no
stack trace, historyHref) and GET .../instances/{instance}/incidents
pages the full history behind the same queryRoles gate.
ResponseShapeVersion moves to v8. HasActiveIncident joins the
fingerprint ETag so raising or resolving an incident without a state
change still invalidates a parked long-poller.
DbMigrator honours SchemaMigration:CommandTimeoutSeconds (default 600)
and LockExpirySeconds (default 900), because a platform migration over
a large table can outrun the default command timeout, and it now exits
non-zero when any schema fails instead of reporting success.
Running the new error-boundary lab in vnext-example against a local
runtime surfaced five real defects, all fixed here:
- The move migration declared its inner foreign key with
principalSchema "public". MultiSchemaNpgsqlMigrationsSqlGenerator does
not rewrite the inner keys of a CreateTableOperation, so every flow
schema pointed at public."Instances" and the backfill failed with
23503 in thirteen schemas.
- LoadActiveIncidentsAsync put no-tracking rows on the EF navigation, so
another context tracking the same aggregate re-inserted them and the
retry request died with 23505 and a half-written response. Loaded rows
now sit in a detached list.
- An abort recorded two incidents: the boundary's verdict plus a bare
pipeline row for the fault. The task steps saved before recording, so
the fault path's reload still read HasActiveIncident false and added
its fallback. The three task steps now record before their own save,
which commits the row and the flag together. One failure leaves one
incident and it carries the boundary verdict.
- A retry whose work faulted again answered "F" and then settled the
instance back to Active, because the aggregate was loaded tracked in
the ambient request unit of work and the ambient commit overwrote the
Faulted an inner scope had persisted. That left an instance looking
healthy, unfinished, and permanently unretryable. Retry now reads
no-tracking and unfaults with a compare-and-set, which also removes a
whole-graph rewrite on every unfault.
- A successful retry resolved only the newest incident, so a recovered
instance kept reporting an active one. Resolve is now set-based over
the whole open set and recomputes the flag.
ignore and log deliberately keep recording no incident and skipping the
rest of the hook, and incident.retryCount still reports 0; both are
confirmed as intended and tracked separately.
Closes #865
* refactor(instances)!: make the incident block carry links instead of content
The state function's incident block embedded the active incident, and
metadata.incident additionally embedded a newest-five history array and a
total count. That cost reads on the two hottest paths in the runtime and
duplicated what the history endpoint already returns. Worse, it left a
staleness hole: resolving incident A and raising incident B inside one
parked state moved no fingerprint member, so a client validating with
If-None-Match kept its 304 and went on showing A.
Both surfaces now carry the same shape, and it is links only:
"incident": {
"hasActiveIncident": true,
"active": { "href": ".../instances/{id}/incidents/active" },
"history": { "href": ".../instances/{id}/incidents" }
}
active is present only while the flag is true, so a client follows it
exactly when there is something to fetch; history is always present. The
identical block is metadata.incident on the single instance GET and on
every item of the list view, so a client learns one shape.
A new endpoint answers the active link: GET .../incidents/active returns
the newest unresolved incident and 404 Instance:100037 when none is open.
The 404 is a normal outcome rather than a failure, because a successful
retry resolves the incident and a client may follow the link just after
that. A caller who fails the queryRoles gate gets 403 instead, so "no
incident" and "not allowed to know" stay distinguishable.
What falls out of the indirection:
- The state function reads no incident row at all. Its
LoadActiveIncidentsAsync call existed only to fill the embedded
summary.
- The instance GET drops two queries, and the list view's per-page batch
query is gone entirely.
- Three repository methods lose their only consumer and are removed:
GetLatestAsync, CountByInstanceAsync and
GetLatestActiveByInstanceIdsAsync. So do the IncidentSummary DTO and
InstanceIncidentConstants.InlineHistoryLimit, since nothing embeds
incidents any more.
- The stale-active ETag gap is closed rather than narrowed: the body
carries only the flag, and the flag is already fingerprint material.
When the block is lifted from an active subflow, active.href addresses
the subflow that owns the incident while history.href stays on the polled
instance, because that link answers "what has gone wrong with the thing I
asked about".
ResponseShapeVersion moves to v9. This is not a breaking change in
practice: v8 ships in the same unreleased version, so the shape is
replaced rather than dual-supported, and the existing vnext-meta
migration entry is amended instead of adding a second one.
Closes #865
* docs(instances): correct the remaining v8-shape references to the v9 link block
Three places still described the embedded incident summary: the second
incident-block entry in the Cursor rules mirror, the shape-version
history and current-version note in the ETag doc, and the incidents
controller's reference to incident.historyHref.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* test(instances): pin which instance each incident link addresses when lifted from a subflow
The lifting rule had no coverage: error-boundary-lab has no subflows, so
nothing asserted that active.href addresses the leaf that owns the
incident while history.href stays on the polled instance. Reaching the
branch through GetInstanceStateAsync would need a full active-subflow
gateway setup to assert four lines, so BuildIncidentHref is internal and
tested directly, the way TaskCoordinator.ResolveGroupEngineOptions is.
The second case guards the fallback: a healthy leaf must not mask an
incident the ancestor carries itself.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(meta): correct the stale v8 claim and pin the two incident blocks to one shape
The vnext-meta validator caught an outright contradiction: the
incident-and-retry-behaviour-corrections entry still said "no response
shape changed, so ResponseShapeVersion stays v8". That was true when it
was written, and false once the block became links in the same release.
It now points at the entry that owns the shape change. The sibling
entry's title still advertised the inline history cap and the
active-only list view its own description says were removed, so it is
retitled too.
stateIncident.apiEndpoints omitted the two instance-read surfaces the
description now anchors its metadata.incident claim on; both are listed.
The validator also observed that "a client learns one shape" rested on
nothing enforceable: the state block and metadata block are two
independently hand-written DTOs, and adding a field to one and
forgetting the other compiles and ships. IncidentBlockShapeTests pins
the equality by reflection, and separately pins that neither block
carries incident content and neither link type carries anything but an
href. Verified by mutation: adding TotalCount back to IncidentInfoDto
turns two of the three red.
Also merges a duplicated XML summary block left on BuildIncidentHref.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
* Feature/plan mode agent council (#973)
* Added council
* docs(agents): consolidate AI guidance into a single source and add council decision log
Make AGENTS.md the one bootstrap file for every coding agent and turn CLAUDE.md into a thin
import (@AGENTS.md + Claude skills + local overrides). Drop the duplicated pipeline step table
from the bootstrap and link the single copy in .claude/rules/vnext-workflow-developer.md.
Remove the hand-maintained Cursor copies: Cursor loads .claude/skills/ directly, so
.cursor/skills/ is deleted, and the three .cursor/rules/*.mdc files become 8-line pointers
that @-include the matching .claude/rules/*.md. The stale vnext.mdc (missing nine sections of
the workflow-developer rule) is replaced by dotnet-coding-standards.mdc.
Gather decision history under docs/: move the root .superpowers/ reports to
docs/superpowers/reports/ and the .cursor/plans/ file to docs/superpowers/plans/ with dated
names. Add docs/agent-council/sessions/README.md as the council decision log and make the
agent-council skill append a row there when a decision is recorded.
Document the layout in AGENTS.md (AI guidance layout), docs/agent-onboarding.md (editing the
AI guidance), docs/agent-council/README.md (Run It) and the root README.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* docs(agent-council): configure the Council Chair and record standing decision precedents
Name the Chair identity so High/Critical decisions no longer close as Awaiting Chair,
and add the review gates and architectural decisions already taken in this repository
so proposals must argue explicitly before contradicting them.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* docs: move council sessions and superpowers records to git-ignored ai-docs, record first council decision
- Agent Council session folders now live under ai-docs/agent-council/sessions/ (local scratch);
the committed record is the one-row decision log in docs/agent-council/sessions/README.md.
Skill, rule, PROCESS/README/CHAIR and onboarding repointed accordingly.
- docs/superpowers/{plans,specs,reports} (65 dated design records) removed from the tree and kept
locally under ai-docs/superpowers/; history remains in git. Every inbound reference from live
docs removed; the AI guidance layout table in AGENTS.md is the single mention.
- First council decision logged: SSE push channel → EXPERIMENT_REQUIRED (Awaiting Chair).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* feat(local): one-command multi-domain runtime via run-docker.sh with per-domain records
Extend etc/docker/run-docker.sh from an infra launcher into the single local entry point:
- `up <domain>` brings up the docker infra plus the Dapr sidecars, runs DbMigrator and starts
orchestration/execution/inbox/outbox (optionally monitor) as locally built binaries, waiting on
/health. `down`, `switch`, `restart`, `status`, `logs`, `domains`, `plan` manage them.
- Domains run side by side with the vnext-runtime port-offset scheme (app ports base+offset; core
is offset 0 and keeps the compose sidecars). Offset domains get their own <service>-<domain>
sidecar set generated from docker-compose.yml and vnext-<domain>-… app-ids; Dapr ports are
derived from the app port because published localhost ports collide under base+offset*100.
Colliding offsets are refused; offset 5 stays reserved for discovery.
- No tracked file is edited: each host receives its launch profile's environment with APP_DOMAIN,
the connection string, Dapr ports/app-ids and cross-host references overridden per process.
Hosts run as `dotnet <dll>` so stored pids are the real processes.
- DbMigrator's swallowed per-schema failures are detected from its log; the script refuses to run
when the docker infra belongs to another compose file with the same project name.
- Every up/down writes ai-docs/local-environments/<domain>.md (+ README index, environments.json)
with ports, app-ids, database, logs and the reproduce command.
- dev/stage compose stacks take a domain too: services read APP_DOMAIN / VNEXT_DB through
${VAR:-default} interpolation instead of the fixed .env values.
Document the flow in README, AGENTS.md (incl. an agent runbook for "bring up domain X") and
agent-onboarding; point scripts/set-domain.sh at the new entry point.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* feat(local): register domains in the vNext CLI and run a per-domain init publisher from run-docker.sh
- `up` registers the domain in `wf` (API port + database) so `wf domain use X && wf sync`
publishes to the right host; the active domain is never switched by the script
- every offset domain gets its own `init-<domain>` package publisher on 3005+offset aimed at
that domain's orchestration; core starts the default `init` on 3005
- sidecar readiness waits on `/v1.0/healthz/outbound`, which turns 204 once components load
(plain `/healthz` stays 500 until the app binds its port, which happens later)
- migrator failure grep no longer trips pipefail when there are zero schema failures
- AGENTS.md runbook: component loading step (wf use/check/sync, async runtime publish,
known `wf check` quirk); README documents CLI registration and the init publisher
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Update .gitignore
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Tayfun Yılmaz <tyilmaz@burgantech.com>
* docs(agents): commit the integration-test policy, repo map and runtime-integration-test skill (#974)
The team's integration-test policy, the sibling-repo map, the Aether "propose, don't edit"
rule and the Helm parity rule only existed in a git-ignored CLAUDE.local.md, so no other
developer's agent knew them and four committed files pointed at a file that was not there.
- docs/testing/integration-testing.md: the contract — when an integration test is required,
where tests live (vnext-example, VNext.Testing.Sdk), the `../<repo>` sibling rule, bringing
up the locally built runtime, VNEXT_BASE_URL external mode, scenario/README/TEST-SCENARIOS
obligations, debugging via Elastic/OpenObserve/MockLab, the Aether local-feed procedure and
its revert-before-PR rule, Helm config/resource parity, PR reporting, known gaps.
- .claude/skills/runtime-integration-test: the procedure that executes that contract.
- AGENTS.md: "Platform repositories" table (GitHub URL, purpose, when to consult, trust rules)
replacing the Context7 block, plus an integration-test paragraph under Testing; the
duplicated Context7 block in dotnet-coding-standards.md now points at that table.
- create-github-pr: pre-flight guard that refuses to open a PR while a `-local` Aether feed
is live, and an optional "Integration test evidence" section in the body template.
- docs/README.md, docs/agent-onboarding.md, CLAUDE.md: index rows and skill bullet;
agent-council and cross-domain-lab skills repointed from CLAUDE.local.md to the new doc.
- .gitignore: resolve merge-conflict markers committed in 4e879215 (both `.vnext-local/` and
`graphify-out/` stay ignored).
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
* Refactor/instance status postgres cas (#975)
* refactor(instances): drop redundant distributed lock and transaction from the Busy CAS path
The instance status flip (Active<->Busy) is already a set-based compare-and-set
(TryTransitionStatusAsync: UPDATE ... WHERE Status = @expected), whose own WHERE clause
and Postgres row lock during the UPDATE already provide the real mutual exclusion.
Two layers wrapped around it were doing nothing the CAS didn't already guarantee:
- ReserveAsync/TakeOverAsync (TransitionAdmissionService) and the resting-status flip
(TransitionSettlement) each additionally acquired the Dapr distributed status lock before
calling the CAS, but run nothing else that needs serializing alongside it (unlike
AcceptAsync, which still holds the lock to also serialize the duplicate-active-job
guard's check-then-insert — untouched here). Removed the lock acquisition from these
three call sites; behavior is unchanged, one fewer Dapr round-trip per admission/settle.
- InstanceBusyManager's four CAS-wrapping methods (MarkBusyAsync, MarkBusyWithPropagationAsync,
TryMarkBusyWithPropagationAsync, TryReleaseAsync) opened an explicit database transaction
(IsTransactional = true) around a read-then-CAS-write pair where the read is purely
informational (fail-fast classification, subflow propagation target) and never gates the
write's condition. Removed IsTransactional; each statement now runs on its own pooled
connection instead of an eagerly-opened, explicitly BEGIN/COMMIT'd one. Verified against
Aether's CompositeUnitOfWork source: CommitAsync/RollbackAsync are no-ops when no
transaction was opened and no domain events are pending, and early-return paths (Skipped/
AlreadyBusy) no longer trigger a wasted rollback round-trip.
Also fixed a latent bug surfaced while touching this file: MarkBusyWithPropagationAsync
propagated Busy to a subflow even when the parent instance was already Completed (only the
already-Busy case is intentional — see the existing
MarkBusyWithPropagationAsync_WhenAlreadyBusyParent_ShouldStillPropagateToSubflow test). A
completed parent's correlation is being closed, not extended, so propagation now returns
before that call.
Verified with a full solution build, the full Application test suite (no regressions beyond
pre-existing unrelated flaky tests), and by rebuilding and running all five affected Docker
images (orchestrator/execution/inbox/outbox/db-migrator) against three live multi-domain
Docker Compose stacks; OpenObserve traces post-deploy show the CAS UPDATE statement itself
averaging under 3ms with no elevated latency or error-rate change attributable to this
change.
Remaining, not done in this commit: the distributed lock in AcceptAsync, the subflow
completion/fault/cancellation lock (TransitionLockScopeFactory), and
PostCommitParentMutationService's lock stay as they are — each protects something the CAS
alone does not (see code comments added at each of the touched call sites for the
distinction). A live two-process concurrency reproduction test and a vnext-example
integration test are still recommended before this change is considered fully verified,
per the repo's integration-test policy for locking-adjacent pipeline changes.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* refactor(instances,tasks): drop three more unnecessary IsTransactional=true wrappers
Follow-up audit after the InstanceBusyManager fix: grepped every remaining
`IsTransactional = true` usage and read each one's body to judge whether the transaction is
load-bearing or, like the busy-flag CAS, protecting nothing beyond what the write's own
guard already provides.
- InstanceCancellationService.ProcessStateTransitionsCancellationAsync: the transaction
wrapped two informational reads, a LOOP of external scheduler cancel calls (network I/O,
no DB), and one final set-based write (MarkManyAsProcessedAsync). The reads never gated
that write's WHERE clause, so the transaction was only holding a pooled DB connection open
across an entire loop of unrelated scheduler round-trips.
- StandardTaskPersistenceStrategy.HandleCreationAsync: an idempotency check-then-insert,
same shape as the accept-time duplicate-job guard flagged as needing serialization — but
here the real protection is that task execution for one instance is already serialized by
the per-instance Busy/CAS mutex (confirmed by tracing every consumer of
ITaskPersistenceStrategy: TaskExecutionEngine, invoked only from TaskCoordinator's pipeline
step and FanOutTaskExecutor's inline items — no standalone/replay path). InstanceTask also
raises no domain events for a transaction to coordinate.
- StandardTaskPersistenceStrategy.HandleCompletionAsync: a single set-based UPDATE with no
read at all — the clearest case, matching its own doc comment.
Left alone: InstanceCommandAppService.PrepareInstanceAsync (a real multi-write aggregate
creation that needs atomicity) and both IsTransactional sites in InstanceRetryAppService
(each wraps two independent CAS writes — TryUnfaultAsync + ResolveAllAsync — where losing
the shared transaction would change partial-failure behavior; left as a separate, harder
question rather than decided here).
Updated the two TaskPersistenceStrategyTests assertions that checked options.IsTransactional
was true.
Verified with a full solution build and the full Application test suite (same pre-existing,
unrelated flaky failures as before — validators/caching/scripting/view-content/job-timeout-
recovery/subflow-terminal-revert — no new ones).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
* feat(discovery): cache registry reads behind the default provider (#976)
Under ServiceDiscovery:Provider=http every cross-domain hop paid a registry
GET — ~35 call sites across the trigger task executors and the Remote* app
services, measured at ~80 ms each — so a cross-domain subflow start burned
that before doing any work.
Adds a read-through cache in front of IDiscoveryRegistryClient: in-process L1,
shared distributed L2, live registry on a miss, plus a hosted service that bulk
reads every registration once per window under a distributed lock.
Scoped to the DEFAULT provider only. Under `dapr` the plain registry client is
registered and nothing on that path changes: it derives app-ids by convention,
makes no network call on its common path, and its own IMemoryCache stays the
only cache there — two multiplying TTLs is a staleness window nobody can reason
about mid-incident. The scope is a registration rule, not a branch in the cache.
A cache of this shape was shipped and deleted before (79da3b6f, "the bulk domain
cache's staleness risk is not worth its latency saving"). That verdict was right
about that implementation, which had four defects this design exists to avoid:
it revalidated over HTTP on every hit against an endpoint with no conditional
-request support, so a hit cost the full 80 ms; it rewrote the whole blob with a
fresh TTL on any miss, making the bound unbounded under traffic; it followed
links.next, which carries the REMOTE's gateway base path, and swallowed the 404
— caching one page forever with nothing logged; and it cached DiscoveryEndpoint,
whose Kind depends on the caller, under a domain-only key.
Notable decisions:
- The cached record carries FetchedAtUtc, validated on read. IDistributedCache
Service is Dapr-backed, so an absolute expiration becomes the state store's
ttlInSeconds — which a component without TTL support ignores SILENTLY.
Correctness must not depend on which component is configured.
- Window 1 h, entry age 2 h, sized to how often a domain's address actually
moves. POST utilities/discovery/refresh (un-deprecated, now synchronous and
reporting its outcome) is what covers the moving case, not a short window.
- Refresh is guarded by a marker AND a lock: the marker defines the window and
prevents the stampede a released lock would allow; the lock serializes racers
inside it. The lease is always released, unlike the once-per-rollout
registration guard. A dead holder stalls at most lease + tick.
- Off by default in code, on in the orchestration host's appsettings. This
reverses a shipped decision and domain teams inherit code defaults.
Cache:Enabled=false restores the previous behaviour literally.
- The bulk read gets its own named HttpClient so a failing refresh cannot trip
the circuit breaker guarding the path a miss falls back to.
- Startup validation of L1Ttl < Tick < Refresh < L2Ttl: every one of these
degrades the cache silently when broken.
Verified end to end in the vnext-example cross-domain lab against locally built
images (Provider=http, 11/11 green). Elastic APM Discovery.Resolve/*: 78 cache /
1 registry, p50 0.182 ms vs 100.7 ms for the forced miss. One pod performed the
single bulk read; the other did none and served all 13 of its resolutions from
the shared cache.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* Integrate graphify knowledge-graph navigation (#977)
* feat(agents): integrate graphify knowledge-graph navigation
Adds .graphifyignore to keep the knowledge graph free of generated EF Core
migration scaffolding, Postman/Mockoon exports, and lockfiles. Adds a
graphify-first navigation rule (.claude/rules, mirrored for Cursor, wired
into AGENTS.md) so agents query the graph before grepping/reading broadly
once graphify-out/graph.json exists.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* docs(readme): document graphify install, graph build, and post-commit hook
Onboards graphify next to the existing Agent Council section: uv/graphifyy
install steps (including the PATH fix via `uv tool update-shell`), how to
build the graph, and `graphify hook install` for auto-rebuild on commit.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
* feat(events): relay sub-state changes post-commit and lock the receiver (#978)
* feat(events): relay sub-state changes post-commit and lock the receiver
A subflow's state change reached its parent only through outbox -> Dapr
pub/sub -> Inbox worker -> sub/state. On preprod (2026-09-08, instance
6aa217b9...) that path took 6 min 30 s when the Dapr Redis pub/sub
connection pool was exhausted. Council session
2026-09-08-substate-postcommit-relay (Chair-approved 2026-09-09) decided to
give InstanceSubStateChangedEvent the same immediate post-commit delivery the
three subflow terminal events already had, on a reusable abstraction.
Registration is the opt-in. The switch in SubflowTerminalRelay is replaced by
IPostCommitEventRelay<TEvent> resolved from DI by the event's runtime type --
the same open-generic shape PostCommitExecutor uses for IPostCommitHandler<TJob>,
plus a per-type MethodInfo cache because this path runs on every hop. Adding a
relayed event is one class plus one AddScoped line; removing that line is the
kill switch. There is no marker interface and no central switch to edit.
ISubflowTerminalEvent stays as the shape the three terminal relays read their
route and span tags from, and their mappers move verbatim.
The fast path walks the whole ancestor chain, not one level. The runner only
sees events raised in its own hop's unit of work, so a parent that is itself a
subflow raised the grandparent's event where the runner could never see it.
SubflowStateService is therefore a second dispatcher call site: it snapshots
the aggregate's events before saving, commits, releases its lock, and hands
them back. Capped by PostCommitRelayDispatcher.MaxRelayDepth; past the cap the
event still travels the outbox.
SubflowStateService now takes the same per-sub-item lock the three terminal
paths take. It was the only parent-mutation path with no lock at all, which
made its SubFlowStateChangedAt read-check-write a real TOCTOU: two deliveries
could both read the same stamp, both pass the check, and the OLDER one land
last. Both delivery paths land in this service, so that duplicate is now
routine rather than rare.
The council's part 1 proposed a CAS on the correlation row instead. It is not
implemented, deliberately: every terminal path mutates the correlation and the
parent in one SaveChanges batch and EF emits Instances before
InstancesCorrelations, so the write order is always P -> C. A correlation-first
CAS inverts that to C -> P with an aggregate load in between and deadlocks
(40P01) against the terminal paths on the hot path. The lock is the mechanism;
the write order stays P -> C. Recorded as an addendum on the decision.
Also here:
- RequiresNew + IsTransactional on the sub-state write. Not for the lock, for
atomicity: without a transaction Aether stages the outbox rows after
UpdateAsync(autoSave) already committed the Instance row, so the upward event
and the state write could diverge.
- FindForSubflowStateChangeAsync: tracked parent, only this child's open
correlation, no DataList. The default detail load pulls the whole instance
data history, unsplit, on the runtime's highest-volume subflow signal.
- Equal ChangedAt stays ACCEPTED; only strictly-older is rejected. A duplicate
delivery carries the same stamp, re-applying it is idempotent, and rejecting
it would close the only recovery path a redelivery has.
- Span renamed to PostCommit.EventRelay, new vnext.delivery.role=relay tag, and
outcomes extended with timeout and depth_exceeded. EventIds 40124-40126 kept
so existing dashboards resolve; 40129-40134 added.
- The sub-state Inbox handler is tagged vnext.delivery.role=backup, like the
three terminal handlers.
- Chair precedent reworded: dual delivery is granted per event type by
registration, each requiring a durable backup, an idempotent order-safe
receiver guard, measured latency evidence and a council row.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* perf(events): coalesce sub:state-changed to one event per activation episode
Chair review on #978: notify the parent at finish states and when the
instance becomes available — the point of the upward notification is the
parent's progression, so the state changes inside an auto-transition chain are
noise. Only the chain's final position is actionable.
Instance.ChangeState no longer publishes; it ARMS. TransitionSettlement
publishes once, at the activation episode's rest point, through
Instance.PublishPendingSubStateChange -- inside the pipeline's unit of work,
so the event and the state it describes still commit together.
The value the parent ends up with is unchanged. The old per-hop burst's LAST
event carried the final CurrentState and so does the single coalesced event;
only the count changes. PreviousState becomes the state the episode started in
(nothing reads it -- pinned as unread by the parent-notification council).
Rest point is deliberately broader than "Active": a parked auto-gate
(BusyParked) and a Busy-subtype state are rest points too -- the instance is
sitting there waiting for input, and its state is exactly what the parent
should see. ShouldPublishSubState is also broader than the activation verdict,
because a lost CAS or an already-Active owner yields no verdict while the
state that hop wrote is still real.
Two exclusions. An open SubFlow correlation: the parent is Busy for the child's
lifetime and the state the client observes is the child's, so the parent's own
move into the SubFlow state is an intermediate superseded by the child's
notification travelling up. And Faulted: InstanceSubFaultedEvent already
carries the faulted state upward; this channel reports progression, not
failure.
Two things that are load-bearing and easy to remove by accident:
- Creation flushes explicitly. InstanceCommandAppService pre-positions a new
instance into its initial state and commits in a unit of work that has no
settlement. When a child's start transition targets its own initial state --
subflow-orchestration-child (child-initial) and -grandchild
(grandchild-initial) both do -- nothing moves afterwards, so the pipeline's
rest point has nothing to publish and this is the parent's ONLY notification
for that child. Without it the parent's effectiveState strands at the
previous value; four integration tests caught exactly that.
- The settlement re-drains the aggregate. ChangeStateStep calls
ExtractAndDeferInstanceEvents the moment it changes the state, long before
settlement, and both the outbox staging and the post-commit relay read
DeferredEvents -- so an event published after that drain would reach neither.
The re-drain is guarded on an ACTUAL publish: only the pipeline consumes
DeferredEvents, while PostCommitParentMutationService settles through the
same method and consumes nothing, so an unconditional drain there would move
Instance.Fault's upward event into a list nobody reads.
Measured on the subflow-orchestration suite (11 tests, same workload before and
after): 53 -> 47 receiver applications, 22 -> 19 relays. Modest here by
construction -- these example flows change state at most once per subflow
episode, so there is little to coalesce. The win is on production-shaped
chains, where the council measured 6 facts per 908 ms chain against a ~1.3 s
delivery with 7.5% of 42803 deliveries writing nothing at the receiver.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* chore(runtime): remove the Monitor API host and prune unused Dapr components (#982)
Every Dapr component a sidecar loads costs a client and a connection pool
whether or not the application ever calls it. An audit of the actual
consumption points in code — the type that injects the abstraction, or the
DaprClient call, not the DI registration, which is lazy and proves nothing —
found several components provisioned for hosts that never touch them.
Remove the Monitor API host
The read-only Monitor API (base path /api/v1/monitor, port 4203) is dropped in
full: both projects, its test project, etc/monitoring/ (6 Dapr components plus
the sidecar config), the compose services and env files, the run-docker.sh host
entry and its --monitor flag, the solution entries and docs/monitoring/. The
Helm chart never had a monitoring deployment, and CI never built its image, so
neither needed a change. vnext-meta/deprecations.json records the removal as a
breaking change for dashboard consumers, and features.json no longer advertises
the monitor incidents endpoint.
docs/monitoring/correlation-and-tracing.md was the runtime's tracing contract
rather than Monitor API documentation, and four other pages link to it, so it
moves to docs/runtime/ instead of being deleted with the rest.
Prune the Dapr components
lock dropped from execution, inbox and outbox — no consumer in
those processes; kept on orchestration (Busy mutex,
resource locks, discovery, schema migration) and on
db-migrator (SchemaMigrationOrchestrator)
state dropped from inbox and outbox — neither registers a
distributed cache; kept on execution, where StateStoreTask
and CacheAsideTask fall back to DAPR_STATE_STORE_NAME
pubsub-broadcast dropped from execution and inbox; deliberately kept on
orchestration for a planned pod-to-pod invalidation path
configuration (vnext-config) stays on every host by decision, even though the
Dapr Configuration API is never called.
The execution host's AddDistributedCache and AddDistributedLock calls go with
the components: nothing in that process resolved IDistributedCacheService,
IDistributedLockService or IResourceLockService.
Drop the eager Redis multiplexer and the placement service
Aether's AddRedis() opens a ConnectionMultiplexer at startup, and vNext has no
IConnectionMultiplexer consumer anywhere — so orchestration, execution and
db-migrator each held an unused Redis connection pool and an avoidable startup
dependency. The call, the Redis appsettings sections and the Redis__* env
entries are removed; the Redis server itself stays as the backing store for the
Dapr state, lock and pubsub components.
No code uses Dapr actors and every state component sets actorStateStore:
"false", so the dapr-placement container, every --placement-host-address
sidecar argument and the DAPR_PLACEMENT_HOST env key are gone as well.
Fix the execution host's store names
.env.execution.dev/.stage and etc/execution/dapr/config.yaml named
vnext-execution-{state,pubsub,secret} while the provisioned components are
vnext-{state,pubsub,secret}. Latent so far — Vault:Enabled is false locally and
no example flow authors a StateStoreTask without an explicit storeName — but
the secret store scope would have failed the execution host at startup the
moment Vault was switched on. All four references now match the components.
docs/runtime/dapr-component-footprint.md records the resulting host ×
building-block matrix with its evidence, the components kept though unused, the
two pubsub name differences that are correct and must be left alone, and the
mirrored work list for vnext-helm-charts (component scopes:, per-app DAPR_* env,
actors/placement off, dead Redis__* wiring).
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix(state-function): make the subflow chain's status visible to long-polling clients (#983)
* feat(state-function): make the chain reserve visible to the state fingerprint
An async transition on a parent that sits in an active SubFlow answers 202 and
then the client's next state poll still reports the pre-transition body: status
Active, the old state, and the transition that was just accepted still listed.
The client follows it and asks for a view the flow has already left.
The accept is not at fault — it reserves the chain correctly, flipping the LEAF
Active -> Busy under the status lock before the 202 commits. The problem is that
nothing it writes is visible to the poller's validation: the parent's own status
does not move (it is Busy for the subflow's whole lifetime by design), its
correlation rows are untouched, and SubFlowStateChangedAt only moves when the
child reports a state change. So the parent's fingerprint is bit-identical before
and after the accept, and the active-subflow snapshot cached against it stays
valid across exactly the transition it must not survive. On the Dapr state store
that snapshot's sub-second TTL is never applied either (Aether truncates it to
zero seconds and omits the metadata, leaving the entry permanent), so the stale
body is served until something else happens to move the fingerprint — measured at
142 seconds in preprod.
Add Instance.EffectiveStatus, the status counterpart of EffectiveState: the
status a client polling THIS instance would observe — the deepest active
SubFlow's status, else its own — and fold it into InstanceStateFingerprint and
the state-function ETag material.
The busy walk is what maintains it. It already visits every level of the chain
synchronously, locally and cross-domain, so it now carries the leaf's resulting
status back up the call stack and stamps each ancestor with it; the bottom of the
chain owns its own visible status, so there the projection rides along in the
same CAS instead of costing a second statement. The gateway answers with
MarkBusyOutput for that reason, and a far side that does not report one is read
as "unknown, write nothing" rather than assumed Busy.
Authority is deliberately narrow: EffectiveStatus is fingerprint material only.
It is never served and never read by a decision — the response still comes from
the live subflow descent — so a missed propagation stays fail-stale (a cached
body survives a moment too long) instead of fail-wrong. The full-build path logs
the drift between the projection and the live value, which costs nothing there
and is the evidence needed before anyone promotes it to a served value.
This is the downward edge. The upward one — a leaf leaving Busy at its own rest
point, which reaches ancestors as an event rather than a walk — follows, together
with the ordering fix its out-of-order guard needs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(subflow): report a sub-item's status upward, and order notifications by sequence
The downward edge made the chain reserve visible to the state fingerprint. This
is the edge back: a sub-item leaving Busy at its own rest point runs in its own
process, with no walk in flight, so the ancestors learn about it only through
the sub:state-changed notification — and today that notification does not carry
a status and, worse, is not always sent.
It is not sent when an episode ends in the state it started in: a $self shared
transition, a retry landing back in the same state. ChangeState arms nothing
when previous == new, so the rest point publishes nothing, and every ancestor
the accept stamped Busy on the way down stays Busy. Nothing later moves them —
the client is long-polling a chain that has already finished, and no subsequent
event exists to correct it. Unlike a dropped state change, this one does not
heal. PublishPendingSubStateChange now takes the settlement's own CAS outcome as
a second reason to publish, and Instance.PropagateEffectiveStateToParent no
longer returns early when only the status moved, so the release walks all the
way up to the level the client actually polls.
Carrying the status upward also exposed the ordering guard for what it is. The
receiver rejects a notification whose ChangedAt is older than the stored stamp,
and ChangedAt is a wall clock from whichever pod ran the child; two consecutive
episodes of the same sub-item can run on different pods. Under a skewed clock a
legitimate notification is discarded, and if it was the one taking an ancestor
out of Busy the deadlock above is exactly what is left. Add a per-instance
notification counter, incremented inside the transaction that publishes the
event and matched by a watermark on the correlation: no clock, no skew. Equal is
still a duplicate and re-applied idempotently. The timestamp comparison stays as
the fallback for events whose publisher predates the counter (seq 0), for the
rollout window only.
A notification that reports no status leaves the parent's projection untouched
rather than guessing one, the same rule the downward walk uses for a
cross-domain answer it cannot read.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(subflow): compare notification sequences only when both sides carry one
The sequence guard measured every delivery against the correlation's watermark,
including deliveries that carry no sequence at all. Once any sequenced
notification had raised that watermark, every seq-less delivery for that
correlation was silently dropped — a publisher that predates the counter, which
is what the other half of a rolling deploy is, and a hand-driven sub/state call.
Found by vnext-example's AFreshSubStateDelivery_IsApplied going red against the
locally built runtime. Sequences now order a delivery only when the delivery and
the correlation both have one; anything else falls back to the timestamp guard,
which for that traffic is exactly the guard that was there before.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* chore(deps): move to Aether 1.0.40 for the honoured sub-second cache TTL
1.0.40 carries burgan-tech/aether#105: the Dapr cache provider no longer
truncates a sub-second TTL to zero seconds and silently drops the ttlInSeconds
metadata, which turned the shortest-lived entry a caller can ask for into a
permanent one. It now rounds up, never below one second, and skips the write
entirely for an already-expired request.
Exactly one call site in this repo asked for a sub-second TTL — the
state-function active-subflow snapshot at 500 ms — so exactly one changes
behaviour: that entry now expires after ~1 s instead of living until something
overwrote it. Verified in the store on the local stack: a snapshot written by
this build carries PTTL 900 ms, while entries left by the previous build still
show TTL -1 (no expiry). Every other TTL in the repo is a whole number of
seconds (30 s and up), where ceiling is a no-op; the 60 s non-subflow path was
confirmed unchanged at TTL 60.
Correctness no longer depends on that TTL either way — the fingerprint carries
the client-visible status since a1362554 — so this is a cost change, not a
behaviour fix: with the TTL finally applied, a poller slower than roughly 1 Hz
rebuilds on every poll. Whether to raise ActiveSubflowTtlMilliseconds now that
the value is actually honoured is a separate, measured decision.
Integration: SubflowOrchestration 22/22 and ChainBusy 14/14 green against a
runtime built on 1.0.40.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* add task-history and action-history system functions over the task jo… (#981)
* add task-history and action-history system functions over the task journal
* rename task-history/action-history function keys to tasks/actions
* perf(instances): stop evaluating extensions on sync start and transition responses (#984)
A sync=true start or transition ran the workflow's Extensions while projecting
its response and returned them under `extensions`. Nothing read them: the client
workflow manager never consumed the field, and a service-to-service caller
already receives `attributes`. Extensions are enrichment, not data, so they
belong to the read surfaces — which still run them unchanged.
The pass cost an extension task round (HTTP calls included) plus a ScriptContext
build — which serializes the instance's whole latest data — on every sync
transition, for a response field no client read.
- EnrichOutputCoreAsync no longer calls IInstanceExtensionService, and the
dependency is gone from InstanceCommandAppService's constructor: the guarantee
is now enforced by the compiler, not by a flag.
- Extensions were the only other consumer of the projection's ScriptContext, so
building one is now gated on workflow.Output actually having mapping code (and
the instance not being a subflow, as before). A workflow without an output
script builds none.
- The `extensions` key stays on the response as an always-empty map, so the
response shape is unchanged for existing clients.
- The `?extensions=` query parameter is removed from start and transition — and
from the internal sub-start, which bound it only to discard it under
SuppressResponseEnrichment, and from the cross-domain calls in
RemoteInstanceCommandAppService which forwarded it to the target runtime. An
old caller that still sends it is not rejected; it is simply not bound.
- Dead after the removal: WorkflowLogs.ExtensionProcessingFailedNonBlocking and
the vnext.extensions.requested span tag.
The read path is untouched: InstanceQueryAppService still runs extensions for
the instance GET, the instance list, the data function and the extensions
endpoint.
Recorded in vnext-meta under 0.0.93 (migrations + deprecations + manifest);
common.props is left at 0.0.90 for the release process.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* chore(tooling): local MCP servers and an evidence rule for verifying local runs (#985)
* chore(tooling): register the local OpenObserve MCP server
Add `.mcp.json` with the `openobserve` HTTP MCP server pointing at the local
instance that `etc/docker/run-docker.sh` starts (`http://localhost:5080`, org
`default`), so agents query runtime logs and traces during local development and
test runs instead of inferring them from host stdout.
The Authorization header is the compose-file root login, already committed in
plaintext in `etc/docker/docker-compose{,.dev,.stage}.yml` — this adds no new
secret exposure.
Document the server and that usage expectation in `CLAUDE.md`, next to the
project skills, including the fact that it only answers while the docker infra
is up.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* chore(tooling): register the local MCP servers and warn when they are down
Declares the project-scoped MCP servers for the local stack in .mcp.json —
openobserve (logs and traces on :5080), postgres, elasticsearch, redis —
and enables them in .claude/settings.json.
Adds a SessionStart hook, .claude/hooks/check-mcp-infra.sh, that reports
which of those backends are unreachable so a session knows up front which
checks it cannot run. It deliberately starts nothing: the docker infra may
be owned by another compose file, and taking that over is never the
hook's call.
The OpenObserve credential is the compose-file root login, not a secret
beyond what etc/docker/docker-compose*.yml already carries.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs(agents): verify local runs through the MCP servers
Adds an always-on rule making observability evidence part of verifying a
change locally: after exercising it, confirm the result through the MCP
servers in .mcp.json (openobserve traces and logs, postgres rows, redis
keys) and quote the measured numbers. A green test run is not evidence
on its own.
The rule also records what a full example-suite run against a locally
built runtime taught us, so it is not rediscovered:
- three traps that produced wrong conclusions — a failing suite that was
really a missing MockLab / partner domain / system task, a span gap
that was an episode traced in a sibling lane, and durations that were
a scenario's own batchTimeoutSeconds and seeded MockLab delays;
- OpenObserve query mechanics (single `vnext` stream, top-level `type`,
microsecond ranges, `body` not `message`, broken csv output format,
numeric span_kind);
- why the postgres server reports CONNECT_TIMEOUT when Aether_WorkflowDb
does not exist yet, and how to tell that apart from a broken server;
- what to do when a backend is unreachable — say which check did not
run, use the host-log and docker exec fallbacks, and never claim a
timing result without trace data.
Follows the AI guidance layout: single source under .claude/rules/, an
8-line pointer under .cursor/rules/, linked from the AGENTS.md rule list
and from the CLAUDE.md MCP section.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(tracing): extend the span tree to every non-pipeline route, with live evidence (#986)
* perf(instances): stop evaluating extensions on sync start and transition responses
A sync=true start or transition ran the workflow's Extensions while projecting
its response and returned them under `extensions`. Nothing read them: the client
workflow manager never consumed the field, and a service-to-service caller
already receives `attributes`. Extensions are enrichment, not data, so they
belong to the read surfaces — which still run them unchanged.
The pass cost an extension task round (HTTP calls included) plus a ScriptContext
build — which serializes the instance's whole latest data — on every sync
transition, for a response field no client read.
- EnrichOutputCoreAsync no longer calls IInstanceExtensionService, and the
dependency is gone from InstanceCommandAppService's constructor: the guarantee
is now enforced by the compiler, not by a flag.
- Extensions were the only other consumer of the projection's ScriptContext, so
building one is now gated on workflow.Output actually having mapping code (and
the instance not being a subflow, as before). A workflow without an output
script builds none.
- The `extensions` key stays on the response as an always-empty map, so the
response shape is unchanged for existing clients.
- The `?extensions=` query parameter is removed from start and transition — and
from the internal sub-start, which bound it only to discard it under
SuppressResponseEnrichment, and from the cross-domain calls in
RemoteInstanceCommandAppService which forwarded it to the target runtime. An
old caller that still sends it is not rejected; it is simply not bound.
- Dead after the removal: WorkflowLogs.ExtensionProcessingFailedNonBlocking and
the vnext.extensions.requested span tag.
The read path is untouched: InstanceQueryAppService still runs extensions for
the instance GET, the instance list, the data function and the extensions
endpoint.
Recorded in vnext-meta under 0.0.93 (migrations + deprecations + manifest);
common.props is left at 0.0.90 for the release process.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs(council): record the route-trace-coverage session decision
Session 2026-09-11-route-trace-coverage, Awaiting Chair. Scope cut from ~40 span
families to 8 approved plus 6 conditional; the hot path settled unanimously on
zero added span documents on the 304 branch; the merge gate moved in-process so
nothing has to be started for the work to merge.
The session folder itself is git-ignored local scratch (team decision
2026-09-07), so this row carries the verdict, the selected approach and the open
conditions on its own.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(tracing): repair the four places the trace tree was wrong
Council 2026-09-11-route-trace-coverage, Part A (APPROVED).
1. JobTimeoutRecoveryService emitted the activation span three times — the same
block, comment included, copied twice. A recovered job timeout produced three
Instance.Activation spans, two detached from the commit that made the rest
point durable, and three samples in the activation histogram for one
activation. Only the call carrying settlingCommit survives.
2. The sub/state relay endpoint could not adopt the child's lane, and the cause
was deeper than a missing Reset: InstanceSubStateChangedEvent was not
lane-aware and SubFlowStateChangedInput carried no lane or episode fields, so
there was nothing to reset from. The carrier is now lane-aware end to end —
event, DTO, both mappings, and the Reset on the endpoint. It meets the
interface's own criterion: the consumer takes the per-sub-item lock and runs
its own transactional unit of work, which is a top-level operation, not an
informational read. All four episode fields travel together.
3. ChildSubflowFaultService opened no span while its cancel twin did, so a
downward fault cascade — the leg that terminates children when a parent
faults — was the one child-termination path invisible in a trace. Its two
early returns now say which "nothing happened" they mean, instead of reading
like a completed fault.
4. TransitionContinuationRequested carried no RootInstanceId, so its handler
could set no root baggage and DaprOrchestrationForwarder could not stamp
X-Root-Instance-Id. The field is nullable and deliberately NOT required: the
contract has seven required members and System.Text.Json enforces them, so a
required addition would poison-loop the Inbox on outbox rows written before
the deploy.
The JobTimeoutRecoveryService tests never ran the service. They mocked
BeginAsync while the service calls the synchronous Begin, so the mock returned
null, the body threw on the first await and the service's own catch swallowed
it — every test in the class exercised the catch block. That is how a
triplicated emit survived with all behavioural assertions green. Fixing the mock
repaired two long-failing tests and made the new guard meaningful.
The new guard counts spans rather than asserting behaviour, because only a
cardinality invariant catches this defect class; verified red by reintroducing
the duplicate and green by removing it again.
Also corrects two stale documents: CHAIR.md and the workflow rule file both said
a lane carrier copies "all three" episode fields. There have been four since
092b9415, and a carrier author following that text ships a partial episode.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(tracing): span the cross-domain hop, the authorization decision and the function cache
Council 2026-09-11-route-trace-coverage, Part B (APPROVE_WITH_CONDITIONS), the
items that do not touch the files the effective-status-fingerprint plan rewrites.
Remote.Send/{clientType} — every Remote* service makes exactly one outbound
cross-domain call and it was visible only as whatever client instrumentation
happened to draw. On the Dapr leg the retry policy, a circuit-breaker open and
the sidecar's ERR_DIRECT_INVOKE normalization were invisible entirely: the
failures that matter most on a cross-domain call were the ones a trace could not
show. The router's SendAsync returns the policy's task rather than awaiting it,
so a using-span in the existing non-async body would have been disposed before
the first attempt ran — reporting near-zero duration, mis-parenting attempts
2..N and never setting the status it exists to carry. The call is therefore
awaited in a private async core while both contract throws stay synchronous in
the outer method (condition RC-1). One span per logical call, never per attempt:
per-attempt spans multiply export volume during the outage they describe, and
the client instrumentation already draws each attempt. The relative path is
neither named nor tagged — it carries instance ids.
This is the one new ActivitySource the council approved (G-3). Registration is
per-host by design, and that rule now lives in the Domain as data
(DeliberatelyUnregistered) with a guard test over all four host files. The guard
asserts the rule rather than a catalogue of span names, so it does not become
maintenance attached to each span; verified by unregistering the new source in
one host and watching it fail. An unregistered source is the silent failure
mode — StartActivity returns null, the span is never created, children flatten
onto the nearest ancestor, and it goes dark in one host only.
Three missing Subflow.Descend sites, all repairs of an existing pattern: the
authorization matrix forward (its authorize sibling has had the span since the
descent ladder was introduced, and the matrix is the more expensive of the two),
the cross-domain view resolve (a real remote hop that appeared as a bare
HttpClient call while the cheap local branch reported a Cache.Get), and the
long-poll ack chain descent (the only descent in the codebase without one).
Auth.Decide — role resolution had a span and the subflow forward had a span,
while the verdict they exist to produce had neither, so a denial could be seen
arriving and never explained. It carries the bit and the role count only: never
grant expressions, role names or caller identity, because a span is exported to
a system with a different access boundary than the workflow's.
Cache.Get/Cache.Set on the function response cache — the only cache in the
runtime whose hit/miss was invisible, because it reaches its store through the
Execution service and so appeared as an Invoke.* span with nothing marking it a
cache. A hit there skips the function's entire task set, which makes it the
branch most worth seeing. Its key is authored by the domain, so it is tagged and
deliberately kept out of the span name; the existing Cache.Get/{key} names are
already unbounded enough to need a runtime field to normalise them at query time.
No new failures: the full Application.Tests suite was diffed against the same
suite on a clean tree, test by test.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(tracing): give each built-in instance read its own envelope span
Council 2026-09-11-route-trace-coverage, Part B — the eleven read entry points
outside the deferral set.
Built-in and custom functions share one route template, so Elastic's
transaction.name is identical for a state poll, a view read and a
custom-function call: per-function latency is unobtainable from the transaction
today. The leaf layer does not help either — Db.* and Cache.* are always on and
are the two top emitters by a wide margin, so the read path is densely
instrumented at the bottom and unstructured at the top. A bounded-cardinality
envelope is the only thing that can carry the distinction, which is why this
item survived every round of scope cutting.
Instance.Read/{kind} now wraps view, schema, master, extensions, the single
instance read, the filtered list, transition history, the active incident, the
incident history, the hierarchy tree and the human-task inbox.
The envelope lives in the query service, not the controller: a descent re-enters
this service once per level, so an envelope at the controller would count one
read where the request performed three.
The kind is a closed set (InstanceReadKinds) because it sits in the span name.
The five kinds that also name a subflow descent reuse the descent vocabulary's
exact strings, so correlating an envelope with the ladder underneath it needs no
translation between two names for the same function.
state and data are deliberately absent. They are sequenced behind the
effective-status-fingerprint plan, which rewrites their fast paths (condition
G-2), and the hot-path decision for them is a transaction tag rather than an
envelope — zero added span documents on the 304 branch, which the council
settled unanimously.
Guarded by a source-level test rather than thirteen behavioural fakes: what
actually fails in practice is "somebody added a public read and forgot the
envelope", and the guard also pins that every kind is a constant and that the
shared vocabulary stays shared. Verified by removing one envelope and watching
it fail.
Full Application.Tests diffed against a clean tree: no new failures. The one
differing name passes in isolation and is the suite's known parallel-collection
flake.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(tracing): guard the registration rule at build time and at startup
Council 2026-09-11-route-trace-coverage, Part A — the catalogue-independent
guards. They assert rules rather than lists of span names, so they keep working
as spans are added instead of becoming maintenance attached to each one.
Two halves, because the failure has two shapes and neither check sees the other.
The repository guard reads all four appsettings.json files and asserts that every
declared source is covered in every host that can emit it, and that no host
registers a source nothing declares. Registration is per-host by design — the
Execution host and the workers each cover their own family with a wildcard and
deliberately omit the others — so that intent now lives in the Domain as data
(DeliberatelyUnregistered) next to the source list. A guard that asserted "every
source in every host" would be asserting a rule this codebase does not follow,
and would have to be silenced the first time it fired.
The startup check reads the MERGED configuration, which is the only place a
deployment override is visible. .NET merges configuration arrays by index, so a
single Telemetry__Tracing__AdditionalSources__0 entry in a chart's free-form
environment block REPLACES the first declared source rather than appending to it
— today that is BBT.Workflow.Pipeline, and with it every pipeline span and the
activation metric, with no code change and no error anywhere. On such a pod the
repository files still look correct. It warns and never throws: a telemetry gap
must not become an outage.
A third guard pins every ActivitySource name literal against the declared set.
Two helpers cannot use the constant and that is structural rather than
sloppiness — BBT.Workflow.Execution.Abstractions has no project references at all
and BBT.Workflow.Execution references only it, so neither can reach
TelemetryConstants. Duplicated literals are therefore allowed; a literal that has
DRIFTED is what this catches, and drift is silent because listeners match sources
by name, so one character produces a source nothing subscribes to.
Both new guards were verified red before green: unregistering the Gateway source
in one host, and introducing a one-character typo in a literal.
No new failures against a clean-tree baseline, diffed test by test.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs(tracing): bring the span reference table up to the code
The reference table is this repository's normative record of the span tree, and
five span families had shipped without reaching it — so the table and the code
had begun to disagree, which is the failure the table exists to prevent.
Adds Instance.Read/{kind}, Auth.Decide, Remote.Send/{clientType},
SubFlow.ChildFault and the function response cache, extends the descent row with
the two new function values, and records why each span is shaped the way it is:
why the read envelope lives in the query service rather than the controller, why
Remote.Send is one span per logical call rather than per attempt, why Auth.Decide
carries no grant content, and why the function cache key stays out of the span
name.
Documents the two registration guards and the literal-drift guard, including the
part reviewer memory kept getting wrong: registration is per-host, not global,
and only the startup check can see a deployment override.
Annotates the 2026-08-30 verification section with its renderer caveat. Those
nine checks were computed in OpenObserve, and check 8 — duration containment — is
exactly the property on which OpenObserve and Elastic disagree. The shape of the
template is reusable; its numbers are not a baseline.
Restores scripts/trace-profile.py, which two committed documents link and which
exi…


What
DaprDistributedCacheService.SetAsyncconverted the requested expiry to whole seconds with an(int)cast and only wrote thettlInSecondsmetadata when the result was greater than zero. Any expiry below one second rounded to0, the metadata was dropped, and Dapr stores such an entry with no TTL at all — the caller asked for the shortest possible lifetime and got the longest one.Both expiry branches now share one computation that rounds up, never falls below the store's one-second granularity, skips the write when the requested lifetime has already elapsed, and clamps very large TTLs instead of overflowing an
int.Why
Reported from vnext runtime 0.0.92 on preprod (2026-09-10). vnext caches a state-function response for 500 ms because that body cannot be validated from the parent row alone. The entry never expired: written 21:39:27, still served 142 seconds later at 21:41:50 with
cache_hit=true, carrying a status the workflow instance had already left. The client acted on the stale body and got a 404. Trace ids5c80e520b1b5c79e4a80d697c980e866,0dbc92d9a7b2015c6daef80fff232272.Lowering the caller's TTL to 1 ms changed nothing — 1 ms rounds to 0 exactly like 500 ms does. No configuration value below 1000 ms could express what the caller meant, which is what made this an Aether-level defect rather than a consumer tuning problem. Redis and .NET Core pass the
TimeSpanthrough unchanged, so the bug was invisible in local development and only appeared where a Dapr state store is configured.Defects fixed
(int)1.9 == 1; a 1.9 s entry expired 47 % early.ttl > 0guard dropped the metadata. The severe case: no error, no log, no span tag.ttlInSeconds: 0instead of dropping it. The singlerequestedTtlswitch makes this structurally impossible to reintroduce.DateTimeOffset.MaxValueas an absolute expiry produced ≈ 2.5 × 10¹¹ seconds; the unchecked cast sent a garbagettlInSecondsto the store.ttl.ToString()used the current culture; nowInvariantCulture.Reviewer notes
This is a behaviour change for sub-second TTLs: they move from "permanent" to "1 second". That is the point — today those callers hold stale data indefinitely — but it is worth a deliberate look.
The skip path is reachable on a normal code path, and it is silent.
DistributedCacheBase.GetOrSetAsyncbuilds the caller'soptionsbefore awaitingfetchFunc()and callsSetAsyncafter, so a sub-second absolute expiry paired with a slow fetch now skips the write entirely: the cache never populates and every request pays the full fetch. Under the framework's defaultBusinesstracing profileInfrastructureActivitySource.StartDiagnosticActivityreturnsnull, so the newcache.skippedtag is a no-op and nothing reports this. The README warns about it and recommendsSlidingExpiration(measured at write time) for short lifetimes, but givingDaprDistributedCacheServicean optionalILogger— whichRedisDistributedCacheServicealready takes — is recommended follow-up work.Entries already written without a TTL are not repaired by this change. They expire only when overwritten. Consumers that depend on the fix (vnext's state-function cache) should flush or key-version their entries when this version rolls out.
The overflow test only fails pre-fix on x64. An out-of-range
double→intconversion is unspecified in C#: it wraps on x64 and saturates toint.MaxValueon ARM64. On an Apple Silicon dev machine the test passes even without the clamp. A code comment next to the clamp says so, to stop a future reader "simplifying" the range check away. CI runs x64, where it is a real guard.Deliberately out of scope
IDistributedCacheService.MinimumTtl— adds a member to a published interface inBBT.Aether.Coreand touches all three providers; its own change, its own design.TimeSpantoStringSetAsync, .NET Core writes an immediately-expired entry).Tests
9 new unit tests in
DaprDistributedCacheServiceTests(the class had none). The fixture substitutesDaprClientand captures the metadata dictionary that actually reachesSaveStateAsync, rather than asserting a mock was called.AbsoluteExpiration = now + 500 msttlInSeconds = "1"AbsoluteExpiration = now + 1.4 s"2"AbsoluteExpiration = now + 60 s"60"AbsoluteExpiration = now - 5 sSlidingExpiration = 500 ms"1"SlidingExpiration = TimeSpan.ZeroAbsoluteExpiration = DateTimeOffset.MaxValue"2147483647"ttlInSecondskeydotnet build framework/BBT.Aether.slnx→ 0 errors, no new warnings.dotnet test framework/test/BBT.Aether.Infrastructure.Tests→ 223/223 passing.Also in this PR
framework/docs/distributed-cache/README.md: a per-provider TTL granularity section, theGetOrSetAsynccaveat, the upgrade note, the fact thatcache.ttl_secondsmeans "as stored" on Dapr but "as requested, truncated" on Redis, and that sliding expiration does not actually slide on Dapr (RefreshAsyncis a no-op). Two examples usedAbsoluteExpirationRelativeToNow, a propertyDistributedCacheEntryOptionsdoes not have — a reader copying either got a compile error. Both fixed.framework/docs/superpowers/specs/andplans/: the design record and implementation plan for this change.🤖 Generated with Claude Code
Summary by Sourcery
Ensure Dapr cache expiration requests are represented safely at the provider's one-second granularity instead of silently producing permanent or invalid entries.
Bug Fixes:
Enhancements:
Documentation:
Tests: