Skip to content

chore(deps): bump pyjwt from 2.12.1 to 2.13.0 - #1444

Open
dependabot[bot] wants to merge 872 commits into
mainfrom
dependabot/uv/pyjwt-2.13.0
Open

dependabot[bot] wants to merge 872 commits into
mainfrom
dependabot/uv/pyjwt-2.13.0

Conversation

@dependabot

@dependabot dependabot Bot commented on behalf of github Jun 16, 2026

Copy link
Copy Markdown
Contributor

Bumps pyjwt from 2.12.1 to 2.13.0.

Release notes

Sourced from pyjwt's releases.

2.13.0

PyJWT 2.13.0 — Security Release

This release bundles five security fixes plus three additional hardening / spec-compliance changes. We recommend all users upgrade.

Security

  • GHSA-xgmm-8j9v-c9wx — JWK JSON accepted as HMAC secret (algorithm confusion). HMACAlgorithm.prepare_key previously rejected PEM- and SSH-formatted asymmetric keys but did not catch a JWK passed as a raw JSON string. In a verifier configured with both symmetric and asymmetric algorithms in algorithms=[…] and a raw-JSON JWK as the key, an attacker could forge HS256 tokens using the JWK text as the HMAC secret. The guard has been extended to reject any JWK-shaped JSON. Reported by @​aradona91.

  • GHSA-jq35-7prp-9v3f — Algorithm allow-list bypass with PyJWK / PyJWKClient. When verifying with a PyJWK, the caller's algorithms=[…] allow-list was checked against the token header alg as a string only; actual verification used the algorithm bound to the PyJWK. An attacker who controlled a registered JWKS key could sign with one algorithm and advertise another on the header. PyJWT now requires the token header alg to match the PyJWK's algorithm before verification. Reported by @​sushi-gif.

  • GHSA-w7vc-732c-9m39 — DoS via base64 decode of unused payload segment when b64=false. For detached-payload JWS (b64=false), the compact-form payload segment was base64-decoded before being discarded in favor of the caller-supplied detached_payload. An attacker could inflate the unused segment to force CPU + memory cost without holding a valid signature. The segment is now required to be empty per RFC 7515 Appendix F, and is no longer decoded. Reported by @​thesmartshadow.

  • GHSA-993g-76c3-p5m4PyJWKClient accepts non-HTTP(S) URIs. PyJWKClient.fetch_data passed its URI to urllib.request.urlopen, which by default also handles file://, ftp://, and data: schemes. An application that fed an attacker-influenced URI into PyJWKClient could be coerced into reading local files or reaching other unintended schemes. PyJWKClient now rejects any URI whose scheme isn't http or https. Reported by @​KEIJOT.

  • GHSA-fhv5-28vv-h8m8PyJWKClient cache wiped on fetch error. A finally-block put(jwk_set=None) cleared the JWK Set cache whenever a fetch raised, turning a transient JWKS-endpoint outage into application-wide auth failure. The cache write was moved into the success path; transient errors no longer evict valid cached keys. Reported by @​eddieran.

Fixed

  • Reject empty HMAC keys outright in HMACAlgorithm.prepare_key with InvalidKeyError instead of accepting them with only a warning. Defends against the os.getenv("JWT_SECRET", "") footgun. Thanks to @​SnailSploit and @​spartan8806 for the reports.
  • Forward per-call options (including enforce_minimum_key_length) from PyJWT.decode through to PyJWS._verify_signature. The option was previously silently dropped between the two layers, so it only took effect when set on the PyJWT instance. Thanks to @​WLUB for the report.
  • RFC 7797 §3 compliance for b64=false: the encoder now auto-adds "b64" to crit, and the decoder rejects tokens that set b64=false without listing it in crit. Thanks to @​MachineLearning-Nerd for the report.

Changed

  • Migrate the dev, docs, and tests package extras to dependency groups, by @​kurtmckee in #1152.

Upgrade notes

Most fixes are invisible to correctly-configured callers. A few behavioral changes you may encounter:

  • Empty HMAC keys now raise. If your app passed "" or b"" as a secret (often via a missing env var, e.g. os.getenv("JWT_SECRET", "")), encode/decode will now raise InvalidKeyError. This is the intended behavior — fix the configuration.
  • PyJWK decoding now requires the token's alg to match the JWK's algorithm. Previously a mismatch was silently honored if the header alg appeared in the allow-list. Tokens that relied on this mismatch will now fail with InvalidAlgorithmError.
  • PyJWKClient now rejects non-HTTP(S) URIs at construction time. Tests or dev environments that fetched JWKS from file:// URIs need to switch to a local HTTP server or load the JWKS by other means (e.g. construct PyJWKSet.from_dict(...) directly).
  • b64=false tokens are now strictly RFC 7515 / 7797 compliant. Tokens with a non-empty compact-form payload segment, or that omit "b64" from crit, will be rejected. PyJWT-produced tokens always satisfy both invariants, so round-trips through PyJWT are unaffected.
  • enforce_minimum_key_length set per-call now takes effect. Callers who passed options={"enforce_minimum_key_length": True} to jwt.decode() previously got no enforcement; they will now get InvalidKeyError on undersized keys, as documented.

Full changelog: jpadilla/pyjwt@2.12.1...2.13.0

Changelog

Sourced from pyjwt's changelog.

v2.13.0 <https://github.com/jpadilla/pyjwt/compare/2.12.1...2.13.0>__

Security


- Reject JWK JSON documents passed as raw HMAC secrets in
  ``HMACAlgorithm.prepare_key`` to close an algorithm-confusion gap that
  the existing PEM/SSH guard did not cover. Reported by @aradona91 in
  `GHSA-xgmm-8j9v-c9wx <https://github.com/jpadilla/pyjwt/security/advisories/GHSA-xgmm-8j9v-c9wx>`__.
- Bind the JWT header ``alg`` to ``PyJWK.algorithm_name`` during
  verification so the caller's ``algorithms=[...]`` allow-list cannot be
  bypassed when decoding with a ``PyJWK`` / ``PyJWKClient`` key. Reported
  by @sushi-gif in `GHSA-jq35-7prp-9v3f <https://github.com/jpadilla/pyjwt/security/advisories/GHSA-jq35-7prp-9v3f>`__.
- Reject non-``http(s)`` URI schemes in ``PyJWKClient`` so attacker-
  influenced URIs cannot read local files or reach unintended schemes via
  urllib's default ``file://`` / ``ftp://`` / ``data:`` handlers. Reported
  by @KEIJOT in `GHSA-993g-76c3-p5m4 <https://github.com/jpadilla/pyjwt/security/advisories/GHSA-993g-76c3-p5m4>`__.
- Preserve the cached JWK Set on fetch errors in ``PyJWKClient.fetch_data``.
  The previous ``finally``-block ``put(None)`` pattern cleared the cache
  on any transient outage, turning one bad JWKS request into application-
  wide auth failure. Reported by @eddieran in `GHSA-fhv5-28vv-h8m8 <https://github.com/jpadilla/pyjwt/security/advisories/GHSA-fhv5-28vv-h8m8>`__.
- Skip the unconditional base64 decode of the compact-form payload segment
  when ``b64=false`` is set in the protected header, and require that
  segment to be empty (RFC 7515 Appendix F detached form). Closes an
  unauthenticated DoS amplifier. Reported by @thesmartshadow in
  `GHSA-w7vc-732c-9m39 <https://github.com/jpadilla/pyjwt/security/advisories/GHSA-w7vc-732c-9m39>`__.

Fixed


- Reject empty HMAC keys outright in ``HMACAlgorithm.prepare_key`` with
  ``InvalidKeyError`` instead of accepting them with only a warning.
  Thanks to @SnailSploit and @spartan8806 for independently flagging the
  footgun.
- Forward per-call ``options`` (including ``enforce_minimum_key_length``)
  from ``PyJWT.decode`` through to ``PyJWS._verify_signature`` so the
  option actually takes effect when set at the call site rather than only
  on the ``PyJWT`` instance. Thanks to @WLUB for the report.
- RFC 7797 §3 compliance for ``b64=false``: the encoder now auto-adds
  ``&quot;b64&quot;`` to the ``crit`` header parameter, and the decoder rejects
  tokens that set ``b64=false`` without listing it in ``crit``. Thanks to
  @MachineLearning-Nerd for the report.

