Singulus adds configurable Ruby-level hardening around two instance-uniqueness patterns:
Singulus::Singleton— one instance per class and Ruby process.Singulus::Multiton— one registered instance per class and normalized key.
It builds on Ruby's standard Singleton for the classic Singleton implementation and provides its own thread-safe keyed registry for Multiton.
Singulus exposes one supported entry point:
require "singulus"The supported root API is intentionally small:
Singulus::SingletonSingulus::MultitonSingulus::Erroras the public base exceptionSingulus.configure,Singulus.configuration, andSingulus.reset_configuration!Singulus::VERSION
All implementation classes, guards, configuration types, and specialized errors live behind a private Singulus::Internal namespace and are not public constants. No pre-release compatibility entry points are shipped.
This gem is aggressive Ruby-level hardening, not a sandbox. Code executing in the same Ruby process can reopen core classes, use native extensions, replace guards, or otherwise modify the runtime. :runtime closes many common Method/UnboundMethod bypasses, but it cannot create a cryptographic or process-isolation boundary.
Singulus.configure do |config|
config.default_mode = :strict
endAvailable modes:
:standard— minimal policy. Classic Singleton delegates to RubySingleton; Multiton keeps constructors private but does not install local hardening policy.:strict— constructor reflection guards, mutation protection, duplication protection, inheritance restrictions, and constructor sealing for classic Singleton.:runtime— everything in:strict, plus process-wideMethodandUnboundMethodguards that activate only for classes marked as runtime-hardened.
The default is :strict.
Singleton and Multiton also expose .with(...), which creates an independent configured inclusion module. It does not mutate the shared Singulus::Singleton or Singulus::Multiton module.
class Configuration
include Singulus::Singleton.with(:strict)
end
class RuntimeConfiguration
include Singulus::Singleton.with(mode: :runtime)
end
class TenantConfiguration
include Singulus::Multiton.with(
:runtime,
retention: :lru,
max_size: 5_000
)
endThe existing two-step DSL remains supported and equivalent:
include Singulus::Singleton
singulus mode: :strictrequire "singulus"
class Configuration
include Singulus::Singleton.with(:runtime)
end
Configuration.instance.equal?(Configuration.instance)
# => trueIn hardened modes, direct constructor access is guarded from the moment the module is included, including attempts to change constructor visibility. Common reflection routes, duplication, constructor redefinition, and inheritance are rejected. After the first instance, classic Singleton also seals its constructor lookup path.
class TenantConfiguration
include Singulus::Multiton.with(:strict)
def initialize(tenant_id)
@tenant_id = tenant_id
end
end
one = TenantConfiguration.instance_for(10)
two = TenantConfiguration.instance_for(10)
other = TenantConfiguration.instance_for(20)
one.equal?(two) # => true
one.equal?(other) # => falseinstance_for(identifier, *args, **kwargs) passes the original identifier as the first initializer argument. Additional initializer arguments are used only when an instance for that normalized key does not already exist.
Prefer immutable identifiers such as integers, symbols, UUID strings, or normalized composite keys. Strings, arrays, and hashes returned as keys are defensively duplicated/frozen recursively where practical.
For domain objects, define a key normalizer:
class TenantConfiguration
include Singulus::Multiton
multiton_key { |tenant| tenant.id }
def initialize(tenant)
@tenant = tenant
end
endThis avoids retaining an ActiveRecord object itself as the registry key.
TenantConfiguration.instance?(10)
TenantConfiguration.instance_count
TenantConfiguration.instance_keys
TenantConfiguration.delete_instance(10)
TenantConfiguration.clear_instancesdelete_instance and clear_instances explicitly release registry ownership. If another part of the application still holds the old object, a later instance_for can create a new object for the same key. Therefore uniqueness is registry/lifecycle scoped, not a claim that no second live Ruby object can ever exist after explicit release or eviction.
The default is strict registry retention:
class TenantConfiguration
include Singulus::Multiton
multiton_retention :forever
endSupported strategies:
Keeps each registered instance until delete_instance/clear_instances or process exit. This is the strongest per-key identity guarantee supplied by Multiton.
multiton_retention :lru, max_size: 5_000Bounds memory by evicting the least recently used registry entry. Once evicted, the same key can create a new instance later.
multiton_retention :ttl, ttl: 300Keeps an instance for a fixed TTL measured with Process::CLOCK_MONOTONIC. Expiration is based on creation time, not sliding access time. After expiration, the same key can create a new instance.
multiton_retention :bounded, ttl: 300, max_size: 5_000Combines TTL expiration with an LRU capacity bound. Entries are evicted when they expire or when the registry exceeds max_size. Use this instead of passing max_size to :ttl or ttl to :lru; those ambiguous configurations are rejected.
multiton_retention :weakStores weak references. Singulus reuses the same object while it remains alive elsewhere, but the garbage collector may reclaim it when no strong references remain. A later instance_for can therefore create a new instance. :weak accepts neither ttl nor max_size.
| Strategy | ttl |
max_size |
Identity scope |
|---|---|---|---|
:forever |
no | no | until explicit release/process exit |
:lru |
no | required | while retained by capacity |
:ttl |
required | no | until expiration |
:bounded |
required | required | until expiration or capacity eviction |
:weak |
no | no | while the object remains strongly referenced |
multiton_key and retention configuration are locked while the registry contains instances. Call clear_instances before changing them deliberately.
Runtime mode additionally guards common bypasses involving:
Method#callMethod#[]Method#to_procMethod#>>/Method#<<UnboundMethod#bindUnboundMethod#bind_call- captured reflection gateways such as
Object#methodandBasicObject#__send__
The patches are installed process-wide once required, but their rejection logic only activates for classes marked :runtime.
Enable runtime mode as early as possible during boot:
Singulus.configure do |config|
config.default_mode = :runtime
endFor Rails, place this in an early initializer before code that might capture constructor references.
A Proc created from a constructor Method before Singulus runtime hardening is enabled cannot be retroactively identified as constructor-derived. Enable :runtime during boot, before untrusted or extension code can capture such references.
bundle install
bundle exec rubocop --parallel
bundle exec rspec
bundle exec bundler-audit check --update
bundle exec rake buildSimpleCov enforces line and branch coverage, while CI tests supported Ruby versions independently from RuboCop.
Generate the YARD reference locally:
bundle install
bundle exec rake yardOpen doc/index.html in a browser. The reference includes configuration,
include-time options, errors, and the class methods installed by Singleton
and Multiton. Installed methods appear on their pattern module's page; call
these on your including class, while calling .with on the pattern module.
The configuration object's reader/writer contract is documented under
Singulus.configuration; its concrete class remains private.
.yardopts excludes private implementation details. Generated HTML and the
YARD cache are ignored by Git. Documentation generation fails on YARD warnings
and runs in the CI quality job and bundle exec rake ci.
Repository initialization, version-control operations, and release-tag creation are managed by the Rubcraft Toolkit. Singulus itself does not prescribe or duplicate those commands.
The repository keeps only the CI/publish boundary: .github/workflows/release.yml reacts to a version tag produced by the Toolkit, verifies that it matches Singulus::VERSION, runs quality checks, builds the gem, and publishes through RubyGems Trusted Publishing (OIDC). Configure a GitHub environment named release and the matching RubyGems Trusted Publisher before the first release.