CrowdRelay Autopilot makes the boring decisions in deterministic Rust

How CrowdRelay Autopilot turns band data into safe, measured actions with deterministic Rust and explicit limits, without putting an LLM in charge.

Updated 6 min read

A dashboard can make a complicated system look organised while still leaving every decision to a person. That was becoming the next limitation of the Virya ecosystem.

CrowdRelay already knew a surprising amount: who had joined Signal, which city was heating up, how ticket inventory was moving, which merch variants were close to stockout, how campaigns performed, which shows were coming next and where a fan was in the lifecycle. What it lacked was a way to turn that state into safe, repeatable action.

I'm now building that layer as CrowdRelay Autopilot.

The key design decision was to resist the obvious reading of “AI automation”. I didn't want a language model with broad credentials deciding prices, spending money or sending arbitrary messages. The core had to stay boring: deterministic Rust, explicit invariants, bounded contexts and actions that can be explained after the fact.

Bounded contexts

So the Autopilot isn't an agent. It's a collection of small business domains.

Ticket Yield knows about sell-through, paid velocity, capacity, price guardrails and cooldowns. Merchandising knows about stock coverage, reorder windows and product economics. Audience Lifecycle knows whether a fan is eligible for a communication and whether a human campaign already touched that person. Booking Opportunity knows about first-party city demand, external market signals and verified outreach targets. Promotion Yield understands ROAS and budget bounds. Experimentation knows when evidence is sufficient to reallocate traffic. Show Operations knows which tasks can be proven from system state and which ones still need a person to physically do something.

None of those domains know PostgreSQL, HTTP, n8n or Meta. That separation matters more than the number of features.

A controller that mixes SQL, API calls and business rules in one function is fast to write and expensive to trust. In ViryaOS the domain produces a typed decision. The application layer runs that decision through the current autonomy policy. Infrastructure persists immutable evidence and creates a durable action. Only the executor is allowed to touch the outside world.

Executing decisions

The execution path is deliberately more conservative than the decision path.

A ticket price change records the old and proposed price. A merch price change also records the version of the economics guardrail that justified it. A booking action records the exact verified target and target version. If state changes before execution, the action is rejected instead of being “approximately correct”.

This also makes crashes less of a problem.

Actions have stable idempotency keys, bounded retries and stale-processing recovery. External emissions have their own ledger. A worker can die after a side effect and restart without turning one booking follow-up into two. A blue/green deployment can't let two workers jointly exceed an action quota. Promotion budget increases reserve their financial delta transactionally, so concurrent campaign decisions can't quietly overrun a workspace limit.

Autonomy levels

The second design rule was that autonomy should be earned.

Every bounded context has an authority level: observe, recommend, require approval or bounded auto. There's also a global kill switch, a per-context confidence threshold and a maximum number of actions per 24 hours. Deploying the code doesn't turn the band into a robot. The runtime can stay completely idle until a capability is explicitly enabled.

This gives the mobile app a better job too.

Virya Signal shouldn't become another control panel that somebody has to babysit. The operator view is moving toward an exception cockpit: what ViryaOS did, what measurable effect followed, what's waiting for approval and what needs a human. The useful daily summary isn't “there are 47 charts”. It's closer to “23 actions executed, two things need you, zero are blocked”.

Market signals

The market-intelligence layer gets the same distrust.

External signals are typed observations with provenance, confidence and expiry. Streaming momentum, search interest, social momentum or live demand can influence a city opportunity, but they can't create a booking action by themselves. First-party evidence stays dominant and the market contribution is capped. Ten duplicate signals from one category don't get ten votes.

Adaptive pricing

Pricing follows the same principle.

No model decides that a ticket should cost 37.42 PLN. Ticket Yield evaluates a small legal set of moves under explicit constraints. Weak demand doesn't automatically trigger a public discount; it can feed Audience Lifecycle instead. Merch Yield does nothing unless a product has min/max economics configured, and any reduction must preserve the defined margin floor. The aim is to be predictably useful.

Measuring outcomes

The newest part is the outcome loop.

An executed action isn't evidence that the decision was good. Ticket pricing is measured later against a revenue window. Merch pricing uses a gross-revenue proxy over a longer window, because unit count alone doesn't show success. Promotion waits for a full observation window before judging ROAS. Where attribution is weak, ViryaOS deliberately doesn't invent a learning signal.

Eventually this should let the heuristics calibrate from our own history without an opaque machine-learning service.

PostgreSQL 18

PostgreSQL 18 fits this direction unusually well.

The operational data set is getting more scan-heavy: snapshots, action ledgers, measurements, market observations and reconciliation jobs. The stack is moving to PostgreSQL 18 and its asynchronous I/O subsystem, while keeping correctness independent of any specific I/O backend. Runtime operations expose the actual database version, I/O method and concurrency settings, so we can verify what production is doing instead of assuming the Compose file tells the whole story.

I'm deliberately not turning this into a microservice expansion. CrowdRelay remains the kernel and PostgreSQL remains the durable state. The existing Rust worker evaluates and executes work. n8n stays useful as a set of hands for provider integrations, but business decisions don't live there. Moving n8n to a separate home server gives those adapters more room without moving decision-making out of the Rust system.

Build times

The build graph matters as well.

Adding a dozen autonomous capabilities would be an easy excuse for twelve crates, several new runtimes and a pile of generic abstractions. I'm doing the opposite. The bounded contexts are small domain modules with cheap unit tests. Heavy I/O dependencies stay at the infrastructure edge. CI keeps its existing Rust cache strategy, and integration tests are reserved for boundaries that need PostgreSQL or network semantics.

Less attention needed

The result is starting to feel less like software I use to run the band and more like an operating system for the boring parts of running it.

Signal, the website, Synesthesia, ticketing and the automation stack shouldn't create five places that need attention. They should narrow it down to a small number of decisions that can't yet be safely automated.

The system should collect the facts, make the routine decisions, execute within explicit limits, measure what happened and come back to us only when it has to.

Pure policy and performance

The latest refactor kept the rule that matters most to me: the domain crates have no repository, SQL or provider knowledge. evaluate_* functions receive domain snapshots and policies and return decisions. Application code turns those results into durable candidates; infrastructure does the database work.

That separation made a small optimization obvious in booking target selection. The selector used to collect eligible targets into a temporary vector and sort the whole set, even though the caller needed one winner. It now does a single deterministic pass, keeps only the current best candidate and preserves the same tie-break order. The hot path went from allocation plus O(n log n) sorting to allocation-free O(n) selection. A domain unit test locks the deterministic tie behaviour across input order.

I also added a modularity ratchet around the large Rust surfaces. Line counts aren't the goal in themselves; localization tables and fixture-heavy integration tests are different from production orchestration. The rule is that production logic should stay navigable, and extracting it must not push SQL into the domain just to make a file shorter.

Loading…