Two Systems of Action on Rivet Actors

A concrete architecture for a gym revenue-and-relationship queue and a firehouse interoperability layer, built from isolated Actors, per-Actor SQLite, durable workflows, and Rivet's zero-disk storage engine.

~5266 words; about a 27 minute read

These are two very different applications.

  • A gym application that says which lead or member needs a human today, why, who owns the follow-up, and whether it recovered revenue.
  • A firehouse application that accepts old and current operational exports, preserves the originals, maps and validates incident data, helps a human repair it, and makes submission and exit auditable.

They have the same missing layer.

The incumbent systems record things. They do not necessarily make the next responsible action obvious. They also make leaving difficult when the data model, integration contract, or export format belongs to the vendor.

Both new products should therefore be systems of action above systems of record. They should not replace gym billing, class scheduling, CAD, dispatch, ePCR, or an RMS. They should ingest facts, preserve provenance, compute a clear queue of work, require human judgment at consequential boundaries, and make a complete export possible.

The three working papers behind this proposal are the shared Gym and Fire research blueprint, the gym SaaS research and architecture, and the firehouse SaaS research and architecture. Those papers initially describe Cloudflare-oriented deployments. This essay is a new translation of the product requirements into Rivet Actors, not a claim that the gists already chose Rivet.

The most important infrastructure source is Rivet’s article How We Built the First Zero-Disk, S3-Tiered Storage Engine for SQLite. The storage design makes the actor model more than a convenient programming abstraction. Each useful business boundary can own ordinary SQLite semantics without requiring a permanently running process, a local volume, or a database server per tenant.

The short architecture

browser / operator console / webhook / scheduled poll
                         |
                         v
              authenticated actor handle
                         |
        +----------------+----------------+
        |                                 |
        v                                 v
  coordinator actor                 domain actor
  indexes and routing        authoritative SQLite + queue
        |                                 |
        +---------- actor messages -------+
                                          |
                                durable workflow steps
                                          |
                         external vendor / email / NERIS

Inside every stateful actor:

  c.state  = small durable status
  c.vars   = rebuildable clients and caches
  c.db     = isolated relational truth

Below c.db, managed by Rivet:

  in-process SQLite
       -> Rivet VFS and memory cache
       -> replicated hot storage
       -> compacted cold chunks in S3-compatible storage

The shared rules are simple.

  1. Pick actors around business ownership and serialization boundaries.
  2. Read through actions. Send mutations to an actor-local queue.
  3. Commit domain state, an audit record, and an outbox record in one SQLite transaction.
  4. Make every external effect idempotent.
  5. Broadcast progress only after the durable write. Events inform the UI; they are not the database.
  6. Put large raw files and export bundles in application object storage. Put their keys, hashes, versions, and lifecycle state in SQLite.
  7. Treat human approval as a durable workflow state, not a button that directly calls a vendor.
  8. Keep cross-actor analytics, search, and aggregate reporting as derived data. Do not invent distributed joins on the request path.

What zero-disk SQLite actually means

The phrase can sound like “SQLite, but the file is in S3.” That is incomplete and leads to bad application design.

SQLite still runs natively inside the Actor process. Rivet implements a custom SQLite virtual file system, or VFS, which intercepts page reads, page writes, and syncs. According to Rivet’s article:

  • A SQLite page is 4 KiB.
  • Rivet groups 64 contiguous pages into a 256 KiB chunk.
  • The Actor-side VFS caches recently read chunks in memory.
  • A separate replicated hot tier stores active data. Rivet describes RocksDB, Postgres, and FoundationDB as possible backends.
  • A compactor moves chunks that have gone cold to S3-compatible object storage.
  • A later read pulls the whole cold chunk back through the hot tier.

The write path matters more than the diagram.

  • SQLite calls xWrite; the VFS stages dirty pages.
  • SQLite calls xSync at commit; the VFS writes the dirty pages to replicated hot storage as a batch.
  • The transaction returns after hot-tier durability, not after an S3 PUT.
  • S3 is the cold capacity and backup tier. It is not in the synchronous commit path.

