Egenverk Babbla

Changelog for Egenverk Babbla

What changed in each plugin, newest first.

The changelog text is in English, as in the plugin.

Atom feed

Version 0.13.0

Added

  • The assistant's answers are rendered. It replies in Markdown, and the chat showed the markers verbatim ("4 st"). Headings, bullet and numbered lists, paragraphs, line breaks, bold, italic and inline code are now rendered as elements. Only the assistant's own messages are parsed — a colleague who types 3*4*5 means 3*4*5.
  • A last-view cache in localStorage, per user: the conversation you left is on screen at the first paint instead of the inbox → an empty conversation → the messages, which is three states for two round trips. The network result then replaces it in place.

Changed

  • The view follows the agent while it is still working: the typing indicator now scrolls the conversation the way an arriving message does, instead of catching up only once the answer lands.
  • The message list, the channel list and the overlay panels have a thin, dimmed scrollbar of their own instead of the platform slab, in both themes.
  • An underscore only opens emphasis between word boundaries, so net_total and wc_order_stats survive the renderer intact, and a body over 20 000 characters is shown as it came rather than parsed.
  • A message the server has confirmed is cached as soon as it is stored, not at the next poll, so a reload in between cannot come back without it.
  • A conversation restored from the cache keeps its own poll cursor, so a refresh that fails does not make the next poll re-fetch the history and append it under what is already on screen. A failed refresh over cached messages says so, rather than letting them read as current.

Security

  • Nothing is cached when the client has no user id: a key that cannot name its owner would be shared between whoever uses that browser.
  • The Markdown renderer builds DOM nodes and never assigns HTML, so a message body cannot introduce markup. A tag typed into a message stays visible text, exactly as before.
  • The cache key names the site as well as the user: a subdirectory multisite serves every site from one browser origin and a network user keeps the same id across them, so the key alone decided whether site A's conversation was painted over site B. Pruning leaves another site's entry alone.
  • The cache is bounded on every axis: one key per user and site (other users' and older versions' keys are removed on boot), 24 hours, the 30 most recent messages of one conversation, and a 48 KB ceiling above which the messages are dropped and then the whole entry. Leaving a conversation clears its messages from the cache, and a conversation that no longer exists falls back to the inbox.

Version 0.12.0

Added

  • Babbla → Diagnostics (chat administrators only): a status panel that answers why a question went unanswered — whether a key is set and where it comes from, which of the three routes runs a job on this host, how many jobs have been waiting over five minutes, the last failure with its job id and error, and whether the log is on — plus a reader for the log itself and a button that deletes every log file.
  • A "Save and test connection" button in each provider panel on the settings screen. It saves the form, then asks that provider one throwaway question with the key and model just entered, and reports either the model that answered and how long it took, or the provider's own error text verbatim. The test sends no tools, so it isolates "can this site reach this provider at all" from what the assistant does with tools.
  • A diagnostic log (BBL_Log): the job's whole path — queued, dispatched (with the route), started, the provider request and its outcome, the answer or the failure — plus refusals (no key, missing capability, rate limit, budget) and settings changes. Level is Off / Errors only / Errors and warnings / Everything the agent does / Debug on the settings page, default *Errors only*.
  • Log files are day-rotated under wp-content/uploads/babbla-logs-<random>/, closed off with an .htaccess and an index.html and named unguessably, deleted after 14 days by a daily cron event, and removed with the rest of the data when Purge data on uninstall is ticked.
  • BBL_Repository::stuck_jobs() and BBL_Agent_Runner::dispatch_method(), both read by the status panel.

Changed

  • Neither adapter sends an empty tools array any more: with no tools the key comes off the request entirely, which is what the connection test needs and what both APIs expect.

Fixed

  • The log reader read the whole day file into memory before showing 200 lines of it; at debug level on a busy site that is the file most likely to exceed an admin request's memory limit. It reads backwards from the end in 8 KB steps, capped at 1 MB.
  • stuck_jobs() aged a running job from when it was asked rather than from when it was claimed, so a question that queued for a while and is being answered right now was reported as stuck. A running job is now judged on started_at, a queued one on created_at.
  • Testing the provider that is not in use left its result inside a hidden tab: the redirect after a test opens the tested provider's panel (view=), without changing which provider the assistant uses.

