Skip to content

Tale v0.5.22

Choose a tag to compare

@larryro larryro released this 12 Sep 14:46
47a3394

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 OPTIONS answers 204 with Allow; X-Organization-Slug is folded to lowercase; one trailing slash under /api/v1/ routes; a HEAD of a JSON route carries Content-Length; a blank cursor or limit is 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 refuses DELETE. A send takes an Idempotency-Key: a retry replays the first 202 with duplicate: true, and the same key with another body is 409 IDEMPOTENCY_KEY_REUSED (migration 0094). Messages carry finishReason, usage.estimated and stepLimitHit; 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 a minSimilarity floor, cleared with null (from the CLI's configuration declaration too). Document.contentHash is filled for the knowledge entries that only carried it in metadata (migration 0095); a PATCH of metadata is an RFC 7396 merge and a no-op merge skips the write; a document's content is served inline for text; entries carry supersededAt, filter by ?topic= and list their versions.
  • Automations. lastFiredAt now 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 carries id, lastRunId, lastSkippedAt and lastSkipReason (not_deployed, unusable_cron or start_refused) (migration 0096). A cron expression the scheduler cannot honour is refused at write time (-5 no longer reads as 0-5), fields of another trigger kind are refused, a webhook revocation reads revoked: 'webhook', a parked run says what it is waitingFor, and erasure deletes the runs a user started under a bare id.
  • Skills. Every skill names its version: etag and updatedAt. GET carries ETag and answers 304; If-Match guards a save or a delete (412 SKILL_STALE); If-None-Match: * creates only (412 SKILL_EXISTS); any file of the bundle is readable; a bundle the file layer refuses answers 422 SKILL_MALFORMED; disableModelInvocation is writable.
  • Files, websites, projects, tasks. A Range is judged locally: an unsatisfiable one answers a bodiless 416 with Content-Range: bytes */size. The backend used to copy the object store's Content-Length onto 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 carries ETag, Last-Modified and Accept-Ranges. POST /websites for a domain already tracked, or for its www/apex sibling, answers 409 WEBSITE_DUPLICATE_DOMAIN naming 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} edits name, description and externalItemId; a folder names its parentId and can be read alone; an upload handoff names its maxBytes, and a mint whose size exceeds 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 without grant_type is told so, an unknown client_id at authorize is named in the redirect, and discovery lists the acr claim and only the prompt values the provider honours. /status.json carries Access-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 WebDAV OPTIONS answers the methods each target supports. /me names the key that made the request under key and 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 GET or HEAD on /api/app/* and /api/v1/* now carries an ETag and Cache-Control: private, no-cache, and a matching If-None-Match answers a bodiless 304 (weak tags, tag lists and the -gzip/-zstd suffix the proxy appends all match). A route that sets its own directive keeps it; what changes is the REST door's blanket no-store. File content forwards If-None-Match, If-Modified-Since and If-Range to 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 --compressed on 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-key header 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 lastFiredAt reads null until 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. Bug stays Bug (it used to come back as bug); 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 ETag and Cache-Control: private, no-cache; the REST door used to answer no-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.json answers cross-origin requests.
  • The sandbox egress proxy stops gracefully, and a managed deployment failure names the Compose phase that failed.
  • A tale config declaration can clear the embedding similarity floor with minSimilarity: 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/v1 answers Cache-Control: private, no-cache (was no-store) with an ETag; a matching If-None-Match answers 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}/content honours If-None-Match, If-Modified-Since and If-Range, answers 304, and its Cache-Control is private, no-cache (was private, no-store). An unsatisfiable Range answers a bodiless 416 with Content-Range: bytes */size and Content-Length: 0; a malformed or multi-range request answers 200 with the whole body; a HEAD carries ETag, Last-Modified and Accept-Ranges.
  • x-api-key is refused with 401 everywhere; only Authorization: Bearer authenticates.
  • An over-long URL answers 414 URI_TOO_LONG; the documented 431 was never observed.
  • A second POST …/threads/{id}/messages while a turn generates or is queued answers 409 CHAT_TURN_IN_PROGRESS, and DELETE on such a thread is refused the same way. A thread title is non-blank and at most 120 characters.
  • POST /websites for a domain already tracked as a whole site, a bare re-post, or the www/apex sibling answers 409 WEBSITE_DUPLICATE_DOMAIN with data.websiteId and data.domain; only a list under the same spelling merges.
  • PROJECT_AGENT_NAME_TAKEN answers 409 (was 400); DOCUMENT_TITLE_INVALID is answered as INVALID_BODY at the door.
  • Task labels are stored as sent, unique per lower(name), and read back in the order sent.
  • A trigger's lastFiredAt is stamped only when a run starts. A cron expression the scheduler cannot honour, and a trigger field of another kind (cron or timezone on a webhook, event on a schedule), are refused at write time.
  • A blank cursor or limit answers 400 INVALID_QUERY; a negative website page offset answers 200 clamped; run listings with ?include= clamp limit to 25 and cap a page at 8 MiB of rows (isDone: false plus a cursor when cut).
  • Auth mount (/api/auth/oauth2/*): a non-JSON body answers 400 invalid_request (was a bare 415); a token POST without grant_type answers invalid_request naming it; an unknown client_id at authorize is named in the redirect (302 for a navigation, 200 {redirect, url} for a fetch-mode client); discovery lists the acr claim and only the prompt values the provider honours.
  • Error.code is required on every error, and 43 codes no REST route can answer leave the enum (THREAD_NOT_IN_PROJECT, THREAD_SCOPE_CHANGED and AUTOMATION_PROJECT_ARCHIVED among them).

Added

  • Idempotency-Key on chat sends: a replay answers 202 with duplicate: true; the same key with another body answers 409 IDEMPOTENCY_KEY_REUSED.
  • Message.finishReason, usage.estimated and stepLimitHit; idle-thread facts lastMessageId and lastStatus; ?order= and ?since= on the message listing; GET …/messages/{messageId}.
  • GET /runs/{runId} and GET /projects/{id}/runs/{runId} accept ?fields= (an unknown or blank key answers 400 INVALID_QUERY); a projected read is a RunProjection.
  • Search hits carry matchedLegs, similarity and keywordScore; the embedding settings take a nullable minSimilarity.
  • Document.contentHash; a PATCH of metadata is an RFC 7396 merge; GET/HEAD …/documents/{id}/content; knowledge entries carry supersededAt, filter by ?topic=, and GET /knowledge-entries/{id}/versions lists their versions.
  • Triggers carry id, lastRunId, lastSkippedAt and lastSkipReason; a revoked webhook reads revoked: 'webhook'; a parked run carries waitingFor.
  • Skills: etag and updatedAt on every view, ETag/304 on GET, If-Match → 412 SKILL_STALE, If-None-Match: * → 412 SKILL_EXISTS, GET/HEAD /skills/{slug}/files/{path} (404 SKILL_FILE_NOT_FOUND), 422 SKILL_MALFORMED, and disableModelInvocation is writable.
  • GET /conversations/deliveries peeks the delivery queue and POST …/deliveries/{id}/retry retries one delivery (DELIVERY_RETRY_UNAVAILABLE); claim already existed. Me.capabilities.deploymentEditor; Me.key { id, name, expiresAt }; BrowserSession.createdAt.
  • PATCH /projects/{id} edits name, description and externalItemId (nullable) beside archived; {} answers 400. ProjectFolder.parentId (required), ?parentId= and GET …/folders/{folderId}. ProjectUploadHandoff.maxBytes (required); a mint body size over it answers FILE_TOO_LARGE or UPLOAD_POLICY_REJECTED before any upload. WebsitePatch.domain is declared; lifecycleStatus and translations are read-only.
  • A key-less OPTIONS answers 204 with Allow and no CORS headers; X-Organization-Slug is matched case-insensitively; one trailing slash under /api/v1/ routes; a HEAD of a JSON route carries Content-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.json answers Access-Control-Allow-Origin: *, and a WebDAV OPTIONS names in Allow the methods each target supports.

Known issues

  • Unchanged from v0.5.20, where each is described in full: the es/co-cc Colombian 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_search embedding calls inside a harness turn are unmetered; the product edit dialog cannot clear a field; the app's skill editor still carries the retired private visibility.
  • 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-P8 and PERF-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_idempotency adds the table app.chat_send_idempotency and its expiry index. Rows live a day and are swept lazily per organization.
    • 0095_documents_content_hash_backfill moves metadata.contentHash into the content_hash column for knowledge-entry documents and removes the key from the metadata bag (a bag emptied by it reads as null). Set-based and idempotent.
    • 0096_automation_triggers_fire_ledger adds last_due_at_ms, last_run_id, last_skipped_at_ms and last_skip_reason to app.automation_triggers with a partial index on last_run_id; seeds the claim cursor from the old stamp so no past occurrence fires again; fills last_run_id from the newest run each trigger started; nulls last_fired_at_ms on schedule and event bindings that never started a run; and nulls cron/timezone on non-schedule and event on 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 stamps last_fired_at_ms the old way; a fire stamp with no last_run_id beside it from that window is that claim.
    • 0097_task_labels_case_insensitive_unique merges labels that differ only in case onto the oldest row per project (every task's label_ids is repointed, order kept, and the other rows are deleted) and adds a unique index on (project_id, lower(name)). The old UNIQUE (project_id, name) constraint stays this release because the previous image writes against it; it is dropped in a later release.
    • Knowledge database public_web migration 09 replaces the (url, chunk_index) unique key on chunks with (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 new tale-proxy tag.
  • The sandbox egress service needs the KILL capability. tale deploy renders it into the managed Compose file. An own-Compose deployment adds KILL to the sandbox-egress service's cap_add list next to NET_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.example is 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 supersedesPendingBundle recipe 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 the setup-cli action 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 with bun run build:linux-baseline in tools/cli instead.

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