Rivet says this produces low-millisecond durable commits with at least triple redundancy. Those are Rivet’s claims, not measurements from these two applications. The exact quorum placement, failure domains, fencing protocol, recovery-point objective, and point-in-time recovery procedure are not specified in the article. We should measure and verify those before promising an SLA.

The read path creates a second important distinction.

  • An Actor can start on a machine that has none of its database locally.
  • It does not restore the entire database before serving work.
  • SQLite requests pages as needed.
  • Hot pages may be near memory speed.
  • The first access to a cold chunk pays an object-store fetch and rehydration cost.

So “instant startup” does not mean “every first query is instant.” A narrow indexed lookup can warm a small working set. A random scan across old history can fetch many 256 KiB chunks to answer 4 KiB page requests. The applications should keep current operational state compact, keep queries index-backed, and move historical scans off the latency-sensitive path.

This is a very good match for both products. Most gym members, old interventions, fire incidents, and import jobs are idle most of the time. A few are active today. Compute should follow today’s work while old isolated databases fall toward object-storage economics.

Three kinds of state, on purpose

Rivet exposes three application-level state tools. They are not interchangeable.

c.state: small durable status

Good uses:

  • current schema or ruleset version
  • connector health
  • workflow phase
  • a compact revision counter
  • last successful synchronization time

Rivet batches ordinary state saves, with a documented default interval of one second. Before a dangerous external side effect, persist the required progress immediately with saveState, or better, put the operation in a replayable workflow backed by an idempotency record. See State & Storage.

c.vars: rebuildable runtime objects

Good uses:

  • an SDK client
  • a verified public-key cache
  • a prepared query helper
  • an in-memory projection of the hot queue
  • a rate-limit token bucket reconstructed from durable state

c.vars is recreated on wake. Losing it must never lose a customer fact. The Actor lifecycle also means an outbound fetch alone does not keep an Actor alive; critical work belongs in a registered promise or durable workflow.

c.db: relational truth local to the Actor

Good uses:

  • normalized entities and revisions
  • signals and interventions
  • validation findings
  • approvals and submission attempts
  • idempotency keys
  • audit events and outbox records

Rivet gives each Actor an isolated SQLite database. Migrations run in a savepoint, parameterized queries are supported, and c.db.transaction() serializes related writes. The transaction callback must use its tx object; using the outer c.db inside it can deadlock behind itself. The SQLite guide and versioning guide are the operational contract.

Commands, events, and exactly-once fantasies

Actions run in parallel by default. That is useful for reads and dangerous for contested writes.

The default command path should be:

client action
  -> authenticate and authorize
  -> enqueue { operationId, expectedRevision, body }
  -> single actor run loop receives command
  -> SQLite transaction checks idempotency
  -> transaction changes domain rows
  -> transaction appends audit + outbox
  -> commit
  -> broadcast new revision
  -> workflow delivers external effect
  -> workflow records provider result

Rivet’s Queues & Run Loops are durable across sleep and restart and are useful for ordering and backpressure. They do not provide magical exactly-once processing. A message is removed from queue storage when received, not when message.complete() runs. A crash after receipt does not automatically redeliver it.

Therefore:

  • Every command gets a stable operation ID.
  • The Actor stores processed operation IDs under a unique constraint.
  • A state mutation, audit row, and outbox row commit together.
  • A reconciler finds outbox work left incomplete after a crash.
  • External providers receive the same idempotency key on every retry when they support one.
  • Providers without idempotency support need a local attempt ledger and a careful reconciliation policy.

Use Workflows for multi-step provider calls, waits, retries, approval gates, compensation, and timeouts. Workflows roll forward. They replay steps; they do not automatically undo state changed before a failure. Step names must remain stable across deployment, and every step that reaches outside the Actor must tolerate retry.

Events are simpler. Commit first, then broadcast a revision or progress notification. A reconnecting UI reads the current state again. Missing a WebSocket event must never mean missing a payment failure, a NERIS rejection, or an approval.

Authentication is not authorization

Rivet Cloud Actors are private behind the publishable token by default, but an application still needs its own identity model. A self-hosted Actor is public by default unless the network isolates it. Neither fact answers whether Coach A may see Member B or whether Department A may read Department B’s incident.

