Pillar implementation reference
The full pillar-by-pillar table from OpenLoam’s architecture: what the prototype actually implements, and what the roadmap target is for each. This is reference material — for the shape of the system and why it’s built this way, start with the Foundation overview.
| Pillar | Prototype implementation | Roadmap target |
|---|---|---|
| Multi-tenancy | OpenLoam::TenantRecord with a default_scope on OpenLoam::Current.tenant; a missing tenant raises MissingTenantError rather than widening the query. Row-level, tenant_id on every scoped table. |
settled: stays in-gem (ADR 0007) |
| Authorization | OpenLoam::Policy — a policy class per entity, role from OpenLoam::Membership, field-level writable: rules enforced in the controller permit list. |
settled: stays in-gem; an authorization-called guard is the open piece (ADR 0007) |
| Custom fields | A custom_fields json column + runtime OpenLoam::FieldDefinition rows; typed read/write, admin-managed, no migration. OpenLoam::CustomFieldIndex + OpenLoam::CustomFieldValue — a typed-EAV read model (one indexed row per record×field, value_text/value_number/value_boolean/value_datetime) so filter/sort/search is index-backed, not a JSON scan. Maintained from the OpenLoam::CustomFields save/destroy hooks (re-project only when custom_fields changed; soft-delete keeps rows, the base scope hides the record); field_key must be a real FieldDefinition; tenant-scoped throughout. filter(model, key, op, value) / order return a relation the admin index (cf_* params) and perspectives route through; open_loam:index:reindex backfills. Trust at scale: coverage(model, field_key) reports indexed-vs-expected (a JSON pass, so a periodic readout — open_loam:index:coverage); a read over an INCOMPLETE index serves an authoritative JSON-scan fallback (CORRECTNESS over speed — never a silently-partial set), sets partial? (the admin shows “results may be incomplete, reindexing…”), and enqueues a DEDUPED OpenLoam::CustomFieldReindexJob to self-heal (in-process dedup marker; a DB/cache marker is the multi-process path). Cross-entity denormalized joins are DEFERRED (a note — the least-essential of these deltas). Read ACL: a FieldDefinition can declare readable_roles (mirrors writable_roles); filter/order refuse a field the current role may not read (OpenLoam::FieldAccessError → 403), so a filter can’t be an inference oracle on a restricted value (empty roles = any member; a system/no-actor context is trusted). |
numeric-aware ORDER BY; a generation-stamp for O(delta) reprojection; cross-entity join; Postgres jsonb/GIN |
| Event backbone | OpenLoam::Events over ActiveSupport::Notifications; domain.thing.happened naming, tenant/actor stamped on every payload. The formal two-tier contract: ephemeral (Events.subscribe, inline/best-effort, exception propagates to the publisher) vs durable (OpenLoam::DurableEvents.register(key:, to:, call:)) which persists a OpenLoam::EventDelivery row in the event’s tenant and runs the handler in a job with row-state retries + backoff, parked dead past MAX_ATTEMPTS. Durability is the row + a per-tenant EventRedeliverySweepJob, not the queue (survives a lost job / crashed worker / async-adapter txn race); at-least-once, handlers idempotent; a dead-letter admin view requeues; handlers resolve from the boot registry, never constantized from the row. See Events. |
done in-gem: OpenLoam::EventLog captures every publish as an append-only OpenLoam::EventRecord, readable/replayable by name or domain prefix, pruned on a retention window (ADR 0007) |
| Workflow | OpenLoam::Workflow DSL — states with inclusion validation, transitions with from/to/roles, generated bang methods, transition events. |
undo/redo command layer |
| Audit | OpenLoam::Auditable — after_commit writes a tenant- and actor-tagged OpenLoam::AuditRecord with the changeset. See Audit trail. |
settled: stays in-gem (ADR 0007) |
| Soft-delete | OpenLoam::SoftDeletable — a deleted_at column and a second default_scope that composes with tenancy; deleted rows are excluded by default, with_deleted lifts only the deleted_at filter (never tenancy), and soft_delete/restore reuse the audit path. |
wrap discard/paranoia behind the same concern |
| Settings | OpenLoam::Configs over a OpenLoam::Config table (nullable tenant_id = global vs. per-tenant override, JSON value); resolves override → global → declared default, memoized per request in OpenLoam::Current. |
Rails.cache-backed shared layer behind the same API |
| Feature flags | OpenLoam::Features — a thin boolean wrapper over OpenLoam::Configs under the reserved features. key prefix; on?/enable/disable/reset, a feature_defaults registry, and require_feature! (404) / feature_on? guards. Gates a capability, orthogonal to policy. |
percentage / gradual rollout |
| Encryption at rest | OpenLoam::Encryptable — encrypts :field seals with AES-256-GCM under a per-tenant key (OpenLoam::Encryption), decrypts on read, keyed by OpenLoam.tenant! so a wrong-context read fails the auth tag. Keys derive via HKDF-SHA256 from one master key behind a KeyProvider seam. searchable: true adds an HMAC blind index for exact-match lookup; audit changesets redact encrypted fields to [encrypted]. Each ciphertext is BOUND to its (tenant, table, column) via AES-GCM AAD (the v2: format), so a blob transplanted to another column/table/tenant fails the auth tag — old v1: blobs (no AAD) stay readable, and open_loam:encryption:rotate upgrades v1→v2. Record-swap within one tenant+table+column is a documented residual (the id isn’t known at INSERT to bind cheaply). |
Vault/KMS KeyProvider, record-id AAD binding, encrypted custom_fields |
| MFA & step-up | OpenLoam::Totp (RFC 6238, hand-rolled on OpenSSL) + OpenLoam::MfaCredential — a per-user TOTP secret encrypted under a user/<id> key (so it verifies at login before any tenant is chosen) and BCrypt-hashed single-use recovery codes. Login gains a second-factor step; require_sudo! re-challenges sensitive actions within a 5-min window; security.mfa_required_roles (via OpenLoam::Configs) forces enrollment. Rate-limiting/lockout (OpenLoam::AuthThrottle + OpenLoam::AuthAttempt, a global non-tenant DB counter): failed password/TOTP/sudo attempts lock an identifier after N-in-a-window (Configs: security.max_auth_attempts/auth_window_minutes, defaults 10/15). Throttled by the SUBMITTED identifier whether or not the account exists, with an identical generic 429 response — so a lockout is not an existence oracle; a success clears the counter; the window query is the expiry (no reaper). Per-identifier is the primary defense (an optional per-ip knob rides the same store). |
WebAuthn/passkeys, QR rendering; Rack::Attack + cache store for multi-process rate-limiting |
| AI approval gate | OpenLoam::PendingActions + OpenLoam::PendingAction — a TenantRecord composing Workflow (approval IS a role-gated pending → approved → executed machine), Auditable, and Encryptable (the proposed changeset is encrypted at rest so it can’t leak through the audit). stage records intent without touching the target; approve!(by:) executes in a transaction as the approver. OpenLoam does NOT intercept Active Record — this is the primitive a confirm-mode caller invokes. See Confirm-mode. |
the human-in-the-loop consumer is the MCP server |
| Saved views | OpenLoam::Perspectives + OpenLoam::Perspective — a tenant-scoped, audited saved index view (filters/sort/columns/page_size in a json config) with private / role / tenant visibility resolved by an .or chain; default_for picks the most specific default (private > role > tenant). apply filters/sorts only whitelisted columns (never tenant_id/plumbing), and rows are optimistic-locked. |
column-level RBAC on view sharing; a richer in-index column/filter builder |
| Concurrent-edit safety | Optimistic: lock_version on every generated entity; a stale update raises StaleObjectError, which the admin controller turns into a diff-and-retry conflict page (stale_conflict!, encrypted fields compared decrypted). Advisory: OpenLoam::RecordLock + OpenLoam::RecordLocks — a TTL’d per-record “who’s editing” lock (heartbeat on re-acquire, auto-free on soft-delete, manager force_release). The version check is the guarantee; the lock is the courtesy. |
presence/websocket live-lock UI; server-side field-merge on conflict |
| Real-time updates | OpenLoam::EventStream — a text/event-stream endpoint (ActionController::Live) that pushes events matching OpenLoam.broadcast_events (default OFF) to the browser, tenant- and audience-filtered; a vanilla EventSource in the layout updates the bell live. Fan-out is behind a swappable broadcaster seam. |
Redis/SolidCable broadcaster for multi-process |
| Response enrichers | OpenLoam::Enrichers — a process-global registry (register(entity_type, key:, batch:)) whose enrich/enrich_many attach computed cross-module blocks under an enrichments key in admin/API responses, with no FK coupling. batch: resolvers make an index one query, not N; a raising enricher is isolated (key omitted); resolvers run in the current tenant scope. |
timeout/circuit-breaking a slow enricher; column-level RBAC on enrichment output |
| Business rules | OpenLoam::BusinessRules + OpenLoam::BusinessRule (audited TenantRecord) — admin-editable WHEN/THEN rules. A wildcard event subscriber (registered once at boot, Events.subscribe_all) finds active rules whose trigger pattern-matches the event, loads the subject, and runs matching rules tenant-scoped, in priority order, each isolated (a raising rule is logged, never breaks dispatch or siblings). The condition is a safe evaluator — a {field, op, value} tree (and/or/not) read via Condition against a whitelist of real columns + custom fields, refusing tenant_id, encrypted columns, and unknown fields; no eval/send, values are literals (same posture as the Saved-views filter whitelist). Actions are a fixed vocabulary (Actions): notify, emit_event (name validated), set_field (whitelisted attr/custom field — refuses the workflow status column, which would bypass the transition gate), block_transition (a veto? an entity opts into from open_loam_perform_transition!). A thread-local depth guard (MAX_DEPTH) bounds self-triggering; a capped OpenLoam::BusinessRuleRun log records matched/actions/errors. |
call_webhook action; a visual condition/action builder; scheduled (non-event) triggers |
| Pluggable search | OpenLoam::Search — a driver seam (OpenLoam::Search.driver = ..., like EventStream.broadcaster). OpenLoam::Searchable#search delegates to driver.search(self, q, scope: all), so searchable_by and every call site are unchanged. LikeDriver (default) is the original substring LIKE; TokenDriver normalizes each record’s searchable text into open_loam_search_tokens (a TenantRecord, so the match subquery is tenant-scoped for free) and matches with AND semantics via GROUP BY … HAVING COUNT(DISTINCT token) = N — portable (SQLite + PG), word-level, order-independent. after_save/after_destroy maintain the index; soft-deleted rows drop out via the base scope; an encrypted column is never tokenized. open_loam:search:reindex backfills. |
a Meilisearch/Elasticsearch driver behind the same seam; prefix/fuzzy matching; relevance ranking |
| SSO (OIDC) | OpenLoam::Sso + OpenLoam::SsoProvider (per-tenant, client_secret encrypted under the tenant key) + OpenLoam::SsoIdentity (the durable sub↔User link). Shipped: OIDC Authorization Code flow end-to-end — home-realm discovery by email domain (provider_for, a blessed cross-tenant unscoped lookup like Membership.tenants_for), JIT provisioning + account linking (by sub first, then verified email), IdP group→role mapping. Wired into the existing SessionsController (sso_start/sso_callback), so MFA and the tenant flow still apply. Protocol providers sit behind a builder seam (authorization_url/exchange → normalized Claims); OidcProvider uses discovery + the userinfo endpoint (back-channel TLS, client-secret-authenticated — no hand-rolled JWKS for the prototype). Safety: an unverified email is refused (no takeover); state is the callback CSRF check; the client secret is never rendered back. Offline by design: tests/demo inject FakeProvider via the builder, so no test touches the network; OidcProvider is never constructed in the suite. See Single sign-on. |
Seams: SAML (raises NotImplementedError behind the same interface until built); SCIM 2.0 provisioning (RFC 7644); full id_token + JWKS validation |
| Dictionaries | OpenLoam::Dictionary + OpenLoam::DictionaryEntry (both TenantRecord, audited) — per-tenant managed lookup lists. OpenLoam::Dictionaries is the read API (get/entries/default/label_for), memoized per request in OpenLoam::Current.dictionary_cache keyed with the tenant id (same posture as Configs). Integrates with custom fields: a OpenLoam::FieldDefinition of field_type: "dictionary" stores the dictionary key in its config json; the shared open_loam/custom_fields/_fields partial renders a select of the active entries and shows the entry label on read, while the stored value stays the plain code. |
reorder UI beyond a position field; per-entry validation/constraints; dictionary-typed API serialization of labels |
| Task progress | OpenLoam::Progress + OpenLoam::ProgressJob (TenantRecord, deliberately NOT audited — progress is high-frequency churn). start/advance(by:, message:)/complete!/fail!/cancel!; computed percent/eta_seconds/stale?. Each meaningful change publishes open_loam.progress.updated (added to the default broadcast_events), delivered live over the SSE bridge — the frame’s safe_payload carries only id/percent/status. The broadcast is THROTTLED to once per whole percent (persist every tick, push ~100 frames not 10k). Cancel is cooperative (cancelled? re-reads the status column); stale? flags a dead-heartbeat job (a reaper is roadmap). |
a reaper daemon for stale jobs; batched persistence for very high-volume jobs; pause/resume |
| Scheduler | OpenLoam::Scheduler + OpenLoam::ScheduledJob (TenantRecord, audited) + OpenLoam::Cron (stdlib 5-field cron + interval:N, timezone-aware, no gem). register is a declarative registry (like broadcast_events); sync_tenant materializes tenant-scope defaults per tenant from on_tenant_created/open_loam:sync. tick (rake open_loam:scheduler:tick, cron-driven) does an atomic claim so no two workers double-fire: Postgres SELECT … FOR UPDATE SKIP LOCKED, SQLite a transactional claim (single-process-correct for the prototype); a locked_until stamp frees a crashed worker’s rows after the TTL. Cross-tenant scan is a blessed unscoped (the runner has no tenant). Code-exec guard: job_class must resolve to a real ActiveJob::Base subclass (validated at save AND enqueue) — never arbitrary constantize-and-perform. Tenant jobs enqueue with tenant_id: under as_tenant; system jobs once. Failure-isolated per job. See Scheduler. |
a resident daemon / in-process scheduler; a distributed advisory-lock claim; per-run history rows |
| Bulk import / export | OpenLoam::Export (policy/encryption-aware CSV of a tenant-scoped relation — readable columns only, encrypted fields → [encrypted], blind-index columns dropped), OpenLoam::Import (a mapping engine: preview/allowed_targets/run(dry_run:, progress:)/error_csv, per-row save with a skipped-row error log, update-or-create by a match key, whitelisted targets + allowed_model guard so no crafted mapping/entity_type escapes the policy), OpenLoam::Bulk (soft-delete / set-field / export-selected, policy-checked per record, ids resolved through the tenant scope). See Bulk import / export. |
streaming export for very large sets; async bulk over huge selections; a saved import-mapping profile |
| Configurable dashboard | OpenLoam::Widgets (a process-global registry — register(key:, title:, roles:, &block); built-ins registered from the engine at boot) + OpenLoam::Dashboard (for(actor:, role:) → ordered, role-visible, resolved widgets) + OpenLoam::DashboardWidget (TenantRecord, audited — a tenant’s chosen widgets/order). A widget is a DATA PROVIDER (returns {kind:, ...}), never arbitrary code; the roles: filter is enforced server-side so a hidden widget’s provider is NOT called; a raising provider is isolated into an error tile. |
drag-reorder UI; per-role default layouts; richer widget partials/charts |
| Auto OpenAPI | OpenLoam::OpenApi — introspects the app into an OpenAPI 3.1 document (and a markdown rendering), no annotations/gem. Encodes the bearerAuth scheme, a component schema per entity, a separate *Input request schema of writable fields only, the 5 CRUD paths, and the tenancy guarantee. Served at /admin/api_docs (a plain server-rendered explorer) with a .json format; open_loam:openapi:export writes it to disk. |
per-operation examples; response pagination metadata; a public (tokened) /api/openapi.json |
| Content translations | OpenLoam::Translatable (translates :name) + OpenLoam::Translation (TenantRecord, audited, polymorphic, unique per record+locale+field). Encrypted fields refuse translates at class load. Distinct from Rails i18n (developer UI strings). |
inline editing on the entity form per locale; translation completeness reporting; a fallback-locale chain |
| Override registry | OpenLoam::Overrides — a thin uniform front over OpenLoam’s OWN keyed registries: disable(registry, key) / replace(registry, key) { … }. check! (run from the engine at boot) warns about STALE overrides. Structural pieces — views, controllers, routes — are overridden the Rails way (path-shadowing / prepend), NOT here. |
wire more registries into disable/replace as needs arise; per-tenant overrides |
| Notifications / API / webhooks | OpenLoam::Notifications, a token-auth JSON API per entity, OpenLoam::Webhooks with HMAC-signed outbound ActiveJob delivery, OpenLoam::InboundWebhooks for a public POST /webhooks/:token receiver. See Inbound webhooks. |
encrypt the source secret at rest; a open_loam:inbound:prune retention task |
| Admin | Generated Hotwire-free ERB console: CRUD, comments, attachments, global search, filtering, pagination, permission-aware. | evaluate Avo as an alternate backend (see ADR 0006) |
| Background | ActiveJob (webhook delivery, digests); tenant context carried explicitly in jobs via OpenLoam.as_tenant. |
Solid Queue defaults |
Nothing here is exotic — a new project gets all of it wired together and agreeing with each other on day zero.