Conventions and deliberate deviations
Silas follows Rails conventions by default. Where it doesn’t, that’s a decision, and this page records the reasoning so the next reader doesn’t have to guess (or “fix” something load-bearing).
Style is rubocop-rails-omakase
— Rails’ own baseline, unmodified except for excluding the generated skeleton
apps (chaos_host/, examples/, spec/dummy/). CI enforces RuboCop,
Brakeman, bundler-audit, a Zeitwerk eager-load check, and a 90% line-coverage
floor.
Naming and structure
Adapters::, not Engines::
The pluggable inference backend lives at Silas::Adapters::Base /
Silas::Adapters::RubyLLM, configured with config.adapter. It was called
Engines:: until 0.4, which collided with Silas::Engine — the Rails engine
— in the same namespace. Every comparable seam disambiguates the same way:
ActiveJob has QueueAdapters::, ActiveStorage has Service::, RubyLLM has
Provider. The old constants and config.engine still work with a
deprecation warning until 2.0.
class << self vs module_function
Both appear, by rule, not by accident (the same split RubyLLM uses):
module_functionfor pure utility modules whose methods are all legitimately callable —Budget,MessageBuilder,Instructions,Inbox,Slack,StepRunner.class << self+privatewhere a module has real internals worth hiding —Ledger(execute!,claim!,guarded_transactionare not public API) — or holds state, likeEval’s scenario registry.
module_function publishes every method on the module, so it is the wrong
tool wherever encapsulation matters. Don’t “unify” these.
Instrumentation
Every notable moment in the durable loop emits an ActiveSupport::Notifications
event named <event>.silas — the Rails convention (sql.active_record), so
subscribe(/\.silas\z/) catches everything. Silas::LogSubscriber turns them
into log lines at levels an operator can filter on: parks and rescues INFO,
budget breaches WARN, failed turns and nondeterminism ERROR, per-step and
per-token chatter DEBUG.
Payloads always carry turn_id/session_id where they exist, so a subscriber
never has to join. The full event list lives in lib/silas/instrumentation.rb;
tool.silas (duration + effect_mode + how it settled) is the single most
useful span, and resume.silas carries parked_for — how long the human took.
Event names and payload keys are a public contract: dashboards get built on
them, so renaming one is a breaking change, and spec/silas/instrumentation_spec.rb
pins them.
Generator templates are .rb.tt
Rails’ own convention. A template containing ERB is not valid Ruby, so a bare
.rb extension puts it in front of RuboCop’s parser and Zeitwerk’s loader —
both of which are right to complain. .tt keeps them out of each. The install
generator’s templates predate this and are plain .rb because none of them
interpolate; new templates use .tt.
Channels ship a generator, not a catalogue
The engine serves webhook routes for Slack alone, because Slack is the only
transport whose whole dialect Silas ships (signature scheme, Block Kit
approvals, the interactive-actions endpoint). Every other transport is scaffolded
into the host app by rails g silas:channel, since only the host knows the
vendor’s signature scheme and payload shape — and a framework promising to keep
six drifting vendor APIs working is a framework that breaks.
What that seam does own, because getting it wrong is a security bug rather than
a preference: Silas::Webhook.verify_hmac (constant-time compare, replay
window, fail-closed on a missing secret) and Silas::Channel.approval_url (a
signed expiring link, host required and never guessed). See docs/channels.md.
The inbox speaks “Signals”; the API speaks database strings
The inbox is themed dark-first (direction “Signals”: one white “lamp” accent
that never means state, plus an aspect colour per run state), and two states
are relabelled in the UI only: waiting renders as held, completed
as clear (TraceHelper::UI_LABEL). The database strings and the JSON API
are untouched — an operator who reads “held” in the inbox and greps the API
will find waiting, and both names appear in the docs for exactly that
reason. Approval and question cards are hoisted to the top of a session (the
trace keeps a one-line stub); the hoisted card’s DOM id is
dom_id(invocation, :approval) — distinct from the trace row’s
dom_id(invocation) — so Turbo can replace each independently.
Deprecations
Everything removable goes through Silas.deprecator
(ActiveSupport::Deprecation, registered in app.deprecators[:silas]), so a
host silences or raises on Silas deprecations exactly as it does Rails’.
Messages name the replacement and the removal version; a warning you can’t
act on is noise.
Deliberate deviations
Status columns are strings with constants, not Rails enums
Turn::STATUSES, ToolInvocation::STATUSES, Step states and
approval_state are validated strings.
Enums generate scopes and predicate methods on the model, and the ledger
performs its compare-and-swap claims through update_all(status: "completed")
at the SQL level — where an enum’s integer/string mapping is one more thing
between the code and what the database actually holds. For a state machine
whose correctness is the product, the literal value in the column being the
literal value in the code is worth more than the generated sugar.
Webhook controllers skip CSRF
Silas::Channels::BaseController calls skip_forgery_protection. No browser
session originates a webhook, so no CSRF token can exist. Each route
authenticates the request itself instead:
- Slack routes verify an HMAC-SHA256 signature with a 300-second replay window.
- Email approve/decline routes require a purpose-scoped, expiring
MessageVerifiertoken; possession of the token is the credential, so CSRF would add nothing an attacker holding it couldn’t already do.
Both are covered by request specs including the negative cases (unsigned,
wrong secret, stale, tampered, expired, wrong-purpose, replayed). The
suppression is recorded with this reasoning in config/brakeman.ignore. The
inbox controllers keep protect_from_forgery; the JSON API is
ActionController::API and is token-authenticated.
Nothing is mass-assigned
There is no Model.new(params) or update(params) anywhere, and therefore no
strong-parameters ceremony. Every controller reads scalars explicitly
(params[:input].to_s.strip) and passes them as keyword arguments. The one
bulk value — a session’s metadata hash on the JSON API — is assigned
explicitly to a single JSON column, so it cannot reach any other attribute.
Indexes cover query paths, not every foreign key
silas_memories.session_id, turn_id and superseded_by_id are provenance
columns: written, never used in a WHERE. silas_turns.job_id is
observability. They are deliberately unindexed — an index nobody reads is a
write cost on the durable loop’s hot path. The columns that are queried are
indexed, including the partial unique index on silas_turns that enforces
one active turn per session.
Conventions worth knowing
- Escaping: no
raw,html_safe, or<%==anywhere. Tool arguments, tool results, and model output are rendered escaped, because a framework that displays LLM and third-party output is the last place to hand-wave XSS. - Time:
Time.current/Time.zonethroughout; noTime.noworDate.today. - Concerns live in
app/models/concerns/silas/; the engine isisolate_namespace Silasso host apps can’t collide with it. - Trace partials are rendered in two contexts (the inbox controllers and
Turbo’s broadcast jobs, which use the host’s renderer). They therefore
build engine routes through
silas_engine_path, never bare route helpers, and the shared helper is registered host-wide. Seespec/silas/inbox/broadcasting_spec.rb.