Use Authentication and Access Control together.

  • Pass a short-lived application token through connection parameters.
  • Reject invalid credentials in onBeforeConnect.
  • Normalize userId, tenant, role, and permissions into connection state.
  • Authorize every action.
  • Authorize queue publication separately.
  • Authorize event subscription separately.
  • Verify that the authenticated tenant is allowed to address the Actor key.
  • Fail closed. Access callbacks must return actual booleans.

Use array keys, as recommended by Actor Keys:

["gym", gymId]
["gym", gymId, "connector", provider]
["agency", agencyId]
["agency", agencyId, "incident", sourceIncidentId]
["agency", agencyId, "import", sha256]

Do not concatenate user-controlled components into a delimiter-based string. Array components avoid ambiguous keys and make the tenant boundary reviewable.

Application one: the gym attention queue

The gym application is not a churn oracle and not a robot friend.

It takes facts already spread across a gym platform, payment processor, workout app, lead form, and messaging tools. It turns those facts into a ranked, owned, human queue.

The first playbooks are deliberately boring:

  • new lead has not booked an intro
  • intro was booked, then missed
  • new member has not completed the first five classes
  • attendance has declined against that member’s own baseline
  • payment failed
  • membership hold is expiring
  • an old discount needs review

“Boring” is good. Each signal can be explained. Each intervention can be measured. The system can show the evidence instead of pretending a model knows a member’s feelings.

Gym Actor map

ActorKeyOwnsWhy this boundary
Gym["gym", gymId]normalized members and leads, ranked signals, interventions, assignments, approvals, outcomes, audit, outboxOne gym needs an atomic and queryable “today” queue. A typical 100–250-member location is a sensible consistency boundary.
GymOrganization["gym-org", orgId]location directory, organization roles, aggregate referencesMulti-location coordination without making one global application Actor.
GymConnector["gym", gymId, "connector", provider]credentials reference, webhook dedupe, poll cursor, rate limits, source healthA broken or throttled provider affects one gym and one integration.
GymExport["gym", gymId, "export", exportId]export snapshot workflow, manifest, checksums, expirationPortability is a product capability, not an admin script.

Why not start with one Actor per member?

  • The product’s main screen ranks members together.
  • Assignment and work claiming need location-level invariants.
  • Reporting asks what happened across the gym.
  • The initial tenant is small enough for one indexed SQLite database.

An Actor per member becomes useful only when a gym Actor is demonstrably hot, a member history becomes very large, or privacy policy requires stronger physical partitioning. Design Patterns recommends actor-per-entity but also warns that cross-shard queries require coordination. Here, the location-level attention queue is itself the domain entity.

Gym SQLite schema

The core tables can stay plain.

  • members and leads: canonical internal identity, current status, source references
  • source_events: provider, external ID, occurred time, received time, payload hash, parser version
  • attendance_facts, booking_facts, payment_facts, membership_terms
  • baselines: versioned personal attendance baseline and calculation window
  • signals: type, evidence JSON, score, confidence, first seen, last seen, ruleset version
  • interventions: signal, assignee, due time, proposed action, approval state, completion state
  • outcomes: result, attributed amount, attribution window, confidence, recorded by
  • idempotency: operation ID, command type, result revision
  • audit_events: actor, action, before/after references, reason, timestamp
  • outbox: effect type, provider, idempotency key, payload reference, status, attempt count

Identity resolution deserves its own explicit table and operator flow. A lead-form email, billing customer, workout-app user, and attendance profile may describe the same person. False merges damage privacy and trust; false splits hide the pattern. Store candidate links, evidence, resolver version, and human overrides. Never silently overwrite the source identities.

Gym ingestion and intervention flow

  1. A provider sends a webhook, or a connector schedule wakes for a poll.
  2. The GymConnector verifies the request and records the provider event ID or content hash.
  3. It normalizes the source envelope but does not decide the business outcome.
  4. It sends an idempotent command to the Gym Actor.
  5. The Gym Actor commits the source fact and recalculates only affected deterministic playbooks.
  6. A new or changed signal produces an intervention with evidence, reason, urgency, owner, and due time.
  7. Connected coaches receive a revision event and re-read the queue.
  8. A coach claims, edits, approves, or dismisses the proposed action.
  9. A workflow delivers an email or SMS only after approval.
  10. Later facts close the loop: booked intro, completed class, recovered payment, retained membership, or no verified outcome.

