Module: Insika::EnvSchema

Defined in:
lib/insika/env_schema.rb

Overview

STRICT config, environment layer. OpenClaw's config discipline — "recusa boot com chave desconhecida, no silent config compat" — applied to the env vars the engine reads at boot. A declarative registry of the keys the engine OWNS (config over convention: the schema IS data), used two ways:

· `validate(env)` — returns structured Findings: a value that fails its type
(INSIKA_PORT=abc), an UNKNOWN key inside a namespace the engine owns
(INSIKA_EGRES_ALLOW_HTTP — a typo the runtime would otherwise ignore in
silence), and a DEPRECATED legacy key still set under the old HARNESS_ prefix.
Unknown-key detection is scoped to the OWNED prefixes only, so the platform's
own vars (Railway's RAILWAY_*, PORT, PATH, the litestream sidecar's
LITESTREAM_*, the deployment's DEEPSEEK_*/CONSUMER_*) are never flagged.
· `enforce!(strict:)` — the boot gate. WARNS on every finding by default and
lets the engine come up (last-known-good — a rotated key or a typo must never
take the whole service down, same reasoning as the resilient DEEPSEEK boot);
RAISES ConfigError only when strictness is on (INSIKA_CONFIG_STRICT truthy,
or `strict: true`).

RENAME (env pass 2): the engine's owned prefix is now INSIKA_. The old HARNESS_ names still work — reconcile_legacy! backfills INSIKA_* from any HARNESS_* alias at boot (new name wins), and read gives the same dual-read to the self-contained readers (Telemetry, TurnTiming, SubagentGraph) that receive an env hash directly. A legacy name in use surfaces as a :deprecated warning (never fatal) so operators get a clear "rename to INSIKA_*" without a broken boot.

Insika::Doctor reuses validate for its env:* checks; the boot roots call enforce!. A deployment layers its own keys on via extra: (see config/deployment.rb) so insika env and insika doctor see the full picture.

Defined Under Namespace

Classes: Finding, Spec

Constant Summary collapse

PREFIX =

The engine's owned prefix and its deprecated predecessor.

"INSIKA_"
LEGACY_PREFIX =
"HARNESS_"
OWNED_PREFIXES =

Prefixes the engine fully OWNS: an unknown key under one of these is a typo, not a foreign var. INSIKA_ (current) and HARNESS_ (legacy, still honored during the deprecation window). Deliberately NOT OPENCLAW_ (shared with the OpenClaw gateway product, which sets its own OPENCLAW_HOME/STATE_DIR/… — the engine merely borrows 3 names for interop), nor LITESTREAM (the sidecar owns it), nor OTEL_ (the OpenTelemetry SDK owns its env).

[PREFIX, LEGACY_PREFIX].freeze
BOOLEANS =
%w[1 0 true false yes no on off].freeze
DEFAULT =

The engine's own keys. Deployment/app keys (DEEPSEEK_, CONSUMER_, …) are NOT here — a root passes them as extra:.

[
  spec(name: "INSIKA_DB", type: :path, description: "SQLite path; durable config+state. Unset -> ephemeral memory."),
  spec(name: "INSIKA_BIND", description: "Bind address for the transport server."),
  spec(name: "INSIKA_PORT", type: :integer, description: "Port for the transport server."),
  spec(name: "INSIKA_PUBLIC_URL", type: :url, description: "Public base URL (A2A agent card, links)."),
  spec(name: "INSIKA_ENV", description: "Environment name shown in the Studio (falls back to RACK_ENV)."),
  spec(name: "INSIKA_A2A_AGENT", description: "Agent id to expose over inbound A2A (opt-in)."),
  spec(name: "INSIKA_A2A_REMOTES", type: :csv, description: "Comma-separated remote A2A endpoints."),
  spec(name: "INSIKA_EGRESS_ALLOW_HTTP", type: :boolean, description: "Allow plain http egress from data-tools (default: https only)."),
  spec(name: "INSIKA_EGRESS_ALLOW_PRIVATE", type: :boolean, description: "Allow egress to private/loopback ranges (SSRF guard off)."),
  spec(name: "INSIKA_EGRESS_HOSTS", type: :csv, description: "Comma-separated host allowlist for data-tool egress."),
  spec(name: "INSIKA_OTEL", type: :boolean, description: "Turn on OpenTelemetry export (opt-in)."),
  spec(name: "INSIKA_MODEL_PRICING", description: "JSON rates table (USD per million tokens) for the estimated-cost attribute; unset -> no cost reported."),
  spec(name: "INSIKA_TURN_TIMING", type: :boolean, description: "Emit per-turn TTFB breakdown in responses (opt-in)."),
  spec(name: "INSIKA_SUBAGENT_DEPTH_CAP", type: :integer, description: "Max delegation depth in the subagent graph (default 5)."),
  spec(name: "INSIKA_SUBAGENT_FANOUT_CAP", type: :integer, description: "Max parallel children in spawn_subagents (default 8)."),
  spec(name: "INSIKA_CONFIG_STRICT", type: :boolean, description: "Refuse boot on any config finding instead of warning."),
  spec(name: "INSIKA_BOOT_ID", description: "Boot generation id shared by all workers of one container start; the recovery task sweep runs once per id. Unset -> every boot sweeps."),
  spec(name: "INSIKA_DRAIN_TIMEOUT", type: :integer, description: "Seconds a stopping worker waits for in-flight turns before abandoning them to the next boot's recovery (default 20)."),
  spec(name: "INSIKA_TICK_INTERVAL", type: :integer, description: "Seconds between tick passes (outbox drain + stale recovery sweep). Default 60; 0 disables."),
  spec(name: "INSIKA_TICK_STALE_AFTER", type: :integer, description: "Seconds a :queued/:running task must sit untouched before the tick sweeps it (default 900). Must exceed the largest turn_timeout of the deployment."),
  spec(name: "INSIKA_TENANCY", enum: %w[single_tenant multi_tenant], description: "single_tenant (default: one operator credential) or multi_tenant (per-tenant + operator tokens resolved from the store)."),
  spec(name: "INSIKA_ONBOARDING", type: :boolean, description: "Expose the public onboarding surface (/start.md, /models.json, /docs) in production (opt-in)."),
  spec(name: "INSIKA_RELAY_TOKEN", secret: true, description: "Bearer the relay consumer sends us. Unset -> the relay channel is not mounted."),
  spec(name: "INSIKA_RELAY_DELIVER_URL", type: :url, description: "Consumer callback the relay POSTs each reply to."),
  spec(name: "INSIKA_RELAY_DELIVER_TOKEN", secret: true, description: "Bearer the relay sends TO the consumer's callback (optional)."),
  spec(name: "INSIKA_WIDGET_ORIGINS", type: :csv, description: "Exact-match origins allowed to embed the web widget. Unset -> the widget channel is not mounted."),
  spec(name: "INSIKA_WIDGET_AGENTS", type: :csv, description: "Agent ids a widget visitor may address. Unset -> the widget channel is not mounted."),
  spec(name: "OPENCLAW_GATEWAY_TOKEN", secret: true, description: "Bearer for /v1 + /a2a (falls back to ADMIN_TOKEN)."),
  spec(name: "OPENCLAW_AGENTS_DIR", type: :path, description: "Directory of OpenClaw-style agent packs."),
  spec(name: "OPENCLAW_PLUGIN_DIR", type: :path, description: "Directory of plugins to load."),
  spec(name: "ADMIN_TOKEN", secret: true, description: "Studio login token; unset -> /studio fail-closed."),
  spec(name: "OTEL_SERVICE_NAME", description: "Service name for OTEL spans (default: insika).")
].freeze

Class Method Summary collapse

Class Method Details

.boolean?(value) ⇒ Boolean

Returns:

  • (Boolean)


83
# File 'lib/insika/env_schema.rb', line 83

def boolean?(value) = BOOLEANS.include?(value.to_s.strip.downcase)

.canonical(name) ⇒ Object

The current (canonical) name for any owned key: HARNESS_X -> INSIKA_X; an INSIKA_ key or any non-owned key is returned unchanged.



143
144
145
146
# File 'lib/insika/env_schema.rb', line 143

def canonical(name)
  s = name.to_s
  s.start_with?(LEGACY_PREFIX) ? PREFIX + s[LEGACY_PREFIX.length..] : s
end

.default_warn(message) ⇒ Object



243
# File 'lib/insika/env_schema.rb', line 243

def default_warn(message) = Kernel.warn("[config] #{message}")

.enforce!(env = ENV, extra: [], strict: nil, warn: method(:default_warn)) ⇒ Object

BOOT GATE. Validates the environment, WARNS every finding via warn (a callable taking a String), and RAISES ConfigError only when strict. strict defaults to the INSIKA_CONFIG_STRICT flag (HARNESS_CONFIG_STRICT still honored). -> [Finding] (also on the happy path). The root keeps booting on warnings (last-known-good).



232
233
234
235
236
237
238
239
240
241
# File 'lib/insika/env_schema.rb', line 232

def enforce!(env = ENV, extra: [], strict: nil, warn: method(:default_warn))
  strict = truthy?(read("INSIKA_CONFIG_STRICT", env)) if strict.nil?
  findings = validate(env, extra: extra)
  findings.each { |f| warn.call("config: #{f.message}") }

  errors = findings.select { |f| f.severity == :error }
  raise Insika::ConfigError.new("strict config check refused boot", findings: errors) if strict && errors.any?

  findings
end

.index(specs) ⇒ Object

-- internal ------------------------------------------------------



247
# File 'lib/insika/env_schema.rb', line 247

def index(specs) = specs.each_with_object({}) { |s, acc| acc[s.name] = s }

.known_specs(extra: []) ⇒ Object

-> the specs the engine + a root know about (DEFAULT + extra), for insika env.



226
# File 'lib/insika/env_schema.rb', line 226

def known_specs(extra: []) = (DEFAULT + Array(extra)).sort_by(&:name)

.legacy?(name) ⇒ Boolean

Returns:

  • (Boolean)


148
# File 'lib/insika/env_schema.rb', line 148

def legacy?(name) = name.to_s.start_with?(LEGACY_PREFIX)

.legacy_alias(name) ⇒ Object

HARNESS_X -> INSIKA_X for the deprecated alias of a canonical key; nil if name is not an INSIKA_ key.



136
137
138
139
# File 'lib/insika/env_schema.rb', line 136

def legacy_alias(name)
  s = name.to_s
  s.start_with?(PREFIX) ? LEGACY_PREFIX + s[PREFIX.length..] : nil
end

.owned?(name) ⇒ Boolean

Returns:

  • (Boolean)


149
# File 'lib/insika/env_schema.rb', line 149

def owned?(name) = OWNED_PREFIXES.any? { |p| name.to_s.start_with?(p) }

.present?(value) ⇒ Boolean

Returns:

  • (Boolean)


89
# File 'lib/insika/env_schema.rb', line 89

def present?(value) = !value.nil? && !value.to_s.strip.empty?

.read(canonical_name, env = ENV) ⇒ Object

Reads a canonical INSIKA_* key, falling back to the deprecated HARNESS_* alias (new name wins). For the self-contained readers that get an env hash directly and so never see reconcile_legacy!'s process-wide backfill. -> value | nil.



154
155
156
157
158
159
# File 'lib/insika/env_schema.rb', line 154

def read(canonical_name, env = ENV)
  value = env[canonical_name]
  return value if present?(value)

  (legacy = legacy_alias(canonical_name)) ? env[legacy] : value
end

.reconcile_legacy!(env = ENV, warn: method(:default_warn)) ⇒ Object

BOOT backfill: copies every still-set HARNESS_* var to its INSIKA_* name (new name wins if both are set) so the process ENV speaks the new names before any read. Generic (not schema-bound) so plugin/deployment HARNESS_* keys migrate too. WARNS once with the migrated list. -> [migrated legacy names]. Idempotent.



165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
# File 'lib/insika/env_schema.rb', line 165

def reconcile_legacy!(env = ENV, warn: method(:default_warn))
  migrated = env.keys.map(&:to_s).select { |k| k.start_with?(LEGACY_PREFIX) }.sort.each_with_object([]) do |legacy, acc|
    canonical = PREFIX + legacy[LEGACY_PREFIX.length..]
    next if present?(env[canonical]) # the new name already wins
    next if env[legacy].nil?

    env[canonical] = env[legacy]
    acc << legacy
  end
  unless migrated.empty?
    warn.call("deprecation: #{migrated.join(', ')} — HARNESS_* env vars are renamed to INSIKA_*; " \
              "honored via the new names this run, please update your environment (legacy names removed in a future release)")
  end
  migrated
end

.spec(name:, type: :string, secret: false, required: false, enum: nil, description: "") ⇒ Object



91
92
93
# File 'lib/insika/env_schema.rb', line 91

def spec(name:, type: :string, secret: false, required: false, enum: nil, description: "")
  Spec.new(name: name, type: type, secret: secret, required: required, enum: enum, description: description)
end

.truthy?(value) ⇒ Boolean

Truthy per the engine's convention (telemetry/turn_timing agree). The single home for "is this env flag on?".

Returns:

  • (Boolean)


87
# File 'lib/insika/env_schema.rb', line 87

def truthy?(value) = %w[1 true yes on].include?(value.to_s.strip.downcase)

.validate(env = ENV, extra: []) ⇒ Object

-> [Finding]. extra = deployment/app specs to fold into the known set (and to widen unknown-key detection over their names). Never raises.



185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
# File 'lib/insika/env_schema.rb', line 185

def validate(env = ENV, extra: [])
  specs = index(DEFAULT + Array(extra))
  findings = []

  # VALUE + required checks over the KNOWN specs (owned or not — DEEPSEEK_API_KEY
  # is not prefixed). Dual-read: the canonical name wins, the legacy alias is honored.
  specs.each_value do |s|
    legacy = legacy_alias(s.name)
    set_name = if env.key?(s.name) then s.name
               elsif legacy && env.key?(legacy) then legacy
               end

    if set_name.nil?
      findings << Finding.new(key: s.name, kind: :missing_required, severity: :error,
                              message: "#{s.name} is required (unset)") if s.required?
      next
    end

    msg = s.error_for(env[set_name])
    findings << Finding.new(key: set_name, kind: :invalid, severity: :error, message: "#{set_name} #{msg}") if msg
  end

  # OWNED-PREFIX scan: a legacy alias of a known key is DEPRECATED (warn); any other
  # owned key with no matching spec is UNKNOWN (a typo the runtime would ignore).
  env.each_key do |raw|
    key = raw.to_s
    next unless owned?(key)

    if legacy?(key) && specs.key?(canonical(key))
      findings << Finding.new(key: key, kind: :deprecated, severity: :warn,
                              message: "#{key} is deprecated — rename to #{canonical(key)}")
    elsif !specs.key?(canonical(key)) && !specs.key?(key)
      findings << Finding.new(key: key, kind: :unknown, severity: :error,
                              message: "#{key} is not a known config key (typo? unknown key in an owned namespace)")
    end
  end

  findings
end