Security

  • Nothing from a conversation reaches the log: messages, tool results and channel names are never logged, a context value that is not a scalar is written as its size instead of its content, and anything key-shaped (an sk- key, a Bearer token, the configured key itself — whether it is stored in the database or defined in wp-config.php) is redacted from both the message and the context before the line is written.

Version 0.11.0

Added

  • Setup help on the settings screen. A "Getting started" card states the four steps from an empty install to a working assistant, each provider tab carries the link to that provider's API key page, the prefix its keys start with, and example model ids, and the price fields say where the two numbers come from.
  • A Help panel (WordPress's own, top right of the settings screen) with four tabs: API keys (including the wp-config.php constants, which never reach the database), Models (the field takes any id the provider documents, so a model released after this build works), Cost and limits (what the budget, rate limit, prices and cache TTL actually do), and What is sent (which data leaves the site, and that staff-to-staff chat never does).

Fixed

  • The assistant could not answer at all on a reasoning model. OpenAI's /v1/chat/completions refuses to combine function tools with the reasoning effort such a model applies by default, and the error reached the channel as "Function tools with reasoning_effort are not supported for …". The adapter now retries that one error once with reasoning_effort: none, which is the API's own advice; the parameter is never sent up front, because a model that does not know it would reject the request.
  • The provider is no longer decided by which tab is open: the tabs are navigation (bbl_view, ignored when saving) and each panel carries an explicit "Use <provider> for the assistant" radio, with an *In use* badge on the provider actually in use. Looking at the other provider's key no longer switches the assistant over on save, and a form that arrives without the field keeps the stored provider instead of resetting to Anthropic.
  • Number and price fields were small-text (50px), which clipped a seven-digit token budget and a four-decimal price; they now have a width that fits the values they accept.
  • The provider tabs' focus ring was a box-shadow, which forced-colors mode drops — a keyboard user in Windows High Contrast saw no focus at all. There is an outline for that mode, and the active tab's bottom border works there too.
  • The radios are wrapped in a fieldset with a screen-reader legend, so the two options are announced as the Provider group rather than as two loose radios.
  • The active tab carries nav-tab-active in the markup, not only through a CSS sibling rule.
  • The active tab's border-bottom-color was a no-op: WordPress sets border-bottom: none on .nav-tab, so the colour applied to a zero-width border.

Version 0.10.0

Changed

  • The settings screen is grouped into Provider, Assistant, Limits and cost and Data instead of one twelve-row table.
  • The provider is picked with tabs (Anthropic / OpenAI) instead of a dropdown, and each tab's panel holds that provider's API key and model, so only the provider in use is on screen. The tabs are two radio inputs named provider styled as WordPress nav tabs, with the panels revealed by a CSS sibling rule — the visible tab *is* the stored setting, it works without JavaScript, and nothing has to be kept in sync.
  • Number and price fields use WordPress's small-text width, price labels carry the store's currency code, and the system prompt is a taller monospace field.

Fixed

  • The top-level menu no longer shows a duplicate Babbla entry above Chat: add_menu_page() mirrors the root as a submenu item, and with no page behind the root that entry was dead.
  • The "Remove key" checkbox only appears when a key is actually stored, and the key field's state line ("Not set." / "Saved (…abcd)") is tied to the field with aria-describedby.

Version 0.9.0

Added

  • Agent usage page (Babbla → Usage, BBL_Usage, chat-admin capability only): a period selector (1/7/30 days), a summary line (questions, tokens, failures, estimated cost), a per-user table (questions, failures, tokens in/out, average answer time, cost), a 30-day tokens-per-day bar graph marking days at or over daily_token_budget, and the 50 most recent jobs with status, tokens and error text. Job metadata only: the page never reads a message body or a channel name, so the chat admin still cannot see other people's conversations.
  • BBL_Repository::usage_by_user(), usage_by_day(), usage_totals() and recent_jobs(). All four filter on finished_at (the indexed column, KEY finished), so an unfinished job is never counted; per-user rows are capped at 50, days at 400 and recent jobs at 50. The summary has its own total query rather than summing the capped list.
  • Two settings, price_in and price_out — the price per one million tokens in the store's currency. Both default to 0, which hides every cost figure. A comma is accepted as the decimal separator.

Changed

  • Babbla is its own top-level admin menu (dashicons-format-chat, just below WooCommerce) instead of three entries under WooCommerce: Babbla → Chat / Usage / Settings. The root has no page of its own, so WordPress opens the first submenu the user may see, and it is registered with whichever tier the user holds — a chat admin without chat access still reaches Usage and Settings. Page slugs (bbl-chat, bbl-usage, bbl-settings) are unchanged, so existing links still work.
  • tests/support/sqlite-wpdb.php rewrites TIMESTAMPDIFF(SECOND, …) into SQLite's strftime arithmetic, and the rewrite now runs for reads as well as writes.

Fixed

  • usage_by_day() kept the oldest days when its LIMIT bit, so today's bar read zero on a full 30-day window; it now takes the newest days and hands them back oldest-first.
  • The per-user average answer time counted failed jobs, letting one timeout dominate the column; it averages answered jobs only.
  • The price field's step allowed two decimals while the setting stores four, so a sub-cent price could not be submitted.
  • The estimated cost follows woocommerce_currency_pos instead of always suffixing the symbol.

Version 0.8.0

Added

  • BBL_Install::migrate_from_wctc() (DB_VERSION 4): an existing 0.7.0 install is moved in place — RENAME TABLE wp_wctc_* TO wp_bbl_* for all five tables, then wctc_settings, wctc_purge_on_uninstall and wctc_db_version copied to their bbl_ keys and deleted. It runs from both entry points: activate() (the rename changes the plugin's file and folder, so WordPress installs Babbla as a new plugin and fires activation before maybe_upgrade() ever runs) and maybe_upgrade(). An empty table already sitting under the new name is dropped so the rows can still move; two populated tables are left untouched for a human to resolve. A failed RENAME TABLE or such a conflict makes the migration report failure: the schema is still created so the plugin runs, but DB_VERSION is left unrecorded and the next request retries instead of declaring the site migrated. A value already stored under the new option key is not overwritten, and wctc_t_*/wctc_rl_* transients are left to expire. Public, so a failed upgrade can be re-run with wp eval 'BBL_Install::migrate_from_wctc();'. Table existence is compared case-insensitively, since MySQL with lower_case_table_names=1 answers in lower case whatever case $table_prefix uses.
  • uninstall.php drops the legacy wctc_ tables and options too, and reads the purge flag from wctc_settings/wctc_purge_on_uninstall when the site was uninstalled before the upgrade ran.

Changed

  • The plugin is renamed to Babbla ("Babbla for WooCommerce"). Every prefix moves with it: classes WCTC_* → BBL_*, functions, hooks, options and table names wctc_* → bbl_*, constants WCTC_VERSION/WCTC_ANTHROPIC_KEY/WCTC_OPENAI_KEY → BBL_*, text domain wc-team-chat → babbla, main file wc-team-chat.php → babbla.php, includes/class-wctc-*.php → class-bbl-*.php, assets/{js,css}/wctc-chat.* → bbl-chat.*, CSS classes .wctc-* → .bbl-*, the JS globals wctcConfig/window.WCTC → bblConfig/window.BBL, and the per-user UI preference key wctc.ui.<id> → bbl.ui.<id> (a user's remembered surface and theme reset once).
  • The REST namespace is babbla/v1 (was wctc/v1) and every error code is bbl_* (was wctc_*); docs/rest-contract.md is bumped to v0.3. No route, payload or field changed.
  • No wctc_ alias is kept: the old hooks, options and namespace are gone, since nothing outside this workspace consumes them before 1.0.

Version 0.7.0

Added

  • New chat UI (assets/js/wctc-chat.js, assets/css/wctc-chat.css): a floating launcher bottom-left opens a compact chat window (inbox → conversation); the admin bar node opens a two-pane drawer that overlays the page without moving it; the Team chat page keeps a two-pane layout. Messenger-style grouped bubbles (same author within 5 minutes, avatar on the last bubble), time dividers after 15 minutes, sending/sent/failed/too-long states, an agent typing indicator, starter suggestions in the empty agent channel, a light/dark/system theme scoped to the chat root, and room creation and management (rename, members, leave, archive/restore).

Changed

  • The dock/undock drawer mode is removed; the drawer no longer adds a body class or pushes page content.

Fixed

  • The window surface always opens on the inbox, and the drawer/page auto-selects the channel the inbox actually lists first (sorted by last_message), not the first channel in fetch order.
  • A background poll no longer marks the open channel read unless the conversation is the view on screen: the window surface can sit on the inbox, and any surface can cover the conversation with an overlay (room info, new room, new DM).
  • Esc now dismisses an open menu first, then an open overlay (new DM, new room, room info), and only then the surface. An overlay opened on top of another overlay keeps the view underneath as the way back, so Esc and Back can no longer bounce between two overlays.
  • Room mutations (create, rename, add/remove member, leave, archive) report the server's error: an invalid member, a bad name, a forbidden action or a 429, instead of failing silently.
  • Room info fills the member list and its actions in place once membership resolves, so "Leave room" reflects real membership on first open without discarding a half-typed room name, and a failing members request shows an error instead of re-rendering the panel in a loop. "Add people" is only offered to a caller who can manage the room. A failed member refetch after a successful add/remove reports the error and keeps the list it had, instead of blanking it. Leaving a room clears the button and closes the panel.
  • The user picker in new DM / new room / add people has a name filter; the members subtitle uses a singular string at one member. wctc_bad_room only claims the name is wrong where there is a name field — the REST API also returns it for a bad audience and an empty archive payload.
  • The open inbox with no channel selected (the window surface's default) polls the channel list every 8 seconds instead of falling through to the badge-only 30-second /unread poll, so a new room or message shows up without selecting a channel first.
  • The closed floating surface marks only its own root inert; it no longer sets inert on the parent node (the page body in window/drawer mode).
  • destroy() now also removes the document-level menu click handler and the matchMedia theme/reduced-motion listeners, and the global * { box-sizing } rule is scoped to the chat root so the chat stylesheet cannot restyle wp-admin.

Version 0.6.0

Added

  • Chat rooms (WCTC_Install::DB_VERSION = '3'): a new wctc_members table and wctc_channels.archived_at; uninstall.php drops wctc_members too. Any user with chat access can create a room (POST /rooms) with audience everyone (dynamic, stored as type: public) or members (type: private, explicit membership). Manage (rename, add/remove members, archive/unarchive) is the room's creator or the chat admin; any member can leave. The chat admin can manage a private room's membership without being able to read its messages, same rule as a DM/agent channel, and is never auto-added. general and the dm/agent types are never manageable through the room routes.
  • A caller who can manage a room but not see it (chat admin who is not a member, or a creator who left) gets last_message: null, unread: 0 and last_id: 0 in every Channel response. PATCH /rooms/{id} without name or archived returns 400 wctc_bad_room; a value equal to the current one writes nothing and posts no system message. last_message.author_name is "" for system messages.
  • PATCH /rooms/{id}, GET/POST /rooms/{id}/members, DELETE /rooms/{id}/members/{user_id}, and GET /rooms (chat-admin-only room list, no message text). Every manage action posts a system message ("created", "renamed", "archived"/"restored", "added", "left"/"removed").
  • POST /channels/{id}/messages into an archived room now returns 409 wctc_archived; reads still work. An archived room drops out of GET /channels but stays reachable by id.
  • deleted_user now removes that user's room memberships (WCTC_Repository::delete_user_memberships()).
  • The Channel object gains last_message (author, truncated body, timestamp, mine, kind, or null), created_by, archived and can_manage; last_message is fetched in one extra query across every visible channel, never per channel.

Changed

  • docs/rest-contract.md bumped to v0.2: rooms, the archived-room 409, and the Channel object's new fields, documented for every route that returns a Channel.
  • @<agent name> mentions target the agent in public and private rooms (WCTC_Agent_Runner::targets_agent()).
  • PATCH /rooms/{id}, POST /rooms/{id}/members and DELETE /rooms/{id}/members/{user_id} share the existing per-user rate limit with posting a message and creating a room.

Version 0.5.0

Added

  • calculate tool (WCTC_Calculator): a whitelisted expression evaluator with its own tokenizer and recursive-descent parser — never eval()/create_function(), no identifier outside sum/avg/min/max/round/abs/pct_change/pct. Numbers, + - * / %, parentheses and unary minus; errors (division by zero, an unknown token/function/bare name, unbalanced parentheses, a malformed or oversized number literal, an over-length expression, a >200-token expression, a non-finite intermediate or final result) are returned as tool errors, never thrown out of the loop. A number token is strictly \d+(\.\d+)? (a leading, trailing or repeated . is rejected) and % scales decimal operands to an exact integer remainder instead of fmod()'s binary-float noise.
  • analytics_report tool (WCTC_Report_Tool): a generic, whitelisted sales/refund breakdown over WooCommerce Analytics — 1-4 metrics from net_sales, gross_sales, orders, items_sold, avg_order, refunds, product_net_revenue, product_qty, optionally grouped by day/week/month/product/category/customer_country/payment_method (HPOS only), with product_ids/category_ids/customer_country filters. No free SQL: every fragment is keyed by a fixed whitelisted name and every value goes through $wpdb->prepare. An order-level metric (net_sales, gross_sales, orders, items_sold, avg_order, refunds) is always rejected with a product/category breakdown or filter, to avoid double counting; refunds is rejected with any filter and only supports no grouping or a date grouping, and (unlike the other metrics) runs its own unbounded s.parent_id > 0 query, merged back in by group and re-sorted in PHP so a limit never drops the group the merge needed. A category_ids filter matches via EXISTS (never a row-multiplying join) and expands to descendant categories through wc_category_lookup; group_by category keeps the join instead, so a product in two categories counts in both, same as WooCommerce Analytics.
  • WCTC_Agent: the tool-calling loop now allows up to 8 rounds (was 5); WCTC_Agent_Runner gives it a 90s deadline (was 60s) under a 120s set_time_limit() (was 90s), so a question needing several analytics_report/calculate round trips has room to finish.

Changed

  • The default system prompt now tells the assistant to use calculate for every calculation and state which numbers it used, and to reach for analytics_report for any question about a period, a breakdown or a comparison — replacing the older "never guess, never work out a number yourself" line from docs/brief-2026-09-18.md. An install that never touched the previous default (kept as WCTC_Settings::LEGACY_DEFAULT_PROMPT) picks up the new default on upgrade; any prompt actually edited is left as stored.

Fixed

  • analytics_report: the SQL ORDER BY/LIMIT used to rank by the wrong column whenever avg_order was the primary metric (its own alias doesn't exist in SQL — the literal average expression does now) or whenever refunds was combined with another metric over a limit (each query's own top-N could disagree, silently zeroing out a shown period's refund total or returning the earliest days instead of the largest). order_by: group_asc with group_by: product is now rejected instead of actually sorting by product id. order_by: group_asc with group_by: category no longer re-sorts rows by name in PHP after the SQL query already ordered them by t.name ASC, which could scramble non-ASCII names under PHP's byte-wise comparison.
  • WCTC_Tools::validate_value() now enforces a schema property's maxLength/minLength for every tool, not just its type/enum/minimum/maximum.
  • calculate's % on decimals falls back to fmod() (rounded to 6 dp) whenever the integer-scaled exact-remainder path would lose precision — either scaled operand at or above 2^53, or a divisor with more decimals than the 6 dp cap keeps, whose own scaled value rounds to 0 without the divisor being zero (previously misreported as "Division by zero."). A number literal's significant-digit count now ignores leading zeros, so 0.000000000000001 is accepted while a 16-significant-digit literal is still rejected.

Version 0.4.0

Added

  • WCTC_Tools: schema-validated, transient-cached tool registry (register/all/run/resolve_tools), a guarded() server-side statement time limit (MariaDB max_statement_time / MySQL MAX_EXECUTION_TIME), analytics_ready() and date_range() (400-day cap, real-calendar-date validated). run() also refuses per-call when the asking user doesn't hold the tool's capability, and resolves both core and wctc_agent_tools filter tools from the same merged registry as all().
  • Core tools (WCTC_Core_Tools): shop_info, sales_summary, top_products, order_lookup against WooCommerce Analytics (wc_order_stats, wc_order_product_lookup) and wc_get_order, read-only, indexed predicates only, always the paid statuses (no status_set/all_non_cancelled). order_lookup is never cached.
  • WCTC_Agent: the provider-agnostic tool-calling loop (max 5 rounds, one shared deadline), history → conversation mapping, token/tool accounting. The tool set is scoped to $config['user_id'] (the asking user from the job row), not inferred from the last human author in history; a tool name outside that set is refused as an is_error tool_result without ever running its callback.
  • Provider adapters WCTC_Provider_Anthropic (Messages API, thinking-safe verbatim replay, byte-faithful "input":{} echo) and WCTC_Provider_OpenAI (Chat Completions), both disable_parallel_tool_use/parallel_tool_calls: false. Both allowlist the provider's stop/finish reason so a max_tokens/length cutoff, a content_filter/refusal decline, or anything else unrecognised (e.g. pause_turn, an empty final text) always errors instead of returning a partial answer; provider error text is scrubbed of API-key-shaped fragments before it's returned.

Version 0.3.0

Added

  • wctc_jobs table (WCTC_Install::DB_VERSION = '2'), one row per agent question; uninstall.php drops it.
  • WCTC_Settings: the wctc_settings option, provider/model/agent-name/system-prompt/capability/rate-limit/budget/cache-TTL settings, wp-config.php API key constants taking precedence over the option, and the WooCommerce > Team chat settings admin page.
  • WCTC_Agent_Runner: targets agent channels and @<agent name> mentions in public channels, gates on agent-enabled/capability/rate-limit, queues a job and dispatches it (fastcgi_finish_request(), else Action Scheduler, else a signed loopback POST /jobs/{id}/run), with a single-UPDATE claim lock (stale after 5 min, max 3 attempts), a daily token budget check, and try/catch/finally so a PHP-level error never leaves a job stuck running. Calls WCTC_Agent (provided by a separate PR) only through class_exists().
  • wp wctc job run <id> and wp wctc job list [--status=<s>] WP-CLI commands.
  • GET /channels/{id}/messages now also returns agent_busy.

Changed

  • The agent's display name now reads from WCTC_Settings::get( 'agent_name' ); the old wctc_agent_name option is no longer used.

Fixed

  • The settings page shows a purge opt-in made through the legacy wctc_purge_on_uninstall option as ticked, so saving the page no longer cancels it.
  • uninstall.php reads the purge flag from wctc_settings['purge_on_uninstall'] (falling back to the legacy wctc_purge_on_uninstall option), instead of an option WCTC_Settings never writes.
  • The channel never sees a raw Throwable message (which may contain paths or SQL) — only a generic notice with the job id; the raw text still lands in the job row's error column. A WP_Error from the engine, already user-readable, is still shown as-is.
  • A blank agent name falls back to the default "Agent"; the @<name> mention regex no longer matches a bare @ when the stored name is empty.
  • maybe_enqueue() leaves a system message and queues no job when insert_job() fails, instead of dispatching job id 0.
  • wp wctc job run now validates its <id> argument and errors on a missing or non-numeric one, instead of silently running job 0.

Version 0.2.0

Added

  • Chat client (assets/js/wctc-chat.js, vanilla JS, no jQuery): drawer opened from the admin bar and a full WooCommerce → Chat page, channel list, composer with optimistic send, polling with backoff, unread badge, dock mode, reduced-motion support.
  • WCTC_Admin: admin bar node, WooCommerce → Chat submenu page, and asset/config loading for capable users.
  • Browser test tests/browser/chat-flow.html driving the client end to end.

Version 0.1.0

Added

  • Plugin skeleton: activation/upgrade hooks, HPOS compatibility declaration, WooCommerce presence check.
  • Storage: wctc_channels, wctc_messages, wctc_reads tables (WCTC_Install), seeded with a public general channel.
  • WCTC_Repository: channel visibility (public, private per-user agent, DM), message pages, read markers, unread counts, lazy DM and agent channel creation.
  • REST API wctc/v1: GET /channels, GET /channels/{id}/messages, POST /channels/{id}/messages (idempotent, rate-limited), POST /channels/{id}/read, GET /unread, POST /channels/dm, GET /users. Contract documented in docs/rest-contract.md.
  • uninstall.php purge, opt-in via the wctc_purge_on_uninstall option.
  • README.md for GitHub: status, features and roadmap, requirements, access model, privacy, hooks, development gates.
  • composer php:compat gate (PHPCompatibility, testVersion 7.4-) next to PHPStan's PHP 7.4 target.

Changed

  • Message bodies are stored as plain text (valid UTF-8, normalised line breaks, control characters removed) instead of through sanitize_textarea_field, which HTML-escaped a lone < and dropped %xx sequences.
  • Read markers are written atomically (INSERT IGNORE + conditional UPDATE) and the stored value is returned, so concurrent POST /read calls no longer collide on the primary key and the marker never moves backwards.
  • Idempotency keys are scoped per user and channel; a failed insert returns 500 wctc_store_failed instead of a 201 with an empty message.
  • DM and agent channel lookups read before inserting, so GET /channels no longer consumes an AUTO_INCREMENT value on every call.