Provider events will be duplicated and reordered. Store both occurred_at and received_at. Apply source-specific version rules. Do not let a late “membership active” event overwrite a later cancellation merely because it arrived last.

Schedules are useful for polling and daily queue refreshes, but scheduled Actions do not immediately retry after failure. A failed one-shot is complete; a failed recurring run waits for the next cadence. Scheduling should wake a retry-aware workflow, not contain the entire connector.

Gym realtime experience

Owners and coaches connect to the Gym Actor.

  • connState carries user, role, and location permissions.
  • The dashboard reads getAttentionQueue({ after, filter }).
  • Mutations enqueue commands: claim, approve, dismiss, complete.
  • The Actor broadcasts queueRevisionChanged after commit.
  • The client re-reads changed rows.

Rivet supports hibernating WebSockets, described in Connections. The Actor can sleep while the connection remains available and wake when needed. Presence is convenient, but it is not required for correctness.

Gym scaling path

Do not shard early.

  • One Actor per location first.
  • One connector Actor per provider and location.
  • One organization coordinator for a multi-location customer.
  • Derived warehouse or search index for cross-location analytics.
  • Shard historical event data by month only after size or write contention is measured.
  • Move a genuinely hot member into a Member Actor only with an explicit projection back into the location queue.

The falsification criteria remain product criteria, not infrastructure criteria. If owners do not perform the work, providers block access, incumbents produce the same results, or recovered revenue cannot support the price, a beautiful Actor graph changes nothing.

Application two: the firehouse interoperability layer

The firehouse application has a higher safety and accountability burden.

It should help a small, volunteer, or combination department move incident data from existing systems into a normalized, reviewable form; validate it against NERIS and local rules; explain failures; preserve every transformation; and produce a complete machine-readable exit package.

It is not dispatch. It is not CAD. It is not an ePCR or patient-care store. It should keep PHI out of the first version. The exact NERIS third-party API, certification, and agency-versus-state submission authority remain open integration questions. “Human-approved submission” may mean either direct submission after approval or a signed export handed to an authorized gateway. The architecture must support both without pretending the contract has been settled.

The public transition is real: the US Fire Administration describes the NFIRS sunset and transition to NERIS, while the Fire Safety Research Institute maintains the NERIS program. Product access, jurisdictional retention, public-records, legal-hold, CJIS, and residency requirements still need contract-by-contract verification.

Firehouse Actor map

ActorKeyOwnsWhy this boundary
Agency["agency", agencyId]connector directory, mapping/rule versions, roles, current import/submission index, agency audit referencesThe agency is the security and policy boundary, but not a bucket for every incident row.
AgencyConnector["agency", agencyId, "connector", provider]cursor, credential reference, rate limits, source schema, health, dedupeRevocation or compromise stays isolated to one source and agency.
Import["agency", agencyId, "import", sha256]raw-object reference, checksum, parser version, mapping version, progress, batch findingsAn immutable import needs one ordered, replayable lifecycle. Content hash makes retries natural.
Incident["agency", agencyId, "incident", canonicalId]normalized revisions, field lineage, findings, remediation, approvals, submission attemptsOne incident needs ordered revisions and an auditable state machine.
AgencyExport["agency", agencyId, "export", exportId]snapshot cutoff, included records, checksums, signed manifest, retentionA complete exit can be generated, verified, and resumed independently.

This is closer to the classic actor-per-entity model. Incidents change independently and can be processed in parallel. The Agency Actor is a coordinator and current index, not the owner of every normalized incident. See Coordinator & Data Actors and Communicating Between Actors.

Never make actors wait synchronously on one another’s queues. Rivet warns that wait: true between Actors can block a run loop and deadlock. Send the request, then have the target Actor send a result command back. Use a workflow to join fan-out results when an import batch spans many incidents.