Changed

  • Migrate the dev, docs, and tests package extras to dependency groups by @​kurtmckee in [#1152](https://github.com/jpadilla/pyjwt/issues/1152) &lt;https://github.com/jpadilla/pyjwt/pull/1152&gt;__
Commits
  • 7144e45 Apply ruff format
  • d2f4bec Restore cast() calls with cross-version type: ignore for prepare_key
  • 22f478c Remove redundant casts in RSAAlgorithm.prepare_key and `ECAlgorithm.prepare...
  • 95791b1 Bundle security fixes and hardening into 2.13.0
  • dcc27a9 [pre-commit.ci] pre-commit autoupdate (#1155)
  • 9d08a9a [pre-commit.ci] pre-commit autoupdate (#1146)
  • b87c100 Bump codecov/codecov-action from 5 to 6 (#1154)
  • 40e3147 Migrate development extras to dependency groups (#1152)
  • See full diff in compare view

Note
Automatic rebases have been disabled on this pull request as it has been open for over 30 days.

njbrake and others added 30 commits April 28, 2026 16:00
QBO POST creates were not idempotent. The 401 refresh path inside
QuickBooksOnlineService._request re-POSTs the same body after refreshing
the access token; if QBO ever partial-commits before returning 401, or
if any future transport-level retry fires, the user gets a duplicate
entity. Intuit's docs explicitly recommend the requestid query param
for this case.

Pass a UUID4 requestid on create_entity, update_entity, and
send_entity_email. The same requestid is reused on the 401 retry
because params is built once per logical call and threaded through
_request unchanged. New tests assert:
- requestid is set on every mutating POST and absent on GET /query
- the 401 retry reuses the same requestid (so QBO collapses the pair)
- distinct logical calls get distinct requestids
- input validation in send_entity_email still short-circuits before
  any network attempt

Investigated as part of issue #1035 (estimate duplication on first
real-user onboarding). Railway logs and DB confirmed only one POST
for that incident, so the duplicate the user reported was the
receipt-rendering issue already fixed in #1055. This PR closes the
latent idempotency gap so a future retry can never produce a real
duplicate entity.

Co-authored-by: njbrake <njbrake@users.noreply.github.com>
* fix(quickbooks): use application/octet-stream for /send endpoint

QBO's /send endpoint requires Content-Type: application/octet-stream,
not application/json. We hardcoded JSON for every QBO request, so
sending an Estimate or Invoice via email reliably 500'd. Confirmed
against Intuit's official PHP SDK (DataService::SENDEMAIL uses
CONTENTTYPE_OCTETSTREAM). The 500 surfaces with no error envelope
because Intuit's email pipeline crashes parsing JSON content-type
with an empty body, so existing error handling couldn't recover
anything useful.

Hit during real-user usage: estimate creation succeeded, but every
attempt to email it to the client returned 500 from QBO. Two retries,
same error.

Add a content_type kwarg to _request defaulting to application/json
(every other endpoint stays unchanged) and override it from
send_entity_email. Body remains empty so the new content type isn't
a lie. Also adopt the exc.response.json() error-body extraction
pattern from qb_create in qb_send so future failures (which will
have valid JSON envelopes now that the content-type is right)
surface Intuit's Fault.Error[] message instead of the bare
HTTPStatusError string.

Tests assert on captured httpx.Request headers via MockTransport,
not the FakeQBService shortcut that bypasses _request entirely:
that shortcut is why the bug never showed up in CI.

Fixes #1063

* refactor(quickbooks): use isinstance for httpx error inspection

CLAUDE.md prefers isinstance over hasattr/getattr at typed boundaries.
The httpx.HTTPStatusError type guarantees a populated .response, so
the hasattr guard was both weaker than necessary and required a
# type: ignore[union-attr] comment to silence the type checker.

Convert all four QB tool error handlers (qb_query, qb_create,
qb_update, qb_send) to the same isinstance(exc, httpx.HTTPStatusError)
pattern. Same runtime behavior, drops the type-ignore.

Addresses /review-pr feedback on #1064.

---------

Co-authored-by: njbrake <njbrake@users.noreply.github.com>
…1070)

* fix(onboarding): tolerate flat-format USER.md, trust timezone column

The onboarding heuristic at backend/app/agent/onboarding.py required
bullet-format - Name: and - Timezone: in user_text. The LLM is free
to rewrite user_text and frequently picks a flat heading-style format
without dashes. The strict regex meant the heuristic returned False
for users with a clearly populated profile, leaving onboarding stuck
on pending and gating heartbeats.

The timezone check also regex-grepped user_text for the timezone,
even though users.timezone is a populated DB column that's the actual
source of truth (set via the dashboard PUT /user/profile endpoint).
The text-grep was a brittle proxy for what the DB already knew.

Two changes:

1. _has_user_timezone now checks bool(user.timezone) directly. Drop
   the regex.
2. _has_real_user_profile loosens the regex to accept both bulleted
   (- Name: X) and flat (Name: X) formats while keeping the same-line
   constraint that prevents matching across newlines.

First real user observed today with this exact pattern: 48 inbound
messages, complete profile in flat format, populated timezone column,
soul customized -- but onboarding_complete was still false because the
heuristic regex didn't match the LLM-chosen format. Just below the
50-message hard ceiling that would have force-completed.

Existing tests updated: timezone is now set on the user object
explicitly (mirroring how production sets it), and a new flat-format
regression test asserts the heuristic fires on the real-world shape.

Fixes #1068

* docs: fix _has_user_timezone docstring to reference real write path

The previous docstring claimed timezone is set by a "dedicated
timezone-update tool". No such tool exists. The column is populated
via PUT /user/profile (dashboard / browser onboarding flow). Updated
to reference the actual mechanism.

Addresses /review-pr feedback on #1070.

---------

Co-authored-by: njbrake <njbrake@users.noreply.github.com>
… links (#1072)

* fix(companycam): drop URL from upload_photo content to stop duplicate links

The success-path ToolResult for companycam_upload_photo embedded the
photo URL in its content string while also attaching the same URL via
ToolReceipt. The LLM saw the URL in the tool result and copied it into
prose, then the receipts appender added the canonical receipt with the
URL, so users saw each photo URL twice in the reply.

The receipt is the canonical channel for surfacing deep links on
plain-text channels. When a tool sets ToolReceipt(url=...), the content
string should describe the action without inlining the URL.

Also adds a generic invariant test that exercises every CompanyCam tool
returning a receipt-with-URL and asserts the URL never appears in the
content string, plus a regression test for the two-photo upload case.

Fixes #1069

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(quickbooks): extend no-URL-duplication invariant to qb_create/update/send

Per review feedback, parallel the CompanyCam invariant test with one for
QuickBooks. Adds FakeQBOServiceWithURL (subclasses QuickBooksOnlineService
so _build_qbo_url returns a real deep link) and asserts that for every QB
tool returning a ToolReceipt with a URL, ToolResult.content does not
contain that URL.

The QB tools are clean today, but this guards against a future regression
that would inline a deep link in the content string.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: njbrake <njbrake@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(quickbooks): accept JSON-string data in qb_create/qb_update

The LLM occasionally over-quotes deeply nested QBO payloads and emits
the `data` argument as a JSON-encoded string instead of an object. The
strict `dict[str, Any]` Pydantic field rejected these, forcing a retry
on the next round and wasting an LLM round-trip plus output tokens.

Add a `field_validator(mode="before")` that parses JSON strings into
dicts on the way in. Behavior is strictly more permissive: existing
dict callers are unaffected, and unparseable / non-object strings still
surface as `ValidationError` so the agent's existing retry hint path
runs instead of blowing up as a ServerError later.

Fixes #1066

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(quickbooks): drop redundant parenthetical from data field descriptions

The validator already accepts both shapes; the prompt-side hedge is
overkill once the runtime is permissive.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: njbrake <njbrake@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…TZ (#1075)

* fix(agent): stop promising timed reminders, anchor time math on user TZ

The agent claimed it could set reminders at specific clock times, but
actually stored them as text in heartbeat_text. The heartbeat system runs
every 30 minutes and surfaces items in any window, not at an exact time.
The user's "set reminders for noon, 3pm, 7:30am" requests therefore
silently became approximate periodic check-ins.

Two changes, no new infrastructure:

1. End the lie. Tool descriptions, usage hints, and the system prompt now
   say plainly that heartbeat is not a scheduler. For one-shot timed
   reminders, the agent routes to calendar_create_event when Google
   Calendar is enabled, or tells the user honestly that it cannot fire
   at exact times and offers to connect calendar or set the reminder
   on their phone.

2. Fix the compounding time-math bug. build_time_user_context now
   appends the IANA timezone to the rendered time string (e.g.
   "(America/Denver)"). Without this anchor, the LLM has been observed
   treating local times as UTC, producing wrong deltas (e.g. saying
   7:30am is "minutes from now" when local was 6:27am).

No new tables, scheduler, tools, or migrations. Calendar integration
already exists; this PR just teaches the agent to use it for the
"remind me at 2pm" use case.

Fixes #1067

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(calendar): add reminder_minutes_before so timed reminders fire on time

The first commit told the agent to call calendar_create_event "with a
short pre-event alert," but the tool API did not actually accept a
reminder parameter. Events created via the tool inherited the user's
Google Calendar default reminders (typically 30 min before start), so
"remind me at 2pm" would fire at 1:30pm. Promise was unfulfillable.

Plumb a new optional `reminder_minutes_before: int | None` field through:

- CalendarCreateEventParams (the agent-facing tool schema)
- CalendarEventCreate (the service DTO)
- _build_event_body (maps to Google's `reminders.overrides`)

Semantics chosen to preserve existing callers:
- None (default): no `reminders` field on the request body, Google
  applies the user's default reminders. Existing behavior preserved.
- 0: popup fires at the exact event start. This is the path for
  "remind me at 2pm".
- N > 0: popup fires N minutes before start.

Capped at 40320 (4 weeks) per the Google Calendar API spec for
`reminders.overrides[].minutes`.

Updates the agent prompts (instructions.md, proactive.md, and the
heartbeat_tools description) to point at the new parameter explicitly:
"call calendar_create_event with reminder_minutes_before=0 so the popup
fires at the exact moment."

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(agents): treat user-chosen assistant names as soft PII

Each user can rename their Clawbolt assistant via SOUL.md to anything
they like. When that custom name shows up in a session transcript and
gets quoted into commits, PR bodies, or GitHub comments, it ties the
content back to a specific user. Add a paragraph to the Privacy & PII
section telling future contributors (human and AI) to generalize to
"Clawbolt" or "the agent" in their own copy, while still allowing
verbatim quotes of upstream messages.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(agents): tighten soft-PII note

Cut the soft-PII paragraph from ~100 words to one sentence. The rule
itself is small; the long version paid the token cost on every load
without adding clarity.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(agent): drop hardcoded 30-min heartbeat interval from prompts

User.heartbeat_frequency is per-user configurable, but my earlier copy
in instructions.md, proactive.md, and the update_heartbeat tool
description claimed the system "runs every 30 minutes." For users who
have changed their cadence, that statement is wrong and could lead the
agent to give the user inaccurate guidance about when items will fire.

Reword to "checks on the user's configured interval and surfaces items
within a window, not at an exact clock time." The "not at an exact
clock time" framing is the load-bearing part for issue #1067 and is
preserved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(agent): generalize proactive.md timed-reminder routing

The proactive.md framing should describe the rule of thumb without
binding the agent's general behavior to a specific integration name.
The concrete tool guidance still lives in instructions.md (which
names calendar_create_event and reminder_minutes_before) where the
operational detail belongs. proactive.md now just states the rule:
the heartbeat system cannot fire at exact times, so route via a
timed-reminder integration if one is connected, otherwise be honest.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(agent): tighten Timed reminders section in instructions.md

Trim ~110 words to ~60. Drop redundant "you cannot fire reminders at
exact clock times yourself" (already covered by "not a scheduler"),
explanatory "Google's notification system delivers the popup" (the
agent doesn't need to explain the implementation), and the
"end can be a few minutes after start" detail (the agent can pick a
sensible duration). Also drop the "Google Calendar" framing in favor
of the more general "calendar tool / calendar integration" so the
copy survives a future Apple Calendar or other provider integration.
HEARTBEAT.md bullet trimmed similarly: cross-reference the Timed
reminders section instead of restating the rule.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(agents): generalize soft-PII rule

Drop the user-chosen-assistant-names example. The principle is "use
judgment for content that ties to a specific user even if it's not on
the hard list"; enumerating examples invites the wrong behavior of
treating the list as exhaustive.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(agents): drop verbatim-quote carve-out from soft-PII rule

The carve-out undermined the rule it lived under. The point of
treating soft PII carefully is to not propagate it into new
persistent artifacts; a verbatim quote in a new PR body is a new
artifact carrying the soft PII forward. If a verbatim quote is ever
unavoidable, judgment can decide on the spot.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: njbrake <njbrake@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…#1071)

* feat: envelope-encrypt credential columns with pluggable KEK provider

Replaces the single-Fernet-key EncryptedString with envelope encryption:
each row gets a fresh DEK that's wrapped by a configurable KEKProvider
and stored inline alongside the ciphertext. OSS ships LocalKEKProvider
backed by ENCRYPTION_KEY; premium plugins override via the auth loader
to plug in KMS-backed per-tenant wrapping.

The driver isn't urgent live-prod compliance: it's that the migration
is trivial today (one user, a handful of rows) and gets exponentially
harder once the premium deployment onboards more tenants. Same
reasoning applies to the structured credential.read log line added at
OAuthService read sites: a small forensic down-payment that's cheap
now and useful later.

- Inline envelope format (clw1.<kek_id>.<b64-wrapped>.<fernet>) means
  any EncryptedString column gets envelope encryption with zero
  schema change.
- Migration 018 re-keys existing rows; idempotent re-runs are safe.
  No automatic downgrade -- restore from backup if needed.
- EncryptedString fails loudly on a non-envelope read so a missed
  migration is visible at startup instead of silently corrupting
  decrypted tokens.
- LocalKEKProvider with no ENCRYPTION_KEY generates an ephemeral
  process-local key and warns. Stored credentials become unreadable
  after restart -- the loudest signal that the operator forgot to
  configure a key.

Fixes #1065

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor: remove dead ENVELOPE_VERSION constant

The version is already encoded in the ENVELOPE_PREFIX (``clw1``);
having both invites them to disagree.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: cover legacy-Fernet decrypt path in migration 018

The previous migration test only exercised the plaintext-passthrough
branch (legacy=None). The branch most production rows actually take --
decrypt with the pre-envelope HKDF/Fernet derivation, then re-encrypt
under the new envelope -- was untested. A bug there could silently
re-encrypt Fernet ciphertext as if it were plaintext, leaving the row
syntactically valid but pointing to garbage.

Adds two tests:
- Legacy Fernet round-trip: encrypt under the old derivation, run
  _rekey with the legacy Fernet, assert the new envelope decrypts back
  to the original plaintext.
- InvalidToken fallback: pass a non-Fernet value with a legacy Fernet
  configured, assert it's treated as plaintext (documenting the
  rotated-key risk that backup-restore is the recovery path for).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: allow premium plugin's get_kek_provider() to return None (#1074)

* feat: allow premium plugin's get_kek_provider() to return None

The KEK provider loader treated the plugin's hook as authoritative: if
the plugin module exposed get_kek_provider(), its return value was
cached and used unconditionally. That coupled the plugin code merge to
the infra rollout: shipping a premium provider class meant the env
vars (KMS ARN, AWS creds) had to be wired the same day or the plugin
would crash on first credential read.

This change lets the plugin signal "not configured yet" by returning
None. The loader then falls back to LocalKEKProvider as it would
without a plugin at all. Premium can ship the KMS provider dormant,
and platform engineering activates it later by setting env vars and
restarting -- no code change required.

Stacks on #1071 (introduces get_kek_provider).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor: hoist sys/types imports + extract fake-plugin helper

CLAUDE.md requires all imports at the top of the file. The two new
tests had inline ``import sys`` / ``import types`` inside their
bodies. Moves both to the module-level import block.

While there, extracts the fake-plugin install pattern into a small
helper so the two tests don't duplicate it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: njbrake <njbrake@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: njbrake <njbrake@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The compaction loop called any-llm `amessages` directly without writing
to `llm_usage_logs`, so trim-based and session-end consolidation calls
were invisible in the per-user usage breakdown. As we lean on the
"infinite session" model, compaction is a non-trivial fraction of
spend per user, and undercounting it breaks the cost picture.

Log usage right after the successful `amessages` call (inside the
try/except so failures stay no-ops). Use the resolved `model` so the
`compaction_model` override is reflected in the breakdown.

Fixes #1076

Co-authored-by: njbrake <njbrake@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ues) (#1079)

* feat(onboarding): bundle UX fixes from first-user observation

Fixes 12 issues from the 2026-04-28 first-user observation session.

Onboarding flow / copy / branching:

- #1050 onboarding too long: rewrite bootstrap.md to require only
  name + timezone, then delete BOOTSTRAP.md immediately. Drop the
  capabilities tour and the personality interrogation.
- #1044 remove "How do you like to talk?" step: personality
  question is gone from bootstrap. The default soul is loaded at
  provision time and stays unless the user customizes.
- #1043 photo access copy: bootstrap.md and the get-started page
  both reassure once that Clawbolt only sees photos the user sends.
- #1040 hide Telegram from the default flow: getVisibleChannels
  drops Telegram entirely when telegram_bot_token_set is false.
- #1029 hide unavailable channels (don't grey out): drop the
  isDisabled codepath in the wizard radio item; visibleChannels is
  the gate.
- #1037 don't re-ask about already-connected tools: move the
  "call manage_integration(action='status') first" rule from
  bootstrap.md (which gets deleted on completion) to the durable
  manage_integration usage_hint.

UI polish:

- #1041 OAuth callback CTAs: replace tertiary <Link> on both
  success and error states with a primary <Button> (full width on
  mobile, auto on desktop).
- #1039 mobile QR is the wrong UX (same-device problem): add a
  prominent "Open Messages" sms: deep-link button above the QR on
  mobile, plus tap-to-copy on the phone number. Desktop still
  shows the QR for cross-device pairing.
- #1038 phone number entry: new normalizeUsPhone helper auto-
  prepends +1 to bare US digits, strips formatting, accepts a
  leading + verbatim, and shows an inline E.164 validation error
  on submit. Applied to PremiumChannelLinkForm, OssLinqForm, and
  BlueBubblesForm.

Mobile reframe: GetStartedPage detects the mobile breakpoint (new
useIsMobile hook) and renders a single-screen flow on phones --
phone input + "Text Clawbolt" button that calls setLinqLink /
setBlueBubblesLink and then opens sms: -- replacing the 4-step
wizard. Desktop keeps the wizard.

Agent behavior:

- #1046 tone down initial-response affirmations: bootstrap.md and
  instructions.md now ban "Great question!", "Absolutely!", "I'd
  love to help!", "Happy to!" with positive examples of the
  desired direct tone.
- #1047 vision only on explicit ask: tighten analyze_photo
  description with concrete positive triggers ("what is this",
  "estimate from this") and explicit negative cases ("DO NOT call
  when routing to CompanyCam, attaching to a job"). Also tighten
  the system_prompt.py:131-141 vision-routing sentence so the
  description change is not shadowed.
- #1045 typing indicator already fires immediately on inbound via
  ingestion._send_early_typing_indicator. Continuous-refresh
  stretch deferred (cancellation complexity not worth it for this
  PR).

Carved out as follow-ups:

- #1048 comfort-level permission setting: reframe to "audit per-
  tool ASK defaults and flip to ALLOW where approval is not
  load-bearing" before adding a per-user toggle.
- #1051 just-in-time feature explainers: real multi-week feature.
- mobile-pairing-setup-code: auto-grab user phone from first
  inbound via one-shot setup code (eliminates phone-number entry
  on mobile entirely).

Tests:

- New: phone.test.ts (9 cases for normalize + validate),
  channel-utils.test.ts (6 cases including empty-config and
  Telegram-hidden semantics).
- Backend regression test for #1037 in test_integration_tools.py
  asserting the manage_integration usage_hint instructs the agent
  to call status before offering connects.
- Updated existing GetStartedPage / ChannelsPage / prompts /
  session-endpoints tests for the new behavior.

DoD:
- pytest 1975 passing
- ruff check + format clean
- ty check clean
- frontend tsc + knip clean
- frontend tests for touched files all pass

Visual Playwright verification not run -- the OSS app validates
LLM credentials at startup and the sandbox has no key. The 56
frontend tests covering the changed components plus the structural
nature of the changes (mobile/desktop layout split, copy edits,
phone-input parsing) make the regression risk low, but visual
sign-off should happen on njbrake's side before merge.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(onboarding): rethink bootstrap purpose; generalize vision guidance

Two review-feedback fixes.

1. Bootstrap was incoherent as written. The "delete BOOTSTRAP.md
   immediately on name+tz capture" rule meant onboarding could end in
   2 turns, before the dictation hint, photo-access reassurance, or
   any tone-shaping had a chance to land. Once BOOTSTRAP.md is
   deleted, those guidance bits are gone from the system prompt
   forever, so a 2-turn exit defeats the point of having a bootstrap
   at all.

   Replace the exit rule with a softer two-condition gate: name + tz
   captured AND the conversation has texture beyond data capture
   (answered something useful, or a "things worth weaving in" moment
   has come and gone). Restore the "things worth weaving in" section
   so the LLM has something substantive to do during the onboarding
   window. Trust the LLM to judge "feels established" rather than
   forcing a transactional exit.

2. analyze_photo description and the system_prompt media-handling
   section were enumerating specific user phrases ("what is this",
   "estimate from this", "save to the Acme job") and named
   integrations (CompanyCam) as positive/negative triggers. That
   encodes our specific integrations and presumes user intent. Drop
   the examples; replace with the general rule: don't analyze unless
   the user asked or there's a clear need to see the contents.

DoD: 1975 tests pass, ruff check + format + ty all clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(onboarding): system-driven exit + BlueBubbles email + OSS persist

Five review-feedback fixes across the onboarding fixup PR.

1. System-driven bootstrap exit (the big one). Bootstrap.md no longer
   tells the LLM when to call delete_file; the system removes
   BOOTSTRAP.md itself once name + timezone are captured AND the user
   has sent at least MIN_USER_MESSAGES_FOR_AUTO_EXIT (=4) messages.
   The message floor ensures the conversation has texture even when a
   maximally-cooperative user supplies name + tz in turn 1, so the
   "things worth weaving in" content (dictation hint, photo policy)
   has a chance to land before the bootstrap context disappears.

   Five completion paths in OnboardingSubscriber, evaluated in order:
   (1) defense-in-depth (BOOTSTRAP.md missing for any reason);
   (2) auto-exit, primary path (name + tz + >=4 messages);
   (3) strict heuristic backstop for mid-flight users from the
   pre-2026-04 bootstrap who customized SOUL.md;
   (4) hard ceiling at 50 messages;
   (5) pre-populated user.

   Bootstrap.md gets a "You don't decide when onboarding ends"
   section telling the LLM not to call delete_file or announce a
   transition. Adds a priority signal for the things-worth-weaving-in
   section (dictation is the most universally useful). Em dash
   removed (CLAUDE.md compliance).

   New tests: auto_exit fires on name+tz+>=MIN_TURNS; auto_exit does
   not fire below the message floor; auto_exit does not fire without
   timezone.

2. BlueBubbles email-address sms: link. When the BlueBubbles backend
   is configured with an iCloud email, ``sms:user@icloud.com`` is
   malformed and most OS handlers silently reject it. Detect the
   email shape (``includes('@')``) in MobileGetStarted and
   TextAssistantCard; render a copy-the-address UX with explicit
   "send a note from your iCloud-connected device" copy. Hide the
   QR on desktop when address is an email (QR encoding the bad
   sms: URI was useless anyway).

3. OSS phone persistence in mobile flow. Previously the mobile
   "Text Clawbolt" button only persisted the phone for premium
   users; OSS users typed their number, the button cleared, and
   their first inbound was silently rejected by the backend's
   allowed_numbers gate. Now OSS users persist via
   updateChannelConfig({linq_allowed_numbers: ...}) or the
   bluebubbles equivalent.

4. normalizeUsPhone 10-digit edge case. A bare 10-digit input like
   ``1234567890`` previously stripped the leading 1 and produced
   ``+1234567890`` (9-digit body) which passed the loose E.164
   regex. Now drop the leading 1 only when the digit string has
   exactly 11 chars; 10-digit input stays as-is so the malformed
   ``+11234567890`` is caught by the validator instead of silently
   shifting the user's intent.

5. Mobile-flow test coverage. New describe block in
   GetStartedPage.test.tsx mocks window.matchMedia for the mobile
   breakpoint and asserts: single-screen layout renders (not
   wizard); phone validation error on bad input; OSS persists via
   linq_allowed_numbers; OSS bluebubbles persists via
   bluebubbles_allowed_numbers; empty-state copy when no iMessage
   backend is configured.

DoD: 1978 backend tests pass (+3), ruff/format/ty clean, frontend
typecheck/knip clean, 62 frontend tests for touched files pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: untrack PLAN_onboarding_fixup.md (working scratch, not landed code)

---------

Co-authored-by: njbrake <njbrake@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…gured (#1080)

Bug from dev testing: a user had Telegram configured (no iMessage
backend) and got the "ask your admin to enable iMessage" empty state
on the mobile get-started page. The mobile flow was hard-gated on
``imessageBackend !== null``; Telegram was a complete blind spot.

Fix:

1. ``MobileGetStarted`` now branches by available channels. New
   ``MobileImessageFlow`` and ``MobileTelegramFlow`` siblings replace
   the inlined iMessage logic. When both backends are configured, a
   tab toggle (``MobileChannelToggle``) lets the user pick; default
   is iMessage (most clawbolt users on mobile reach for Messages
   first). When only one is configured, the toggle is hidden and the
   flow renders directly.

2. ``MobileTelegramFlow``: numeric Telegram user ID input with the
   "send /start to @userinfobot" help text; "Open Telegram" button
   that persists the ID (``setTelegramLink`` for premium,
   ``updateChannelConfig({telegram_allowed_chat_id})`` for OSS) and
   then opens the bot via the deep link from
   ``api.getTelegramBotInfo()``. Linked card mirrors the iMessage
   pattern with a re-open Button and copy of the bot username.

3. Empty-state copy is now channel-agnostic: "No messaging channels
   are configured on the server yet. Use web chat for now, or ask
   your admin to enable iMessage or Telegram."

4. ``activeChannel`` is derived rather than seeded from props at
   mount. The previous ``useState`` initializer ran once when
   ``channelConfig`` was still undefined and locked the wrong
   default. New pattern: store only the user's explicit toggle
   choice; fall back to ``imessageAvailable ? 'imessage' :
   'telegram'`` which re-evaluates as ``channelConfig`` loads.

Tests: 4 new mobile-flow tests covering Telegram-only render,
toggle when both available with iMessage default, OSS persists
Telegram via ``updateChannelConfig``, non-numeric ID rejected
inline. Existing 19 tests still pass.

DoD: 1978 backend tests, ruff/format/ty clean, frontend
typecheck/knip clean, 23 frontend tests on GetStartedPage.

Co-authored-by: njbrake <njbrake@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add a no-DB-touching ``/api/health/live`` endpoint as a safer target
for the deployment platform's healthcheck path. Today's ``/api/health``
opens a sync DB session and runs ``SELECT 1`` from inside an async
handler. When the event loop is blocked (sync DB call backed up under
load, slow third-party HTTP call, etc.), the healthcheck call piles up
on the same blocked worker and starts returning 502 to Railway's edge,
which then refuses to roll traffic to a fresh container. Worse: the
existing /api/health is the path Railway actually probes during deploy.

The new ``/api/health/live`` returns ``{"status":"ok",
"database":"not_checked"}`` instantly without acquiring any DB
connection. Premium will switch ``railway.toml``'s healthcheckPath to
this in a follow-up PR. ``/api/health`` stays for richer "is the
system actually working" checks (ops dashboards, monitoring).

Co-authored-by: njbrake <njbrake@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The /autoplan skill writes PLAN_<topic>.md files at repo root as
ephemeral working state. PLAN_onboarding_fixup.md from a previous
session was sitting untracked at the root. Add the pattern so future
planning docs do not surface in git status, and remove the stale one.

Co-authored-by: njbrake <njbrake@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ded retries (#1083, #1088) (#1089)

* fix(channels): bluebubbles reliability - typing fire-and-forget + bounded retries

Two BlueBubbles fixes that combine to turn "BlueBubbles flakiness drops
user replies" into "BlueBubbles flakiness adds a few seconds of delay".

#1083: typing indicators block reply delivery

The outbound dispatcher in ChannelManager._run_outbound_dispatcher was
awaiting typing-indicator calls inline. Because the dispatcher is a
single asyncio task, every queued reply waited behind the typing call.
Production observation: an agent reply sat ~27s before being delivered,
behind a 30s typing-indicator timeout.

Fix: typing/stop_typing are now spawned as background tasks via a new
_spawn_typing_task() helper. Tasks are tracked on self._typing_tasks
and cancelled in stop_all(). BlueBubbles typing calls also get an
explicit 3s timeout so orphan tasks self-terminate quickly.

#1088: zero retry on transient send failures

The dispatcher catches Exception from send_text/send_media and silently
drops the reply. Self-hosted BlueBubbles servers running on consumer
hardware (the maintainer's basement Mac) hit transient failures
regularly: Mac waking from sleep, brief WiFi blip, BlueBubbles process
restart. Without retry the user never sees the reply.

Fix: new _post_with_retry helper. 4 attempts (1 initial + 3 retries),
1s/2s/4s backoff, 10s per-attempt timeout. Retries on
httpx.ConnectError, ReadTimeout, WriteTimeout, PoolTimeout,
RemoteProtocolError and 5xx responses. Never retries 4xx (caller
error). tempGuid is generated once before the retry loop so BlueBubbles
client-side dedupe stops a partial-success retry from creating a
duplicate iMessage. Typing indicators are intentionally not retried.

Worst-case timing: if BlueBubbles is fully unreachable, send_text
spends up to ~47s exhausting retries before giving up. Acceptable
because most basement-Mac flakes are sub-10s and clear on the first
retry.

Closes #1083, #1088

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(openapi): regenerate frontend types for /api/health/live

PR #1081 added the /api/health/live liveness endpoint but did not
regenerate frontend/openapi.json or frontend/src/generated/api.d.ts,
which broke the frontend-lint check on main. Including the
regeneration here so this PR can pass CI; the change is unrelated to
the BlueBubbles reliability work but blocks merge.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: njbrake <njbrake@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…se (#1091)

Closes mozilla-ai/clawbolt-premium#332. In premium prod, tenants are
routed via tenant-specific bots and the global TELEGRAM_BOT_TOKEN env
var is empty, so the OSS /api/channels/telegram/bot-info endpoint
returns 404 by design. The mobile get-started page and admin Channels
page still called it on every load, producing four 404s per session.

useChannelStates fired useTelegramBotInfo(isPremium) without checking
whether a bot token was configured. Gate the query on
channelConfig.telegram_bot_token_set so the call only fires when the
endpoint can actually return useful data.

Adds a regression test in ChannelsPage.test.tsx that mocks
telegram_bot_token_set=false and asserts api.getTelegramBotInfo is not
called.

Co-authored-by: njbrake <njbrake@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
#1092)

* perf(oauth): cache load_token reads for 30s to dedupe per-turn DB hits

Production logs showed 6+ credential.read action=load entries per
inbound message. A single agent turn reads each integration's token
multiple times: once during specialist auth_check at registry build,
again inside factory.create() when the OAuth client is constructed,
and again per actual tool invocation. Each read was a fresh DB roundtrip.

Add a per-process TTL cache (30s) keyed on (user_id, integration).
Both positive and negative results are cached so unconnected
integrations also stop spamming the DB.

Cache invalidation:
- save_token drops the entry so a refresh's new value is observed.
- delete_token drops the entry so the next load returns None.
- refresh_token explicitly drops the entry before its post-advisory-
  lock reload. The whole point of that reload is to detect a peer
  worker's just-persisted refresh; a stale cache would mask it and
  cause a redundant HTTP refresh that may overwrite the rotated
  refresh_token.

Cross-worker staleness is bounded by the 30s TTL. Within that window
a worker may return a token whose access_token was rotated by a peer,
but the cached access_token is still valid until its own expires_at,
so callers behave correctly.

Tests:
- load_token_cached_within_ttl
- save_token_invalidates_cache
- delete_token_invalidates_cache
- load_token_caches_negative_lookup
- refresh_token_bypasses_cache_for_post_lock_reload (cross-worker race)

Closes #1085

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(oauth): address review feedback on token cache

Three changes from the self-review of PR #1092:

1. Bug: build_on_refresh_callback._persist also reloads under the
   advisory lock to detect a peer worker's just-persisted token.
   Without bypassing the cache there, the same race the refresh_token
   bypass was meant to prevent could still occur via the on-refresh
   callback path. Two providers (QuickBooks, Google Calendar) refresh
   via this callback when a tool call hits 401, so this is a real
   prod hazard, not theoretical.

2. Refactor: extract _load_token_uncached() helper. Both post-lock
   sites now call it, so future post-lock callers will not silently
   regress.

3. UX: shorter TTL for negative cache entries
   (_NEGATIVE_TOKEN_CACHE_TTL_SECONDS = 5.0). Cross-worker OAuth
   completion no longer leaves a 30s window where one worker reports
   the user as still unconnected. Positive entries keep the 30s TTL
   since they are the dominant per-turn dedup case.

Also expands the OAuthService docstring to document cache lifetime,
invalidation, and the post-lock bypass requirement so future readers
understand the cross-worker semantics.

Adds regression test test_build_on_refresh_callback_bypasses_cache_for_post_lock_reload.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: njbrake <njbrake@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… expiry (#1093)

* feat(oauth): background scheduler proactively refreshes tokens before expiry

Closes #1087.

When a user message arrived during the 5 minute pre-expiry window,
get_valid_token would refresh inline on the user-facing path, adding
~150ms to the reply latency. With three integrations per user
(CompanyCam, QuickBooks, Google Calendar) and tokens that typically
last an hour, every active user hit this path several times an hour.

Add OAuthRefreshScheduler that sweeps every 2 minutes, finds tokens
expiring within the next 6 minutes (one minute past the 5 minute inline
threshold so the sweep always wins the race), and refreshes them in
the background. Inline refresh in get_valid_token remains as a safety
net for tokens the sweep missed (cold start, sweep paused, etc.).

The sweep:
- skips tokens with expires_at <= 0 (non-expiring tokens never need
  refresh)
- skips tokens without a refresh_token (cannot be refreshed)
- continues past individual failures so one user's revoked grant does
  not block all other users' background refreshes
- reuses the existing refresh_token() advisory-lock path so a
  concurrent inline refresh in another worker does not double-refresh

Wired into the OSS lifespan in backend/app/main.py. Premium will pick
up the same scheduler in a follow-up once OSS_REF is bumped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(oauth): address review feedback on refresh scheduler

Three changes from the self-review of PR #1093:

1. Hoist oauth_refresh_scheduler import to the top of main.py per the
   project rule that all imports live at module top (no inline or
   deferred imports inside functions, except TYPE_CHECKING). The two
   inline imports inside lifespan() are now a single top-level import.

2. Add random jitter (+/- 15s) to the inter-sweep sleep so schedulers
   running in N uvicorn workers do not synchronize and stampede the
   DB and advisory locks at the same instant. Effective interval
   stays in the [105s, 135s] range.

3. Add test_scheduler_start_is_idempotent covering the lifecycle
   contract that calling start() twice does not spawn a second sweep
   task. Locks in the existing guard so a future regression that
   removes it gets caught.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(oauth): gate background refresh on recent user activity

Address security concern raised during review: refresh tokens for
some providers (notably QuickBooks, 100 day inactivity expiry) are
intentionally designed to expire when a user stops using the
integration so re-consent is required on return. Background refresh
as originally proposed would silently keep dormant accounts' refresh
tokens alive forever, bypassing that signal.

Gate the sweep on recent activity: only refresh tokens for users
whose most recent channel_routes.last_inbound_at is within the
_REFRESH_ACTIVITY_WINDOW_DAYS (14 day) window. Inactive users still
get the inline refresh path on their next interaction; if their
refresh token has naturally expired, the inline path detects it and
prompts re-auth as the provider intended.

Activity signal is "received an inbound message via any channel
route" -- captures iMessage, Telegram, webchat, etc.

Two new regression tests:
- test_refresh_sweep_skips_inactive_users
- test_refresh_sweep_skips_users_with_no_channel_route

Existing sweep tests updated to mark their test_user as recently
active so they continue to exercise the happy path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: njbrake <njbrake@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes mozilla-ai/clawbolt-premium#333.

Production logs from a short user session showed 4-6 GET /api/user/profile
401 responses paired with POST /api/auth/refresh on every page load. The
existing reactive flow does the right thing functionally (401 -> refresh
-> retry) but produces noisy 401s in network panels and adds a round
trip per page load near token expiry.

Decode the access token's `exp` claim when it is set, and have the
openapi-fetch middleware refresh proactively when the current time is
within 30 seconds of expiry. The reactive 401 path remains as a safety
net for tokens whose exp we could not decode (opaque tokens, JWT
parsing failure) and for any race where the token expires between the
proactive check and the request hitting the server.

The proactive-refresh decision is extracted to a small _shouldProactivelyRefresh
helper for direct testing; the JWT decode is similarly exposed.

Tests:
- _decodeJwtExp: valid/invalid/missing-exp/unparseable
- _shouldProactivelyRefresh: inside leeway, outside, null exp
- setAccessToken: tracks/clears exp, handles opaque tokens
- tryRefresh: dedup, 401 clears tokens, no-refresh-token early-return

Co-authored-by: njbrake <njbrake@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…1095)

Closes #1084.

Production logs showed the agent's first turn after a user idle gap
always logged hit_ratio=0.00 with cache_create > 0 and cache_read=0.
The next turn within the same session correctly hit cache. The cause
is not a non-deterministic prompt; the stable prefix is byte-stable
per user. The cause is the default Anthropic ephemeral cache TTL of
5 minutes: any user with a >5min gap between messages misses on
their first turn back.

Switch to the 1-hour extended TTL via cache_control.ttl="1h":

- 1h cache_create costs 1.5x normal (vs 1.25x for 5min ephemeral).
- Cache reads are unchanged at 0.1x normal.
- For a user whose median inter-message gap is >5min and <1h, hit
  rate goes from ~0% to ~100% on those returning turns; net per-
  message cost drops by roughly 35%.
- For users actively messaging within 5min: identical behavior.

Adds settings.llm_cache_extended_ttl (default True) as an escape
hatch in case a non-Anthropic provider rejects the ttl field.
Anthropic itself silently ignores unknown cache_control keys per
their docs.

Tests cover both TTL modes and confirm the cache_boundary split
still applies cache_control to only the stable prefix.

Co-authored-by: njbrake <njbrake@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Compaction is a routine operation, not edge case. For an active user
at ~15k tokens/day, the 400k token trim threshold lands every ~27
days; for a power user at 5x that rate, every 5-6 days. Across a
multi-tenant deployment, compactions fire frequently enough that
their cost and quality matter.

Add a single space-delimited key=value log line per run so log
aggregators (Railway, Loki) can group / filter without JSON parsing:

  compaction.summary user=<id> trimmed_count=N trimmed_chars=N
    input_tokens=N output_tokens=N duration_ms=N
    memory_updated=bool user_updated=bool soul_updated=bool
    summary_len=N

Fields support three audit angles:
- Frequency: count occurrences per user per week.
- Cost: sum input/output tokens; multiply by current model price.
- Quality: rate of all-False *_updated flags. Persistent emptiness
  signals an upstream issue (agent isn't surfacing facts in
  conversation, or the prompt isn't extracting them).

Two regression tests pin the field set so future changes are
intentional.

Co-authored-by: njbrake <njbrake@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Follow-up to #1095 which added the setting but missed the env example
and configuration docs. test_env_example failed on main with:

  AssertionError: Settings fields missing from .env.example:
    ['LLM_CACHE_EXTENDED_TTL']
  AssertionError: Settings fields missing from docs/self-host/configuration.md:
    ['LLM_CACHE_EXTENDED_TTL']

Adds both entries with the same description: 1-hour Anthropic cache
TTL by default, set to false on non-Anthropic providers that reject
the ttl field.

Co-authored-by: njbrake <njbrake@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(agent): execute parallel tool calls within a turn

Run all approved tool calls from a single LLM turn concurrently. Tools
that share a non-None ``concurrency_group`` serialize within that group
in submission order; tools with different keys (or ``None``) fan out via
``asyncio.gather``. The model is responsible for sequencing dependent
calls across turns; within a turn we only fan out what the model asked
for.

Why now: the agent often emits 5-10 read-only tool calls in a single
turn (multiple ``calculate``, ``read_file``, calendar reads). Running
them serially adds up to seconds of latency for work that has no real
dependency between calls.

Why ``concurrency_group``: workspace document writes (USER.md, SOUL.md,
MEMORY.md, HEARTBEAT.md), heartbeat updates, and reply senders touch
shared state another tool could touch in the same turn. A static
serialization key per Tool keeps fan-out correct without trying to
infer races at runtime. Existing groups: ``workspace_writes`` for
workspace mutations, ``user_outbound`` to preserve reply order.

Other changes:
- typing indicator now fires once per tool round instead of once per
  tool, since concurrent tools would otherwise emit a burst of
  identical signals.
- result and record append order continues to follow ``approved_entries``
  position, not completion order, so the inspection UI and any
  downstream consumer see tools in the order the model emitted them.
- registry-wide test asserts every ``MODIFIES_PROFILE`` or
  ``SENDS_REPLY`` tool declares a concurrency_group so future
  contributors cannot silently regress this.
- AGENTS.md (CLAUDE.md is a symlink) gains a checklist step covering
  when to set ``concurrency_group``.

Approval flow is intentionally untouched: the sequential approval pass
stays as-is and is a follow-up.

Fixes #1098

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(agent): address self-review of parallel tool execution

Followups from the PR #1099 review.

1. Allow ``Tool.concurrency_group`` to be a callable on validated args.
   Mirrors ``ApprovalPolicy.resource_extractor`` and lets one Tool route
   distinct calls to distinct buckets. Workspace writers now key by file
   path (``workspace_path:USER.md``), so two writes to different files
   parallelize while two writes to the same file still serialize.
   Heartbeat updates share the ``workspace_path:HEARTBEAT.md`` key with
   the workspace tool, so a heartbeat update and a workspace write to
   HEARTBEAT.md cannot race.

2. Extract ``_execute_single_tool`` from the inner closure into a
   private method on ``ClawboltAgent``, and lift ``_ToolEntry`` plus the
   bucketing logic to module level as ``_resolve_concurrency_group`` and
   ``_bucket_by_concurrency_group``. The scheduler is now a pure
   function exercised directly by ``test_bucket_by_concurrency_group_pure_function``.

3. Tag ``manage_integration`` with ``concurrency_group="user_integrations"``.
   It mutates the per-user ``tool_configs`` row and the OAuth token
   store; two enable/disable calls in a single turn must serialize.

External-API mutators (Calendar, QuickBooks, CompanyCam) intentionally
stay untagged. Different entities do not race against each other; if the
LLM batches duplicate-id mutations the upstream API surfaces its own
conflict, which is the correct corrective signal.

Tests:
- ``test_callable_concurrency_group_keys_by_args``: same-key calls
  serialize, different-key calls overlap.
- ``test_resolve_concurrency_group_handles_string_callable_and_none``:
  resolver contract pinned for static, callable, and None.
- ``test_bucket_by_concurrency_group_pure_function``: bucketing
  invariants exercised without an agent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: njbrake <njbrake@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
)

* feat: add data_sharing_consent + dedicated setter endpoint

Privacy gate for user-research / admin-content visibility, ahead of
the clawbolt-premium /admin/shared-data router (item 3 of premium
issue #325). OSS-side change here: column + migration + endpoint +
tests + regenerated OpenAPI / TS types. Premium consumes the column
in a follow-up PR after this lands.

What changed:

- ``User.data_sharing_consent: bool`` (NOT NULL, default False) and
  ``User.data_sharing_consent_at: datetime | None`` (nullable). The
  defaults are intentionally opt-out — existing users see no behavior
  change until they explicitly toggle on. ``server_default='false'``
  on the migration so the backfill runs at the database level.
- Migration ``019_add_data_sharing_consent`` (down_revision: ``018``).
- ``GET /api/user/data-sharing-consent`` returns current state.
  ``PUT /api/user/data-sharing-consent`` toggles the bool AND always
  stamps ``data_sharing_consent_at`` with the current UTC time —
  whether the value changed or not, whether opting in or opting out.
  This makes the column track "last toggled at" rather than "first
  opted in at" — the cheaper guarantee to keep correct: a one-shot
  accidental double-PUT can't drift the meaning.
- ``UserProfileResponse`` exposes both fields read-only.
  ``UserProfileUpdate`` deliberately does NOT accept consent —
  routing it through the generic patch endpoint would lose the
  timestamp guarantee. The dedicated route is the only writer.

Tests:

- Default state on a new user: ``False`` + ``None`` (regression for
  the migration's nullable=False boolean).
- Opt-in stamps timestamp; opt-out also stamps timestamp (the
  symmetric guarantee).
- ``PUT /user/profile`` silently strips ``data_sharing_consent`` even
  when bundled with other fields, so the backdoor is closed.

Why this lives in OSS rather than premium-only: ``User`` is the
canonical row and has to grow the column for premium to read it.
Premium pins ``OSS_REF`` to a clawbolt commit in CI; once this PR
merges, premium bumps the ref and ships the gated admin router that
reads (and PII-redacts) message content for consenting users.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

* fix: address review feedback on #1100

Three changes from review pass:

1. Drop em dashes from comments and docstrings to match the OSS
   CLAUDE.md style rule. Eight instances across migration 019,
   models.py, schemas.py, user_profile.py, test_profile_endpoints.py.
2. Pin extra='ignore' on UserProfileUpdate.model_config so the
   silent-strip contract for ``data_sharing_consent`` survives a
   future pydantic global-default change. The dedicated-endpoint
   test relies on this contract; without the explicit pin, a
   pydantic upgrade that flipped the default to "forbid" would
   start returning 422 instead of silently dropping the field.
3. Extract a ``_data_sharing_consent_now`` helper so tests can
   pin the clock to a known instant. New test asserts the helper
   is the only source of "now" the route consults, by
   monkeypatching it to a fixed datetime and verifying the
   response's timestamp matches.

Regenerated frontend openapi spec and TS types to reflect the new
``model_config`` declaration.
…1101)

* feat: envelope-encrypt messages.body and processed_context at rest

Item 4 (envelope encryption) of the clawbolt-premium privacy redesign,
issue #325. Switches the two user-content columns on the ``Message``
table from plaintext ``Text`` to ``EncryptedString``. Per-row DEKs are
wrapped by ``LocalKEKProvider`` in OSS and by KMS-backed providers in
premium — the same pattern PR #1071 introduced for OAuth tokens.

What's encrypted

- ``Message.body`` — the raw text the user / channel sent.
- ``Message.processed_context`` — the same content after media
  transcription / OCR / preprocessing. Encrypting body without this
  would be theater: ``processed_context`` is what the agent reads on
  every turn and contains the full content surface.

What's NOT encrypted (and why)

- ``tool_interactions_json`` — structured tool args/results. Often
  contains user content, but encrypting it complicates future tool-
  replay debugging and the premium audit log already redacts it
  before surfacing to admins. Tracked for a follow-up.
- ``external_message_id`` — channel-side ID; needs to be searchable
  in cleartext for inbound webhook idempotency.
- ``media_urls_json`` — pointers, not bytes.

Migration 020

- Stacks behind ``019`` (the data_sharing_consent column from a
  separate in-flight PR). If ``019`` doesn't land, this rebases to
  depend on ``018`` directly.
- Streams rows in 1000-row batches with ``WHERE id > :last`` so a
  multi-million-message database doesn't load the whole result set
  into memory.
- Idempotent on re-run: rows already in envelope format are returned
  by identity from ``_rekey``, the loop detects "nothing to do" via
  ``is`` comparison, and no UPDATE fires.
- No automatic downgrade: rolling back would require unwrapping every
  envelope at a moment in time, which fails if the KEK rotates
  between upgrade and downgrade. Restore from backup is the recovery
  path.

Deployment ordering: run ``alembic upgrade head`` BEFORE rolling out
the new application code. ``EncryptedString`` raises on a non-envelope
read, so a code-deployed / migration-not-run gap fails the very first
request rather than silently corrupting data.

Tests

- ``test_message_body_round_trip_through_orm`` writes plaintext via
  the ORM, reads it back as plaintext, and confirms the underlying
  PostgreSQL column holds an envelope (the at-rest guarantee). This
  is the regression that catches "developer accidentally bypassed
  the type decorator."
- ``test_message_body_empty_string_passes_through`` ensures empty
  bodies don't trigger wrap calls on the KEK provider — measurable
  on high-throughput deployments where half of outbound messages are
  tool-call-only and have empty bodies.
- ``test_migration_020_rekey_helper_envelopes_plaintext`` covers the
  happy path, idempotent re-runs, and empty/None passthrough.
- ``test_migration_020_processed_context_uses_distinct_column_context``
  confirms each column gets its own ``EncryptionContext`` tag for
  future per-column key rotation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

* fix(migration): point 020 at 018, not 019

The e2e-playwright CI job runs `alembic upgrade head` to bring up a
real app instance for browser tests. With down_revision='019', that
fails on this branch standalone because revision 019 (data_sharing
_consent in clawbolt#1100) only exists on a separate in-flight branch.

Migrations 019 and 020 touch different tables (users vs messages) so
their order is functionally independent. Whichever PR merges second
will see a two-heads conflict from alembic and needs a one-line
down_revision bump. Pinning to 018 here lets each PR pass CI on its
own without cross-branch dependencies, and the rebase cost falls on
whichever merges second instead of blocking both.

* fix: address review feedback on #1101

Four review changes:

1. Drop em dashes from comments and docstrings to match the OSS
   CLAUDE.md style rule. Ten instances across the migration,
   models.py message docstring, and test_encryption.py.

2. Hoist ``from sqlalchemy import text`` to the top of test_encryption
   (CLAUDE.md: "All imports at the top of the file. No inline or
   deferred imports inside functions."). Imported as ``_sa_text`` to
   avoid colliding with anything that wants ``text`` as a local name
   in a test fixture later. Added a top-level ``_alembic_op`` import
   alongside it for the new e2e migration test.

3. Operator preflight in migration 020: refuse to run on a non-empty
   messages table when ``ENCRYPTION_KEY`` is unset. ``LocalKEKProvider()``
   with no configured key falls back to an ephemeral process-local KEK,
   which would leave message bodies unrecoverable on the next process
   restart. The 018 (oauth_tokens) precedent didn't bother with this
   check because OAuth tokens are rare and re-issuable; message
   bodies are not. Empty messages tables pass through (nothing to
   lose). Migration docstring spells out the rationale.

4. End-to-end migration test against the real test database
   (``test_migration_020_full_upgrade_loop_against_real_db``).
   Inserts plaintext rows via raw SQL (bypassing the type decorator
   to simulate the pre-migration state), runs ``upgrade()`` against
   the test connection via monkeypatched ``op.get_bind``, then
   asserts envelopes on disk for non-empty rows + empty-string
   passthrough on the all-empty row. A second ``upgrade()`` invocation
   confirms the idempotent fast-path terminates instead of looping
   forever (regression test for the cursor reach-around at the end
   of the batch).

Also sharpened the migration's cursor comment to spell out exactly
when the in-loop ``last_id = row_id`` fires and why the end-of-batch
reach-around is load-bearing for already-migrated databases. Added
a one-line note in the docstring that alembic wraps ``upgrade()``
in a single transaction so operators on million-row deployments
should plan a maintenance window.

* test: seed ENCRYPTION_KEY in the migration 020 e2e test

The preflight check added in the previous commit fired during the
e2e test because the test database has no ENCRYPTION_KEY configured
by default. Monkeypatch ``settings.encryption_key`` to a synthetic
32-byte value before running ``upgrade()``, which is the operator
workflow the preflight is documenting (set the key, then migrate).

* test: capture chat_session_id as plain int to avoid DetachedInstanceError

The e2e migration test creates ``cs`` (ChatSession) inside one session
context, closes the session, then references ``cs.id`` later in
verification raw-SQL params. SQLAlchemy 2.0 raises
``DetachedInstanceError`` when an attribute access on a detached
instance triggers a refresh; the trip wire here was the ``cs.id``
read after ``db.close()``.

Capture the id into a plain ``int`` while the session is still open
so the verification queries don't re-touch the ORM object.

* fix(migration): chain 020 onto 019 now that #1100 has merged
…flagging (#1102)

* feat: add /report inline command for user-initiated conversation flagging

Item 5 of clawbolt-premium issue #325. Users on iMessage / Telegram /
SMS-only deployments need a way to flag a conversation for admin
review without ever opening the web app. Solution: intercept the
literal text ``/report [reason]`` in the inbound pipeline, persist a
``ReportedConversation`` row, and send the user a fixed acknowledgement.
The premium ``/admin/reported-conversations`` router (separate PR)
will consume the rows.

What ships in this PR

- New ``ReportedConversation`` model with ``user_id`` /
  ``session_id`` FKs (CASCADE delete on user/session removal),
  ``anchor_seq`` (the inbound message's seq at report time so the
  admin UI can highlight the surrounding window), ``reason`` (the
  optional free-text after ``/report``), ``created_at``,
  ``dismissed_at``, and ``reviewed_admin_user_id`` (FK with SET NULL
  on admin deletion). Indices on user_id, session_id, created_at.
- Migration 021 creates the table with ``server_default=func.now()``
  on ``created_at`` and ``server_default=''`` on ``reason``.
- ``_parse_report_command(text)`` recognizes ``/report``, ``/report
  reason``, ``/Report`` (autocap), ``/REPORT``, with leading/trailing
  whitespace stripped. Rejects ``/reportbot`` (different word) and
  ``please /report this`` (mid-sentence) so users typing /report in
  normal conversation aren't surprised by a flag.
- ``_handle_report_command`` resolves (or creates) the user's
  session, captures the latest message seq as ``anchor_seq``,
  persists the row, and publishes the canonical ack to the message
  bus. Failures inside the persistence path log + roll back but
  STILL send the ack so the user never sees a mysterious silence
  for ``/report``.
- ``process_inbound_from_bus`` calls the report intercept BEFORE
  the approval-response intercept on purpose: a user mid-approval
  who decides to file a report should still be able to. The /report
  ack short-circuits the agent pipeline; the approval gate stays
  pending and resolves on the next message.

Tests

- Eight ``_parse_report_command`` parser tests: bare /report, with
  reason, case-insensitive, whitespace stripping, ``/reportbot``
  rejection, mid-sentence rejection, empty input, plain prose.
- Three end-to-end intercept tests: persists row + sends ack +
  doesn't call agent pipeline; ``anchor_seq`` points at the latest
  message in the session; non-/report messages reach the pipeline
  normally and don't write rows.

Why an inline command rather than a web-app form

The clawbolt premium issue #325 left this open. Most clawbolt users
never open the web app (they live on iMessage / Telegram), so a
web-only report flow misses the population that most needs it. An
inline command is verifiable (the text comes through the user's own
authenticated channel route) and zero-friction (no auth surface to
build, no email integration to maintain).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

* fix: address review feedback on #1102

Four review changes:

1. Hoist ``ChatSession`` and ``Message`` to the module-level import
   line (CLAUDE.md: "All imports at the top of the file"). The bus
   import inside ``_handle_report_command`` is left lazy on purpose
   and now carries a comment explaining why: ``backend.app.bus``
   transitively imports ingestion via the channel manager, so a
   top-level bus import would form a cycle. Other functions in this
   file already do the same.

2. Add ``User.reported_conversations`` ORM relationship + the
   matching ``ReportedConversation.user`` reverse side. Disambiguated
   with ``foreign_keys=`` because the table has two FKs to
   ``users.id`` (``user_id`` for the reporter,
   ``reviewed_admin_user_id`` for the admin who closed the report).
   We don't expose the admin-side relationship; the audit log already
   answers "what did this admin do" without an ORM round-trip.

3. Restructure ``_handle_report_command`` so the ack-send is in a
   single ``finally`` block. Exactly one publish_outbound call
   regardless of which failure path the persistence takes (session
   resolution failure, missing ChatSession row, persistence
   exception, or success). The previous shape was correct but
   structurally fragile.

4. Cap the persisted ``reason`` text at 4096 chars. Defends against
   accidentally storing a 100KB rant or pasted transcript when a
   user goes long after ``/report``. Truncation is silent so the
   report still files; the user just sees the same canned ack.
   Implemented inside ``_parse_report_command`` so the cap applies
   uniformly. New parser test confirms the slice happens.
Extends the at-rest encryption coverage from migration 020
(Message.body, processed_context) to five more user-content columns.
Same pattern: per-row DEKs wrapped by ``LocalKEKProvider`` (or the
premium KMS-backed provider). The ``EncryptedString`` type decorator
on each ORM column transparently decrypts on read, so existing
application code keeps working unchanged.

What's now encrypted

- ``HeartbeatLog.message_text``: the proactive message text the
  heartbeat scheduler sent (or would have sent on a skip).
- ``HeartbeatLog.reasoning``: the LLM's free-text rationale, often
  paraphrasing user content back.
- ``HeartbeatLog.tasks``: serialized task state with user-authored
  task descriptions.
- ``MemoryDocument.memory_text``: the user's working memory file
  (notes, reminders, recent context). One of the most sensitive
  content surfaces in the database.
- ``MemoryDocument.history_text``: compacted older sessions.

What's still plaintext on these tables

- ``HeartbeatLog.action_type`` and ``channel``: short enums needed
  for filtering / aggregation, no PII.
- ``HeartbeatLog.created_at``: timestamp.

Migration 022

- Operator preflight (mirrors 020): refuses to run on a non-empty
  target table when ``ENCRYPTION_KEY`` is unset, since
  ``LocalKEKProvider()`` would fall back to an ephemeral key and
  leave content unrecoverable on the next restart.
- Streams in 1000-row batches with ``WHERE id > :last`` cursor
  advancement. ``heartbeat_logs`` can grow large on chatty
  deployments; ``memory_documents`` is at most one row per user but
  uses the same loop for code uniformity.
- Idempotent: rows already in envelope format are returned by
  identity from ``_rekey`` and the loop skips the UPDATE. The
  end-of-batch cursor reach-around guarantees an already-migrated DB
  doesn't loop forever on the same SELECT.
- No automatic downgrade. Restore from backup if needed.

Tests

- ``test_migration_022_rekey_helper_envelopes_plaintext_per_table_column``:
  helper threads the (table, column) tuple into the envelope context
  for all five (table, column) targets so the on-disk envelope
  matches what ``EncryptedString.process_result_value`` will pass
  on read. Idempotent re-run + empty/None passthrough also covered.
- ``test_migration_022_full_upgrade_loop_against_real_db``: inserts
  plaintext rows directly via raw SQL (bypassing the type decorator)
  into both target tables, runs ``upgrade()``, asserts envelopes on
  disk for non-empty rows + empty-string passthrough on the
  all-empty rows. Re-runs ``upgrade()`` to confirm idempotent
  termination.
- ``test_migration_022_refuses_when_encryption_key_unset_and_data_exists``:
  the operator preflight raises rather than silently encrypting
  under an ephemeral key.

Deployment ordering: same as migration 020. Run
``alembic upgrade head`` BEFORE rolling out the new application code.
``EncryptedString`` raises on a non-envelope read, so a
code-deployed / migration-not-run gap fails the very first request.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
…it (#1104)

* fix(bus): keep response future after resolve so SSE handler can read it

The webchat SSE flow has three actors that race:

1. The chat router calls register_response_future(request_id) and
   returns a request_id to the client.
2. The bus consumer + handler runs and produces an OutboundMessage,
   the dispatcher calls resolve_response(request_id, msg).
3. The client opens the SSE stream and the handler calls
   get_response_future(request_id) to read the result.

Order (1) -> (2) -> (3) is normal for fast short-circuit replies
like /report or approval acks. Pre-fix, resolve_response popped
the future from the registry, so step (3) saw None, registered a
fresh future, and the spinner hung until the SSE timeout.

Fix: resolve_response now leaves the future in the registry. The
TTL cleanup task scheduled in register_response_future evicts
the entry 300s later regardless of whether it was resolved, so
memory stays bounded.

Verified end-to-end: POST /api/user/chat with /report now delivers
the SSE reply within ~1s instead of hanging.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: ruff format

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds an end-user surface for ``data_sharing_consent``. Until now the
column was settable only via PUT /api/user/data-sharing-consent, with
no UI consumer outside the admin "Shared" tab; users had no way to
opt in even if they wanted to.

Two surfaces share one component (``DataSharingConsentSection``):
- Onboarding (Get Started page): a non-numbered "Help improve
  Clawbolt" card after Step 4. Visible at first run; not blocking.
- Settings (About You page): a "Privacy" section beneath the user
  bio so people can change their mind any time.

Default OFF in both places: opt-in only. The setter endpoint already
stamps ``data_sharing_consent_at`` on every flip, so opt-out moments
are auditable. Admin reads continue to re-check consent at fetch
time, so toggling off has immediate effect.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
#1106)

* refactor(privacy): move data-sharing consent to a Privacy settings tab

Users were looking on the Settings page for the privacy toggle, not on
About You where it was first wired up. Move the section to a new
"Privacy" tab in Settings so it sits next to Model / Storage /
Heartbeat / Telegram, where people expect config knobs to live.

The same ``DataSharingConsentSection`` component is reused; the
onboarding card on Get Started is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(settings): add 'privacy' to showOssSettingsTabs default list

Without this, the new Privacy tab is filtered out by .filter((t) => visibleOssKeys.includes(t.key)) and never renders.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ttings tab (#1107)

* refactor(nav): rename Advanced -> Personalize, fold Approvals into Settings tab

Cleans up two adjacencies that drifted:
1. The "Advanced" sidebar group mixed content surfaces people actively edit
   (Knowledge / Personality / About You / Priorities) with a config knob
   (Approvals). Folks were looking for Approvals on the Settings page,
   not under a chevron labeled "Advanced".
2. PR #1106 added a Privacy tab to Settings but forgot to add the key
   to ``showOssSettingsTabs``, so the tab silently filtered out for
   every visibility tier. Same fix lands here for both ``approvals``
   and ``privacy``.

Changes:
- Sidebar group renamed to "Personalize"; ``NAV_PERSONALIZE`` now holds
  Knowledge, Priorities, Personality, About You. Approvals removed.
- ``SettingsPage`` gains an "Approvals" tab that renders the existing
  ``PermissionsPage`` component (its h2 dropped so the Settings page
  heading dominates). Privacy + Approvals added to ``showOssSettingsTabs``.
- ``/app/permissions`` now redirects to ``/app/settings/approvals`` so
  any existing bookmarks still land in the right place.
- ``AppShell.test.tsx`` updated to assert "Personalize" and that
  Approvals is no longer a sidebar item.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(e2e): rename Advanced -> Personalize in OSS smoke nav assertion

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
njbrake and others added 17 commits June 3, 2026 09:17
* build: add html-to-image dependency for snippet image export

Premium's admin activity feed gains a Share snippet dialog that can export
a PNG of the selected conversation (mozilla-ai/clawbolt-premium#607). The
DOM-to-image rasterization uses html-to-image; premium builds from this
frontend's node_modules, so the dependency lives here and is imported from
the premium overlay.

* build: tell knip html-to-image is used by the premium overlay

knip runs against OSS source only, where nothing imports html-to-image
(the premium share-snippet overlay does), so it flags the dependency as
unused. Add it to ignoreDependencies alongside the other premium-overlay
deps.
* feat: connect ServiceTitan and AppFolio in the web app, not chat

Connecting ServiceTitan (Tenant ID, Client ID, Client Secret) and AppFolio
Vendor Portal (single-use magic link) used to happen through chat tools
(connect_servicetitan, appfolio_connect). That left those secrets in the
user's message thread, where they cannot be cleared.

Move secret entry to the authenticated web app:

- Remove the connect_servicetitan and appfolio_connect chat tools, their
  auth factories, names, and the now-unused hidden-core-factory pairings.
- Extract the validate-and-persist orchestration into reusable
  servicetitan.auth.connect_credentials and
  appfolio_vendor.service.connect_via_magic_link.
- Add POST/DELETE /api/integrations/{servicetitan,appfolio_vendor} endpoints
  that collect the secrets over an authenticated session.
- Surface a connect_form key on the tool config so the Settings page renders
  a credential form (Connect/Disconnect) instead of an OAuth button.
- Route the agent to the web app: data-factory auth_check reasons,
  manage_integration connect, SKILL.md Connecting sections, and the
  not-connected/expired tool hints no longer accept secrets over chat.

Fixes #1337

* fix: address review feedback on web-app integration connect

- Route users to the Integrations page (not "Settings"): the connect form's
  sidebar label is "Integrations". Updated auth_check reasons, SKILL.md,
  manage_integration, and the not-connected/expired tool hints.
- Map upstream outages to HTTP 502, not 400: add ServiceTitanUnavailableError
  and AppFolioUnavailableError for network/5xx failures so a vendor outage no
  longer reads as bad user input. Credential rejection stays 400.
- Mask secrets in schemas: client_secret and magic_link are SecretStr now
  (write-only in OpenAPI, masked in logs/reprs); endpoints read
  get_secret_value().
- Gate ServiceTitan on SERVICETITAN_APP_KEY: status, the live status feed,
  the chat connect action, and the web connect_form all report/treat it as
  "not configured by admin" when the operator has not set the App Key, instead
  of advertising a connect flow that would hard-fail.
- Frontend: trim modal inputs before submit; invalidate the tools query on
  disconnect onSettled (so a 404 still refreshes the badge); hoist mid-file
  imports in ToolsPage to the module header.

Added tests for the 502 upstream paths and the ServiceTitan not-configured
states. Regenerated OpenAPI types.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…story cache (#1424)

* fix: emit dynamic system content after history to keep the message-history cache

The agent placed dynamic system content (memory, tool guidelines,
integration status, cross-session context) in the `system` param, ahead
of the conversation history in the prompt-cache prefix. Anthropic builds
its cache prefix in tools -> system -> messages order, so any change to
that dynamic block invalidated the cache for everything after it,
including the entire message history. Every memory write (a routine
event for an active user) re-billed the full history at uncached input
price on the next turn.

Move the dynamic content out of `system` and inject it into the current
user turn (after time context, before the user's message), and add an
explicit cache_control breakpoint on the prior-history message so the
history is independently cacheable instead of relying on automatic
prefix caching the dynamic suffix used to break.

- SystemPromptBuilder.build_parts() splits sections into stable/dynamic
  halves via the existing dynamic flag; CACHE_BOUNDARY is removed.
- apply_history_cache_breakpoint() stamps the message before the current
  inbound turn (the last user-role string-content message); the marker
  advances forward as the conversation grows.
- The dynamic half ships on the user turn; the stable half is the only
  thing in the cacheable system param.

Regression tests assert the dynamic block lands on the user turn (not
system) and the breakpoint sits on the prior-history message, not the
volatile current turn.

Fixes #1420

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test: hoist inline imports to module scope in cache tests

Move the function-local imports added in the prior commit
(apply_history_cache_breakpoint, _cache_control,
build_agent_system_prompt_parts, prepare_system_with_caching) to the top
of the test modules, per the repo rule that all imports live at module
scope. Flagged by CodeRabbit on #1424.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…oken hysteresis (#1425)

Each user has one ever-growing conversation, so the trim window is the
permanent definition of how much raw history the assistant sees. The
window was governed by a turn cap (target 80, trigger ~96) that bound far
below the 400K token budget, so an active user permanently worked from
only the last few active days of raw history.

Make the token budget the primary governor and add the token-side
hysteresis the path never had:

- context_trim_target_tokens 400K -> 120K (drop-to budget).
- New context_trim_trigger_tokens (150K): trim fires when the prompt
  exceeds the trigger and drops to the target, mirroring the existing
  turn hysteresis. Without it, the resting state would sit at the cap and
  re-fire compaction every message once tokens are the binding constraint
  (the per-message-compaction failure #1193 fixed for the turn path).
- context_trim_target_turns 80 -> 600: demote the turn cap to a backstop.
  Tokens bind first in normal use (~330 turns for a token-light user);
  the turn cap only catches pathological message-count bloat.
- Startup validation extended to the invariant
  target_tokens < trigger_tokens <= max_input_tokens.

This widens raw recall from ~3 active days to ~2 weeks for a typical
messaging user and, as a side effect, fires compaction less often. It
depends on the cache-ordering fix (#1420, landed in #1424): without it a
larger window would re-send up to 150K tokens at full input price after
every memory write.

This is a recall-for-cost trade, not a cost reduction: the full window is
sent every turn, so per-turn history cost scales with window size (~3-4x,
order +$15/month for the measured user). The target is the dial.

Fixes #1421

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bumps [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) from 4.0.18 to 4.1.0.
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.0/packages/vitest)

---
updated-dependencies:
- dependency-name: vitest
  dependency-version: 4.1.0
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [react-router](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router) to 7.17.0 and updates ancestor dependency [react-router-dom](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dom). These dependencies need to be updated together.


Updates `react-router` from 7.13.1 to 7.17.0
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router@7.17.0/packages/react-router)

Updates `react-router-dom` from 7.13.1 to 7.17.0
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router-dom/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router-dom@7.17.0/packages/react-router-dom)

---
updated-dependencies:
- dependency-name: react-router
  dependency-version: 7.16.0
  dependency-type: indirect
- dependency-name: react-router-dom
  dependency-version: 7.16.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [starlette](https://github.com/Kludex/starlette) from 0.52.1 to 1.0.1.
- [Release notes](https://github.com/Kludex/starlette/releases)
- [Changelog](https://github.com/Kludex/starlette/blob/main/docs/release-notes.md)
- [Commits](Kludex/starlette@0.52.1...1.0.1)

---
updated-dependencies:
- dependency-name: starlette
  dependency-version: 1.0.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…ion time (#1426)

* fix: anchor compaction history breadcrumbs to event time, not compaction time

Compaction replaced the [TIMESTAMP] placeholder in every HISTORY.md
breadcrumb with the moment the compaction ran, so once older messages
were trimmed the agent lost when events actually happened. It would then
read a timeless or wrongly-stamped breadcrumb and place a past action
"today", e.g. claiming a calendar event added the previous evening was
scheduled today.

The conversation handed to the compactor already carries the per-message
time markers added for history (the [Weekday, YYYY-MM-DD HH:MM] markers),
so the model can anchor each breadcrumb to the event's own time. This:

- Tells the compaction prompt the markers exist and to use them.
- Mandates one timestamp format ([YYYY-MM-DD HH:MM], or date-only when the
  time is unknown) instead of the mix of weekday/AM-PM/range shapes seen
  in production history.
- Forbids propagating the batch's opening marker time onto every event,
  and forbids relative-time words ("today", "tomorrow") that drift once
  the breadcrumb is read days later.
- Keeps the now-time substitution as a fallback only for events with no
  visible marker.

The code change is just a rename plus a comment: .replace("[TIMESTAMP]",
now) already leaves real timestamps untouched, so it degrades cleanly into
the no-marker fallback.

Regression test covers a multi-line summary mixing a concrete event time
(preserved verbatim) and a placeholder (filled with the fallback).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: tighten compaction Step 4 prompt, cut duplicated marker-cadence fact

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix: route history-window overflow through compaction

Token-light conversations could grow past the conversation_history_limit
row cap (500) while staying under the 150k token trim trigger forever.
Rows beyond the window were silently excluded from LLM context without
ever passing through trim_messages, so the trim-driven compaction path
never saw them: no MEMORY.md update, no HISTORY.md breadcrumb, no
watermark advance. The turn backstop could not catch this either, since
its default (600 turns, trigger 616) was unreachable behind a window
that holds at most ~250 user turns.

Three changes:

1. load_conversation_history now routes rows that fall outside the
   window through trigger_compaction_for_dropped, plus a 32-row headroom
   batch so the path does not re-fire on every subsequent message. The
   watermark advance plus batching is what makes this safe; PR #843
   removed the old loader-driven compaction because it re-fired per
   message with no watermark. When every overflowed row is filtered
   (approval prompts, blank placeholders) the watermark still advances
   so the branch cannot re-detect the same rows forever.

2. context_trim_target_turns default drops from 600 to 200 so the turn
   backstop is reachable inside the loader window (2 * 217 = 434 <= 500
   rows). The trigger buffer constant moves to config.py so the config
   validator can compute the effective trigger without importing agent
   code.

3. log_config_warnings warns when 2 * (effective trigger + 1) exceeds
   conversation_history_limit, the configuration that reproduces the
   original bug.

Fixes #1427

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: re-check live watermark before compacting dropped rows

The window-overflow path in load_conversation_history runs earlier in
the same turn than the trim path in process_message, and both can fire
when a conversation crosses the row cap and the token budget together.
The trim path computes its dropped list from a session snapshot taken
before the overflow path advanced the watermark, so the overlap would
be compacted twice: double LLM cost and a duplicate audit row for the
same seq range.

trigger_compaction_for_dropped now reads the live watermark inside the
phase-1 transaction and filters out rows already covered, skipping
entirely when nothing remains.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: cap window-overflow compaction batch at 200 rows per turn

A legacy session can arrive with a multi-thousand-row backlog above the
watermark. Compacting it all at once would feed the whole backlog into
a single compaction LLM call and blow its context. The batch is always
a contiguous prefix starting at the oldest row above the watermark, so
capping it is safe: the watermark advances to the end of the capped
batch and the next message picks up the next chunk.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
get_or_create_session_async loaded every message the user had ever
produced, including rows at or below the trim watermark that
load_conversation_history filters out in Python a moment later.
Messages are never deleted, so the per-message DB load grew with the
user's lifetime message count: O(lifetime) rows materialized into ORM
objects and DTOs (including multi-KB tool_interactions_json blobs) per
inbound message, forever.

The watermark filter now runs SQL-side via a dedicated builder
(seq > last_trim_seq, indexed by the uq_message_seq constraint), so the
hot path loads only the visible window. Every consumer of this method
either applies the same filter anyway (load_conversation_history,
admin_compact_visible_messages) or never reads messages at all
(heartbeat persistence, inbound recovery refresh). Full-transcript
surfaces (webchat history endpoint, admin views) go through
load_session_async / list_sessions_async, which are unchanged.

The issue also proposed a tail LIMIT. Deliberately not included: a
naive LIMIT would hide the oldest visible rows from the window-overflow
compaction path (issue #1427), which must see a contiguous prefix
starting at the oldest row above the watermark. The row count above the
watermark is bounded by that path instead.

Fixes #1428

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
)

compact_session reads MEMORY.md / USER.md / SOUL.md, runs an LLM call
that takes tens of seconds, then writes full rewrites. Compaction runs
as a background task, so the conversation keeps going during the call:
the agent's workspace tools can write a new fact in that window, and a
second compaction for the same user can land first (the code already
acknowledges concurrent compact_session tasks). The blind overwrite
then clobbers the newer value, silently, including explicit user saves.
HISTORY.md was already protected (advisory lock + FOR UPDATE in
append_history); the other three files were last-writer-wins.

write_memory_async / write_user_async / write_soul_async now accept an
optional expected_current and perform the compare inside the write
transaction under FOR UPDATE (plus the per-user advisory lock for the
no-row-yet MEMORY.md branch, mirroring append_history). On drift the
write is skipped and False is returned; compaction logs the skip and
records memory_updated=False on the audit row. Losing one batch's
extraction is recoverable (the conversation sent to the LLM is kept in
the event row's prompt_text audit column); clobbering a durable file is
not.

Fixes #1429

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The only message-side cache breakpoint sat before the current inbound
user turn and never moved during the tool loop. Every round N > 0
re-sent the current turn (carrying the full dynamic context: memory up
to 25KB, integrations, cross-session) plus all prior rounds' tool calls
and results as uncached input. With max_tool_rounds=10 and large tool
results the per-turn cost grows quadratically with round count.

apply_in_turn_cache_breakpoint stamps a cache_control marker on the
trailing tool_result block when the request ends in tool results, so
round N reads the current turn plus rounds 0..N-1 from cache and pays
cache-write only on the newest round. Message dicts are re-serialized
from typed messages every round, so the marker advances with the loop
instead of accumulating: at most four breakpoints per request (system,
tools, prior-history tail, in-turn), which is Anthropic's limit. Round
0 ends in the current user turn (string content) and is a no-op, same
as today.

Fixes #1430

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* fix: retry compaction events stuck in pending on startup

trigger_compaction_for_dropped advances the trim watermark
synchronously, then runs the compaction LLM call as a fire-and-forget
background task. When the process dies mid-call (deploy restart, OOM)
or the call fails (provider outage), the CompactionEvent row stays
'pending' forever: the watermark is already advanced, so the seq range
never reaches the LLM again and its facts are never extracted into
MEMORY.md. The row records everything needed to recover (the seq range)
and messages are never deleted, but nothing acted on it.

recover_pending_compactions sweeps stale pending rows on app startup,
mirroring the inbound_recovery pattern: per-process pg_try_advisory_lock
on a dedicated AsyncConnection, lookback window (new
compaction_retry_lookback_minutes setting, default 7 days, 0 disables),
best-effort semantics. Each row is claimed by incrementing the new
retry_count column (migration 039) before the LLM call so a crash
mid-retry still consumes the attempt; rows at the 3-attempt cap stay
'pending' for admin visibility but stop being selected, so a poisoned
range cannot retry forever. A 10-minute grace floor keeps the sweep
from racing a compaction call that is legitimately still in flight.
Ranges whose messages were deleted are exhausted immediately.

Fixes #1431

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs: document COMPACTION_RETRY_LOOKBACK_MINUTES

Covers the .env.example and configuration.md completeness checks in
test_env_example.py for the setting added by the compaction retry sweep.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…te (#1439)

trigger_compaction_for_dropped advances the trim watermark
synchronously, so dropped rows vanish from LLM context on the very next
message, but the compaction LLM call that extracts their facts into
MEMORY.md is async and may still be in flight (or may have failed). In
that window the agent has amnesia for the dropped range, immediately
after the trim, when the dropped content is most likely still topical.
The deterministic trim summary the trim turn saw was never persisted,
so it existed for one turn only.

While the user has a recent (under 60 minutes) 'pending' compaction
event, a terse deterministic summary of the covered rows is rebuilt
from the durable message rows (same summarize_dropped_messages shape)
and injected as a dynamic system-prompt section. Once the event flips
to 'completed', the note disappears and MEMORY.md carries the facts:
the context now contains either the compacted facts or the note, never
neither.

Design choices: the summary is recomputed per turn instead of persisted
(inputs are durable, the summarizer is cheap and deterministic, no
migration needed); the note rides the dynamic half of the prompt so it
never busts the history cache; the 60-minute window keeps a permanently
stuck event from pinning a stale note forever (that case belongs to the
retry sweep, issue #1431). Watermark semantics are untouched.

Fixes #1432

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…ristic, erosion signal) (#1440)

Four small fixes from the conversation-design review, bundled per issue
#1433:

1. Same-row trim atomicity. History rebuild expands one outbound DB row
   into a tool-call AssistantMessage, its ToolResultMessages, and a
   final-reply AssistantMessage, all sharing the row's seq. trim_messages
   treated the reply as a separate block, so it could drop the tool-call
   half while keeping the reply; the watermark then advanced over the
   shared seq and silently filtered the kept reply from the next turn.
   The reply now trims atomically with its block (live-loop messages
   carry seq=None and are unaffected).

2. Remove the dead cross-session context section. Migration 026
   collapsed sessions to one per user, so the query excluding the
   current session always returned empty; every message paid a wasted
   DB query. Removed build_cross_session_context, the
   get_other_session_messages_async store method, and the
   current_session_id plumbing through the prompt assemblers.

3. Accurate proactive trim. A fresh ClawboltAgent is built per message,
   so the trim decision always used the chars/4 + flat-overhead
   heuristic, which ignores tool schemas and real system prompt size.
   A bounded process-local LRU now carries the last API-reported
   input_tokens per user across agent instances; the heuristic remains
   the cold-start fallback.

4. Memory erosion signal. Compaction full-rewrites MEMORY.md and the
   compliance audit encourages deletion, so a valid line can vanish on
   any cycle with no signal. The compaction.summary log line now carries
   memory_lines_added / memory_lines_removed (multiset diff,
   reorder-insensitive) so an aggregator can alert on large unexplained
   removals. Persisting the counts on compaction_events can follow once
   migration 039 (PR #1438) lands, avoiding an Alembic head collision.

Fixes #1433

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…es (#1441)

Four fixes for failure modes observed in production sessions:

1. Numeric tool args for string fields: LLMs emit numeric-looking values
   (work order numbers, street numbers, numeric event titles) as JSON
   numbers; Pydantic v2 rejects int/float for str fields, failing the
   whole tool call. The agent now stringifies the offending values and
   revalidates once before surfacing the error.

2. Text-only inbound messages no longer wrapped in "[Text message]:":
   the wrapper only earns its keep next to media parts; alone it is
   formatting noise the model can parrot back as its own reply.

3. AppFolio: appfolio_get_work_order retries with the canonical
   customer id on a scope 401 (mirrors the invoice tool), and a
   no-match search now explains why a number may be missing (closed,
   archived, or a different property manager) instead of dead-ending.

4. QuickBooks SKILL.md: amounts, balances, and statuses must be quoted
   from rows a query returned this turn, not from conversation memory
   of an earlier pull.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
… reports (#1443)

AppFolio successes were only logged as a byte count, so a 200 OK with a
surprising shape left nothing to debug from after the fact. The concrete
gap: a work-order number search whose raw response carries only an
internal id and no user-facing number. The formatter falls back to the
id, the agent reads a false mismatch and can refuse to act, and we keep
no record of what AppFolio actually returned.

We can't reach AppFolio ourselves, so the only way to debug a user's
report later is to have captured the raw response at the time. Add an
INFO-level raw-response log in the one request funnel (covers list,
search, get, and writes):

- short scalar fields (id, numberForDisplay, customer_id, status) pass
  through intact, which is what's needed to reconstruct the
  display-number-to-internal-id mapping;
- base64/long strings collapsed (reuses the request summarizer) so photo
  blobs don't bury the structure;
- credential keys (jwt, access_token, refresh_token, ...) and PII keys
  (names, addresses, email, phone) redacted, so neither secrets nor
  tenant data reach the logs;
- a one-line shape descriptor matching log_unexpected_response_shape;
- truncated at 8000 chars; non-JSON bodies log size + content-type only.

Adds unit and wire tests for the summarizer, secret and PII redaction,
and the success-path log line.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@dependabot dependabot Bot added dependencies Pull requests that update a dependency file python:uv Pull requests that update python:uv code labels Jun 16, 2026
njbrake and others added 6 commits June 16, 2026 16:36
…arden log PII (#1445)

The raw-response logging (shipped previously) revealed the actual
work_order_search response: it is a universal-search hit schema, not the
maintenance work-order schema. The user-facing number lives in
``result_text`` ("<number> - <unit>") and the customer id is a list under
``customer_ids``. The formatter reads ``numberForDisplay`` and
``customer_id``, so it matched neither, fell back to the bare internal
``id``, and the agent reported a false "that number doesn't match" and had
no customer_id (invoices then 404'd until a 105-row list call backfilled
it).

- Add ``_normalize_search_hit`` to remap a search hit into the work-order
  shape (result_text -> numberForDisplay, customer_ids[0] -> customer_id)
  before formatting. No extra API call: the data was already in the search
  response. Kills the false mismatch and the invoice 404.

PII hardening for the raw-response log (separate, prompted by the same
captures): the exact-key redaction missed several PII-bearing keys
(company_email, business names like portfolio_name, *_last_four,
company_primary_address_1, and free-text description/instructions that
carry tenant name + phone). Add those to the set plus a narrow PII
substring check (email/phone/address/name/last_four/ssn/tax_id) for schema
variants, while keeping the identifier fields the debugging needs
(id, numberForDisplay, result_text, customer_id, customer_ids, status).

Adds tests for the search remap, the search-tool output, and the expanded
redaction.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nd not partially apply (#1450)

* fix(approval): re-prompt on ambiguous reply instead of aborting the batch

When the agent batches several tool calls behind sequential approval
prompts (for example rescheduling a multi-day job stored as one calendar
event per day) and the user replies with short filler ("lol", "haha",
"ok wait"), the classifier returned no decision and ingestion resolved
the gate as INTERRUPTED. That aborted the remaining items, so a
multi-event operation was left partially applied and the agent reported
that it "skipped" the rest because they "weren't approved" -- confusing
for a casual reply, and requiring a second confirmation to finish.

Filler is neither a yes/no nor a genuine change of subject, so treat it
as such: classify_approval_response now returns a dedicated "ambiguous"
result, and ingestion re-sends the approval prompt ("Sorry, I didn't
catch that as a yes or no.") while leaving the gate pending, so the
blocked agent loop keeps waiting for a real decision. A clear yes/no then
completes the whole batch in one step. Re-prompts are capped (the
approval timeout remains the outer bound) so persistent filler still
falls back to INTERRUPTED. Genuine new requests still classify as
unrelated and interrupt as before.

Fixes #1449

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(calendar): scope update/delete approvals per action, not per event

calendar_update_event and calendar_delete_event extracted an approval
resource of f"update {event_id}" / f"delete {event_id}", scoping the
stored permission to a single event. Rescheduling a multi-day job is one
update per day, each with a distinct event_id, so "always allow" on the
first day never matched the others: the within-turn approval cache
(keyed on (tool_name, resource)) missed, the persisted ALWAYS override
missed, and the user was re-prompted on every day even after choosing
"always allow" (observed in production: always_allow, always_allow,
approved, then interrupted across four prompts for one reschedule).

Use a coarse constant resource ("update event" / "delete event"),
matching calendar_create_event's existing "create event". Now one
approval (yes or always) covers every same-type mutation in the batch,
and "always allow" persists tool-wide as the user expects.

Fixes #1449

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ls (#1453)

* feat(approval): add blanket "always all" approval for resource-scoped tools

Resource-scoped tools (like qb_send) remember an "always allow" decision
per resource: the user had to approve every new invoice recipient
individually. Add a distinct ALWAYS_ALLOW_ALL decision that persists a
tool-level permission (resource=None), so one "always all" reply covers
every recipient.

The store and resolution layers already supported tool-level entries and
the resolution order (resource match, then tool match, then default), so
this only adds a way to record the blanket decision:

- New ApprovalDecision.ALWAYS_ALLOW_ALL, fast-path keywords ("always all",
  "allow all", "always everyone", etc.), and an LLM-classifier label.
- format_approval_message gains an opt-in "always all" line, inserted
  before the "never" trailer so context.py can still identify stored
  approval prompts in history.
- ApprovalPolicy.resource_noun drives the wording; only tools that set it
  (qb_send: "recipients") offer the blanket option. The per-resource
  "always" still works, and a resource-level NEVER still overrides the
  blanket ALWAYS.

Fixes #1451

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(approval): scope ALWAYS_ALLOW_ALL to opt-in tools only

The "always all" / "allow all" keywords resolve to ALWAYS_ALLOW_ALL
globally, but only tools that declare a resource_noun show the option.
As written, the blanket decision persisted a tool-level grant for any
tool, so a typed "allow all" on a resource-scoped tool that never offered
it (e.g. web_fetch, scoped by domain) would grant a broader permission
than was advertised.

Gate the tool-level write on the tool having opted in (resource_noun
set); otherwise scope the grant to the resource actually shown. Add a
regression test covering the opt-out case.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* style(approval): address review nitpicks

- Replace "--" separator with a period in an approval.py comment, per the
  no-em-dash/no-double-hyphen copy rule.
- Move the _is_approval_prompt import in test_approval.py to the top of the
  file, per the all-imports-at-top rule.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…1456)

With prompt caching, usage.input_tokens counts only the uncached slice
of the prompt; the cached slice is reported in cache_read_input_tokens
and cache_creation_input_tokens. The agent loop recorded only
usage.input_tokens as _last_input_tokens, so the token-side trim
trigger read a mostly-cached 165k-token context as ~7k and never
fired. Every trim-driven compaction in production has come from the
216-user-turn backstop instead (visible as uniform ~34-seq
compaction_events ranges).

Worse, the ContextLengthExceededError retry path trims with the same
undercounted figure, so once a conversation reaches the model's hard
context limit the retry trim is a no-op and the agent fails on every
message until the turn backstop happens to fire.

Record the full prompt size (input + cache creation + cache read) for
the trim signal. Billing/usage accounting fields are unchanged.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…#1455)

* fix: bump genai-prices to price claude-opus-4-8 (fixes $0 usage rows)

genai-prices 0.0.57 predates claude-opus-4-8, so calc_price raised
LookupError for the model deployed in prod and compute_cost fell through
to Decimal('0.000000'). Every llm_usage_logs row for anthropic/claude-opus-4-8
was written with cost=0 while token counts logged correctly.

Bump the lock to 0.0.69 (first version with opus-4-8 pricing is 0.0.63);
this pulls in genai-prices' httpx2 stack (pydantic-org, same maintainer).

The test suite covered claude-opus-4-7 and claude-sonnet-4-6 but never
the actually-deployed claude-opus-4-8, so CI stayed green while prod
zeroed out cost. Add claude-opus-4-8 to both the is_known_model
parametrize list and the nonzero-cost smoke test so a future model
launch that outruns the pricing data fails CI instead of silently
zeroing revenue data.

Note: this only fixes rows written after deploy. Existing cost=0 rows
for claude-opus-4-8 need a separate backfill from stored token counts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat: add backfill script for $0 llm_usage_logs rows

Pairs with the genai-prices bump: that fixes rows written after deploy,
this repairs the historical claude-opus-4-8 rows already persisted with
cost=0. Recomputes cost from the token counts stored on each row (input,
output, and both cache buckets) via services.llm_pricing.compute_cost and
updates in place.

Safety: dry-run by default (--apply to commit); only touches cost=0 rows
with non-zero tokens so settled costs are never re-priced; only writes
when the recomputed cost is > 0 so a still-unknown model is a no-op;
idempotent. The --model guard refuses to run when genai-prices does not
know the model, catching "I forgot to bump genai-prices first". Keyset
paginated by id to bound memory on large tables.

Tests cover reprice-on-apply, dry-run writes nothing, unpriceable rows
stay zero, already-costed rows are untouched, and zero-token rows are
skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bumps [pyjwt](https://github.com/jpadilla/pyjwt) from 2.12.1 to 2.13.0.
- [Release notes](https://github.com/jpadilla/pyjwt/releases)
- [Changelog](https://github.com/jpadilla/pyjwt/blob/master/CHANGELOG.rst)
- [Commits](jpadilla/pyjwt@2.12.1...2.13.0)

---
updated-dependencies:
- dependency-name: pyjwt
  dependency-version: 2.13.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
@dependabot
dependabot Bot force-pushed the dependabot/uv/pyjwt-2.13.0 branch from e4c3956 to 850477a Compare July 14, 2026 17:05
@njbrake
njbrake force-pushed the main branch 2 times, most recently from 82563d8 to c3aa550 Compare August 14, 2026 18:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file python:uv Pull requests that update python:uv code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants