Repository navigation
Synth 0.11.3
0.11.3 (2026-10-21)
A major platform release: observability and metrics (ADR-094), a unified egress layer — address
policy, TLS/CA trust and proxy (ADR-099, replacing PROP-088), a storage layer with PostgreSQL
(ADR-096/097/098), an instance maintenance contour — backup, migrations, restart (ADR-091/092),
analytics and reporting (PROP-102) and offline distribution for closed networks (PROP-101).
148 commits after v0.10.1 (864 files, +122 344 / −3 728) — the gap since 0.10.1 is closed by a single
release.
Observability and metrics (ADR-094)
- Prometheus export.
GET /metrics— the text Prometheus exposition format 0.0.4, outside
/api/v2, on the same port. Disabled by default (metrics.enabled=false, envSYNTH_METRICS_ENABLED):
when disabled it returns 404, not 401. An optional bearer token (envSYNTH_METRICS_TOKENor the file
{dataDir}/secrets/metrics-token, 0600) → 401 on a wrong token; the response carries
Cache-Control: no-store. Scraping/metricsitself is not counted in the HTTP metrics. - An in-house mini-registry with no new dependencies (
packages/core/src/metrics/): counter/gauge/histogram,
fixedlabelNames, a cap of 2000 series and thesynth_metrics_series_dropped_totalcounter; the
correctness of the renderer is pinned by golden tests. - A cardinality contract. HTTP metrics carry only the
method, route template andstatuslabels;
session_id,user_id, raw paths and query strings are forbidden, and an unknown route collapses to
other. SSE/api/v2/eventsand/ws/*are excluded from the latency histogram (long-lived connections
distort p95) — a gauge of active streams is used instead. /readyis separate from/health./healthis liveness (the process is alive),/readyreturns
200 once the application is initialized (storage context built, migrations applied, queue ready); 503
happens only at startup and in the fail-closed state. Storage degradation does not take the pod out
of rotation:/readystays 200, and degradation is signalled by a metric and an alert.- Metric domains: HTTP; process/Node (RSS, heap, CPU, event loop lag, uptime,
build_info); sessions
and queue; LLM (provider/model/outcome, latency, tokens, retries, cost); storage ({dataDir}size,
records per store, degraded/backend); events (ingested/decisions/journal); scheduler; webhooks; hybrid;
a mirror of the decision metrics (ADR-084). - Runtime toggle in the UI. The admin section “Metrics” (Settings → Administration, category
metrics, ADMIN role): status (running,/ready, storage, version, uptime,
series/bytes/droppedSeries), a toggle, a write-only token, copying/metricsand a
scrape_configexample. Priority isSYNTH_METRICS_ENABLED> runtime (settings.json/PG) >
config.yaml>false; the change is applied after a process restart (restartRequired→ a banner
and a graceful-restart button). API:GET/PUT/DELETE /api/v2/settings/metrics,
GET /api/v2/settings/metrics/status,PUT/DELETE /api/v2/settings/metrics/token(with an env token →
409CONFLICT; the value is never returned). - Shipped artifacts. Grafana dashboards (
charts/synth/files/dashboards/: overview, LLM, events-ops)
and alert rules (charts/synth/files/alerts/synth-rules.yaml, 19 rules) live in the repository; the dev
stack isdocker-compose.observability.ymlplus thedeploy/observability/directory; the runbook is
docs/OBSERVABILITY.md. - Helm. The
metrics.enabled,metrics.existingSecret/tokenKey/tokenOptionalandmetrics.ingress
flags (a dedicated Ingress for/metricsonly — it points straight at the backend Service, UI-nginx does
not proxy/metrics), plus an optionalServiceMonitorwithprometheus.io/*annotations. The
dashboards and alerts can select the environment and the instance (env/instance), so a single
Prometheus serves both dev and test. - ADR-094 remainder: JSON logs with
request_id/session_idand OpenTelemetry tracing are separate
tasks.
Unified egress layer: address policy, TLS/CA, proxy (ADR-099)
- An address policy for outgoing requests. The
egress.toolsandegress.webhookssections and the
mcp.networkPolicykey share one format{mode, list, exceptions}, with modesdeny/allow(webhooks
also acceptoff— dev only). The policy applies live, per request, and acts on every redirect
hop. Addresses are normalized by a single helper (the IPv4-mapped IPv6 bypass is closed), DNS resolves
all A/AAAA records, and every address is checked against the CIDR list (fail-closed on a resolution error
or an empty result). One guard (engine/net/egress-guard.ts) replaces three separate implementations:
web_fetch, webhooks, remote skills and URL instruction sources, and the provider favicon all go
through it. - A single canonical list of blocked ranges. The default list of private ranges did not cover multicast,
reserved and broadcast, and core kept a second, dead list (applied only when no policy was passed — which
never happens at runtime), creating a false impression of full coverage. There is now one source of truth —
packages/shared/src/types/mcp.ts,DEFAULT_MCP_PRIVATE_CIDRS: 26 ranges (multicast, reserved,
broadcast,192.0.0.0/24with the NAT64 prefixes,198.18/15, the IANA IPv6 blocks); the lists in core are
removed. The NAT64 prefixes64:ff9b::/96and64:ff9b:1::/48are intentionally not blocked: on
IPv6-only networks with DNS64 the resolver returns the synthesized AAAA records exactly from them — blocking
them would break the egress to public hosts. - Log and audit. Append-only
{dataDir}/audit/egress.jsonl: scope, host, url without query and
userinfo, method, status (allowed/blocked/error), httpStatus, bytes, durationMs, reason, the rule
that matched, sessionId/projectId/actor. Bodies and headers are never logged; retention is
egress.log.retentionDays. Policy changes are recorded in{dataDir}/audit/security.jsonl. Reading:
GET /api/v2/audit/securityandGET /api/v2/audit/egress(ADMIN/AUDITOR). - Offline URL check.
POST /api/v2/settings/egress/check(scope+url) parses the URL, resolves
DNS and compares against the policy without making an HTTP request (the endpoint itself does not
become an SSRF probe); behind a proxy it honestly reportsaddressChecksActive: false, because only the
proxy knows the destination address. - TLS/CA trust — a single source. The
security.tlssection plusSYNTH_TLS_*env; priority is
env > runtime (UI) >config.yaml> defaults. A CA bundle (several certificates in one PEM),
rejectUnauthorized, a default mTLS client and per-host profiles (mappings[]:exact,
*.suffix,*patterns, case-insensitive, first match wins — otherwise the default). The bootstrap file
is{dataDir}/security-tls.json(0600, read before the database connection), PEM material lives in
{dataDir}/secrets/tls/(0600). PostgreSQL uses the same profile resolver by host, so a “certificate
for the database” is not configured separately. TLS parameters in the PostgreSQL DSN are forbidden —
trust is defined only bystorage.postgres.sslmodeandsecurity.tls. - Runtime TLS settings from the UI. API
GET/PUT /api/v2/settings/tls,
PUT/DELETE /api/v2/settings/tls/ca,PUT/DELETE /api/v2/settings/tls/client,
POST/PUT/DELETE /api/v2/settings/tls/mappings[/:id],POST /api/v2/settings/tls/validate. Responses
carry metadata only (sources, subject,hasKey,readOnly): PEM and keys are never returned; settings
coming from config/env are read-only (a mutation → 409CONFLICT). Changes are audited
(scope:'tls') and applied after a restart (restartRequired). - The proxy is part of the egress layer. The
proxysection: HTTP/HTTPS/SOCKS5, Basic authentication,
TLS/mTLS to an HTTPS proxy (separate from the trust towards the target),noProxy,fromEnv,
fallbackDirect, timeout. The model is profiles + host rules:mode: all|selected,
defaultProfile,profiles.<name>(the name is immutable),rules[]— declaration order is priority,
the first match wins;noProxyalways takes precedence over the rules;action: directcarries
no profile. Host patterns:exact,.suffix,*.suffix,host:port, IP literal, CIDR,*; a CIDR
matches only an IP literal of the target (DNS is not resolved for rules — otherwise the route would
depend on DNS). - Fail-closed transport. An unreachable proxy →
EGRESS_PROXY_UNAVAILABLE(no silent “go direct”; the
fallbackDirectopt-in is explicit and marked in the log asvia:'fallback'). An authentication failure
(407/SOCKS5 refusal) →EGRESS_PROXY_AUTH_REQUIRED/EGRESS_PROXY_AUTH_FAILED, and the direct route
stays forbidden even withfallbackDirect. A broken configuration (unknown scheme, Basic without a
password,ntlm/negotiate) fails the startup (EGRESS_PROXY_CONFIG_INVALID). - Proxy passwords and TLS material live in env or files only. The
defaultprofile uses
SYNTH_PROXY_PASSWORDor{dataDir}/secrets/proxy-password; a profile<name>uses
SYNTH_PROXY_<NAME>_PASSWORDor{dataDir}/secrets/proxy/<name>.password(0600); a profile’s TLS
material is{dataDir}/secrets/proxy/<name>/{ca.pem,client.crt,client.key}. The values are write-only:
they are never returned, never logged and never written to the audit log; with an env password a UI
mutation → 409CONFLICT. Passwords and userinfo are not stored inconfig.yaml. - API and UI.
GET/PUT/DELETE /api/v2/settings/proxy(profiles/rules/mode; status
saved/running/defaults,isOverridden,password,restartRequired),
PUT/DELETE /api/v2/settings/proxy/password,.../profiles/:name/password,
.../profiles/:name/tls/ca,.../profiles/:name/tls/client,POST /api/v2/settings/proxy/check
(connectivity to the proxy using the saved configuration) andPOST /api/v2/settings/proxy/test
(target check: aHEADwithout redirects or body, the policy is checked before the request; the
response carries the route, rule, profile, proxy,ok,httpStatusandlatencyMs). The new admin
section “Network” holds “Proxy” (mode, profile table, rules with priority, write-only password and
profile PEM, target check, a “restart required” notice) and “Trusted CAs (TLS)”. - Honest limitations. Domain proxy authentication (NTLM/Negotiate/Kerberos) is not implemented
in the application —auth.typeisbasiconly; it is delegated to an intermediate egress proxy/gateway
(Squidlogin=NEGOTIATE,pxfor NTLM/PAC) — ADR-099 §25. SOCKS5 is best-effort: trust towards the
target through SOCKS is not supported (requestTlsis not accepted),proxy.tlsis incompatible with
socks5://, GSSAPI is absent — §26. The per-host route works in the HTTP transport only: git and
stdio-MCP receive a single proxy through the child environment and cannot pick a proxy per host (in
mode: selectedthe proxy env is not set for them at all). Runtime reload of proxy/TLS without a
restart is a remainder.
Storage layer and PostgreSQL (ADR-096/097/098, PROP-032)
- Domain ports instead of concrete classes.
packages/core/src/storage/ports/*— a port expresses what
the domain actually does (session visibility, search, “light” listings, label uniqueness, append-only
journals, secret resolution) rather than a generic KV; the config dictionaries (providers, models,
agents) use theDocumentRepository<T>port. The assembly point isStorageContext
(createFileStorage/createMemoryStorageplus the asynchronousbuildStorageContextfor the database). - A contract suite is the “passport” of a port (
packages/core/src/storage/contract/*): it is
mandatory for any implementation; the in-memory backend is the semantic reference. - PostgreSQL as an optional backend.
storage.backend: file | memory | postgres(env
SYNTH_STORAGE_BACKEND, fail-closed on an unknown explicit value). The driver is a separate in-tree
package@itphx/synth-storage-postgres(core as a peer dependency,pgas an optional dependency),
connected through a dynamicimport()only whenpostgresis selected — so a standalone install and
a local home never pull in the database. All domain ports are implemented; the run against a live
database is gated by envSYNTH_TEST_POSTGRES_DSN(thetest-postgresjob in CI). Parameters live in
thestorage.postgressection; the password comes only from envSYNTH_POSTGRES_PASSWORDor the file
{dataDir}/secrets/postgres-password. - Coordination in multi-replica deployments. PG advisory locks (
PgMutex/PgLeader) in
StorageContext.locks— for the scheduler and leader election. - Degraded mode (ADR-098). An unreachable database →
storage.degradedMode: fallback-file | halt(env
SYNTH_DEGRADED_MODE).fallback-filebrings the service up on a separate directory
{dataDir}/fallbackin a strictly read-only mode: access is ADMIN-only, mutations → 503
STORAGE_DEGRADED_READONLY,/health→status: 'degraded'. There is no automatic return — only a
restart. Configuration/dependency errors and “the schema is newer” are fail-closed (no fallback is
applied): a typo in the config must not look like an empty store. - Moving data between backends.
synth storage migrate --to postgres|fileis idempotent;--dry-run
shows the per-entity counters, and without--yesthe command refuses.
Instance maintenance (ADR-091/092)
- Backup.
synth backup --out <dir>— an archive of the data with a manifest and checksums (its own
streaming ustar writer, no external dependencies); the flags are--with-secrets,--with-workspace,
--live(a warning about the non-transactional snapshot) and--encrypt(AES-256-GCM with an external
operator key; a.encarchive can be restored only through the CLI — the key is not available to
the server). - Restore.
synth restore <archive>follows a strict order: manifest → format → schema version →
checksums →--dry-run→--yes→ safety copy → cleanup of the target → unpacking. A restore returns
the directory to the snapshot;workspaces/is preserved when it is not in the archive (protection
against losing project working files).synth doctoris mandatory after a restore. - Diagnostics.
synth doctor [--json]— data-dir, schema, master key, secret permissions, data
integrity, providers, locks, version; exit 1 on anyfail. - Schema version and migrations.
{dataDir}/meta.json
(schemaVersion/instanceVersion/migrations); the migration registry is idempotent. At startup there
is a compatibility guard (data newer than the version → startup is refused), then a mandatory safety
copy and the migrations under an inter-process lock. The manual mode issynth migrate --dry-run/
--yes(without--dry-runand without--yesthe command refuses). - Update and rollback.
synth update [--apply] [--to <version>] [--dry-run] [--yes] [--json]— check,
install or roll back the package. Downgrading the version does not migrate data backwards: rolling
data back is only possible by restoring a backup, rolling the version back ishelm rollback(K8s) or
synth update --to <prev> --apply. - Removal.
synth uninstall [--purge|--purge-db|--dry-run|--yes]— by default the data is preserved;
--purgerequires--yesand warns about losing the master key and the safety copies,--purge-db
drops the database schema whenstorage.backend=postgres. - Admin API and UI. The section “Maintenance” (Settings, ADMIN) and
/api/v2/maintenance/*:
status,doctor,migrations(plusmigrations/apply),backups(create, download, delete),
restore(/restore/validate,POST /restore— a deferred restore applied at the next startup;
DELETE /restorecancels it) andaudit. A restore from the API/UI is offline: data is not wiped
under a live process. - Instance restart.
POST /api/v2/maintenance/restart {confirm:true}→ 202
{status, restartId, mode, startedAt}; the process shuts down gracefully with exit code 75 and is
brought back by its supervisor. The modes areSYNTH_RESTART_MODE=disabled|process|supervisor(default
disabled;synth serve --restart-modetakes priority over env);disabled→ 409
RESTART_UNSUPPORTED; a repeated call is idempotent; the audit record isrestart.requested. In a
multi-replica deployment only the handling pod restarts — the whole Deployment is restarted with
kubectl rollout restart. - Maintenance audit. A port in the storage layer and the journal
{dataDir}/audit/maintenance.jsonl:
backup.created,restore.scheduled|applied,migrate.applied,update.applied,
rollback.applied,restart.requested,uninstall.purge. - Portable project transfer.
synth project export|import(CLI) and the HTTP routes
GET /api/v2/projects/:id/export+POST /api/v2/projects/import— an archive
<name>.synth-project.tar.gzwith a manifest and sha256, optionally withworkspace/**. The import
is a materialization (a newprojectIdand new session ids), not thesession.requestedfunnel;
--merge-intoimports into an existing project. The export and the import go through the instance
storage context (StorageContext): onstorage.backend=postgresthe database projects are visible —
previously the export returned 404 for every database project, while the import went into the file
store (split-brain). The UI buttons in the project section are a remainder. - No secrets in the portable archive by default. The
secrets/directory, keys, credentials and
API keys never get into the archive; additionally, when the session payloads are packed the Gateway
obfuscation map (ADR-036) is stripped —obfuscation_mapof a session and
messages[].metadata.secretsof a message (the “placeholder → real secret” mapping), so a project
member does not carry away other members’ secrets. The explicit opt-in--with-secrets(CLI) /
?withSecrets=true(HTTP) is forADMIN/LEADonly (in standalone/core — the local owner); the
issuance is written to the maintenance audit journal (export.created, detailwithSecrets: true),
and such an archive is a secret itself. The import accepts a map, so an archive produced with the
opt-in imports correctly. - The session export: access by reading, the map by privilege.
GET /api/v2/sessions/:id/export
returns the session object (camelCase, the publish/fork format is unchanged). The right to export is
the right to read the session, while the Gateway obfuscation map (obfuscationMap,
messages[].metadata.secrets) is a separate privilege: the full export — the session owner
(ownerId ?? userId),ADMINandAUDITOR(the same privilege set as
GET /api/v2/sessions/:id/secrets); other readers (a project memberVIEWER/EDITOR, a project
owner who does not own the session,LEAD) — 200 with a cleaned body (no map, the conversation
is carried over in full, the stripping is done on a copy, the stored session is not modified);
no read access to the session — 403FORBIDDEN.
Analytics and reporting (PROP-102)
- Daily rollups instead of recomputing history. The
AnalyticsRollupRepositoryport (ADR-096) with
file, in-memory and PostgreSQL implementations; the rollup unit is one day in UTC. Four families:
usage(tokens and cost),activity(sessions, messages, tools, errors, turns),decision
(persistent decision-layer metrics) andprompt(a projection ofusageat read time). Derivatives
(cache_hit_rate,reasoning_ratio,output_tps, totals per currency) are computed on read and
are not persisted. - A read-only API.
GET /api/v2/analytics/{summary,usage,activity,decision,prompt,export}— the
periodfrom/to(YYYY-MM-DD, UTC, 30 days by default),groupBy
(day,user,project,model,agent,provider) and filters by axis.exporttakes
report=usage|activity|decision|promptandformat=csv|json(CSV — RFC 4180 + BOM,
Content-Disposition: attachment); there is no server-side PDF/XLSX — the browser print covers PDF. - RBAC.
ADMIN/LEADget the cross-instance overview,AUDITORreads, and a regularUSERsees
only their own rows (summaryanddecision→ 403). - UI. A separate lazily mounted “Analytics” window (entry points: the user menu and the right panel):
tabs “Cost and spend”, “Activity”, “Decision”, “Cache and efficiency”, presets of 7d/30d/90d/custom,
grouping, CSV/JSON export and browser printing. - Backfill.
synth analytics rebuild [--from <date>] [--dry-run|--yes]— an idempotent rebuild of the
rollups from the message history and the LLM Gateway log; without--yes(and without--dry-run) the
command refuses. - Retention.
analytics.retentionDays(400 by default), pruned at startup and every 6 hours. The
rollups contain no message text, no prompt content, no keys and no PII. - Remainder: spend limits, quotas and alerts, hourly rollups and a separate
analytics:readscope are out
of scope.
Session access authorization (security)
- A single prefix-guard instead of checks inside handlers (
server/session-access-guard.ts): the
guard is mounted once on/:idand/:id/*together withrequireAuth, so it covers every:idroute
— including future ones; the access kind (read/write) is derived from the HTTP method.- This is a behaviour change: the check used to live as a call inside the handlers — of 22 core
routes of the session resource it was present in 4, and themessages/queuesub-resources were not
checked at all, meaning any authenticated user could read and modify someone else’s session. Now one
decision point covers/status,/prompt(the system prompt composition) and/stats(per-agent and
per-user statistics) as well asmessagesandqueue.
- This is a behaviour change: the check used to live as a call inside the handlers — of 22 core
- Project member roles.
VIEWERreads,EDITOR/OWNERmutate (canReadProjectversus
canEditProjectContent); a read-level access never widens the rights to change. - The export is not a read. Portable artifacts are accessed as a disclosure of secrets: the session
exportGET /api/v2/sessions/:id/export— access by the right to read, but the obfuscation map only
for the session owner,ADMINandAUDITOR; the portable project archive strips the obfuscation map
by default, and the privileged?withSecrets=true(ADMIN/LEAD) keeps it, recorded in the
maintenance audit journal (export.created, detailwithSecrets: true). - The session export: cleaning instead of 403. The hybrid publish/fork carries the sessions of a
project with the user's own token (forkProjectToLocal→remoteClient.exportSessionfor every
session of the project): a 403 would break the project transfer for an ordinary member. Therefore
readers of the session without the map privilege get 200 with a cleaned body — the secrets do not
leave the instance and the feature keeps working.AUDITORis in the full set because it is the same
privilege set asGET /api/v2/sessions/:id/secrets(the map itself is a secret);LEADis not an
audit role and does not read the map through the dedicated endpoint. - The session export: access is decided by the single guard, the map is a separate privilege. The
routeGET /api/v2/sessions/:id/exportis registered after the prefix-guard
(app.use('/:id')/app.use('/:id/*')), so access to the session (403/404) is decided by it, and the
second copy of the rule in the handler is gone (invariant 16). The obfuscation map privilege is
decided by its own route-level guardcreateSessionExportGuard: the full export — session
owner,ADMIN,AUDITOR; other readers (project member,LEAD) — 200 with a cleaned body; no
read access — 403FORBIDDEN. - Stripping the obfuscation map — for every carrier of session messages. The payload cleaning
removed onlysession.obfuscationMapandsession.messages[].metadata.secrets, but a session also
carries messages inpendingMessages[]andcheckpoints[].messages[]— the obfuscation map kept
leaving the instance through the session export and the portable project archive. The cleaning is now
recursive and copy-on-write:metadata.secretsis removed anywhere in the payload, the source object
is not mutated, and the transfer format is unchanged. - The placeholder secret values are a privilege, not a consequence of reading. The real values of
messages[].metadata.secretswere returned by the ordinary session read to any reader, including aVIEWER
member of the project, although the obfuscation map itself is available only to the session owner,ADMIN
andAUDITOR. The values are now cleaned for the readers without the privilege (the placeholder keys are
kept, the stored session is not mutated — the change is applied to a copy of the response). - The workspace import is realpath-contained. Unpacking
workspace/**during a project import
checks the path by realpath: a pre-existing symlink inside the workspace can no longer be used to
write outside it; the error staysinvalid-archive(400VALIDATION_ERROR). - A limit on the unpacked volume when importing a project archive. The archive upload was limited
to 100 MiB, but the entry contents were buffered in full — a gzip bomb (100 MiB in → gigabytes in
memory) exhausted the instance memory. The import now checks 100 000 entries, 128 MiB per
entry and 2 GiB in total, and the check runs while the archive is being read (the per-entry
limit — from the tar header, before the content is buffered); exceeding →invalid-archive
(400VALIDATION_ERROR). The project export is not limited (the archive is streamed to disk); the
instance backup restore is a different artifact and is not limited by these limits. - Importing a session into a project is a write.
POST /api/v2/sessions/importrequired only the read
right on the target project, although importing a session into a project changes its content: a member with
theVIEWERrole could import a session into someone else's project. The edit level is now required
(canEditProjectContent—EDITOR/OWNER), just like for any other project mutation (invariant 21: a
read-level access never widens the rights to change). - The IDOR matrix.
packages/backend/src/routes/session-idor-matrix.test.ts— a negative and a
positive matrix (session owner,VIEWER/EDITORmember, project owner,ADMIN,LEAD,AUDITOR,
a foreignUSER) plus a route inventory test over the mounted application: a new:idroute outside
the matrix fails the test. - The git read-routes: project access at the boundary.
POST /api/v2/git/scan,GET /api/v2/git/log
andGET /api/v2/git/commitdid not check access to the project — in managed mode any authenticated
user could read another project’s git data by its id. The check is now a boundary guard
(projectGuard('read'|'write')): read requires the read right on the project
(owner/member/LEAD/ADMIN), write — the right to change the content; a request for a non-existent
project in managed mode answers 403 instead of 404 (fail-closed, no existence disclosure). - The git routes: one project id. The guard took the project id from the query, while the handler read
it from the body, soPOST /api/v2/git/scan?project_id=<own>with the body{"projectId": "<other>"}
passed the check and then read (and, for the write routes, mutated) someone else's project. There is now a
single resolver, a query/body mismatch gives 403 (fail-closed), and the authorised id is put into the Hono
context — the handlers take the id only from there. - The git tool arguments are validated. The parameter values went into the git argv without validation and
without a separator, so a value from the model could turn out to be a flag (commit="--output=<path>"in
git_diffwrote an arbitrary file with the process rights), andremoteingit_push/git_pullwas not
compared against the project registry — the child git went to an arbitrary host bypassing the egress address
policy and pushed the repository content out. There is now an allowlist of the value form, the dangerous
options are explicitly banned (--output,--exec,--upload-pack,--force,--mirrorand others — 17
entries), andremoteis bound to the repository registry of the project (ADR-076). - Instance-wide setting mutations are ADMIN-only. In a managed backend the mutations of
/api/v2/settings/*were protected byrequireAuthonly, so any authenticated user could change
instance-wide settings. The ADMIN gate was added with one factory option and one line at mounting
time — for all instance settings; standalone is unchanged (one key → ADMIN), and reads stay available
by role. - The proxy password in the settings body — an explicit refusal.
auth.passwordin
PUT /api/v2/settings/proxyis no longer silently ignored:validateProxyConfigrejects it from
the raw input, before the schema parsing (both inauthand inprofiles.<name>.auth), with a hint
of where to set the password. The password value does not appear in the error text. - Other: an anonymous request to an ADMIN route → 401
UNAUTHORIZED(403 — only for an authenticated
user without the role); a CSP for the SPA in core and nginx; escaping the workspace through a symlink
is closed by a single realpath helper (fail-closed on a broken symlink or a loop),
/api/v2/fs/workspace-root*→ 403 in managed mode;POST /api/v2/fs/uploadhas a size limit (100 MiB
by default, configurable viastorage.maxUploadBytes/SYNTH_MAX_UPLOAD_BYTES).
Public API platform and external events — brought to maturity (ADR-052/ADR-089)
The service-key/webhooks/OpenAPI/SDK contour, external event ingestion, bindings/routes/keys and the
scheduler shipped in 0.10.0 — they are not new in 0.11.3. In this release they were brought to
maturity:
- Moving keys to PostgreSQL without reissuing them.
synth storage migratecarries service-keys and
binding keys over with their original id, prefix and at-rest hash — a token issued earlier keeps
working after a file → postgres switch. Previously the migration recreated the secret, and external
consumers (the gateway, the installer key) received 401. - Ownership of externally created sessions. In a standalone install a session materialized from an
external event was invisible to the human owner: it did not appear in the list,GETby id returned
403 and WS events did not arrive — the external identity resolver returned the profile UUID instead of
thelocalliteral. The resolver is normalized now; the external identifier (tg:<id>) stays external
— for routing, bindings andmetadata.principal_id. Legacy sessions with a UUID owner are normalized
on read (the data is not rewritten). - Hub notification about new sessions is an invariant, not a point patch: the event detection was
extracted into a shared seam,packages/core/src/events/child-session.ts, used by both the managed
EventHuband the standalone/ws/events; the path that creates a session publishessession.created.
The gaps are closed: child sessions of thetasktool,forkProjectToLocal, project import/transfer and
subagent resume. In standalone mode a WS subscription registry was added — a session created after
the client connected no longer stays without subscribers (“0 subscribers — EVENT LOST”). Subscription
is strictly by owner (no owner — fail-closed). - The public contract: the dead
Session.enginefield ('synth'|'opencode'|'cline') was removed —
there is no logic behind it (engine adapters are PROP-050). This is a breaking change for OpenAPI/SDK
consumers: the responses ofGET /sessions*,GET /sessions/:id/messagesand the bodies for creating
and updating a session no longer containengine; the data of existing sessions is read as is, and no
migration is required.
Offline distribution (PROP-101)
- Portable archives for a closed network (
scripts/offline/): the application + a pinned Node 22 +
ripgrep + launchers + installers. Linux x64, macOS Intel/ARM, Windows x64 (per-user, no admin rights).
Native packages (.deb/.rpm/.msi/.pkg) are not produced. - Building:
node scripts/offline-bundle.mjs [--platform <name>|all] [--out <dir>] [--skip-build] [--source-date-epoch <s>]— reproducible packaging, amanifest.json(Synth/Node/ripgrep versions,
sha256,gitCommit) andSHA256SUMS. The build works without internet: through a pre-populated
cache (--node-cache/--rg-cache) or local unpacked archives (--node-dir/--rg-dir); the archives
are verified against the official SHA256 sums and the build fails on a mismatch. - Installers:
install.sh/uninstall.sh(Linux/macOS) andinstall.ps1/uninstall.ps1(Windows) — a
conflict with a foreign launcher leads to a refusal without a half-finished install; asystemduser
unit; the offline RU/EN docs inshare/docs. - The installer verifies the bundle integrity (fail-closed). The manifest already carried the sha256 of the
top-level files, but the payload (app/**) was covered by counters only — a substituted bundle in a closed
network was installed and executed without a check. The bundle now carriesfiles.sha256— the sha256 of
every file, recursively, — andinstall.sh/install.ps1verify everything before any filesystem
operation and before running the bundled node, parsing the manifest with system tools only (awk / .NET) and
never executing the code of the bundle being checked; a mismatch is a refusal. ThesynthVersionvalue from
the manifest is validated against a strict pattern before any filesystem operation (it used to reach
rm -rf/Remove-Item -Recurse -Force, and the value../../etcdeleted/etc). - Multi-arch images:
amd64(kaniko) +arm64(buildx, without QEMU) and a best-effort manifest
list; the offline imagessynth,synth-backendandsynth-uiare built locally and stored as
docker savearchives withSHA256SUMS. - Helm without Keycloak: the example
charts/synth/examples/values-apikey.yamlsets
backend.auth.apiKey.enabled: true(the key lives only in a Secret) andbackend.oidc.enabled: false;
it is incompatible with OIDC and the chart fails to render.
UI
- A reworked settings window: five groups (
personal, “Overview”, “AI and models”, “Integrations”,
“Operations”), 30 categories, a search over categories/sections/fields (ru/en) that scrolls to the
section and highlights it, a global “Advanced” toggle (rare categories and sections are hidden by
default but are still found by the search) and 262settings.help.*tooltip keys with a single ⓘ icon. - One control height: the standard is 30 px on desktop and 40 px on touch
(--control-height/--control-height-touchin the design system) — inputs, buttons, selects, segmented
controls and icon buttons were brought to one size; a live sweep over all categories found 136 controls,
all 30 px, with no deviations. - New admin sections: “Metrics” (observability), “Network” (proxy and trusted CAs/TLS) and
“Maintenance” (backups, migrations, restart, audit). - A new “Analytics” window — lazily mounted, in its own chunk.
- Logs: the “Logs and audit” section gained the tabs “Security (policies)” and “Outgoing requests”.
- Mobile adaptation (ADR-044), PWA and project/session icons and colours come from earlier releases; in
0.11.3 they received the unified touch control standard (40 px).
Reliability and fixes
- Egress proxy on Node 22:
EgressDispatcherunconditionally declared a new-style handler
(onRequestStart), whilefetchin Node 22 (undici 6.x) returns a legacy handler (onHeaders) — the
response was lost and the request hung. The wrapper became style-aware, the one-shot dispatcher is
released throughdestroy(); regression tests for the legacy contract were added (on Node 26 this path
was blind). - FileMutex: an owner token was added —
release/touchonly succeed for their own lock, a
stale takeover overwrites the token;ScheduleStoreextends the lock with a heartbeat during a long
mutation. - Stop is no longer lost. A stop request landing in the window between queuing the message (Phase 1
writes the session) and creating the abort controller used to reach nothing:abort()was called on a
controller that did not exist yet, so the LLM call ran to completion even though the user had already
pressed Stop. The stop is now re-checked right before the model call, the call is not started at all,
andstream.cancelledis published exactly once. Found by the first live CI run of the release, locked
in by a deterministic regression test. - Core fixes:
qa_pass/qa_reasonare no longer lost on persistence; a subagent permission is no
longer lost in thenonecontext mode; session read-modify-write goes through
SessionManager.withLock; a micro-first answer is no longer overwritten by a stale snapshot. - Docker: the permissions of
packages/backend/distin the image no longer depend on umask/ACL
(chmod a+rX) — otherwise a non-privileged uid hit EACCES. - Dependencies: MCP SDK 1.32.1 and simple-git 4.0.2 — closing a critical audit finding.
- CI: recursive pnpm 12 tasks (the intentional
core ↔ storage-postgresworkspace cycle is allowed),
the storage driver is built before the core tests (the degradation tests import it dynamically), and
the PostgreSQL contract suite moved into a separatetest-postgresjob. Waiting for the version to
appear in the npm registry can no longer hang forever: the request has a timeout and an overall wait
budget. - An internal security audit before the release — a mandatory
security-gateinrelease.yaml: the
build does not proceed without a substantive audit record for the released version
(docs/security/releases/<version>.json, mandatorycommand+result, fail-closed); a secret scan of
the tree and the history (15 rules, allowlist.gitleaks.toml); a dependency audit (in ordinary CI
pnpm auditstays report-only and only the release gate blocks). Documents:
docs/security/README.md,docs/security/AUDIT-CHECKLIST.md. - The gate reads test results from the JSON report instead of the human-readable summary, and prints
the output of a failed command: parsing vitest's summary depended on its format and once stopped a
release run while the tests were green. Counters now come from the machine-readable report
(numPassedTests/numFailedTests), an unreadable report is fail-closed, and a failure is readable
from the job log without local reproduction.
Documentation
- A new runbook
docs/OBSERVABILITY.md(metrics,/healthversus/ready, the metric catalog,
scraping, dashboards and alerts, several environments in one Prometheus, diagnostics, security) and an
extendeddocs/MAINTENANCE.md(backup and restore, schema migrations, PostgreSQL as a backend, the
degraded mode, moving data between backends, rebuilding the analytics rollups, migrating the storage to
Kubernetes). - The configuration (
security.tls,proxy,egress.*,metrics,analytics) and the API reference
docs/API_V2.mdgained sections on egress/TLS/proxy, analytics, maintenance and the portable project
transfer — RU and EN are in sync. - The decision catalog: ADR-094 (observability), ADR-096/097/098 (storage layer, the “storage” extension
class, degradation), ADR-091/092 (backup/restore, maintenance), ADR-099 (the egress layer; PROP-088 is
marked as replaced), PROP-101 (offline distribution), PROP-102 (analytics), PROP-100 (address pinning —
still a proposal).
Known limitations and open items
- The offline image archives and multi-arch (arm64) builds are not published in 0.11.3. Building them
requires a Docker daemon (buildx and a container-run verification), while the release runner works in a
container without/var/run/docker.sock. Published: the npm packages, the amd64 images ingitverse.ru
andghcr.io, the Helm chart, and the portable bundles (archives for an offline install). Publishing
arm64 and the offline archives is deferred to 0.12.0 — the build moves to a runner with a Docker daemon
or to kaniko. - SSRF/TOCTOU (PROP-100) is not implemented. The address check and the connection are separated in
time: the validated address is not pinned to the connection, so a DNS-rebinding window remains. Address
pinning is a separate proposal and is not part of 0.11.3. - Domain proxy authentication (NTLM/Negotiate/Kerberos) is delegated to an intermediate egress
proxy/gateway; the application supports Basic only. - SOCKS5 is best-effort: no trust towards the target (CA/mTLS) and no
proxy.tls. - Runtime reload of proxy/TLS without a restart is not supported: the settings are saved but applied at
the next startup (restartRequired). - The per-host proxy route works in the HTTP transport only; git and stdio-MCP receive a single proxy
through the child environment. The git child processes do not go through the egress address policy either:
remoteis bound to the repository registry of the project (an arbitrary URL cannot be chosen), but the
per-host routing does not apply to the child git, and the address of its connection is not checked by the
policy. - The protected-paths policy does not cover the HTTP file routes. The administrator's deny-list acts on the
agent tools (write_file,execute_shell) and on the/api/v2/fs/workspace-root*routes, but not on the
main file routes/api/v2/fs/*: a path inside the workspace denied by the policy is still served by
GET /api/v2/fs/readandGET /api/v2/fs/download. The fix is to apply the deny check in the single
path-resolution point of the FS routes; planned for 0.12.0. - The
execute_shelloutput is not filtered. The environment-reading commands (env,printenv) count as
read-only, so they pass in theplanmode, and their output — the values ofSYNTH_API_KEY,
SYNTH_PROXY_PASSWORD,SYNTH_SECRET_KEYand the other instance variables — reaches the model context
unfiltered. The fix is to treat the env dumps as sensitive and to add a scrubber of the known secrets to the
tool output; planned for 0.12.0. - The shell denylist matches by form. The
deniedCommandPrefixesrule compares the prefix tokens literally,
so a rule forrm -rf /does not coverrm -fr /orrm --recursive --force /— exactly one spelling
matches. The fix is to normalize the command form before matching (flags in any order, long options) or to
rely on the mutating-command classification and protected-paths; planned for 0.12.0. - The instance agent registry is per-process: the definitions are read at process startup, so in a
multi-replica deployment a replica sees only its own registry (changes made through the API apply in the
process that handled them). Session.enginewas removed from the public contract — a deliberate breaking change for OpenAPI/SDK
(details above).- Analytics: spend limits, quotas and alerts are not included; there is no server-side PDF/XLSX (PDF
is the browser print);decisionrollups are not rebuilt by the backfill (there is no metric history). - Storage: an online backup snapshot is non-transactional (
--live— with a warning); incremental
backup and PITR are a remainder.
How to upgrade
- npm (7 packages):
npm i -g @itphx/synth@0.11.3— the CLI wrapper pulls in@itphx/synth-core,
@itphx/synth-sharedand@itphx/synth-ui;@itphx/synth-backend,@itphx/synth-sdkand
@itphx/synth-storage-postgresare published separately. - Docker: images with the version tag
0.11.3(amd64/arm64) —gitverse.ru/itphx/synth:0.11.3,
gitverse.ru/itphx/synth-backend:0.11.3,gitverse.ru/itphx/synth-ui:0.11.3and the same three images
underghcr.io/itphx/. - Helm: the
synthchart version0.11.3(helm upgrade synth <repository>/synth --version 0.11.3). - Before upgrading — take a backup (
synth backup): the migration at startup may change data
irreversibly. Rolling data back is only possible by restoring a backup; rolling the version back is
helm rollbackorsynth update --to <prev> --apply. - After the upgrade:
synth doctor,GET /healthandGET /ready(storage degradation keeps
/ready= 200).
Artifacts
- npm:
@itphx/synth*@0.11.3— 7 packages (synth,core,shared,ui,backend,sdk,
storage-postgres); npmjs.org and the GitVerse Package Registry. - Docker:
gitverse.ru/itphx/synth{,-backend,-ui}:0.11.3andghcr.io/itphx/synth{,-backend,-ui}:0.11.3
(amd64/arm64); the offline image archives (docker save) and the portable bundles are release assets. - Helm: the
synthchart 0.11.3.
The full list of changes and commits is in the repository changelog: CHANGELOG.md and
.opencode/tasks/log.md, as well as on the GitVerse and GitHub release pages.
Installation
CLI (npm — from npmjs.org or the GitVerse Package Registry):
npm install -g @itphx/synth@0.11.3Base Docker image from the GitHub Container Registry:
docker pull ghcr.io/itphx/synth:0.11.3
docker run -p 4300:4300 ghcr.io/itphx/synth:0.11.3The primary image registry is gitverse.ru/itphx/synth:0.11.3;
all three base images (synth, synth-backend, synth-ui) are published to
both gitverse.ru and ghcr.io (version tag only).