Two S3 roles, kept separate

The firehouse system uses object storage in two unrelated ways.

Rivet’s internal cold tier

  • Stores compacted SQLite chunks beneath c.db.
  • Is controlled by Rivet’s VFS, hot tier, and compactor.
  • Is invisible to application queries.
  • Is not where the application uploads a CAD export.

The application’s evidence store

  • Stores the original CSV, JSON, XML, database dump, attachment, and generated exit bundle.
  • Uses an application-controlled bucket and retention policy.
  • Is referenced from Actor SQLite by object key, version, size, media type, checksum, and encryption/retention metadata.
  • Must support legal policy, deletion, holds, and customer export independently of Rivet’s page tiering.

The raw object is written before normalization. If preservation fails, processing stops. A database row that says “we once received a file” is not provenance.

Firehouse import flow

  1. An agency uploads a file or a connector discovers an export.
  2. The ingress layer validates tenant, coarse type, and size.
  3. It streams the unchanged bytes to the evidence store.
  4. It computes a cryptographic content hash and records object version, source identity, and receipt time.
  5. It addresses an Import Actor by agency and hash. Duplicate delivery reaches the same lifecycle.
  6. A durable workflow selects an explicit parser version and mapping-rules version.
  7. Parsed candidate incidents fan out to Incident Actors with stable source identities.
  8. Each Incident Actor commits a new normalized revision and field-level lineage in one transaction.
  9. Deterministic validators produce findings with rule ID, version, severity, source field, normalized field, and remediation guidance.
  10. Results return asynchronously to the Import Actor, which updates batch progress.
  11. Corrupt or ambiguous records enter quarantine. They do not disappear and do not block unrelated records.
  12. A human repairs or confirms findings. The correction creates a new revision; it does not rewrite history.

The source record, parser, mapping, and normalized revision are all versioned. Re-running an old import with a new parser is a new derivation. The system can compare it with the prior result and explain the change.

Submission as a durable, human-gated workflow

An approved incident revision enters a submission workflow.

draft revision
  -> deterministic validation
  -> human review
  -> approval record
  -> submission preparation
  -> external submission or authorized export
  -> provider receipt / rejection
  -> repair task or accepted state

The approval record includes:

  • exact incident revision
  • exact ruleset and mapping versions
  • approver identity and role
  • time and stated reason
  • digest of the outbound payload
  • intended destination

The workflow checks that the approved revision is still current before the external effect. A later correction invalidates the old approval. The provider call uses a stable attempt key. The response is stored verbatim in the evidence store when large and summarized in the Actor database when small.

Rejection is a product state, not an exception hidden in logs. It creates findings and a repair task, preserves the provider response, and can route back to the same reviewer. Ordinary programming and provider failures remain internal errors; intentional safe domain failures can use Rivet’s UserError with stable public codes.

Portability as a testable workflow

Every agency should be able to request an exit bundle containing:

  • raw source objects or a complete manifest with retrieval instructions
  • normalized incidents in an open documented format
  • revisions and field lineage
  • mapping and validation rule versions
  • findings, remediations, approvals, attempts, responses, and audit events
  • object hashes and a signed top-level manifest
  • a schema/data dictionary

The AgencyExport workflow picks a consistent cutoff revision, fans out snapshot requests, collects object references, writes manifests, verifies hashes, and records completion. Large payloads never travel in an Actor queue or WebSocket. Only bounded metadata and object references do.

An automated restore/portability test should periodically create a new empty environment from an export and compare record counts, hashes, and selected incident histories. “We have an export button” is not an exit right unless the output can be consumed.

Firehouse operational posture

  • Keep patient-care data out of v1.
  • Encrypt source credentials separately; store only a secret reference in Actor SQLite.
  • Make connector revocation tenant- and provider-specific.
  • Append corrections as revisions. Apply governed retention or tombstones instead of calling everything immutable forever.
  • Separate operator access from agency access.
  • Log every privileged support action.
  • Test a hot-tier outage, object-store outage, connector replay, malformed archive, partial import, stale approval, duplicate submission response, and old-writer fencing during Actor recovery.
  • Do not claim CJIS, HIPAA, residency, retention, or records-law compliance until the actual deployment and contract have been reviewed.

