Skip to content

DO NOT MERGE — merge train: 2776,2764,2768,2779,2777,2775 - #2786

Closed
vybe wants to merge 26 commits into
devfrom
train/20260914-1332
Closed

vybe wants to merge 26 commits into
devfrom
train/20260914-1332

Conversation

@vybe

@vybe vybe commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Integration surface for #2776, #2764, #2768, #2779, #2777, #2775. Never merged; members merge individually once green.

dolho and others added 26 commits September 14, 2026 11:55
…2763)

`_adopt_legacy_clone` decided whether a legacy `skills_library_url` named an
already-configured source with raw string equality, while the rest of the
platform stores that URL normalized. The same repository written two ways
therefore never matched, the install took the ent#346 "already has sources"
refusal branch on every sync, and filed a fresh un-deduped high-priority alert
each time (#2744 is that flood).

`validate_skills_library_url` is a validator AND a normalizer
(`github.com/o/r` -> `https://github.com/o/r`), and `routers/skills.py:649`
uses it as one when it stores a source. This path called it and discarded the
return. Both representations occur on real installs by construction: the
bundled default source is seeded from `config.TRINITY_DEFAULT_SKILL_SOURCE`, a
bare literal that never passes through the validator, while a source created
via `POST /api/skills/sources` is stored normalized. So either direction of the
mismatch is reachable, and both are covered.

Two changes: assign the validator's return, and compare through the new
`_same_skills_repo`, which normalizes the STORED side too — normalizing only
the setting would still miss every install whose source came from the seed.
`reject_embedded_credentials` keeps seeing the ORIGINAL string; it must judge
what was actually written, not a form we produced.

This cannot weaken ent#346. A match returns an existing source id and creates
no row, so it is the no-op branch; the grant branch
(`count_skill_sources() == 0` -> `create_skill_source`) is untouched, and a key
naming a genuinely different repo still reaches the refusal. Normalizing
removes false positives from the detector without widening what may be granted
— asserted by two of the eight tests, not just claimed here.

Scope is deliberately the scheme/no-scheme split the platform itself creates.
`…/repo.git`, a trailing `/` and the bare `owner/repo` shorthand stay distinct
and are pinned by a test, so collapsing them later is an argued ent#346
decision rather than a quiet widening.

Verified: reverting ONLY the call site (keeping the helper defined) fails
exactly the two regression cases and leaves the other six green — the fix flips
what it targets and nothing else. 496 passed across every skills-related suite.

Fixes #2763

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q19uRCksdn4DiRAJ55rfpZ
…nstance (#2766)

`_resolve_title_auth` preferred the instance-wide Anthropic API key outright
and only fell back to the agent's subscription. On a fleet where every agent
runs on a Claude subscription, thread titles were therefore billed to — and
gated on — a console account no agent was assigned, so an unfunded or revoked
instance key broke a Workspace feature for agents that were otherwise
completely healthy. The reported symptom is HTTP 400 "credit balance is too
low" on every attempt, the generator latching to `failing`, and every new chat
keeping its first-message fallback title.

The credential now FOLLOWS THE AGENT, matching how its chat is already billed.
The mode comes from `subscription_service.derive_auth_mode` — the platform's
one auth-mode derivation (#471) — rather than a second answer to "what is this
agent authenticated as": subscription -> its own OAuth token; api_key -> the
instance key; not_configured -> no credential and the derived title stands.

A subscription-mode agent whose token cannot be read returns None rather than
falling through to the instance key. Falling through is precisely the
shadowing this fixes: a key the agent was never assigned is not a credential it
holds, so a missing token means "no credential for this agent", not "use
someone else's". Same class as #2114 on the agent side, one layer up.

Second half of the issue: a non-200 now records the upstream reason rather than
the bare status. `HTTP 400` alone reads as a transport fault and sends the
operator after the wrong thing; `HTTP 400 · invalid_request_error · Your credit
balance is too low...` points at billing. Only the API's own `error.type` and
`error.message` are used, never the raw body, and the phrase is scrubbed for
credential-shaped text before it reaches the operator-visible health record.

Verified: reverting ONLY the precedence (keeping the new helper defined so the
module still imports) fails exactly the two behavioural regressions plus the
derivation guard, and leaves the other eight green — including the api_key-mode
path, which must keep working unchanged.

Note on the suite: `test_2638_subscription_switch_on_turn.py` is flaky on
`origin/dev` independently of this change — the same selection gives 3/3/0
failures across three runs with this branch's code entirely absent, and 3/0/10/5
with it. It passes standalone both ways. Filed separately; not addressed here.

Fixes #2766

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q19uRCksdn4DiRAJ55rfpZ
…holds (#2700)

Trinity Rule #1 — the requirements change lands before the code that
falsifies them.

`POST …/voice/start` arms the thread's live-call marker before the audio
WebSocket that is the only thing able to clear it exists, so a start whose
socket never opens strands a ~32-minute `409 voice_call_active` on every
typed turn in that thread, naming a call the person cannot end.

- public-access.md §48.3 FR-3b: "no reply lands mid-call" becomes "…once the
  audio bridge is up", and a call whose socket never opens never holds the
  thread.
- runtimes.md §29.10 VOICE-010 (Session lifetime): "this thread is on a call"
  is a property of the session's connection lifetime — an owned lease armed
  by the bridge at connect, renewed while it lives, never past the cap,
  released on every exit.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0176XEqK8PTCAK5K6yZURQLv
… connect and always releases it (#2700)

`POST …/voice/start` armed the thread's live-call marker (TTL = the call cap
+ slack = 1920 s) before the audio WebSocket that is the only thing able to
clear it existed. Both clear paths sit downstream of that socket, and so does
the cap watchdog that would end the session — so a start whose socket never
opened stranded a ~32-minute `409 voice_call_active` on every typed turn in
that thread, from any tab and from the headless `/chat`, naming a call the
person cannot end.

Three moves; the first is the one the issue asked for, the other two are why
it holds.

1. The party that opens the effect closes it. The bridge is now the only
   writer: it arms the marker as the first statement inside the same `try`
   whose `finally` releases it, and that release is unconditional, LAST, and
   keyed on the `portal_session_id` local captured before the `try` — never on
   `ended.portal_session_id`, where an `ended is None` return (the exact case
   the REST `/stop` clear was added for) would re-strand it. The reported
   orphan window goes to zero.
2. The marker is a lease, not a latch. Its TTL is 60 s, renewed every 15 s by
   a bridge-owned task, bounded at the call's own `max_duration + 120` so it
   can never outlive one. Without this, move 1 only relocates the harm: a
   SIGKILL, an OOM or a routine backend deploy mid-call would still strand the
   thread for 32 minutes with this issue's own symptom. The 4x TTL/tick ratio
   and its reason are `agent_call_limiter`'s, copied not invented; the renew
   write goes off-loop via `asyncio.to_thread` for the same reason.
3. The lease has an owner. The value stored is the call's `voice_session_id`
   and a release deletes only on match (`clear_turn_inflight`'s precedent), so
   a closing bridge cannot free the thread of a newer call — a reload, a second
   tab. `owner` is keyword-only and required on both primitives. The legacy
   `"1"` value is treated as unowned and released, so a marker stranded across
   the deploy is not immortal.

Also, in blast radius: the close-out is wrapped in `except Exception` +
`logger.exception` (not `BaseException` — cancellation still propagates), so a
raising `end_session`/persist no longer skips the gemini cancel, the `saved`
frame and the close. The renew task is cancelled as the first statement of the
`finally`, before anything that can await, so no renewal can re-arm after the
release.

The read stays fail-OPEN and `_refuse_turn_during_voice_call` is byte-
unchanged (#2735's `voice_call_id` exemption intact). No new user-visible
surface, state, string or default; no frontend file touched.

The three `test_2694_voice_delta_context.py` tests that pin the old set site
are rewritten in the next commit.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0176XEqK8PTCAK5K6yZURQLv
…n shape end to end (#2700)

The issue's third AC — "the existing `test_2694_voice_delta_context.py` marker
tests still pass" — cannot hold literally: three of them pin what this fix
moves. It is read instead as "the marker's behavioural guarantees stay pinned —
armed while a call is live, released when it ends, held only by its owner,
fail-OPEN on Redis, the TTL as the backstop — and every test that changes gets
stronger".

`test_2694_voice_delta_context.py`
- the primitives test gains the owner token: `owner` is keyword-only and
  required on both, a release from a DIFFERENT call does not free a live
  thread, the legacy `"1"` value is releasable, bytes decode, and the real
  lease/tick/slack constants plus the >=4x TTL:tick ratio are pinned here
  (the bridge tests import them from a fake module and cannot).
- NEW: the renewer ticks at the lease TTL and STOPS at the call's own cap —
  a fake `mark` that raises after 10 calls turns a missing bound into a fast
  red instead of a hang — and a cancelled renewer stops quietly.
- `…marks_the_thread_live_for_the_cap_plus_slack` becomes
  `test_start_workspace_voice_does_not_arm_the_thread_before_a_socket_exists`.
  The recorder takes `owner=None` by default so a RESTORED two-arg `/start`
  arm is recorded rather than raising: `marks == []` is what must bite.
- NEW, the AC-2 test: start a call, never open a socket, then type into the
  same thread. The REAL `mark_voice_call_active` runs against a fake Redis on
  purpose — a recorder stub would swallow the write and pass even with the
  `/start` arm restored, so `fake.store == {}` is the assertion that bites.
  The turn genuinely dispatches (`recorder.calls` non-empty), and a positive
  control arms the lease the way the bridge now does and gets the 409 back.
- the 600-char proximity grep around `persist_voice_call_end(` is replaced by
  `test_the_bridge_resolves_the_real_marker_helpers`: it could never see the
  `if ended:` nesting that IS this bug. What survives is what a source read can
  honestly assert — that the real modules resolve each other (every bridge test
  runs against a fake), that the marker import is hoisted out of the `finally`,
  and that the release is keyed on the pre-`try` local, not on `ended`.

`test_voice_auth.py` — new `TestWorkspaceLiveCallMarker`, here because the
behavioural bridge harness (the importlib load of `routers/voice.py`, the
stubbed voice service, `_FakeWebSocket`, the #762 restore net) exists in this
file and nowhere else. Armed at connect and released on close; released when
`end_session` returns None (Trap C); released when the close path raises, with
the gemini cancel, the `saved` frame and the socket close all still reached;
released under task cancellation; an Agent Detail call never arms a thread
marker (labelled a guard — it passes against the old code too); and the REST
`/stop` release is owner-matched (an API-only guard, D9).

Three harness fixes the tests need: `_FakeVoiceSession` gains
`portal_session_id`/`max_duration`/`end_reason`/`end_message` (the close-out
reads the last two without getattr defaults), the fake `client_portal.voice`
exposes `persist_voice_turn` (imported unconditionally for a portal-bound
session), and `_YieldingWebSocket` awaits once in `receive_text` — the stock
fake pops its queue without a single `await`, so a bridge driven by it never
yields and its `create_task`ed children never start.

Red-on-base proof (both source files restored from a scratch copy, never
`git checkout --`): 10 of the 11 new/rewritten tests fail against `2c5cfe0e`;
the one that passes is exactly the labelled Agent Detail guard.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0176XEqK8PTCAK5K6yZURQLv
… connect gap is named (#2700)

The feature flow said verbatim that the marker is "written by
`start_workspace_voice` once the provider session exists (TTL = the cap +
slack), cleared by the bridge's `finally` and by the REST `/stop`" — the
sentence this fix falsifies.

- `workspace-voice-conversation.md`: the claim is retitled "no reply lands
  mid-call **once the audio bridge is up**", and the mechanism is rewritten —
  an owned lease armed by the bridge at connect, renewed while it lives, never
  past the call's cap, released unconditionally and owner-matched on every
  exit. The rule is named in one clause (a marker whose only closer lives
  downstream of a connection that may never exist is an orphan generator), and
  all three load-bearing properties are stated with the failure each prevents.
  The REST `/stop` is described as what it is: idempotent, owner-matched, and
  API-only — the Workspace passes `restStop: false` and never calls it.
- Known limits gains three bullets: the `/start`→connect gap in #2694's own
  words (the turn posts and is answered normally; what is lost is the voice
  model's opening context, which is exactly where "a reply between two spoken
  rows hides the call's first half" becomes possible again); a half-open peer
  holding the lease until the renewer's cap+slack lifetime ends, including the
  cap path's 5 s `saved`-timeout close; and the key being one per thread.
- The Files row for `routers/voice.py` names the arm, the renew and the
  unconditional release.
- `feature-flows.md` gains the dated index row.
- `learnings.md` gains the durable class entry: arm the effect in the `try`
  whose `finally` releases it; relocating the setter is only a third of the fix
  (a marker held by a live process is a lease, and the renewer needs its own
  bound); an unconditional release needs an owner token; and a test that pins
  the old write site cannot "still pass" — rewrite it and prove it bites.

`architecture/workspace.md` is deliberately NOT edited: its sentence ("a typed
turn is refused (409) while a call is on") states no set site and stays true,
and the detail's home is the feature flow (one home per feature). Also verified
unchanged: the ent#551 `voice_call_id` exemption paragraph.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0176XEqK8PTCAK5K6yZURQLv
…ives, and say so (#2700)

Three claims the #2700 branch makes are stronger than the implementation
provides. All three are the same fact — the renewer's Redis write is
off-loop — and each one is a comment or a Known-limits line a future
reader would build on.

1. The bridge's `finally` claimed that cancelling the renewer first means
   "a renewal already in flight lands before the release, and the delete
   wins". It does not: `asyncio.to_thread` raises `CancelledError` in the
   awaiting coroutine immediately and lets the worker thread run to
   completion, so a renewal inside its `SET` can land after the release's
   `DEL`. Verified with a standalone `asyncio.run` reproduction, not by
   reading the docs. The consequence is bounded and benign — one lease
   (<= 60 s) of extra 409s after a call ends, self-healing, read still
   fail-OPEN — and it needs the renewer to be inside a ~1 ms write at that
   instant AND that write to outlast the whole close-out.

2. The REST `/stop` release is not durable while the bridge is up: the
   renewer re-arms within a tick, so that path frees the thread only once
   the socket is gone. An API-only path either way (`restStop: false`).

3. "a closing call cannot free a newer one" overstated the owner match for
   two concurrent calls on one thread: two live renewers flip-flop the
   value, so a closing call frees the newer one when its own id was the
   last write, and the newer call's next tick re-arms within 15 s. The
   permanent free an unconditional delete would have caused is gone; a
   <= 15 s window in a deliberately rare shape is not.

No behaviour change: comments, one Known-limits bullet, and a fifth clause
on the issue's learnings entry, since "cancelling a task does not cancel
work it already handed to a worker thread" is the durable class here.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0176XEqK8PTCAK5K6yZURQLv
…2693)

A fresh install seeds scout/sage/scribe with only an avatar_prompt, and
generation needs GEMINI_API_KEY, which no new instance has — so the first
screen an operator sees is a fleet of initials.

A local: template may now ship avatar.webp/avatar.png beside template.yaml.
At create, when the template also declares avatar_prompt, the image is
re-encoded through optimize_avatar into /data/avatars before the prompt is
seeded as a DEFAULT avatar, so Generate Default Avatars still overwrites it
once a key exists. Offline, no outbound call. A missing, oversized or
undecodable image degrades to the old prompt-only seed.

The three starter templates ship hand-drawn schematic glyphs (no Gemini key
was available to render the prompts, and the schematic family matches the
approved first-run illustrations). scribe gains the avatar_prompt it lacked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VKzc3MG4Fa1sJ1KuMmBtcV
#2744)

Rule #1: the requirements delta lands before the code.

§21.1.3 described adoption as "idempotent and fail-soft" and said nothing
about the refusal's ALERTING. `_adopt_legacy_clone` runs as the first
statement of every `sync_library()`, so on an install that is past migration
but still carries a non-matching `skills_library_url` the terminal refusal
files a fresh `priority: "high"`, `expires_at: None` operator-queue item on
every sync — unattended under the ent#236 auto-sync loop (300s floor ⇒ 288
rows/day), never expiring, and un-dismissable because each row carries a new
timestamped `request_id`.

The rule this states: that refusal is the designed resting state of a
migrated install, not a failure, so it is `low` + `logger.info` with a stable
URL-derived id whose family prefix is reserved; the two genuine failure
branches keep `high` and their repeat-visible ids by product decision.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0176XEqK8PTCAK5K6yZURQLv
…cho (#2744)

TDD — RED on this commit, green on the next. Proven red for the right reason,
not merely red:

  test_n_syncs_..._exactly_one_item          5 distinct timestamped ids
  test_a_different_refused_url_...           frozen clock ⇒ both URLs share one id
  test_the_terminal_refusal_is_not_high...   priority "high", logger.error
  test_the_stable_id_is_reserved_...         'skills-legacy-adoption-<ts>' unreserved
  test_a_pat_bearing_url_is_never_echoed...  the PAT is in context.url AND the log

  test_the_actionable_branches_keep_high...  GREEN on base, by design (AC 4)

The last one is the anti-regression half: it pins behaviour the fix must NOT
change, and goes red only if the low/stable-id treatment is applied to all
three call sites instead of the one the issue names.

Harness notes, both load-bearing. `_record_adoption_failure` imports
`utc_now_iso` INSIDE its body, so the patch target is `utils.helpers` —
patching `services.skill_service.utc_now_iso` binds nothing and yields a
vacuous test. And `validate_skills_library_url` does a live
`socket.getaddrinfo`: on a sandboxed resolver a terminal-branch test would
silently drive the validation-reject branch and fail as "5 distinct ids" /
"priority is high", reading exactly like the fix regressing — so DNS is
stubbed to the `gaierror` that function already tolerates, and every
terminal-branch test additionally pins which branch produced its item.

New file rather than an append to test_ent346_skills_source_injection.py:

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0176XEqK8PTCAK5K6yZURQLv
…ne per sync (#2744)

`_adopt_legacy_clone()` is the first statement of every `sync_library()`. On an
install that is past migration but still carries a `skills_library_url` matching
no configured source, the terminal "already has sources" branch called
`_record_adoption_failure`, which minted a TIMESTAMPED `request_id` at
`priority: "high"` with `expires_at: None`. One permanent, high-priority,
operator-unclearable row per sync, forever — 17 of them (~17% of everything
pending) on the reporting install, and 288/day at the ent#236 auto-sync floor.

Two behaviour changes, on ONE branch:

  * a STABLE, URL-keyed id (`skills-legacy-adoption-refused-{sha256(url)[:12]}`)
    so `create_item`'s `(agent_name, request_id)` ON CONFLICT DO NOTHING
    collapses N syncs to exactly one row — and, since that conflict target
    ignores `status`, an operator's dismissal finally sticks;
  * `priority: "low"` + `logger.info`, because this is the designed resting
    state of a migrated install, not a failure.

Shaped as a keyword-only `steady_state` flag on the existing emitter rather
than a second method: one #1677 `_ALLOWED_CALLERS` key, and a `False` default
that leaves the two actionable call sites LITERALLY UNCHANGED lines — the
strongest available proof of AC 4. The emitter keeps its name despite now
serving a non-failure; renaming costs the allowlist key and churns a file two
people are editing this week.

The hash is over the RAW `url.strip()`, and is computed INSIDE the try: a
non-str setting value must degrade to a warning and no alarm, not turn a
decorative alarm into a raiser. Normalising the input instead would re-enter
`validate_skills_library_url`, which does a live `socket.getaddrinfo` and can
raise — a network call and a raise path inside a fail-soft alarm.

Two things the stable id makes mandatory, both included:

  * `skills-legacy-adoption-` joins `_RESERVED_ID_PREFIXES`. An id derived from
    an admin-visible URL is guessable, so an agent could pre-create it and
    silence the alarm through the sink's ON CONFLICT (the #1632 C2 class); and
    `is_platform_minted` reads the same tuple to gate the ent#499 responded
    write-back and the ent#329 respond→resume dispatch, which this change makes
    an expected operator action. The FAMILY prefix, so all three call sites and
    the 17 historical rows classify correctly.
  * the URL echo is `strip_url_credentials`-scrubbed. The emitter's docstring
    claimed the credential case was handled, and that was true of `message` and
    of nothing else: `EmbeddedCredentialError` is a `ValueError` subclass, so
    the validation-reject branch is exactly the one a PAT-bearing URL reaches,
    and the raw value landed at ERROR in the Vector-captured log and durably in
    `operator_queue.context` — SQLite, every backup, rendered in the Operating
    Room (Invariant #12, Rule #5). The hash still keys on the raw value;
    scrubbing first would collide two different tokens on one repo.

The #1677 justification is corrected in the same commit: "admin-driven sync
cadence" is false (ent#236's loop is unattended), and the real bound — the only
input is a setting blocked on the generic settings PUT — is co-located as a
comment at the emitter, where it is likelier to stay true.

Unchanged and deliberately so: the `count_skill_sources() > 0` guard itself,
both validators, the grant branch, `expires_at: None`, the `title`/`question`
copy, and `context["alert_type"]`. No schema change — `request_id` and its
unique index shipped in #1631 — so Invariant #9 is not triggered: no
`db/migrations.py` entry and no Alembic revision. Clearing the lingering
`skills_library_url` key stays out of scope pending a separate investigation.

Tests: tests/unit/test_2744_skills_adoption_alert_idempotency.py

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0176XEqK8PTCAK5K6yZURQLv
… bound (#2744)

The reservation of `skills-legacy-adoption-` is enumerated in three live places
and all three now carry it: `requirements/security.md` §26.7's reserved-id
guard, and `operating-room.md`'s two enumerations (the ingestion-guard list and
the #1632 ingestion-caps paragraph).

`operating-room.md`'s "Platform exemption & emitter budget (#1677)" bundled the
skills alarm into a disjunction that includes "operator-driven". That was the
same false claim the `_ALLOWED_CALLERS` justification made — ent#236's
auto-sync drives `sync_library()` unattended on a 300s-86400s timer, so nothing
admin- or operator-driven bounds it. The paragraph now names this emitter's
actual bound, per branch: the terminal refusal is idempotent by a URL-keyed id
(≤1 row per refused URL) and what makes it platform-only is that its only input
is a setting blocked on the generic settings PUT.

Plus the two dated rows (`operating-room.md` Revision History, the
`feature-flows.md` change log) and the Operating Room catalog row.

All three enumerations were ALREADY stale — each omits prefixes the live tuple
carries. Ours is added; their pre-existing drift is deliberately not swept here
(Rule #2) and is named as a follow-up instead.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0176XEqK8PTCAK5K6yZURQLv
…item for the PAT (#2744)

Two gaps the review found in the new file, both in tests only.

`context["reason"] = "already_migrated"` is the discriminator the emitter grew
because one `alert_type` and one title now span both `low` (the benign resting
state) and `high` (a URL that failed validation — the signature of an attempted
injection). Nothing asserted it, so the field the fix added to be read by a
machine could be dropped by a later edit in silence.

The credential test asserted the PAT is absent from `context["url"]` and from
the captured log, but not from `question`. `question` is credential-free only
because neither ent#346 validator echoes the URL in its `ValueError`
(`validate_skills_library_url` names the hostname or the resolved IP;
`reject_embedded_credentials` names neither) — the scrub does not reach it. A
validator message that starts echoing the URL would reopen the leak durably in
`operator_queue.question` with no guard. Sweeping the serialized item covers
every field the emitter writes, not the two that were remembered.

Both were verified to bite: dropping the `reason` key fails the first, and
reverting `strip_url_credentials` to the raw url fails the second.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0176XEqK8PTCAK5K6yZURQLv
…#2744)

`QueueItemDetail.vue` renders every `context` key, so the discriminator the
emitter grew would have surfaced to operators as a `reason | already_migrated`
row. Put to the product owner as keep / drop / rename; the answer was drop —
nothing reads the key today, so removing it is non-breaking.

The steady-state branch is now discriminated by `priority: low` plus the
`logger.info` level alone. The assertion added in 69274de7 to pin the key goes
with it; it lived inside an existing test, so no test is removed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0176XEqK8PTCAK5K6yZURQLv
…ct (#2734)

Trinity Rule #1 — the requirements delta lands before the code.

FR-5 stops describing a derived staleness mark and describes two
unconditional facts instead: when the canvas was written, and when the
agent last finished a run. The age-threshold rejection is kept verbatim
and extended one step — the verdict it replaced could not know what a
canvas is for either, and it fired on the writing run's own output,
because a run completes after it writes and the stamp that would exclude
it (`updated_by_execution_id`) is optional and absent on most live
canvases. That is why "Updated just now" and "may be out of date" were
rendered together.

Also records the two properties a reader of the code would otherwise have
to rediscover: the second fact is OMITTED, never narrated, because the
field is null both for "never ran" and for a failed read and the payload
cannot tell them apart; and `stale` stays computed and unrendered, kept
so the derivation is recoverable rather than because it is endorsed.

The feature flow section is retitled and rewritten with a Change Log row,
the observability bullet follows it, and the user doc carries the new
user-facing sentence.

Refs #2734

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0176XEqK8PTCAK5K6yZURQLv
`decorate` already reads `last_completed_execution_at` once per agent to
derive the staleness verdict. It now also carries that instant on the
payload as `agent_last_run_at`, so the header can state the fact instead
of compressing it into a conclusion.

Three properties are load-bearing:

* The field is DECLARED on `CanvasSummary`, not merely set on the dict.
  Every canvas read route declares `response_model=`, and FastAPI filters
  a dict through it, dropping an undeclared key silently — while the
  voice panel route (no `response_model`) and the portal payload (plain
  dicts) would have kept it. Omitting the declaration would have shipped
  the fact on two surfaces out of three and looked like a frontend bug.
  A `Field(description=...)` rather than a comment, so `model_fields` can
  be asserted and the text reaches OpenAPI and the MCP tool schema.

* The read is normalised beside the read, not in `db/canvas.py`.
  `MAX(completed_at)` is a raw boundary #1474 never covered; a naive
  stored row used to fail quiet in a lexicographic compare, and rendered
  it would be parsed as LOCAL time by the browser. `is_stale` keeps
  receiving the RAW value — it is retired in place and its comparison is
  not this change's to alter.

* Absence is omission, never narration. The field is null both when the
  agent has never finished a run and when the read failed, and the
  payload cannot tell those apart — so it asserts neither. The failure is
  logged, so an operator sees what a reader cannot.

`empty_canvas` declares the key too: it bypasses `decorate`, and two
constructors of one response shape drift unless something pins them.

Tests: 8 new, all shown failing on base (`KeyError: 'agent_last_run_at'`,
`8 failed, 54 passed`) before the source change. The round trip starts
from `decorate`'s own output, so a misspelled dict key cannot pass by
being copied into the test.

Refs #2734

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0176XEqK8PTCAK5K6yZURQLv
…ss verdict (#2734)

The header said "Updated just now" and "may be out of date" at the same
time. The two were not in conflict by accident: the verdict was derived
from the same timestamp it contradicted, and it fired on the writing run's
own output, because a run completes after it writes and the stamp that
would exclude it (`updated_by_execution_id`) is optional and absent on
most live canvases.

So the verdict is gone and two neutral facts take its place:

    Updated 2h ago · agent last ran 40m ago

Both are unconditional, both come from `freshness()`, and both are
measured against the SAME injected clock — so whatever is wrong with that
instant is wrong for both by the same amount in the same direction, and
their relationship, the only thing a reader is judging, cannot invert. The
warning pill, its title and the note paragraph are deleted; the header now
carries no tooltip and nothing that appears and disappears as a derived
value flips.

Four details that are decisions, not incidentals:

* The second fact is OMITTED when absent, never narrated. The field is
  null both for "never ran" and for a failed server read, and the payload
  cannot tell them apart — so the gate is `Date.parse`, not truthiness,
  because this module's `relativeTime` answers "at an unknown time" for a
  bad value and that is a narrated non-fact.

* `basis-full` puts the line on its own row. The sibling h3 is
  `flex: 1 1 0%`, so it contributes basis 0 to line-breaking: inline, the
  span never wraps and the TITLE truncates instead — from ~35 characters
  to ~16 at 400px, on every canvas. It also pre-resolves the collision
  with the header buttons arriving in #2623.

* A 60s tick drives `now`. Agent Detail does not poll, so a `computed`
  with no time dependency would keep saying "agent last ran just now" for
  hours — a liveness claim, not a provenance one. The tick refreshes the
  string, not the payload, so it can only make the agent look less
  recently active than it is.

* `canvasChanged` compares the run time too. It is a property of the
  world, not of the loaded object, so comparing `updated_at` alone
  discarded every poll carrying only a fresher run time — on the one
  surface that polls every ~3s.

`stale` is still computed and still ships; nothing renders it. The MCP
read descriptions now say so, since they were the only text that ever
explained the field to an agent.

Tests: 8 new, all shown failing on base (`8 failed | 139 passed`) before
the source change. Baseline: `CanvasPanel.vue` raw_gray 25 → 21, the four
gray classes on the deleted note — the one entry edited in place, so no
unrelated drift is absorbed with it.

Closes #2734

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0176XEqK8PTCAK5K6yZURQLv
…it (#2693)

CodeQL raised four py/path-injection alerts on _install_bundled_avatar: the
template dir and agent name are already contained by
_resolve_local_template_dir and name validation, but the analyzer cannot
follow those callees. Normalize and prefix-check the final source and
destination paths inline, the same barrier routers/avatar.py uses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VKzc3MG4Fa1sJ1KuMmBtcV
…2764) — mechanical, per the merge-train note on the PR

#2777 passes steady_state=True to the same call; without **kw the stub
raises TypeError on the merged tree and the ent#346 negative control fails.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KtVjZEsxjyz2E4x5XdQg99
…) — mechanical, per the merge-train note on the PR

The body says the share link was NOT widened; the wire payload was. The
share payload now drops the field, pinned by a test.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KtVjZEsxjyz2E4x5XdQg99
# Conflicts:
#	docs/memory/feature-flows.md
#	tests/registry.json
@vybe vybe added the ui PR touches the frontend UI — triggers Playwright e2e tests label Sep 14, 2026
@vybe vybe closed this Sep 14, 2026
@vybe
vybe deleted the train/20260914-1332 branch September 14, 2026 14:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ui PR touches the frontend UI — triggers Playwright e2e tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants