Documentation
¶
Overview ¶
Package settings holds Ken's operator-editable runtime configuration. Values come from env/compiled defaults, are overridden by rows in app_setting (written by the web UI), and are exposed as an atomically-swapped Snapshot that live consumers (rate limiter, login guard, client-IP resolver, ACME host policy) read without a restart.
Index ¶
Constants ¶
This section is empty.
Variables ¶
var Fields = []Field{ {Key: "rl_enabled", Group: "Rate limiting", Label: "Enabled", Type: "bool", Live: true, Get: func(v Values) string { return boolStr(v.RLEnabled) }, Set: func(v *Values, s string) error { v.RLEnabled = truthy(s); return nil }}, intField("rl_ip_rpm", "Rate limiting", "Per-IP requests / minute", "Sustained per-IP request rate.", func(v Values) int { return v.IPPerMin }, func(v *Values, n int) { v.IPPerMin = n }, 1, 1_000_000), intField("rl_ip_burst", "Rate limiting", "Per-IP burst", "Bucket size — absorbs short bursts (page loads).", func(v Values) int { return v.IPBurst }, func(v *Values, n int) { v.IPBurst = n }, 1, 1_000_000), intField("rl_token_rpm", "Rate limiting", "Per-token requests / minute", "Per agent token (MCP).", func(v Values) int { return v.TokenPerMin }, func(v *Values, n int) { v.TokenPerMin = n }, 1, 1_000_000), intField("rl_token_burst", "Rate limiting", "Per-token burst", "", func(v Values) int { return v.TokenBurst }, func(v *Values, n int) { v.TokenBurst = n }, 1, 1_000_000), intField("rl_block_after", "Rate limiting", "Auto-block after", "Consecutive over-limit rejections before an IP is blocked (0 = never block).", func(v Values) int { return v.BlockAfter }, func(v *Values, n int) { v.BlockAfter = n }, 0, 1_000_000), intField("rl_lockout_sec", "Rate limiting", "Auto-block lockout (seconds)", "", func(v Values) int { return v.LockoutSec }, func(v *Values, n int) { v.LockoutSec = n }, 1, 7*24*3600), {Key: "rl_allow_cidrs", Group: "Rate limiting", Label: "Always-allowed CIDRs", Type: "cidrs", Live: true, Help: "Comma-separated CIDRs exempt from rate limiting (loopback is always exempt).", Get: func(v Values) string { return v.AllowCIDRs }, Set: setCIDRs(func(v *Values, s string) { v.AllowCIDRs = s })}, intField("login_max_fails", "Login", "Max failed logins", "Failures from one IP before a lockout.", func(v Values) int { return v.LoginMaxFails }, func(v *Values, n int) { v.LoginMaxFails = n }, 1, 10000), intField("login_lockout_sec", "Login", "Login lockout (seconds)", "", func(v Values) int { return v.LoginLockoutSec }, func(v *Values, n int) { v.LoginLockoutSec = n }, 1, 7*24*3600), intField("session_ttl_hours", "Session", "Session lifetime (hours)", "New sessions only.", func(v Values) int { return v.SessionTTLHours }, func(v *Values, n int) { v.SessionTTLHours = n }, 1, 24*30), {Key: "trusted_proxies", Group: "Network", Label: "Trusted proxy CIDRs", Type: "cidrs", Live: true, Help: "X-Forwarded-For is honored only from these peers. Blank = none. Sensitive: over-broad values let a client forge its IP.", Get: func(v Values) string { return v.TrustedProxies }, Set: setCIDRs(func(v *Values, s string) { v.TrustedProxies = s })}, {Key: "tls_mode", Group: "TLS", Label: "TLS mode", Type: "enum", ReadOnly: true, Help: "Set via KEN_TLS in the unit; a mode switch (off/acme/file) needs a service restart.", Get: func(v Values) string { return v.TLSMode }}, {Key: "tls_domains", Group: "TLS", Label: "ACME domains", Type: "domains", Live: true, Help: "Comma-separated hostnames the Let's Encrypt cert is issued for. Live (acme mode); a new host is issued on demand.", Get: func(v Values) string { return v.TLSDomains }, Set: setDomains(func(v *Values, s string) { v.TLSDomains = s })}, {Key: "tls_email", Group: "TLS", Label: "ACME account email", Type: "email", ReadOnly: true, Help: "Set via KEN_TLS_EMAIL; used when registering the Let's Encrypt account (not editable here).", Get: func(v Values) string { return v.TLSEmail }}, {Key: "curation_langs", Group: "Curation", Label: "Curation language(s)", Type: "langs", Live: true, Help: "Comma-separated language codes you can read (e.g. fr,zh). Agents are told to author entries in these so you can review and promote them; proposals outside them are flagged on the review queue. Blank = off.", Get: func(v Values) string { return v.CurationLangs }, Set: setLangs(func(v *Values, s string) { v.CurationLangs = s })}, intField("comm_max_body_bytes", "Inter-session comms", "Max message size (bytes)", "Message bodies are atomic; there is no multi-part send. Keep this small — tool arguments are generated token by token by a model, so even 64 KiB is a five-figure token count.", func(v Values) int { return v.CommMaxBodyBytes }, func(v *Values, n int) { v.CommMaxBodyBytes = n }, 256, 1<<20), intField("comm_max_unacked", "Inter-session comms", "Max unacknowledged per channel", "Backpressure. Past this a send is refused so two auto-processing sessions cannot loop unboundedly.", func(v Values) int { return v.CommMaxUnacked }, func(v *Values, n int) { v.CommMaxUnacked = n }, 1, 100_000), intField("comm_message_ttl_sec", "Inter-session comms", "Lifetime after delivery (seconds)", "How long a DELIVERED but unacknowledged message survives. The clock starts when the recipient is first handed the message, not when it was sent — a session goes 16 h between pulls on a weeknight and 64 h over a weekend, so a send-anchored clock expired mail during the exact window nobody could read it.", func(v Values) int { return v.CommMessageTTLSec }, func(v *Values, n int) { v.CommMessageTTLSec = n }, 60, 30*24*3600), intField("comm_undelivered_ttl_sec", "Inter-session comms", "Lifetime before delivery (seconds)", "Backstop for a message NOBODY has ever polled — it only bounds mail addressed to an endpoint that never comes back. Undelivered mail is bounded first by the per-channel unacknowledged cap, which is a volume limit and therefore immune to how long a human is away. Set this longer than the longest absence you expect: a fortnight's leave is 400 h.", func(v Values) int { return v.CommUndeliveredTTLSec }, func(v *Values, n int) { v.CommUndeliveredTTLSec = n }, 3600, 90*24*3600), intField("comm_body_retention_sec", "Inter-session comms", "Body retention after settling (seconds)", "How long a message's TEXT survives after it is acknowledged or expires. ZERO restores the old behaviour of deleting the body the moment it is acknowledged — which destroyed 97% of one deployment's message bodies through the ordinary poll/act/acknowledge path, because the unacknowledged inbox was the only place a body ever existed.", func(v Values) int { return v.CommBodyRetentionSec }, func(v *Values, n int) { v.CommBodyRetentionSec = n }, 0, 90*24*3600), intField("comm_metadata_ttl_sec", "Inter-session comms", "Metadata retention (seconds)", "How long a settled message's row survives after it was created. NOTE: since bodies now survive acknowledgement (see Body retention), this is also the ONLY bound on a body that was never delivered — retention cannot reclaim one, because a message nobody received has no settle time to measure from. Raising this for a longer audit trail multiplies retained undelivered bytes with it.", func(v Values) int { return v.CommMetadataTTLSec }, func(v *Values, n int) { v.CommMetadataTTLSec = n }, 60, 90*24*3600), intField("comm_reply_deadline_sec", "Inter-session comms", "Reply deadline (seconds)", "Default deadline for a message that requires a response; past it the sender is told the reply is overdue instead of waiting forever.", func(v Values) int { return v.CommReplyDeadlineS }, func(v *Values, n int) { v.CommReplyDeadlineS = n }, 30, 7*24*3600), intField("comm_pairing_code_ttl_sec", "Inter-session comms", "Pairing code lifetime (seconds)", "How long a code you mint stays usable. Short is good: it only has to survive being pasted into two sessions.", func(v Values) int { return v.CommPairingCodeTTLS }, func(v *Values, n int) { v.CommPairingCodeTTLS = n }, 30, 24*3600), intField("comm_poll_wait_max_sec", "Inter-session comms", "Max long-poll wait (seconds)", "Ceiling on how long a receive call may block. Clamped to 30 in code regardless: a wait that ties the client's tool timeout turns a successful empty poll into an error.", func(v Values) int { return v.CommPollWaitMaxSec }, func(v *Values, n int) { v.CommPollWaitMaxSec = n }, 1, 30), intField("comm_provenance_window_sec", "Inter-session comms", "Hearsay window (seconds)", "If a token received an inter-session message this recently, entries it authors are flagged on the review queue as possibly second-hand. 0 disables the flag.", func(v Values) int { return v.CommProvenanceWindowSec }, func(v *Values, n int) { v.CommProvenanceWindowSec = n }, 0, 7*24*3600), {Key: "comm_files_enabled", Group: "Inter-session comms", Label: "File exchange enabled", Type: "bool", Live: true, Help: "Lets paired sessions exchange files (same-host handoff, or relayed through Ken). Off by default — the relay stores bytes on this server's disk.", Get: func(v Values) string { return boolStr(v.CommFilesEnabled) }, Set: func(v *Values, s string) error { v.CommFilesEnabled = truthy(s); return nil }}, intField("comm_file_max_mb", "Inter-session comms", "Max file size (MB)", "Cap on one relayed or offered file.", func(v Values) int { return v.CommFileMaxMB }, func(v *Values, n int) { v.CommFileMaxMB = n }, 1, 1024), intField("comm_file_budget_mb", "Inter-session comms", "Relay storage budget (MB)", "Global cap on bytes held in the relay at once. The relay shares this server's disk with the knowledge base; filling it would fail durable writes over chat traffic.", func(v Values) int { return v.CommFileBudgetMB }, func(v *Values, n int) { v.CommFileBudgetMB = n }, 1, 100_000), intField("comm_file_min_free_mb", "Inter-session comms", "Free-space floor (MB)", "Uploads are refused when the disk has less than this free, even under budget, so the knowledge base always has headroom. 0 disables the floor.", func(v Values) int { return v.CommFileMinFreeMB }, func(v *Values, n int) { v.CommFileMinFreeMB = n }, 0, 1_000_000), intField("comm_file_ttl_sec", "Inter-session comms", "File lifetime (seconds)", "How long an offered or undelivered file survives before it expires and its bytes are deleted.", func(v Values) int { return v.CommFileTTLSec }, func(v *Values, n int) { v.CommFileTTLSec = n }, 60, 30*24*3600), intField("comm_grant_ttl_sec", "Inter-session comms", "Transfer grant lifetime (seconds)", "How long a one-time upload/download URL stays valid. Short is right: it only has to survive being handed to curl.", func(v Values) int { return v.CommGrantTTLSec }, func(v *Values, n int) { v.CommGrantTTLSec = n }, 60, 3600), intField("comm_endpoint_idle_sec", "Inter-session comms", "Idle session cleanup (seconds)", "How long a registered session with no message traffic survives before it is removed. An actively polling session refreshes its last-seen and is never removed; this only reaps sessions that registered and went away. Keep it comfortably longer than a working session's idle gaps.", func(v Values) int { return v.CommEndpointIdleTTLSec }, func(v *Values, n int) { v.CommEndpointIdleTTLSec = n }, 300, 90*24*3600), intField("comm_claim_lease_sec", "Inter-session comms", "Station claim lease (seconds)", "When several sessions staff one station, the first to poll a message claims it and the others do not see it. This is how long that claim lasts before the message returns to the queue for another session to pick up. It only matters if you use stations; too short and a session still working gets undercut, too long and a session that died holds its messages out of reach.", func(v Values) int { return v.CommClaimLeaseSec }, func(v *Values, n int) { v.CommClaimLeaseSec = n }, 30, 3600), intField("station_note_page_kib", "Stations", "Notebook page size (KiB)", "The largest a single notebook page may be. A page bigger than this is a document rather than a note, and notebook pages travel in every backup.", func(v Values) int { return v.StationNotePageKiB }, func(v *Values, n int) { v.StationNotePageKiB = n }, 1, 1024), intField("station_note_revision_kib", "Stations", "Revision history per page (KiB)", "How much edit history one page keeps before the oldest revisions are dropped. This is an undo buffer, not an archive — it exists so a session can recover from its own bad overwrite.", func(v Values) int { return v.StationNoteRevisionKiB }, func(v *Values, n int) { v.StationNoteRevisionKiB = n }, 0, 4096), intField("station_notebook_kib", "Stations", "Notebook per station (KiB)", "Total size of a station's current notebook pages, excluding history. Roughly sixty full pages at the default page size.", func(v Values) int { return v.StationNotebookKiB }, func(v *Values, n int) { v.StationNotebookKiB = n }, 64, 65536), intField("station_locker_blob_kib", "Stations", "Locker file size (KiB)", "The largest single file a station may store. The locker is for memory and instruction files, not payloads.", func(v Values) int { return v.StationLockerBlobKiB }, func(v *Values, n int) { v.StationLockerBlobKiB = n }, 1, 4096), intField("station_locker_total_kib", "Stations", "Locker per station (KiB)", "Total locker storage one station may use. Everything here lands verbatim in your backups, and Ken cannot inspect it.", func(v Values) int { return v.StationLockerTotalKiB }, func(v *Values, n int) { v.StationLockerTotalKiB = n }, 16, 65536), intField("station_max_open_tasks", "Stations", "Open tasks per station", "How many tasks a station may have open at once. A list longer than this is not being worked, and refusing is more honest than letting it grow.", func(v Values) int { return v.StationMaxOpenTasks }, func(v *Values, n int) { v.StationMaxOpenTasks = n }, 10, 5000), intField("station_task_text_bytes", "Stations", "Task line length (bytes)", "A task's one-line summary and its resolution line. Short by construction: anything needing more belongs in the detail field or a notebook page.", func(v Values) int { return v.StationTaskTextBytes }, func(v *Values, n int) { v.StationTaskTextBytes = n }, 64, 4096), intField("station_task_detail_bytes", "Stations", "Task detail size (bytes)", "The optional detail and context attached to a task. A task needing more than this is really a notebook page with a plan.", func(v Values) int { return v.StationTaskDetailBytes }, func(v *Values, n int) { v.StationTaskDetailBytes = n }, 256, 65536), intField("station_task_list_limit", "Stations", "Task list page size", "The default AND maximum number of tasks one listing returns. It bounds how much of the pile a session can pull into its context at once.", func(v Values) int { return v.StationTaskListLimit }, func(v *Values, n int) { v.StationTaskListLimit = n }, 5, 200), }
Fields is the ordered registry that drives both the form and validation.
Functions ¶
This section is empty.
Types ¶
type Field ¶
type Field struct {
Key, Group, Label, Help, Type string // type: int | bool | cidrs | domains | langs | email | text | enum
Live bool // applies live vs needs a restart
ReadOnly bool // display only
Get func(Values) string
Set func(*Values, string) error
}
Field describes one editable setting: how to render it and how to parse/validate it into Values.
type FieldError ¶ added in v1.7.0
type FieldError struct {
// Key is the field the operator must change.
Key string
// Refs are other fields the Message mentions, in {1}, {2}… order.
Refs []string
// Message is the reason, with {n} placeholders and no field names.
Message string
// Standalone means Message already names Key via {0}, so a renderer must not
// prefix it. A per-field error is the opposite: its Message is a bare reason and
// wants "<label>: " in front.
Standalone bool
}
Apply validates the submitted form, persists the diffs from default, and swaps FieldError is a validation failure that names its field by KEY rather than by label, because the two are not the same string on the operator's screen.
The settings form resolves every label through the translation bundle, with the Go registry text only as a fallback. A message built here out of f.Label therefore names the field as the CODE calls it, not as the FORM shows it — which is how an error came to say "Lower Lifetime after delivery first" on a page whose field was labelled "Message lifetime". Carrying the key instead lets the renderer resolve each name exactly as it rendered the form, in whatever language that was.
Message may contain {0} for Key's label and {1}, {2}… for Refs', matching the placeholder style the bundles already use. It contains no field names of its own.
KNOWN LIMIT, stated rather than implied: the REASON text is still English. It comes from Go errors returned by each field's Set, and translating those is a separate piece of work. So a Spanish operator gets Spanish field names inside an English sentence — which is strictly better than an English name they cannot find on the page, and worse than the finished thing.
type Live ¶
type Live struct {
// contains filtered or unexported fields
}
Live holds the current snapshot atomically and applies edits.
func New ¶
New builds a Live seeded with defaults (env-derived). Call Load to fold in the persisted overrides.
func (*Live) Apply ¶
func (l *Live) Apply(ctx context.Context, form map[string]string, updater string) (*Snapshot, []FieldError)
the new snapshot in live. Returns the resulting snapshot and any field errors (on error nothing is persisted or applied).
type Snapshot ¶
type Snapshot struct {
Values
Resolver *clientip.Resolver
AllowNets []*net.IPNet
Domains []string
// CurationLangSet is the normalized curation languages (lowercased BCP-47
// PRIMARY subtags, de-duplicated, in order) — read by the MCP instructions
// and (later) the review-queue guardrail. Empty ⇒ feature off.
CurationLangSet []string
}
Snapshot is Values plus the derived objects consumers read.
type Values ¶
type Values struct {
RLEnabled bool
IPPerMin int
IPBurst int
TokenPerMin int
TokenBurst int
BlockAfter int
LockoutSec int
AllowCIDRs string
LoginMaxFails int
LoginLockoutSec int
SessionTTLHours int
TrustedProxies string
TLSMode string // read-only display (a mode switch needs a listener restart)
TLSDomains string // acme hostnames — live
TLSEmail string // acme account email — live (affects new registrations)
// CurationLangs is the operator's comma-separated list of language codes the
// human curator can read (e.g. "fr,zh"). Blank ⇒ the feature is off: agents get
// no language guidance and nothing is flagged. Stored verbatim; the normalized
// set consumers read is derived into Snapshot.CurationLangSet.
CurationLangs string
// Inter-session communication (COMM) limits. COMM is CORE and on by default; an
// operator who sets KEN_COMM_ENABLED=0 leaves these inert, since that choice is
// restart-level (it decides whether a second database is opened at all).
// Everything here applies live on top of whichever way that resolved.
//
// They are bounds on an EPHEMERAL subsystem that shares a disk with the durable
// knowledge base, so the defaults are deliberately conservative: the failure
// they guard against is message traffic filling the volume and failing KB writes.
CommMaxBodyBytes int
CommMaxUnacked int
CommMessageTTLSec int
CommUndeliveredTTLSec int
CommBodyRetentionSec int
CommMetadataTTLSec int
CommReplyDeadlineS int
CommPairingCodeTTLS int
CommPollWaitMaxSec int
// CommProvenanceWindowSec is how recently a token must have RECEIVED an
// inter-session message for a version it authors to be marked as possible
// hearsay (docs/COMM.md §7). 0 disables the marking.
CommProvenanceWindowSec int
// File exchange (docs/COMM.md §11) — gated separately from COMM itself because
// the byte relay is the bulk of the subsystem's risk. Sizes are in MB in the
// form (an operator thinks in MB); converted to bytes where enforced.
CommFilesEnabled bool
CommFileMaxMB int
CommFileBudgetMB int
CommFileMinFreeMB int
CommFileTTLSec int
CommGrantTTLSec int
// CommEndpointIdleTTLSec is how long a session endpoint with no traffic and no
// live attachment survives before the sweeper removes it (its channels cascade).
// Sessions register once and never unregister, so without a positive value the
// row set grows forever — but a non-positive value must DISABLE the sweep, never
// mean "sweep everything now" (see the sweep guard in internal/comm).
CommEndpointIdleTTLSec int
// CommClaimLeaseSec is how long a station-bound reader holds a claimed message
// before it returns to its station's unclaimed tail (docs/STATIONS.md S4). It
// bounds how long a session that claimed a message and then died can strand it
// from the station's other readers, so it is sized against a MODEL TURN rather
// than a request. Only bound endpoints are affected; unbound traffic never
// claims anything.
CommClaimLeaseSec int
// Station bounds (docs/STATIONS.md §9). Every one of these is a BACKUP decision
// before it is a storage decision: station assets live in ken.db, so whatever a
// station may hold, the nightly snapshot copies — and S12's ×15 multiplier is the
// reason the reasons are attached rather than the numbers standing alone.
StationNotePageKiB int
StationNoteRevisionKiB int
StationNotebookKiB int
StationLockerBlobKiB int
StationLockerTotalKiB int
StationMaxOpenTasks int
StationTaskTextBytes int
StationTaskDetailBytes int
StationTaskListLimit int
}
Values are the raw, editable settings.
func DefaultsFromEnv ¶
func DefaultsFromEnv() Values
DefaultsFromEnv builds the baseline from the same env vars the components read, so the settings UI starts from what the operator configured at launch.