The buying motion is also architectural. County, regional, or state deployments need organization coordinators, delegated administration, SSO, policy inheritance, and privacy-safe aggregate reporting. They should not become one enormous Actor. Agencies remain isolated data Actors beneath a regional directory and derived aggregate index.

The cold-data-aware data model

The storage engine tiers physical chunks, not semantic rows. Application table names do not guarantee that “history” lands in cold storage, but access patterns still influence page locality.

For both products:

  • Keep current queue or current incident state in compact indexed tables.
  • Put append-heavy history in separate tables.
  • Avoid latency-sensitive scans across all source events or audit rows.
  • Use covering indexes for the first screen after wake.
  • Paginate every history action.
  • Warm a small, known working set when an Actor wakes if measurements justify it.
  • Run broad analytics from a derived store, not across thousands of sleeping Actor databases.

The first cold read may fetch 256 KiB for a 4 KiB logical page. That read amplification is acceptable when nearby pages are used next. It is painful under random historic access. We should benchmark three conditions separately: Actor memory-cache hit, replicated-hot-tier fetch, and S3 rehydration.

Current limits are architecture, too

Rivet’s current Limits page documents constraints that should override marketing-shaped intuition:

  • 10 GiB combined SQLite and KV storage per Actor
  • about 1.31 MiB of raw dirty SQLite page data per commit
  • 1,000 queued messages by default
  • 128 KiB effective hard queue-message size
  • 20 MiB HTTP request and response bodies
  • 60-second default Action timeout
  • 128 SQLite operations waiting behind an active coordinated transaction
  • 32 in-flight requests per Actor per IP by default

The zero-disk article describes a design without a fixed per-database ceiling comparable to 10 GiB. The published limits page still says 10 GiB per Actor. Until Rivet clarifies which runtime, release, or deployment mode removes that limit, design and capacity tests must honor 10 GiB.

Consequences:

  • Store raw imports, attachments, generated exports, and large provider responses outside Actor SQLite.
  • Keep queue commands small and pass object references.
  • Batch imports so a single transaction stays below the dirty-page limit.
  • Never run SQLite VACUUM in an Actor database; Rivet warns it can exceed the commit limit.
  • Split a growing tenant before the limit becomes an emergency.
  • Keep request handlers short; long work belongs in workflows.
  • Surface queue saturation as a retryable product state and alert.

Failure behavior, stated plainly

Actor compute dies

  • Uncommitted staged SQLite pages are lost, as expected for an uncommitted transaction.
  • Rivet says an acknowledged commit survives in replicated hot storage.
  • A replacement Actor starts without restoring a local volume and fetches pages on demand.
  • Correctness still depends on fencing the old writer. Verify this behavior rather than inferring a multi-region guarantee.

Hot storage is unavailable

  • Writes stop unless the configured hot backend still has quorum.
  • Hot-only reads may stop.
  • S3 is not a synchronous fallback database.
  • Recently written chunks may not have been compacted to S3 yet.

S3 or compatible object storage is unavailable

  • Hot data may continue serving.
  • A cold-only page read can block or fail.
  • Compaction and backup shipping can build a backlog.
  • Application evidence uploads must fail closed for firehouse imports; do not normalize a raw file that was not preserved.

A workflow crashes after an external call

  • The next attempt may not know whether the provider accepted the call.
  • Use provider idempotency keys where possible.
  • Store outbound digest and attempt state before sending.
  • Reconcile ambiguous attempts instead of blindly repeating them.

A client misses realtime events

  • Nothing important is lost.
  • It reconnects, reads the latest revision, and resumes.

A queue handler crashes after receiving a message

  • The queue does not automatically redeliver the received message.
  • The durable domain row or outbox reconciler must reveal unfinished work.

Testing the boundaries, not only the happy path

Rivet’s Testing guide provides setupTest for isolated registries and real Actor clients. The useful test suite is mostly failure injection.

