OpenLoam ๐ฑ
In short: OpenLoam is a Rails starter kit for business apps โ multi-tenancy, permissions, audit trails, workflows, and an event bus already built in, so you skip months of plumbing and start on real features. It's also AI-native: convention-driven code that AI coding agents (Claude Code, Codex, etc.) can safely extend, with human-approval gates before any agent write takes effect.
The fertile Rails foundation where AI agents grow business software.
๐ Documentation: deliveristsio.github.io/open-loam/
Why OpenLoam exists
Every serious back-office app โ CRM, ERP, ops console, internal tool โ re-derives the same ~80%: who's the tenant, who's allowed, what changed and when, how do modules talk, where's the admin. Teams burn months on this before shipping a single thing a customer cares about.
And now a second shift: AI coding agents (Claude Code, Codex, opencode) can write real features โ if the codebase is legible to them. Sprawling, snowflake architectures confuse agents as much as they confuse new hires. Convention is what makes a codebase safe for an agent to extend.
OpenLoam fuses the two: a pre-built business foundation that is deliberately agent-legible. Rails already leans convention-over-configuration โ arguably the most agent-friendly framework there is. OpenLoam extends that philosophy from "how to structure a controller" up to "how a multi-tenant, permissioned, audited business domain is built" โ and ships the agent conventions to match.
Where OpenLoam sits
The pieces exist in Rails, but scattered โ foundation shape in Bullet Train and the commerce products, an event backbone in Rails Event Store, custom-entity modeling only inside commerce. No Rails project unifies them into a single, agent-legible business foundation. The closest structural analogs live in other stacks: Frappe/ERPNext in Python, and Open Mercato in TypeScript โ whose module system and convention-first, agent-legible approach directly inspired OpenLoam. OpenLoam brings that idea to Rails, the substrate it always suited.
What's already decided
Each pillar ships as a convention with sane defaults, overridable when you truly need to โ never a blank page.
| Pillar | What you get, out of the box |
|---|---|
| ๐ข Multi-tenancy | Tenant isolation baked into every query, background job, and event. New models are tenant-scoped by default; a missing tenant context raises, never silently widens a query. |
| ๐ Permissions & auth | Password login, roles, policies, and field-level write access โ declared, not hand-rolled per controller. Tenant selection limited to a user's memberships. Plus feature-string permissions (OpenLoam::Permissions): grant a role wildcard capability strings (equipment.*) and check OpenLoam.can?("equipment.edit") / require_permission! โ deny-by-default, a finer layer under the coarse role. |
| ๐พ Custom fields | Define fields at runtime (a custom_fields JSON column + a OpenLoam::FieldDefinition row), so agents and admins extend a model without a migration for every idea. Filtering and sorting on a custom field is index-backed at scale via a typed read-model projection (OpenLoam::CustomFieldIndex), not a per-row JSON scan โ with coverage accounting (is the index complete or drifting?), a read-time gap that falls back to the authoritative source for correctness and self-heals in the background (deduped), and an honest "results may be incomplete" signal while it does. A field can declare readable_roles: filtering or sorting on a field a role may not read is refused, so a filter can't become an inference oracle on a restricted value. |
| ๐ Workflow | Declared states, transitions, and role-gated approvals on any entity; each transition emits an event and is audited. |
| ๐ก Event backbone | A first-class domain event bus (domain.thing.happened, publish/subscribe) so modules stay decoupled and workflows are legible. Two subscriber tiers with a formal contract: ephemeral (OpenLoam::Events.subscribe, in-process, synchronous, best-effort) for cheap fan-out, and durable (OpenLoam::DurableEvents.register) which persists each delivery as a row in the event's tenant and hands it to a background job โ at-least-once with retry + backoff, a dead-letter view with manual requeue, and a periodic sweep that redelivers a lost job (row state, not the queue, is the source of truth). A handler is resolved from a boot-time registry, never constantized from the stored row. |
| ๐ Notifications | Tenant-scoped in-app notifications, created from events, surfaced in the admin. |
| ๐ API & webhooks | Token-authenticated JSON API per entity (policy-aware) and per-tenant signed outbound webhooks on domain events. Inbound webhooks too: a public /webhooks/:token receiver that HMAC-verifies each call over the raw body, resists replays (a (source, delivery-id) idempotency ledger), answers every auth failure with a uniform 401, and publishes the verified event onto the bus so durable subscribers react. |
| ๐งพ Audit | Every change โ who, what, when, in which tenant โ recorded by default. |
| โฉ๏ธ Undo / history | The audit trail is an undo stack: each record's History screen reverts a change with one click, and the undo is itself recorded โ so undoing an undo is redo. Walks back one step at a time (never clobbers a newer edit); encrypted fields and workflow state are never reverted here (state changes undo via the reverse transition). |
| ๐๏ธ Soft-delete | Deleting a record hides it instead of erasing it โ excluded from every query by default, still tenant-scoped in the recycle bin, restorable, and recorded in the audit trail. |
| โ๏ธ Settings | A key-value settings store with a global default and a per-tenant override โ typed values, resolved override โ global โ default, cached per request, and never leaking between tenants. |
| ๐ฉ Feature flags | Runtime on/off capabilities per tenant for rollout or kill-switch โ a global default plus per-tenant override, a OpenLoam::Features.on? guard, and an admin screen. Gates a capability, not a person โ distinct from roles and policies. |
| ๐ Encryption at rest | Mark a field encrypts and it is transparently AES-256-GCM encrypted with a per-tenant key (HKDF, KMS-pluggable) and decrypted on read โ a DB dump leaks nothing and tenant A's key never opens tenant B's data. A keyed blind index keeps an encrypted email/phone findable by exact match; the audit trail records the change, never the value. |
| ๐ MFA & step-up auth | TOTP second factor for admin login (RFC 6238, no dependency), with single-use recovery codes; the secret is encrypted per-user so it verifies in any tenant. require_sudo! re-challenges for sensitive actions within a short window โ orthogonal to role. MFA can be required per role. Failed password / TOTP / sudo attempts are rate-limited and locked out (per-identifier, configurable), so an online brute-force of a 6-digit code is throttled โ and a lockout is enumeration-safe (a locked known and unknown identifier respond identically). |
| ๐ฆ AI approval gate | An agent running under confirm-mode stages a write as a PendingAction with a before/after preview instead of committing it; a manager approves (a role-gated workflow transition) and only then does it execute โ audited to the approver. The human-in-the-loop primitive for agent writes; encrypted fields never appear in the preview or audit. |
| ๐ค MCP server | An MCP server (bin/rails open_loam:mcp:serve, stdio) exposes OpenLoam to an AI agent: discover entities/schema/policy, read tenant-scoped records (only fields the role may see), and propose writes that are staged for human approval โ never committed. Every gate is a OpenLoam gate reused (tenancy, read-ACL, the approval gate); the agent acts as its API token's user, no more. |
| ๐ Saved views | A user names a view of an entity's admin index โ filters, sort, columns โ and keeps it private, shares it to a role, or makes it the tenant default. Filters only ever touch whitelisted data columns; a stored view is optimistic-locked so shared edits don't clobber. |
| ๐ Concurrent-edit safety | Optimistic locking (lock_version) turns a stale save into a clean "this changed since you opened it" conflict โ with a diff and a retry, never a silent clobber โ and an advisory RecordLock shows "Anna is editing this" with a manager take-over. The version check is the guarantee; the lock is the courtesy. |
| ๐ก Real-time updates | A per-tenant Server-Sent-Events stream pushes selected events to the browser โ the notification bell increments live, no polling. Opt-in per event pattern (default off, tenant- and audience-filtered), behind a broadcaster seam so Redis/SolidCable drops in for multi-process. |
| ๐งฉ Response enrichers | One module attaches a computed block onto another's entity at read time โ no foreign-key coupling (billing annotates an Equipment without Equipment knowing billing exists). A batch path resolves N records in one query; a failing enricher is isolated, and each runs tenant-scoped. |
| โก Business rules | A manager declares, per tenant, WHEN a condition holds THEN run actions โ evaluated on domain events, no deploy. The condition is data, never code: a whitelisted {field, op, value} tree over real columns and custom fields (no eval, no send, tenant/encrypted columns refused), and the actions are a fixed safe vocabulary (notify, emit an event, set a whitelisted field, veto a transition). Rules fire tenant-scoped in priority order, each isolated, with an execution log that shows why it acted. |
| ๐ Pluggable search |
searchable_by and Model.search(q) stay put; the strategy behind them is a swappable driver. Ships two: a portable substring LIKE (default, zero-setup) and a word-level token index (order-independent, AND-semantics, still plain SQL โ no external service), with the seam ready for Meilisearch/Elasticsearch. Swapping is a one-line initializer change, no call-site edits. Tenant-scoped, and an encrypted field's plaintext is never tokenized. |
| ๐ชช SSO (OIDC) | Per-tenant single sign-on: each tenant connects its own identity provider. Home-realm discovery routes a user to their IdP by email domain; a verified identity is just-in-time provisioned (or linked to an existing account), with IdP group โ role mapping. The client secret is encrypted at rest (per-tenant key). An unverified email is refused โ no silent account takeover. OIDC ships end-to-end; SAML and SCIM are documented seams behind a protocol interface. |
| ๐ Dictionaries | Per-tenant managed lookup lists โ named sets of entries (value/label/color/icon/position/default) an admin curates without a deploy. Usable as a custom-field type: a dictionary field renders a select of the list's active entries and stores the chosen value, showing its label on read. Tenant-scoped and cached per request. |
| โณ Task progress | A long-running job (import, reindex, report) reports percent / counts / ETA to the admin, pushed live over SSE โ no polling. OpenLoam::Progress.start/advance/complete!; the browser bar moves as the job runs. The broadcast is throttled to once per whole percent, the job supports a cooperative cancel, and a stalled job (dead heartbeat) is flagged. Tenant-scoped; the frame carries only id/percent/status. |
| ๐ฐ๏ธ Scheduler | Per-tenant recurring jobs โ cron (0 7 * * *) or interval โ that enqueue an ActiveJob on schedule. A runner (open_loam:scheduler:tick, wired to system cron) claims due jobs atomically (Postgres SKIP LOCKED; SQLite a transactional claim), so multiple workers never double-fire one. job_class is whitelisted to a real ActiveJob (no arbitrary code). Tenant-scope jobs run per tenant; system-scope once. A stdlib cron-next calculator (no gem), timezone-aware. |
| ๐ฅ Bulk import / export | CSV export of any entity's current filtered view โ policy- and encryption-aware (an encrypted field is redacted, never a plaintext dump). CSV import with a column-mapping engine: dedupe by a key (update-or-create), per-row validation with a skipped-row error log and a downloadable error file, a dry-run that commits nothing, and live progress (backgrounded, reported via the task bar). The mapping only targets policy-permitted fields โ no crafted column reaches tenant_id or a field a role can't write. Plus datatable bulk actions (select rows โ soft-delete / set-field / export), policy-checked per record and tenant-scoped. |
| ๐ Configurable dashboard | The admin home is a grid of module-provided widgets on a registry โ a metric or short list each. A manager picks which widgets appear and in what order, per tenant; a widget's roles: filter is enforced server-side (a hidden widget's data is never even computed). Widgets query tenant-scoped models (no cross-tenant leak), and a raising widget is isolated into an error tile โ the dashboard never breaks. Ships built-ins (recent activity, unread notifications, pending approvals, running tasks). |
| ๐ Auto OpenAPI | The JSON API documents itself. OpenLoam::OpenApi introspects the generated per-entity API controllers โ columns/types, exposed fields, custom fields, the bearer-token security scheme, and the tenancy guarantee โ into an OpenAPI 3.1 document, with no hand-written annotations and no external gem. A server-rendered explorer at /admin/api_docs (no Swagger-UI/external JS), a .json endpoint for tooling, and open_loam:openapi:export for CI. Request schemas expose only writable fields (never tenant_id); encrypted fields are typed as plain strings โ the doc describes shape, never data. |
| ๐ Content translations | Translate the DATA in a record's fields per locale โ a product name, a category label โ distinct from Rails i18n (developer UI strings, still Rails-native). translates :name adds a read-time overlay: record.name returns the current locale's translation when one exists, else the record's own column (the base value, never lost). Locale is request state (a /admin switcher); translations are tenant-scoped, audited, additive rows. An encrypted field can't be translated โ that would store plaintext, so it's refused at load. |
| ๐งฌ Override registry | Disable or replace an entry in one of OpenLoam's keyed registries โ a built-in dashboard widget, a default broadcast pattern โ from an initializer, without forking or monkeypatching: OpenLoam::Overrides.disable(:widgets, "open_progress") / .replace(:widgets, "audit_recent") { โฆ }. The value over a raw monkeypatch: a stale override (a key that no longer exists) is warned about at boot, so a typo isn't a silent no-op. Deliberately small โ structural pieces (views, controllers, routes) still use standard Rails path-shadowing; this fills the gap for the in-gem registries. |
| ๐ฅ๏ธ Admin surface | An internal console generated from your models โ comments, attachments, global search, filtering, pagination โ not a second app to maintain. |
| ๐ค Agent conventions | An AGENTS.md (byte-budgeted), generators as the one interface, and structural guardrails so an AI agent can add a domain feature safely โ and a human can read what it did. |
You write the 20% that is your business. OpenLoam is the 80% that every business app shares.
Agent-native by design
OpenLoam treats "an AI agent will extend this" as a first-class constraint:
- One obvious way to add an entity, a permission, an event, a screen โ so an agent's output is predictable and reviewable.
-
A contract they read โ an
AGENTS.mdmap plus generators as the only interface, so an agent extends the app the same way every time. (Live schema access over MCP is on the roadmap.) - Boundaries agents can't accidentally cross โ tenancy and permissions are structural, not conventions an agent might forget.
The result: a codebase where "add a Subscription entity with an admin screen,
tenant-scoped, audited, emitting subscription.created" is a small, safe task โ
for an agent or a human.
Status
Working prototype โ every pillar in the table above is built, tested, and
exercised end to end by a demo app, a generator harness, and an agent
benchmark. Some two dozen OpenLoam:: modules, from tenancy through SSO, each
added the same way: a small in-gem implementation behind a convention, wired to
agree with the rest.
What's in the repo
| Path | What it is |
|---|---|
lib/ |
The open_loam gem โ every pillar as a small OpenLoam:: module, plus the open_loam:install and open_loam:entity generators that are the whole interface. |
demo/ |
An equipment-rental app built with those generators (454 tests / 1,534 assertions as of 2026-08-22), carrying the generated guardrail tests: tenant isolation, no-context-raises, a lint against .unscoped in app/, and a 32 KB AGENTS.md budget. |
ai/ |
The agent benchmark โ golden_tasks.md and recorded runs. First run: 10/10 tasks, zero isolation or authorization violations; a vanilla-Rails control under the same prompts enforced isolation in 1/10. |
docs/_agents/ |
Deep-dive conventions (encryption, SSO, scheduler, โฆ) linked from AGENTS.md, so the agent contract stays inside its byte budget. |
.github/ |
CI runs the generator harness and the demo suite on every push. |
Security-hardened by adversarial review. Each batch of features went through an independent adversarial security review; the reviews found and closed real cross-tenant account-takeover chains, privilege escalations, and PII-leak vectors โ every fix landing with a regression test that reproduces the exploit. That the power features (a business-rules engine, bulk import, SSO) are where the holes appeared, and that the guardrails and reviews caught them, is the whole thesis in miniature.
How honest the "prototype" label is โ deliberately, each pillar is a
minimal in-gem implementation rather than a wrapper around
acts_as_tenant/pundit/paper_trail/Rails Event Store: the smallest surface
that proves the conventions and the agent flow. Those swaps have since been
evaluated one at a time and settled โ the in-gem versions stay, and the one real
gap they exposed (event capture) was closed in-gem too, in
ADR 0007. Custom
fields use the portable Rails json column (not Postgres jsonb/GIN) because
the demo runs on SQLite. See How OpenLoam works for
the pillar-by-pillar breakdown and the decisions behind them.
Try it
cd demo && bundle install && bin/rails db:migrate db:seed
bin/rails test # guardrail + entity tests
bin/rails server # โ http://localhost:3000/adminSign in as anna@example.com (manager in both branches, so she gets the tenant
picker) or tomek@example.com (Warsaw only) โ password password123 for both.
New here? Start with the Getting Started walkthrough โ
it builds a multi-tenant feature from rails new to a working approval flow,
showing the real commands and exactly what you didn't have to write.
- Architecture map โ a visual tour: the module graph, a request lifecycle, the event flow
- Getting started โ hands-on, install to first feature
- Overview โ plain-language product, use cases, evidence, and risks
- Concept & positioning
- How OpenLoam works โ the diagrams above + how every pillar is built
- Tenant isolation & Authorization โ the two flagship guarantees, in depth
- Agents โ the agent contract, guardrails, the golden-tasks benchmark, and subsystem deep-dives (encryption, SSO, scheduler, events, inbound webhooks, bulk, confirm-mode)
- Reference โ configuration, generators, backward compatibility
- Roadmap โ ordered backlog, Mercato-informed
- Backward-compatibility contract โ the frozen public surfaces
- Architecture decisions & lessons โ why things are the way they are, and the gotchas
- Agent pack โ everything an AI agent should load to extend a OpenLoam app correctly
- Manifesto
- Contributing
MIT licensed โ open-core, like the foundations it stands on.