In 0.9.0 we made GoatFlow easy to stand up. In 0.10.0 we made it tell the truth once it’s running.
This is the biggest release since the platform/product split: 93 commits and 263 changelog entries. A lot of GoatFlow’s surface area was laid out early as a skeleton, with the routes, screens and settings in place so the shape of the product was clear. 0.10.0 is where much of that skeleton gets its meat: ticket notifications that are evaluated and sent, SLA escalation computed in working time, a reports page with real numbers, search backends that reindex, dashboards that load live data. We call it the real release because it fills in what the earlier releases sketched.

Sign-In and Self-Service
- LDAP / Active Directory login works. The
ldapprovider used to return “not yet implemented”. It now searches the directory, binds as the user to check the password, and verifies the server certificate on StartTLS/LDAPS. - Forgotten-password reset for agents and customers, plus customer self-registration. Reset links and password re-checks are rate limited.
- Passkey management and recovery codes. Your profile lists each passkey with when it was added and last used, warns when a key was set up on a different host name, and lets you remove it. Passkey-only users get recovery codes.
- Every login now has a session. OIDC, SAML and
POST /api/v1/auth/loginused to hand out tokens with no session behind them, so “log out” and Admin → Sessions → kill did not revoke anything. Tokens now carry a session id (sid) and are refused the moment the session is gone.
before after
────── ─────
login ─► JWT login ─► session row ─► JWT{sid}
logout ─► cookie cleared logout ─► session deleted
JWT still valid JWT{sid} refused on every route
That change has one cost you will notice: everyone signs in again once after upgrading. Tokens from 0.9.0 have no sid, so they are refused.

PostgreSQL, For Real
0.9.0 could not be installed on PostgreSQL at all: golang-migrate stopped at migration 5 with “duplicate migration file”. 0.10.0 runs the full suite against both MariaDB and PostgreSQL, and the SQL portability lint (cmd/gk-lint) now checks every query against the schema of both databases in pre-commit.
| 0.9.0 | 0.10.0 | |
|---|---|---|
| Fresh PostgreSQL install | fails at migration 5 | works |
| MySQL-only SQL in Go code | caught in review, sometimes | caught by gk-lint |
| Unknown table/column in a query | runtime error | lint error |
| Upserts | hand-written per driver | database.ConvertUpsert |
If you tried 0.9.0 on PostgreSQL, install 0.10.0 fresh. There is no 0.9.0 PostgreSQL database to upgrade.
OTRS-Compatible Storage
Attachments and raw article bodies can now live where OTRS keeps them, and goatflow-storage is a working CLI for moving between database and filesystem storage. Together with the import tooling, an OTRS or Znuny installation can come across without rewriting its article store first.
The Plugin Platform Checks Its Inputs
0.10.0 is the release where the plugin sandbox stopped trusting plugins to be polite.

- Permissions are enforced. Every HostAPI call and every SQL statement a plugin runs is checked against the
resources.permissionsit declared. gRPC plugins used to bypass the sandbox; they now go through it too. - Org scoping holds. A plugin query that mentioned
org_idcould skip the organisation filter. It can’t any more. - Plugins run as the caller. Routes, widgets, plugin UIs and MCP calls carry the user’s identity, organisation and language.
Plugins also got new HostAPI capabilities: CreateArticle, article attachments, RenderMarkdownToPdf with branding, and a shared Tiptap editor partial.
A Marketplace That Remembers
The marketplace index used to describe one version per plugin. Publish a release that needs a newer GoatFlow, and every older host was simply locked out.

Now each entry can carry its release history:
{
"name": "goat-kb",
"latest_version": "0.2.0",
"min_host_version": "0.10.0",
"versions": [
{ "version": "0.2.0", "min_host_version": "0.10.0" },
{ "version": "0.1.1", "min_host_version": "0.8.0" },
{ "version": "0.1.0", "min_host_version": "0.8.0" }
]
}
GoatFlow picks the newest version it can actually run:
host 0.9.x ──► goat-kb 0.1.1 (0.2.0 needs 0.10.0, skipped)
host 0.10.0 ──► goat-kb 0.2.0
Older GoatFlow builds ignore the versions field and behave exactly as before. The admin marketplace page shows a version picker and a “Requires GoatFlow ≥ X” badge, and gk install [email protected] pins a version from the CLI.
Three gaps closed along the way:
- Update checked nothing. Only install looked at
min_host_version. Update now checks too. - A failed update deleted your plugin. Update removed the installed plugin before downloading the new one. It now downloads and verifies the signature in a staging folder, and only swaps once that succeeds.
- Plugins were told they run on GoatFlow 0.6.4. The gRPC runtime hard-coded it. They now get the real version, and a plugin that needs a newer GoatFlow is refused at load with a clear log line instead of failing later.
On the admin marketplace page, goat-kb on an older install shows the update with a version picker; goat-kanban is up to date:

Two plugins ship on the new index today: goat-kb 0.2.0 and the new goat-kanban 0.1.0, drag-and-drop boards over real ticket states, scoped to the queues you can see.