Shared tests:

  • duplicate command with the same operation ID changes state once
  • concurrent contested mutations serialize through the queue
  • SQLite rollback leaves no partial domain, audit, or outbox rows
  • Actor sleeps, wakes, and rebuilds c.vars without losing truth
  • workflow resumes after a process failure
  • migration failure rolls back before Actor startup
  • auth rejection and cross-tenant Actor-key access fail closed
  • missed events recover through a revision read
  • schedules are tested with real short schedules, not fake timers

Gym tests:

  • out-of-order membership events do not resurrect a cancellation
  • a signal shows its exact source evidence and ruleset version
  • approval is required before outbound contact
  • two coaches cannot both claim an intervention
  • attribution can be revised without rewriting the original outcome assertion

Firehouse tests:

  • raw object exists and matches its hash before parsing begins
  • same file imported twice resolves to the same import lifecycle
  • parser upgrade creates a new derivation with preserved old results
  • stale approval cannot submit a newer incident revision
  • rejection creates a repairable finding with the original response preserved
  • export round-trip reconstructs selected incident histories and validates hashes

A build order that can falsify the products

Shared foundation

  1. Choose Rivet Cloud or a self-hosted topology and document the actual hot-tier, S3-compatible store, backup, and recovery configuration.
  2. Implement application identity, array Actor keys, fail-closed authorization, and tenant-scoped audit.
  3. Implement the command envelope, idempotency table, transaction pattern, outbox, and workflow retry policy once.
  4. Add structured logs and metrics for Actor wake, queue depth, command latency, SQLite transaction latency, workflow retries, hot/cold reads, and connector health.
  5. Build export and restore tests before calling either product portable.

Gym sequence

  1. Import one provider’s files or webhooks; show normalized facts, no automation.
  2. Ship three deterministic playbooks: new lead, intro no-show, failed payment.
  3. Add owned interventions, human approval, and completion.
  4. Add one messaging provider through an idempotent workflow.
  5. Measure response, booking, payment recovery, retention, and owner time.
  6. Add attendance decay only after identity and baseline quality are trustworthy.
  7. Add providers only when connector economics support the subscription price.

Firehouse sequence

  1. Build bring-a-file validation with raw preservation, hashes, explicit parser versions, and downloadable findings.
  2. Add one named source connector and replay-safe imports.
  3. Add incident revision, lineage, repair, and human approval.
  4. Integrate the authorized NERIS path only after access and certification are verified.
  5. Build rejection repair and end-to-end provenance.
  6. Build and test the complete exit bundle.
  7. Add county or regional coordination without weakening agency isolation.

This order makes failure informative. The gym dies early if owners ignore the queue or connectors cost more than the product. The firehouse dies early if access is closed, free official tooling is sufficient, or departments cannot authorize a neutral layer. Infrastructure should help us learn those facts, not hide them behind a grand platform build.

What the architecture buys us

For the gym:

  • One location owns one queryable and serializable attention queue.
  • Provider failures are isolated by connector.
  • A sleeping gym costs storage, not a permanently running tenant process.
  • Coaches get realtime coordination without making events authoritative.
  • Revenue attribution stays beside the intervention and evidence that produced it.

For the firehouse:

  • Every incident owns ordered revisions and submission history.
  • Imports and incidents fan out without sharing one giant database lock.
  • Raw evidence remains outside the operational database and survives parser changes.
  • Human approval is durable and bound to an exact revision.
  • A complete exit is an ordinary workflow with hashes, not a future migration project.

For both:

  • Local SQLite transactions express local invariants plainly.
  • Actor isolation limits concurrency bugs and tenant blast radius.
  • Zero-disk compute moves without volume attachment or full database restoration.
  • Hot replicated storage keeps S3 latency out of commits.
  • Cold chunks make long-idle state cheaper without changing application SQL.
  • Workflows make waits, retries, and human gates explicit.

The architecture does not buy product-market fit, legal compliance, perfect durability, or exactly-once effects. It does not remove the need for provider contracts, restore drills, connector maintenance, privacy policy, or operator judgment.

That is fine. The goal is smaller and more useful: give each meaningful thing one owner, keep the facts close to that owner, move work durably, preserve the original evidence, and always let the customer leave with their data.

Primary references