Project

open-loam

0.0
The project is in a healthy, maintained state
OpenLoam pre-decides the 80% every business app shares (multi-tenancy, roles and field-level permissions, audit trails, a domain event bus, an admin surface) and ships the agent conventions (AGENTS.md, generators as the interface, structural guardrails) that make the codebase safe for AI agents to extend.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Runtime

~> 3.1
~> 3.3
>= 7.1
 Project Readme

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.md map 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/admin

Sign 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.


MIT licensed โ€” open-core, like the foundations it stands on.