Things That Said Yes
A sample of the 154 entries under Fixed:
| Area | Before | After |
|---|---|---|
| Ticket notifications | saved, never sent | sent, recipients follow OTRS rules |
| SLA escalation | times never computed | first response / update / solution, events raised |
PUT /api/tickets/:id | answered 200, saved nothing | saves, records history |
| Zinc / Elasticsearch | reindex answered 501 | works, honours queue permissions |
| Dashboard widgets | showed 0 on error | show data or say they can’t |
make test-unit | could pass after running almost nothing | fails when a package fails to load |
One page that used to say “under construction” now has real numbers: Admin → Reports & Analytics shows ticket totals, a created-vs-closed trend, per-queue backlog and agent activity, with CSV and JSON exports.

Operations

GET /healthperforms real checks; Prometheus metrics on/metrics; structured logs controlled byLOG_FORMAT/LOG_OUTPUT.- Graceful shutdown with connection draining, sized to fit the stop grace period.
- Scheduled jobs and startup migrations run once across replicas, under a lock. A failed migration is no longer marked as applied.
- The Helm chart works out of the box: a runner Deployment, persistent file storage, working probes, Valkey and Ingress. The air-gapped bundle contains every image the chart needs.
- Upgrades install the new versions of bundled plugins, keeping any file an admin changed.
Compose users: deploy/docker-compose.yml now requires GOATFLOW_SECURE_KEY (64 hex characters, openssl rand -hex 32) and SMTP_HOST in .env. The key must be the same for the app, customer portal and runner, so compose refuses to start without it rather than letting each process invent its own.
The Security Sweep

32 entries under Security, and a vulnerability assessment before the tag. The highlights:
- SQL injection in the customer ticket list
orderparameter. - Stored XSS in the ticket view’s description card.
POST /api/v1/auth/loginignored the second factor.- SSO login CSRF: OIDC
stateand SAMLRelayStateare now bound to the browser that started the login. - Identity-provider and webhook URLs could reach internal addresses.
- Identity-provider secrets and webhook headers are encrypted at rest.
- Database error text no longer reaches the client, in GoatFlow and in 13 first-party plugin repos.
By the Numbers
- 93 commits since 0.9.0
- 263 changelog entries: 30 added, 22 changed, 154 fixed, 32 security, 22 removed
- 33 migrations (26 in 0.9.0), identical on MariaDB and PostgreSQL
- 2 databases the full test suite runs against
- 2 plugins on the marketplace with per-version compatibility
- Go 1.25.12,
grpc v1.83.2,goldmark v1.7.17,golang-jwt v5.3.1
What’s Next
1.0.0 is the production release, and we’re getting there in small steps rather than one big drop. Each release takes a slice of the 1.0.0 list and ships it properly, so that 1.0.0 itself is just the release candidate once it has held up.
| Release | Theme |
|---|---|
| 0.11.0 | Security basics: security policy and CVE process, a general API rate limit, vulnerability scanning in CI, an OWASP Top 10 review, a hardening guide |
| 0.12.0 | Performance and observability: a load-test harness and baseline, tuning driven by it, OpenTelemetry tracing, circuit breakers |
| 0.13.0 | Documentation and quality: the full API reference, admin, troubleshooting and plugin guides, measured test coverage |
| 0.14.0 | A Calendar & Appointments plugin |
| 0.15.0 – 0.18.0 | Process management in core, on the OTRS data model: the engine and OTRS process import, then activity dialogs, transitions and actions, and process SLAs |
| 0.19.0 – 0.20.0 | A process designer plugin: forms first, then drag-and-drop |
| 1.0.0-rc | OTRS module check, a third-party security audit, 85% test coverage |
No dates beyond the next release. The full list is in the ROADMAP.
Bonus Track: Sixteen Characters of Accept
Every release has one bug worth retelling. 0.10.0’s was found by the demo deploy smoke test, and it lived in the code that protects the demo.
On a public demo, GoatFlow blocks non-admins from changing passwords or MFA. JSON clients get a 403, browsers get redirected. To decide which, the guard checked the Accept header:
func wantsJSON(c *gin.Context) bool {
accept := c.GetHeader("Accept")
return accept == "application/json" ||
c.GetHeader("X-Requested-With") == "XMLHttpRequest" ||
c.ContentType() == "application/json" ||
len(accept) > 0 && accept[:16] == "application/json"
}
len(accept) > 0 was meant to guard the slice. It guards nothing: accept[:16] needs sixteen bytes, not one. curl sends Accept: */*. Three bytes. Every curl request to a guarded page panicked with slice bounds out of range [:16] with length 3 and came back as a 500.
Browsers never hit it, because their Accept header is long. So the demo looked fine for everyone who used it in a browser, and broke for the first script that asked politely.
The fix is the function the code was trying to be:
func wantsJSON(c *gin.Context) bool {
return strings.HasPrefix(c.GetHeader("Accept"), "application/json") ||
c.GetHeader("X-Requested-With") == "XMLHttpRequest" ||
c.ContentType() == "application/json"
}
The same smoke test found the guard’s second problem: with no Referer, the browser redirect pointed the page back at itself. It now falls back to the dashboard. Both have regression tests that fail on the old code.
- Source & containers: GoatFlow on GitHub
- Full changelog: CHANGELOG.md
- Helm chart:
oci://ghcr.io/goatkit/charts/goatflow - Photos: CC0 / public domain from Wikimedia Commons — mountain goats by Jonatan Moerman, puzzle pieces by Profpcde, card catalogue by MarkBuckawicki, server racks by Derrick Coetzee, padlock by Bich Tran.
Questions? Feedback? Open a GitHub Discussion and let us know what you think!