Tale v0.5.22
0.5.22 is a fix release on the 0.5 line that carries more than its fixes: a security fix at the API door, a third set of REST contract corrections (contract 1.3.0 → 1.4.0), validated reads and realtime hints where the app used to poll, archived work back in search, a clean shutdown for the sandbox egress proxy, and one opt-in CLI build target. Four platform migrations (0094–0097) and one knowledge-database migration apply themselves on tale deploy, each written to run while the previous image is still serving. No configuration-file change and no new environment variable. The Known issues from 0.5.21 are unchanged; two verification gaps are listed with them.
Highlights
An API key no longer opens a browser session (#3332)
Any request carrying an x-api-key header acted as the key holder's browser session: through it a key could mint further API keys at the auth mount and open /api/app/* and the /events stream, none of which a key is meant to reach. Found beyond the evaluation report and proven on a local stack. The header is refused with 401 on every route; the REST door authenticates with Authorization: Bearer only. Nothing changes for a client that already sends Bearer.
The third API evaluation pass (#3332)
The third external black-box evaluation of the REST and MCP API ran against 0.5.21 and reported 83 findings. Every one was second-confirmed from source before a fix was designed, and the server logs of the test window turned wire symptoms into root causes. Three headline corrections to the report: the "double send" is a settle race, not a missing guard; skills writes were already serialized, what was missing was a precondition; orphan upload blobs are reclaimed 24 hours after their 30-minute expiry. The contract moves to 1.4.0, and every wire change is listed under API contract changes. By family:
- Door and edge. An over-long URL answers 414 in the JSON envelope, from the proxy's own rule and from the backend's guard alike. The proxy's header budget is 64 KiB: it was 32 KB, and over HTTP/2 a 33 KB URL used to fail header decoding and close the connection with no response, so a client retried it forever. A key-less
OPTIONSanswers 204 withAllow;X-Organization-Slugis folded to lowercase; one trailing slash under/api/v1/routes; a HEAD of a JSON route carriesContent-Length; a blankcursororlimitis a 400; every schema refusal reads as prose; a 413 names the cap. - Chat. A second send while a turn generates or is queued answers 409
CHAT_TURN_IN_PROGRESS, and a thread mid-turn refusesDELETE. A send takes anIdempotency-Key: a retry replays the first 202 withduplicate: true, and the same key with another body is 409IDEMPOTENCY_KEY_REUSED(migration 0094). Messages carryfinishReason,usage.estimatedandstepLimitHit; a thread lists its messages with?order=and?since=and reads one by id. The automatic thread title used to be asked of a thinking-by-default model that spent its whole reply budget reasoning and answered nothing, a paid miss on every new thread; the title call now prefers a model that runs without thinking, and a call that returns no text is logged as a miss with its tokens booked. - Knowledge and documents. A search hit names the legs that matched it (
matchedLegs,similarity,keywordScore); fusion is by rank, with admission judged before fusion; the organization's embedding settings take aminSimilarityfloor, cleared withnull(from the CLI's configuration declaration too).Document.contentHashis filled for the knowledge entries that only carried it inmetadata(migration 0095); aPATCHof metadata is an RFC 7396 merge and a no-op merge skips the write; a document's content is served inline for text; entries carrysupersededAt, filter by?topic=and list their versions. - Automations.
lastFiredAtnow means a run started. The schedule scan used to stamp the field before asking for a run, so a binding whose automation had nothing deployed "fired" every occurrence while the worker logged that it had nothing to run. A trigger now carriesid,lastRunId,lastSkippedAtandlastSkipReason(not_deployed,unusable_cronorstart_refused) (migration 0096). A cron expression the scheduler cannot honour is refused at write time (-5no longer reads as0-5), fields of another trigger kind are refused, a webhook revocation readsrevoked: 'webhook', a parked run says what it iswaitingFor, and erasure deletes the runs a user started under a bare id. - Skills. Every skill names its version:
etagandupdatedAt.GETcarriesETagand answers 304;If-Matchguards a save or a delete (412SKILL_STALE);If-None-Match: *creates only (412SKILL_EXISTS); any file of the bundle is readable; a bundle the file layer refuses answers 422SKILL_MALFORMED;disableModelInvocationis writable. - Files, websites, projects, tasks. A
Rangeis judged locally: an unsatisfiable one answers a bodiless 416 withContent-Range: bytes */size. The backend used to copy the object store'sContent-Lengthonto that empty answer, and the edge aborted the response with "unexpected EOF". A malformed or multi-range request answers 200 with the whole body; a HEAD carriesETag,Last-ModifiedandAccept-Ranges.POST /websitesfor a domain already tracked, or for its www/apex sibling, answers 409WEBSITE_DUPLICATE_DOMAINnaming the row; the shared web corpus keys its chunks by domain as well as URL, so the sibling of a tracked site indexes instead of colliding (knowledge-database migration 09). A taken project-agent name is 409, not 400. Task labels keep the spelling they were given and are unique per project without regard to case (migration 0097).PATCH /projects/{id}editsname,descriptionandexternalItemId; a folder names itsparentIdand can be read alone; an upload handoff names itsmaxBytes, and a mint whosesizeexceeds it is refused before any byte moves. - Auth, status, WebDAV. At the auth mount a non-JSON body is 400
invalid_request(it was a bare 415), a token request withoutgrant_typeis told so, an unknownclient_idat authorize is named in the redirect, and discovery lists theacrclaim and only the prompt values the provider honours./status.jsoncarriesAccess-Control-Allow-Origin: *, so a browser dashboard can poll it without a proxy, and the status page shows a backend, a database and an object-store row from a new internal probe. A WebDAVOPTIONSanswers the methods each target supports./menames the key that made the request underkeyand says whether the caller may edit the deployment configuration.
Validated reads, and hints instead of polling (#3331)
A source review of the 2026-09-12 efficiency report (18 findings against 0.5.21). The report's premise was wrong: the app is not "pure polling", it holds two EventSource lanes, the organization hint stream and a per-thread reply stream, both proxied in production. Three of its findings were real:
- The video-link chips polled an empty endpoint every two seconds, forever. Video links were the only domain that never emitted a realtime hint, so their reads polled to compensate, on every idle chat page, for as long as it stayed open. Every write to a video-link job now hints its uploader; the chip reads key under that entity and poll only as a five-second fallback while a job is live.
- Two reads were fetched twice. The composer's model catalog was one fetch per mount with two callers per chat page; it is one shared query per organization, invalidated by an org-wide hint when a credential changes. The Inbox status badges share one counts request per connector.
- Nothing on either JSON surface could be validated. Every 200 JSON
GETorHEADon/api/app/*and/api/v1/*now carries anETagandCache-Control: private, no-cache, and a matchingIf-None-Matchanswers a bodiless 304 (weak tags, tag lists and the-gzip/-zstdsuffix the proxy appends all match). A route that sets its own directive keeps it; what changes is the REST door's blanketno-store. File content forwardsIf-None-Match,If-Modified-SinceandIf-Rangeto the object store, which answers 304 itself. A run read takes?fields=status,finishedAt, and a run listing with?include=reads at most 25 rows and answers at most 8 MiB of them, ending early with a cursor at the last row that fit. The API reference shows--compressedon every curl example and gains a section on caching, compression and partial reads; a 304 still costs one request against the rate limit.
Archived work shows in search, labelled (#3326)
The ⌘K palette returns archived tasks and projects, and rows belonging to an archived project, each labelled Archived or Archived project; live rows still list first. Archived material was decided to stay searchable, and its two labels shipped, some releases ago, but the palette kept the older predicate while the agent's search path implemented the decision, so the same query answered differently depending on who asked, and a live task inside an archived project was unfindable through either. Archived work also says so where it lives: a board card and a list row carry the badge (a lighter colour was the only cue, which WCAG 2.1 AA 1.4.1 does not allow), and an archived project carries it beside its breadcrumb name on every tab. On the tasks page, Show archived and Search tasks now agree; typing in the search box used to silently remove the rows the toggle had just revealed. Chats and contacts are unchanged.
The sandbox egress proxy shuts down cleanly (#3324)
Foreground Tinyproxy wrote a PID file it did not need during startup, and its root supervisor lacked the permission to signal the proxy after it changed user to nobody, so a stop could not drain and ended with the container killed (exit 137). The PID file is gone and KILL joins the proxy's restricted capability set, in the repository Compose file and in the managed Compose the CLI renders; the SSRF firewall and the privilege drop are unchanged. The container smoke test boots with a read-only /tmp, checks the graceful stop and starts the same container again. A managed deployment failure now says whether Compose validation or Compose startup failed, with fixed labels only; Docker output and credentials stay private.
A baseline x64 CLI executable for CPUs without AVX2 (#3327)
The default Linux x64 executable targets bun-linux-x64, which assumes AVX2; on an on-prem host with an Intel Ivy Bridge Xeon it dies on startup with Illegal instruction. bun run build:linux-baseline in tools/cli compiles the same sources for Bun's bun-linux-x64-baseline target, and the setup-cli GitHub action takes linux-baseline: 'true' on a Linux x64 runner; any other runner, or any other value, is refused with an error before the toolchain is installed. The build is opt-in: there is no tale_linux_baseline release asset, and the install script still downloads the default executable.
Behaviour changes
- Any request with an
x-api-keyheader is refused with 401, on every route. - A URL past the documented cap answers 414 in the JSON envelope instead of a bare 431 or a dropped connection.
- A second chat send during a turn is refused (409), and a thread cannot be deleted mid-turn.
- A trigger's
lastFiredAtreadsnulluntil it has started a run. After migration 0096, schedule and event bindings that never started a run lose the stamp the scan had been advancing; a binding that did fire keeps its stamp. - Task labels keep their case.
BugstaysBug(it used to come back asbug); labels that differed only in case are merged onto the oldest one. - A website registered twice, or under its www/apex sibling, is refused with 409 instead of creating a second row.
- The ⌘K palette lists archived tasks and projects, labelled, after the live rows; board cards, list rows and an archived project's breadcrumb carry an Archived badge.
- Video-link chips no longer poll an idle chat page.
- JSON reads on both API surfaces carry
ETagandCache-Control: private, no-cache; the REST door used to answerno-store. - The automatic thread title is asked of a model that can answer without thinking wherever the organization has one.
- The status page shows backend, database and object-store rows, and
/status.jsonanswers cross-origin requests. - The sandbox egress proxy stops gracefully, and a managed deployment failure names the Compose phase that failed.
- A
tale configdeclaration can clear the embedding similarity floor withminSimilarity: null; an omitted floor leaves the stored one in place.
API contract changes
info.version of the OpenAPI document moves from 1.3.0 to 1.4.0, and every /api/v1 answer names the version it implements in X-Tale-Api-Version. Changed meanings first, additions after.
Changed
- Every 200 JSON read of
/api/v1answersCache-Control: private, no-cache(wasno-store) with anETag; a matchingIf-None-Matchanswers 304 with no body. Routes that chose their own directive keep it./api/app/*JSON reads gain the same pair (they had no directive). GET …/files/{documentId}/contenthonoursIf-None-Match,If-Modified-SinceandIf-Range, answers 304, and itsCache-Controlisprivate, no-cache(wasprivate, no-store). An unsatisfiableRangeanswers a bodiless 416 withContent-Range: bytes */sizeandContent-Length: 0; a malformed or multi-range request answers 200 with the whole body; a HEAD carriesETag,Last-ModifiedandAccept-Ranges.x-api-keyis refused with 401 everywhere; onlyAuthorization: Bearerauthenticates.- An over-long URL answers 414
URI_TOO_LONG; the documented 431 was never observed. - A second
POST …/threads/{id}/messageswhile a turn generates or is queued answers 409CHAT_TURN_IN_PROGRESS, andDELETEon such a thread is refused the same way. A thread title is non-blank and at most 120 characters. POST /websitesfor a domain already tracked as a whole site, a bare re-post, or the www/apex sibling answers 409WEBSITE_DUPLICATE_DOMAINwithdata.websiteIdanddata.domain; only a list under the same spelling merges.PROJECT_AGENT_NAME_TAKENanswers 409 (was 400);DOCUMENT_TITLE_INVALIDis answered asINVALID_BODYat the door.- Task labels are stored as sent, unique per
lower(name), and read back in the order sent. - A trigger's
lastFiredAtis stamped only when a run starts. A cron expression the scheduler cannot honour, and a trigger field of another kind (cronortimezoneon a webhook,eventon a schedule), are refused at write time. - A blank
cursororlimitanswers 400INVALID_QUERY; a negative website pageoffsetanswers 200 clamped; run listings with?include=clamplimitto 25 and cap a page at 8 MiB of rows (isDone: falseplus a cursor when cut). - Auth mount (
/api/auth/oauth2/*): a non-JSON body answers 400invalid_request(was a bare 415); a token POST withoutgrant_typeanswersinvalid_requestnaming it; an unknownclient_idat authorize is named in the redirect (302 for a navigation,200 {redirect, url}for a fetch-mode client); discovery lists theacrclaim and only the prompt values the provider honours. Error.codeis required on every error, and 43 codes no REST route can answer leave the enum (THREAD_NOT_IN_PROJECT,THREAD_SCOPE_CHANGEDandAUTOMATION_PROJECT_ARCHIVEDamong them).
Added
Idempotency-Keyon chat sends: a replay answers 202 withduplicate: true; the same key with another body answers 409IDEMPOTENCY_KEY_REUSED.Message.finishReason,usage.estimatedandstepLimitHit; idle-thread factslastMessageIdandlastStatus;?order=and?since=on the message listing;GET …/messages/{messageId}.GET /runs/{runId}andGET /projects/{id}/runs/{runId}accept?fields=(an unknown or blank key answers 400INVALID_QUERY); a projected read is aRunProjection.- Search hits carry
matchedLegs,similarityandkeywordScore; the embedding settings take a nullableminSimilarity. Document.contentHash; aPATCHof metadata is an RFC 7396 merge;GET/HEAD …/documents/{id}/content; knowledge entries carrysupersededAt, filter by?topic=, andGET /knowledge-entries/{id}/versionslists their versions.- Triggers carry
id,lastRunId,lastSkippedAtandlastSkipReason; a revoked webhook readsrevoked: 'webhook'; a parked run carrieswaitingFor. - Skills:
etagandupdatedAton every view,ETag/304 onGET,If-Match→ 412SKILL_STALE,If-None-Match: *→ 412SKILL_EXISTS,GET/HEAD /skills/{slug}/files/{path}(404SKILL_FILE_NOT_FOUND), 422SKILL_MALFORMED, anddisableModelInvocationis writable. GET /conversations/deliveriespeeks the delivery queue andPOST …/deliveries/{id}/retryretries one delivery (DELIVERY_RETRY_UNAVAILABLE);claimalready existed.Me.capabilities.deploymentEditor;Me.key { id, name, expiresAt };BrowserSession.createdAt.PATCH /projects/{id}editsname,descriptionandexternalItemId(nullable) besidearchived;{}answers 400.ProjectFolder.parentId(required),?parentId=andGET …/folders/{folderId}.ProjectUploadHandoff.maxBytes(required); a mint bodysizeover it answersFILE_TOO_LARGEorUPLOAD_POLICY_REJECTEDbefore any upload.WebsitePatch.domainis declared;lifecycleStatusandtranslationsare read-only.- A key-less
OPTIONSanswers 204 withAllowand no CORS headers;X-Organization-Slugis matched case-insensitively; one trailing slash under/api/v1/routes; a HEAD of a JSON route carriesContent-Length; 204, 304 and 416 carry no content headers; a 413 names the cap; every schema refusal reads as prose. - Every response declares its headers in the document, and a contract fingerprint test fails when the contract changes without a version bump.
- Outside the document:
/status.jsonanswersAccess-Control-Allow-Origin: *, and a WebDAVOPTIONSnames inAllowthe methods each target supports.
Known issues
- Unchanged from v0.5.20, where each is described in full: the
es/co-ccColombian cédula detector still ships switched off and a locale-agnostic PII toggle still widens national-ID matching to every locale (the PII library is untouched in this range); thinking-block replay on the native Anthropic connector is not done and the live Max-plus-tool-call check is still owed;rag_searchembedding calls inside a harness turn are unmetered; the product edit dialog cannot clear a field; the app's skill editor still carries the retiredprivatevisibility. - The egress proxy's shutdown fix was verified natively and in a nested guest whose AppArmor profile differs from production's; the production-profile lifecycle check and the hosted container smoke are still owed.
- The app was not observed in a browser after the validated-read change; two manual boxes,
PERF-P8andPERF-P9, cover the idle-page silence and validated reads through the proxy.
Migration notes
- Four platform migrations and one knowledge-database migration apply on the first boot of the new images, each written to run while the previous image is still serving:
0094_chat_send_idempotencyadds the tableapp.chat_send_idempotencyand its expiry index. Rows live a day and are swept lazily per organization.0095_documents_content_hash_backfillmovesmetadata.contentHashinto thecontent_hashcolumn for knowledge-entry documents and removes the key from the metadata bag (a bag emptied by it reads asnull). Set-based and idempotent.0096_automation_triggers_fire_ledgeraddslast_due_at_ms,last_run_id,last_skipped_at_msandlast_skip_reasontoapp.automation_triggerswith a partial index onlast_run_id; seeds the claim cursor from the old stamp so no past occurrence fires again; fillslast_run_idfrom the newest run each trigger started; nullslast_fired_at_mson schedule and event bindings that never started a run; and nullscron/timezoneon non-schedule andeventon non-event bindings. During the roll, an occurrence the new image claims for an undeployed automation is also claimed by the old image, which starts nothing either but stampslast_fired_at_msthe old way; a fire stamp with nolast_run_idbeside it from that window is that claim.0097_task_labels_case_insensitive_uniquemerges labels that differ only in case onto the oldest row per project (every task'slabel_idsis repointed, order kept, and the other rows are deleted) and adds a unique index on(project_id, lower(name)). The oldUNIQUE (project_id, name)constraint stays this release because the previous image writes against it; it is dropped in a later release.- Knowledge database
public_webmigration 09 replaces the(url, chunk_index)unique key onchunkswith(domain, url, chunk_index). The database image applies it in its knowledge role at boot; it has no down migration by design.
- The proxy image changes: its header budget is 64 KiB and an over-long API URL is answered at the edge. It rolls with
tale deploy; an own-Compose deployment pulls the newtale-proxytag. - The sandbox egress service needs the
KILLcapability.tale deployrenders it into the managed Compose file. An own-Compose deployment addsKILLto thesandbox-egressservice'scap_addlist next toNET_BIND_SERVICE; without it the new image still runs, but a stop times out instead of draining. - No configuration-file change and no new environment variable;
.env.exampleis untouched. The sandbox, sandbox-runtime, sandbox-buildkitd and sandbox-llm-gateway images have no source change in this range. - Managed deployments whose last run ended in the 0.5.20 provisioning failure still follow the
supersedesPendingBundlerecipe in the 0.5.21 notes.
Upgrading
-
On the 0.5 line (0.5.0 – 0.5.21):
tale update tale deploy
The migrations above apply on the first boot of the new images.
-
Managed deployments move by pinning the CLI and the runtime to this release's commit, preparing a new bundle and applying it with the pinned CLI — see Managed deployments on the CLI install page. On a Linux x64 host whose CPU lacks AVX2, pass
linux-baseline: 'true'to thesetup-cliaction so the bundle embeds the baseline executable. -
New install:
curl -fsSL https://raw.githubusercontent.com/tale-project/tale/main/scripts/install-cli.sh | bash mkdir tale-05 && cd tale-05 tale init tale deploy
On a CPU without AVX2 the downloaded executable aborts with
Illegal instruction; build it from source withbun run build:linux-baselineintools/cliinstead.
What's Changed
- feat(cli): build a baseline x64 executable for CPUs without AVX2 by @yannickmonney in #3327
- perf(platform): close the 2026-09-12 efficiency report's findings by @larryro in #3331
- fix(platform): show archived work in search, labelled as archived by @Israeltheminer in #3326
- fix(sandbox): correct foreground egress lifecycle permissions by @yannickmonney in #3324
- fix(platform): close the 2026-09-12 API evaluation's third-pass findings by @larryro in #3332
Full Changelog: v0.5.21...v0.5.22