Skip to content

Cross-cutting

Purpose

Shared infrastructure, security, observability, and resilience requirements that apply across all Virtufin services. Every service SHALL conform to these requirements.

Topic and state-key naming conventions live in pubsub-topics/spec.md; scenario registry conventions live in scenarios/spec.md. This spec covers the access patterns (API-mediated access, ownership) and the non-naming cross-cutting concerns.

Requirements

Requirement: API-Mediated Pub/Sub and State

Services MUST NOT call DaprClient.PublishEventAsync, DaprClient.GetStateAsync, DaprClient.SaveStateAsync, DaprClient.DeleteStateAsync, GetBulkStateAsync, or any other Dapr pubsub or state API directly. Services MUST use the virtufin-api's Pubsub and State gRPC services for all pubsub and state operations.

The virtufin-api is the only service that talks to Dapr pubsub/state on behalf of other services. This gives the API a single place for access control, observability, and state ownership. It does NOT enforce topic naming -- see Pub/Sub Topics spec §No Topic or Scenario-State Validation Anywhere; topic naming is a convention, not an API-enforced contract.

Scenario registry management (create / pause / archive / delete) uses the same generic State.SaveState / State.GetState / State.DeleteState / State.QueryState RPCs above, not dedicated scenario RPCs -- per scenarios/spec.md, "No new RPCs SHALL be added to virtufin-api for scenario management"; virtufin-api stays pure infrastructure with no domain logic.

