Skip to content

Synth 0.11.3

Choose a tag to compare

@RamazanovDV RamazanovDV released this 20 Oct 21:24
· 586 commits to main since this release

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, env SYNTH_METRICS_ENABLED):
    when disabled it returns 404, not 401. An optional bearer token (env SYNTH_METRICS_TOKEN or the file
    {dataDir}/secrets/metrics-token, 0600) → 401 on a wrong token; the response carries
    Cache-Control: no-store. Scraping /metrics itself is not counted in the HTTP metrics.
  • An in-house mini-registry with no new dependencies (packages/core/src/metrics/): counter/gauge/histogram,
    fixed labelNames, a cap of 2000 series and the synth_metrics_series_dropped_total counter; the
    correctness of the renderer is pinned by golden tests.
  • A cardinality contract. HTTP metrics carry only the method, route template and status labels;
    session_id, user_id, raw paths and query strings are forbidden, and an unknown route collapses to
    other. SSE /api/v2/events and /ws/* are excluded from the latency histogram (long-lived connections
    distort p95) — a gauge of active streams is used instead.
  • /ready is separate from /health. /health is liveness (the process is alive), /ready returns
    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
    : /ready stays 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 /metrics and a
    scrape_config example. Priority is SYNTH_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 →
    409 CONFLICT; 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 is docker-compose.observability.yml plus the deploy/observability/ directory; the runbook is
    docs/OBSERVABILITY.md.
  • Helm. The metrics.enabled, metrics.existingSecret/tokenKey/tokenOptional and metrics.ingress
    flags (a dedicated Ingress for /metrics only — it points straight at the backend Service, UI-nginx does
    not proxy /metrics), plus an optional ServiceMonitor with prometheus.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_id and OpenTelemetry tracing are separate
    tasks.

Unified egress layer: address policy, TLS/CA, proxy (ADR-099)

  • An address policy for outgoing requests. The egress.tools and egress.webhooks sections and the
    mcp.networkPolicy key share one format {mode, list, exceptions}, with modes deny/allow (webhooks
    also accept off — 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/24 with the NAT64 prefixes, 198.18/15, the IANA IPv6 blocks); the lists in core are
    removed. The NAT64 prefixes 64:ff9b::/96 and 64:ff9b:1::/48 are 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/security and GET /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 reports addressChecksActive: false, because only the
    proxy knows the destination address.
  • TLS/CA trust — a single source. The security.tls section plus SYNTH_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 by storage.postgres.sslmode and security.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 → 409 CONFLICT). Changes are audited
    (scope:'tls') and applied after a restart (restartRequired).
  • The proxy is part of the egress layer. The proxy section: 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; noProxy always takes precedence over the rules; action: direct carries
    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
    fallbackDirect opt-in is explicit and marked in the log as via:'fallback'). An authentication failure
    (407/SOCKS5 refusal) → EGRESS_PROXY_AUTH_REQUIRED/EGRESS_PROXY_AUTH_FAILED, and the direct route
    stays forbidden even with fallbackDirect. 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 default profile uses
    SYNTH_PROXY_PASSWORD or {dataDir}/secrets/proxy-password; a profile <name> uses
    SYNTH_PROXY_<NAME>_PASSWORD or {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 → 409 CONFLICT. Passwords and userinfo are not stored in config.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) and POST /api/v2/settings/proxy/test
    (target check: a HEAD without redirects or body, the policy is checked before the request; the
    response carries the route, rule, profile, proxy, ok, httpStatus and latencyMs). 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.type is basic only; it is delegated to an intermediate egress proxy/gateway
    (Squid login=NEGOTIATE, px for NTLM/PAC) — ADR-099 §25. SOCKS5 is best-effort: trust towards the
    target through SOCKS is not supported (requestTls is not accepted), proxy.tls is 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: selected the 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 the DocumentRepository<T> port. The assembly point is StorageContext
    (createFileStorage/createMemoryStorage plus the asynchronous buildStorageContext for 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, pg as an optional dependency),
    connected through a dynamic import() only when postgres is 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 env SYNTH_TEST_POSTGRES_DSN (the test-postgres job in CI). Parameters live in
    the storage.postgres section; the password comes only from env SYNTH_POSTGRES_PASSWORD or 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-file brings the service up on a separate directory
    {dataDir}/fallback in 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|file is idempotent; --dry-run
    shows the per-entity counters, and without --yes the 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 .enc archive 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 doctor is mandatory after a restore.
  • Diagnostics. synth doctor [--json] — data-dir, schema, master key, secret permissions, data
    integrity, providers, locks, version; exit 1 on any fail.
  • 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 is synth migrate --dry-run /
    --yes (without --dry-run and without --yes the 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 is helm rollback (K8s) or
    synth update --to <prev> --apply.
  • Removal. synth uninstall [--purge|--purge-db|--dry-run|--yes] — by default the data is preserved;
    --purge requires --yes and warns about losing the master key and the safety copies, --purge-db
    drops the database schema when storage.backend=postgres.
  • Admin API and UI. The section “Maintenance” (Settings, ADMIN) and /api/v2/maintenance/*:
    status, doctor, migrations (plus migrations/apply), backups (create, download, delete),
    restore (/restore/validate, POST /restore — a deferred restore applied at the next startup;
    DELETE /restore cancels it) and audit. 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 are SYNTH_RESTART_MODE=disabled|process|supervisor (default
    disabled; synth serve --restart-mode takes priority over env); disabled → 409
    RESTART_UNSUPPORTED; a repeated call is idempotent; the audit record is restart.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.gz with a manifest and sha256, optionally with workspace/**. The import
    is a materialization (a new projectId and new session ids), not the session.requested funnel;
    --merge-into imports into an existing project. The export and the import go through the instance
    storage context
    (StorageContext): on storage.backend=postgres the 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_map of a session and
    messages[].metadata.secrets of 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 for ADMIN/LEAD only (in standalone/core — the local owner); the
    issuance is written to the maintenance audit journal (export.created, detail withSecrets: 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), ADMIN and AUDITOR (the same privilege set as
    GET /api/v2/sessions/:id/secrets); other readers (a project member VIEWER/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 — 403 FORBIDDEN.

Analytics and reporting (PROP-102)

  • Daily rollups instead of recomputing history. The AnalyticsRollupRepository port (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) and prompt (a projection of usage at 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
    period from/to (YYYY-MM-DD, UTC, 30 days by default), groupBy
    (day,user,project,model,agent,provider) and filters by axis. export takes
    report=usage|activity|decision|prompt and format=csv|json (CSV — RFC 4180 + BOM,
    Content-Disposition: attachment); there is no server-side PDF/XLSX — the browser print covers PDF.
  • RBAC. ADMIN/LEAD get the cross-instance overview, AUDITOR reads, and a regular USER sees
    only their own rows (summary and decision → 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:read scope 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 /:id and /:id/* together with requireAuth, so it covers every :id route
    — 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 the messages/queue sub-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 as messages and queue.
  • Project member roles. VIEWER reads, EDITOR/OWNER mutate (canReadProject versus
    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
    export GET /api/v2/sessions/:id/export — access by the right to read, but the obfuscation map only
    for the session owner, ADMIN and AUDITOR; 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, detail withSecrets: 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.exportSession for 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. AUDITOR is in the full set because it is the same
    privilege set as GET /api/v2/sessions/:id/secrets (the map itself is a secret); LEAD is 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
    route GET /api/v2/sessions/:id/export is 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 guard createSessionExportGuard: the full export — session
    owner, ADMIN, AUDITOR; other readers (project member, LEAD) — 200 with a cleaned body; no
    read access — 403 FORBIDDEN.
  • Stripping the obfuscation map — for every carrier of session messages. The payload cleaning
    removed only session.obfuscationMap and session.messages[].metadata.secrets, but a session also
    carries messages in pendingMessages[] and checkpoints[].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.secrets is 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.secrets were returned by the ordinary session read to any reader, including a VIEWER
    member of the project, although the obfuscation map itself is available only to the session owner, ADMIN
    and AUDITOR. 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 stays invalid-archive (400 VALIDATION_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
    (400 VALIDATION_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/import required only the read
    right on the target project, although importing a session into a project changes its content: a member with
    the VIEWER role 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/EDITOR member, project owner, ADMIN, LEAD, AUDITOR,
    a foreign USER) plus a route inventory test over the mounted application: a new :id route 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
    and GET /api/v2/git/commit did 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, so POST /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_diff wrote an arbitrary file with the process rights), and remote in git_push/git_pull was 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, --mirror and others — 17
    entries), and remote is 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 by requireAuth only, 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.password in
    PUT /api/v2/settings/proxy is no longer silently ignored: validateProxyConfig rejects it from
    the raw input, before the schema parsing (both in auth and in profiles.<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/upload has a size limit (100 MiB
    by default, configurable via storage.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 migrate carries 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, GET by id returned
    403 and WS events did not arrive — the external identity resolver returned the profile UUID instead of
    the local literal. The resolver is normalized now; the external identifier (tg:<id>) stays external
    — for routing, bindings and metadata.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
    EventHub and the standalone /ws/events; the path that creates a session publishes session.created.
    The gaps are closed: child sessions of the task tool, 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.engine field ('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 of GET /sessions*, GET /sessions/:id/messages and the bodies for creating
    and updating a session no longer contain engine; 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, a manifest.json (Synth/Node/ripgrep versions,
    sha256, gitCommit) and SHA256SUMS. 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) and install.ps1/uninstall.ps1 (Windows) — a
    conflict with a foreign launcher leads to a refusal without a half-finished install; a systemd user
    unit; the offline RU/EN docs in share/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 carries files.sha256 — the sha256 of
    every file, recursively, — and install.sh/install.ps1 verify 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. The synthVersion value 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 ../../etc deleted /etc).
  • Multi-arch images: amd64 (kaniko) + arm64 (buildx, without QEMU) and a best-effort manifest
    list; the offline images synth, synth-backend and synth-ui are built locally and stored as
    docker save archives with SHA256SUMS.
  • Helm without Keycloak: the example charts/synth/examples/values-apikey.yaml sets
    backend.auth.apiKey.enabled: true (the key lives only in a Secret) and backend.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 262 settings.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-touch in 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: EgressDispatcher unconditionally declared a new-style handler
    (onRequestStart), while fetch in 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 through destroy(); regression tests for the legacy contract were added (on Node 26 this path
    was blind).
  • FileMutex: an owner token was added — release/touch only succeed for their own lock, a
    stale takeover overwrites the token; ScheduleStore extends 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,
    and stream.cancelled is 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_reason are no longer lost on persistence; a subagent permission is no
    longer lost in the none context 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/dist in 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-postgres workspace 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 separate test-postgres job. 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-gate in release.yaml: the
    build does not proceed without a substantive audit record for the released version
    (docs/security/releases/<version>.json, mandatory command+result, fail-closed); a secret scan of
    the tree and the history (15 rules, allowlist .gitleaks.toml); a dependency audit (in ordinary CI
    pnpm audit stays 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, /health versus /ready, the metric catalog,
    scraping, dashboards and alerts, several environments in one Prometheus, diagnostics, security) and an
    extended docs/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.md gained 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 in gitverse.ru
    and ghcr.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:
    remote is 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/read and GET /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_shell output is not filtered. The environment-reading commands (env, printenv) count as
    read-only, so they pass in the plan mode, and their output — the values of SYNTH_API_KEY,
    SYNTH_PROXY_PASSWORD, SYNTH_SECRET_KEY and 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 deniedCommandPrefixes rule compares the prefix tokens literally,
    so a rule for rm -rf / does not cover rm -fr / or rm --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.engine was 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); decision rollups 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-shared and @itphx/synth-ui; @itphx/synth-backend, @itphx/synth-sdk and
    @itphx/synth-storage-postgres are 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.3 and the same three images
    under ghcr.io/itphx/.
  • Helm: the synth chart version 0.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 rollback or synth update --to <prev> --apply.
  • After the upgrade: synth doctor, GET /health and GET /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.3 and ghcr.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 synth chart 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.3

Base 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.3

The 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).