No inline topic-shape validation (clarification): Services that publish directly to Dapr for any reason (e.g., the WebSocketManager's per-frame user-data publish path, which is high-volume and low-value-for-mediation) SHALL NOT validate the topic name against the patterns in Pub/Sub Topics spec §Topic Patterns before calling DaprClient.PublishEventAsync. Topic-pattern shape (universe, scenario ID, venue, entity, event) is Virtufin domain vocabulary; a direct-publish service is pure infrastructure and SHALL NOT encode or enforce it. Any topic Dapr/NATS itself accepts as legal SHALL be passed through unvalidated. This was previously a MUST (see pubsub-topics/migration.md for the superseded rule); reversed because inline pattern-matching duplicated domain knowledge into a service (virtufin-websocketmanager) whose charter is explicitly "no business logic."

Scenario: Service publishes an event

  • WHEN a service needs to publish an event
  • THEN it SHALL call Pubsub.PublishEvent on the virtufin-api (gRPC)
  • AND it SHALL NOT call DaprClient.PublishEventAsync directly

Scenario: Service publishes a high-volume per-frame event (e.g., WebSocketManager market data)

  • WHEN a service publishes per-frame user-data events directly to Dapr (high volume, low mediation value)
  • THEN it SHALL NOT validate the topic name against Pub/Sub Topics spec §Topic Patterns -- that is domain vocabulary, not this service's concern
  • AND any topic Dapr/NATS itself accepts as legal SHALL be passed through to DaprClient.PublishEventAsync unvalidated

Scenario: Service reads or writes state

  • WHEN a service needs to read or write persistent state
  • THEN it SHALL call the API's State gRPC service (SaveState, GetState, GetAllState, RegisterKeys, DeleteState)
  • AND it SHALL NOT call any Dapr state API directly

Scenario: Dapr sidecar usage

  • WHEN a service uses Dapr for service-invocation, mTLS, distributed tracing, or metrics
  • THEN the Dapr sidecar is the right path; pubsub and state go through the API
  • The sidecar stays even after the API-mediates pubsub/state; the two concerns are independent

Requirement: Topic Naming (delegated to pubsub-topics spec)

Topic patterns, CloudEvents envelope contract, scenario ID conventions, NATS permissions, state-key taxonomy, and migration rules are defined in pubsub-topics/spec.md. This spec does NOT duplicate those rules; services SHALL conform to the patterns in that spec.

Scenario: Service publishes a domain event

Scenario: Service publishes a scenario-scoped event

  • WHEN a service publishes to sc.<scenarioId>.<domain>.*
  • THEN the API does NOT validate that scenarioId is registered, active, or otherwise -- per scenarios/spec.md, virtufin-api carries no domain logic, and no service validates scenario state before publish

Scenario: Subscriber consumes another service's events

  • WHEN a service needs to consume another service's events
  • THEN it SHALL call Pubsub.Subscribe with the topic string duplicated locally
  • AND the local constant SHALL carry a comment cross-linking to the publisher's Configuration/Topics.cs source of truth (or scenario registry entry for scenario-scoped topics)

Scenario: Topic name is a public contract

  • WHEN a publisher changes its topic name
  • THEN it is a breaking change for every subscriber; the constant is part of the service's public contract

Requirement: Per-service State Service Names

Each service uses its own state service entry (the entry in the API's services.json matching the service's name). Callers use the regular State.* RPCs with the service field set to the per-service name (websocketmanager, workmanager). There are no State.*System* RPC variants.

The service field selects the Dapr state store to use and SHALL name an entry registered in services.json; the match is exact and case-sensitive. Its effect is limited to that resolution. The {service}.{entity}.{id} key convention is caller-side discipline — it keeps keys legible and makes prefix queries useful — and is NOT enforced by the API. See the Trust Model requirement.

Scenario: Service reads or writes its own state

  • WHEN a service needs to read or write its own persistent state
  • THEN it SHALL call the regular State.* RPCs with service: "<service-name>"
  • AND it SHALL NOT call any State.*System* variant (none exist)

Scenario: Service keeps to its own key namespace

  • WHEN a service writes state
  • THEN it SHALL prefix its keys with its own service name, by convention
  • AND it SHALL NOT rely on the API to reject a key that does not

Scenario: Unregistered or wrong-case service name

  • WHEN a caller passes a service that is not registered in services.json, or differs from a registered name only by case
  • THEN the API SHALL reject the call as a caller error (InvalidArgument), not as a server fault

Requirement: Lifecycle Events as CloudEvents v1.0

Services that publish lifecycle events MUST format them as CloudEvents v1.0 envelopes. The CloudEvents attributes (ce-id, ce-source, ce-type, ce-time, ce-specversion, ce-datacontenttype) MUST be carried in the request metadata field, prefixed with ce-. The CloudEvents data field MUST be the request's data field.

Scenario: Connection lifecycle event

  • WHEN a service publishes a connection lifecycle event (e.g., a WebSocket connection is established, disconnects, or fails)
  • THEN it SHALL set ce-type to com.virtufin.<service>.lifecycle.<state>
  • AND ce-source to its own service URN (e.g., urn:com.virtufin.websocketmanager)
  • AND the data payload to a JSON object with connection_id, url, instance_id (and any state-specific fields)

Scenario: Worker lifecycle event

  • WHEN a service publishes a worker lifecycle event (created, started, stopped, error)
  • THEN it SHALL set ce-type to com.virtufin.workmanager.lifecycle.<state>
  • AND ce-source to urn:com.virtufin.workmanager
  • AND the data payload to a JSON object with worker_id, group, topic (and error_type / error_message for error events)

Requirement: API Endpoint Configuration

Services that call the virtufin-api MUST accept the API's gRPC endpoint via configuration. The endpoint SHALL be configurable via environment variables (VIRTUFIN_API_HOST, VIRTUFIN_API_GRPC_PORT) with sensible defaults (localhost:5002).

Scenario: Production deployment

  • WHEN a service is deployed to a cluster
  • THEN VIRTUFIN_API_HOST and VIRTUFIN_API_GRPC_PORT SHALL be set to the cluster-internal address of the virtufin-api service

Scenario: Local development

  • WHEN a service runs locally
  • THEN the default localhost:5002 is used unless overridden

Requirement: Observability

Every service SHALL emit OpenTelemetry traces and metrics. Health checks SHALL gate readiness until the service is fully initialized.

Scenario: Distributed tracing

  • WHEN a request is processed across multiple services
  • THEN each span SHALL be exported to the configured OpenTelemetry collector with correlation context

Scenario: Liveness and readiness

  • WHEN a service starts up and before it recovers its persistent state
  • THEN the readiness health check SHALL fail, preventing traffic routing

Scenario: Startup recovery

  • WHEN a service restarts after a crash
  • THEN it SHALL restore its persisted state before marking itself healthy

Requirement: Resilience

All Dapr operations and external calls SHALL use retry with exponential backoff and circuit breaking.

Scenario: Transient Dapr failure

  • WHEN a Dapr API call fails with a transient error
  • THEN the operation SHALL be retried up to 3 times with exponential backoff before failing

Scenario: Persistent failure

  • WHEN Dapr API calls consistently fail beyond a threshold
  • THEN the circuit breaker SHALL open, failing fast for subsequent calls until the break duration elapses

Requirement: Security

All service-to-service communication SHALL be encrypted. User-supplied code execution's isolation level SHALL match its engine class: subprocess-based engines SHALL run code in a sandboxed subprocess with restricted I/O; in-process engines run with the same trust as the host process and SHALL require their code source be fully-trusted rather than sandboxed at execution time. URL-based code fetching SHALL prevent SSRF.

Authentication at the API gateway is a single flat API-key set: a presented key is matched against the configured keys and yields a key name used for log attribution only. There is no binding from key to permitted service, key namespace, or topic. Authorization between first-party services is out of scope by the Trust Model requirement.

Scenario: Service-to-service communication

  • WHEN one service communicates with another
  • THEN Dapr mTLS SHALL encrypt the connection

Scenario: API key scope

  • WHEN a caller presents a valid API key
  • THEN it SHALL be authorized for every service, state key, and topic the gateway exposes
  • AND operators SHALL treat issuing a key as granting full access to platform state and pub/sub

Scenario: Credential propagation across the proxy

  • WHEN the gateway proxies a call to a backend service
  • THEN it SHALL NOT forward the caller's API key to that backend
  • AND it SHALL forward only an explicit allow-list of headers (tracing and correlation identifiers), since backends authenticate to the gateway with their own credentials

Scenario: Subprocess-isolated code execution

  • WHEN a worker runs on a subprocess-based engine (e.g. Python)
  • THEN the engine SHALL launch it in a sandboxed subprocess with a configurable blocked-module/restricted-I/O list and no ambient access to the host process's own memory or file handles

Scenario: In-process code execution

  • WHEN a worker runs on an in-process engine (e.g. compiled C# source, managed DLL, native DLL)
  • THEN the worker code SHALL execute with the same privileges as the host process, with no sandboxing at execution time
  • AND the service SHALL restrict that engine to fully-trusted code sources only (e.g. a registered, content-addressed package feed) rather than relying on runtime isolation

Scenario: SSRF prevention

  • WHEN fetching code from a user-supplied URL
  • THEN the service SHALL reject URLs targeting private IP ranges (loopback, RFC1918, link-local) unless the host is in an explicit allowlist

Requirement: Native AOT Compilation

Services targeting AOT-capable runtimes SHALL compile to native code. All JSON serialization SHALL use source-generated contexts.

Scenario: Build and deployment

  • WHEN a service is built for production
  • THEN it SHALL compile to a self-contained native binary with trimmed dependencies

Requirement: Configuration

Every service SHALL support configuration via command-line arguments, environment variables, and configuration files. Ports SHALL be configurable. Dapr component names SHALL be configurable.

Scenario: Port override

  • WHEN the HttpPort environment variable or --http-port argument is set
  • THEN the service SHALL listen on that port instead of the default

Scenario: Dapr component override

  • WHEN pubsubName is configured
  • THEN the service SHALL use the named Dapr pubsub component for all pub/sub operations

Requirement: Containerization

Every service SHALL be containerizable as a minimal Docker image based on the chiseled runtime image.

Scenario: Docker build

  • WHEN a service's Dockerfile is built
  • THEN the resulting image SHALL contain only the native AOT binary and its runtime dependencies on a runtime-deps base

Requirement: Trust Model

The Virtufin service mesh SHALL be treated as a trusted mesh. Every caller holding a valid API key is first-party and mutually trusting. service names, state key prefixes, and topic names are routing and component-resolution identifiers; they are NOT security boundaries, and the platform SHALL NOT be relied upon to isolate one service's data from another's.

Concretely: all registered services resolve to one Dapr state store and one pub/sub component; the API validates that a service is registered but does not check that a state key begins with it; QueryState filters are not scoped to the caller's prefix; and any authenticated caller may publish to or subscribe to any topic. A valid API key therefore grants full access to platform state and pub/sub.

This is a deliberate position, not an unimplemented intention. Isolation enforced at the gateway would stop an honest mistake but not a determined caller, who can reach the same Dapr components through the sidecar; real isolation requires per-service stores and credentials, which also forecloses legitimate cross-service reads.

Scenario: Service reads another service's state

  • WHEN a service calls State.GetState with a key outside its own {service}. prefix
  • THEN the call SHALL succeed
  • AND this SHALL NOT be treated as a defect in the API

Scenario: Broad state query

  • WHEN a caller issues QueryState with a filter broader than its own prefix, including Dapr's {} match-everything query
  • THEN the query SHALL return matching entries across every service's keys
  • AND callers SHALL treat the store as a shared namespace when reasoning about what a query returns

Scenario: Subscribing to another service's topic

  • WHEN an authenticated caller subscribes to a topic it does not own, including state.change or another service's <service>.lifecycle
  • THEN the subscription SHALL succeed

Scenario: Documentation states the boundary

  • WHEN a proto comment, README, or doc page describes state keys, topics, or the service field
  • THEN it SHALL NOT claim namespace enforcement, isolation, or rejection of out-of-namespace access
  • AND where the topic arises it SHALL state that the convention is caller-side discipline

Scenario: Trust set changes

  • WHEN a caller that is not first-party is admitted to the mesh — a third party, a customer-supplied integration, or any principal outside the operating team
  • THEN this requirement SHALL be revisited before that caller is granted an API key
  • AND isolation SHALL be reconsidered as required rather than optional, since the assumption this requirement rests on no longer holds