fix(canvas): the canvas header renders two facts instead of a staleness verdict (#2734) - #2779
Conversation
…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
…) — 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
|
merge-train note — pushed one mechanical commit ( |
vybe
left a comment
There was a problem hiding this comment.
merge-train: batch validated on train/20260914-1332
Fixes #2734
Problem
The canvas header rendered
Updated just nowandmay be out of datein the sameline. The two were not in conflict by accident: the verdict was derived from the same
timestamp it contradicted.
staleasks "did a run of this agent finish after thecanvas was written", and the run that writes a canvas always completes after its own
write — so the writer marked its own output. The only evidence that could exclude it,
updated_by_execution_id, is optional and absent on most live canvases (18 canvases onthe reporting instance, 0 stamped, including the reported
acme-scout/main).The change
That census is from before this branch's base moved. #2619 and #2623 landed on
devwhile this was in review and added two more consumers of the same data; both are named
under Known limits below rather than folded into the claim.
What the code does:
agent_last_run_aton the payload. Server-derived from theMAX(schedule_executions.completed_at)read
decorate()already issues once per agent — zero new queries, on the same routes at thesame frequency. Normalised to Z-suffixed UTC at the read boundary (bug: schedule-triggered executions store naive started_at (no timezone suffix) — UI shows wrong relative time #1474 / Invariant #16):
MAX(completed_at)is a raw boundary that pass never covered, and a naive stored row(
2026-09-02 10:05:00, the pre-2ce62c6bshape) failed quiet in a lexicographic compare butwould be parsed by the browser as local time once rendered.
CanvasSummary, not merely set on the dict. Every operator canvas route carriesresponse_model=, which silently drops an undeclared key — the field would have shipped on thevoice panel and the portal (plain dicts) and vanished on Agent Detail, reading as a frontend bug.
staleis kept, computed exactly as today, and is no longer read by the header. It is retainedso the derivation stays recoverable, not because it is endorsed.
Updated {relative} · agent last ran {relative}in one<span>on everysurface that mounts
CanvasPanel.vue. Both facts are measured against the same injected clock,so the absolute readings may be off but their relationship — the only thing a reader judges —
cannot invert.
design-system-contract.md:47). The field is null bothwhen the agent has never finished a run and when the read failed, and the payload cannot tell those
apart, so the second fact is dropped rather than asserting either. The gate is
Date.parse, nottruthiness: this module's
relativeTimeanswers "at an unknown time", which would be a narratednon-fact.
now, cleared inonBeforeUnmount(the component mounts in three places).Agent Detail does not poll, so a frozen clock would keep claiming the agent "just ran" for hours.
The tick refreshes the string, not the payload, so it can only make the agent look less
recently active than it is.
basis-fullputs the line on its own row. The siblingh3isflex: 1 1 0%and contributesbasis 0 to line-breaking, so inline the span never wraps and the title truncates instead —
from ~35 characters to ~16 at 400 px, on every canvas.
Decisions recorded
Each of these is a decision the product owner made on 2026-09-14, stated with its consequence:
:titleand the note paragraph are deletedupdated_by_execution_idkeeps its existing single consumer (#2577'svisible_to_requester); (ii)–(vi) were not builttitleattribute at all, which is why design-system principle 22 (absolute time on hover) is deliberately unsatisfied here — a named exception, not an oversightstale→ (A) keep it computed as todayagent last ranreadjust now; that is accurate, and no special case suppresses itA malformed stored timestamp resolves to
nullwith a warning log, never a fabricatedor passed-through value — the same omission path as a failed read.
What the reader sees now
Before, on the reported
acme-scout/main:Updated just nowimmediately followed by theamber
may be out of datepill, plus a note paragraph below the header repeating it — thewriter's own run marking the canvas it had just written.
After, the same canvas (proof PNGs in
.plan/proof-2734/, Agent Detail and the Workspacerail, both themes): the header's first row is the title with the PDF and Share buttons, and
the second row reads
Updated 2d ago · agent last ran 40m agoin muted text. No pill, nonote paragraph, no tooltip, nothing that appears and disappears as a derived value flips.
Both facts are present unconditionally, or the second is absent entirely.
Security
The roster payload changes from a saturating boolean to an exact instant. Rostered
Workspace clients already received
stale— one bit derived from this same read, relativeto an
updated_atthey already held. They now receive the raw time, and it is pollable:db/canvas.pymaxes over allschedule_executionsrows for the agent, and a row iscreated by chat, MCP, agent-to-agent, schedules, loops, fan-out and other clients'
portal messages — so repeated reads by one rostered client reveal the arrival times of other
clients' work on a shared agent. That is an activity oracle, not a sharper boolean, and it is
stated at that size deliberately.
It is accepted by the owner as part of the design — the rendered copy is
agent last ran 40m ago. The value is server-derived (never agent-supplied; it comes fromschedule_executions, never fromblocks) and is rendered as Vue text interpolation, so itis DOM-escaped. No new endpoint, auth gate, input path or credential.
/cso --diffreturnedCLEAN with this as the one accepted LOW. If it is ever to be narrowed, the one-line shape is
to omit the field for
audience="roster"reads inclient_portal/agent_page.py— recorded asan available option, not a proposal.
Tests
Red on base first. Backend: 8 new tests,
8 failed / 54 passedbefore the source change(
KeyError: 'agent_last_run_at'). Frontend: 8 new,8 failed / 139 passed. Both independentlyreproduced by a second reviewer.
After (on the rebased tip):
pytest unit/test_ent438_agent_canvas.py unit/test_2577_canvas_visibility_hint.py unit/test_ent536_canvas_prompt_guidance.pypytest unit/test_ent553_… unit/test_ent554_… unit/test_ent555_… unit/test_ent537_…(dev's canvas suites)npm run test:unit(incl. the raw-color and loading-gate ratchets)CanvasPanel.vueraw_gray62 → 58, measured withscan-raw-colors.mjs; one entry edited in placeverify-local --skip-agenton the pre-rebase tipdf564aa6(projecttrinity-verify-df877582):status: pass— unit 15,555 passed / 31 skipped / 0 failed (726 s), integration 70 passed /13 skipped / 2 deselected (the registry's two known false-fails), build + boot green.
Running-backend proof (
.plan/proof-2734/backend-proof.md, taken against this branch's ownimage):
agent_last_run_atpresent in OpenAPI on all three models with itsField(description=…);a deliberately naive stored row (
2026-09-02 10:05:00) reached the payload as2026-09-02T10:05:00.000000Zwhileis_stalestill received the raw value; both failure arms(a raising read, an unparseable row) degraded to
Nonewithout an exception, each logged;GET /api/agents/{name}/canvasreturned 200 through the realresponse_modelfilter with thefield intact, and 404 — the enumeration-safe uniform one — without an ownership row.
The six standing
is_staleassertions are left passing on purpose. The function is retired inplace, not corrected: it still counts the writing run as a run "since", and its tests still pin that.
Changing them would assert a rule nothing consumes. One exception was in scope and is fixed: a test
named for the writing turn carried a fixture in the opposite orientation (write at
10:00:05,completion at
10:00:00— an order a writing run cannot produce), so it never pinned the writer caseat all. That inversion is why this bug shipped green. The docstring and name are corrected; the
assertion is untouched.
Revisit trigger (P12). Every chat turn creates a
schedule_executionsrow, so on an actively-usedagent the second fact may read
just nowon most renders. The pair still carries the signal(
Updated 5d ago · agent last ran just now), and the degenerate case is when both agree and arecorrect — but if the second fact reads
just nowon most canvas renders in practice, the fact torender instead is "N runs since this was written": still neutral, still no verdict.
Rebase note
Rebased from
2c5cfe0eonto61e9453cover #2619 (ent#553 delete/pin/search), #2623 (ent#554 sharelink + PDF) and #2628 (ent#555 open-canvas context), which rewrote the same header region. Six
conflicts, all textual — re-applied, never re-decided:
CanvasPanel.vueheader — the badge and the note paragraph deleted (ours), dev's PDF and Sharebuttons kept untouched, and the single freshness span placed after the buttons: a
basis-fullspan sitting straight after the
h3pushes the buttons onto a third row. Re-rendered to confirm —row 1 is
Pipeline · PDF · Share, row 2 is the freshness line, no third row.raw-color-baseline.json— dev's entry had already moved toraw_gray 62(its new buttons).Re-derived from the scanner to 58 (the note's four gray classes) and edited in place, so the
file's provenance block survives; no other entry moved.
canvasUtils.spec.js— git treats this file as binary (a literal NUL byte at ~line 94, apre-existing
safeLabelcontrol-character fixture ondev), so there is no 3-way merge. Hand-reconciled: dev's file, plus our one import and our
describeblock appended at the end, byte-for-byte from the pre-rebase commit. The NUL fixture is untouched.
models.py— dev'spinnedand ouragent_last_run_atboth kept, in that order.agent-canvas.md(user doc) — dev's rewritten bullets kept; our freshness sentence replaced theold "may be out of date" sentence inside them.
canvas_service.empty_canvas— dev'spinned: Falseand ouragent_last_run_at: Noneboth kept.Four small doc-truth repairs were folded into the commits they belong to, because the new base made
claims false that were true when written: three memory/user-doc sentences and one MCP tool description
said
stale"renders nowhere", which #2619's Manage row contradicts (see Known limits).Docs touched
Nine sites, per the plan's doc table:
requirements/core-agent.md(FR-5 restated — the age-thresholdrejection kept verbatim and extended one step),
feature-flows/agent-canvas.md(section retitled"Freshness — two facts, no verdict" + Change Log row),
architecture/observability.md(the stalenessbullet, keeping the
MAX-not-a-window sentence verbatim),docs/user-docs/agents/agent-canvas.md(user-facing copy),
mcp-server/src/tools/canvas.ts(bothexecution_iddescriptions were false —they promised a mark that no longer renders — plus one clause on
get_canvas/list_canvasessayingwhat the returned
staleis, since that was the only text that ever explained the field to an agent;third-surface sync, Invariant #13, descriptions only),
canvas_service.pymodule docstring,PortalRailCanvas.vueheader comment,canvasUtils.js::freshnessdocstring, and theCanvasPanel.vuetemplate comment.
Known limits
selectedprefers the detail fetch, and ent#475's watch keys on
updated_at, so a completed run that touchesno canvas does not refresh the value the header renders. The 60 s tick refreshes the string, not
the payload. This fails in the safe direction — it under-claims liveness, never over-claims —
and the fix is a metadata poll or a WS trigger on a surface outside this bug. Endorsed direction,
filed rather than built.
stalepill. feat(canvas): delete, pin, search and a stated bound for the canvas pile (ent#553) #2619 added a per-canvas pill inCanvasPanel.vue'sManage list that reads the payload flag we deliberately keep. It is not the header, it is a
different affordance ("which of these should I retire"), and removing it is feat(canvas): delete, pin, search and a stated bound for the canvas pile (ent#553) #2619's product call,
not this PR's. Named so the docs and this body stay honest: the verdict is gone from the header,
not from every pixel of the app.
SharedCanvas.vue's mark is now inert, and that page shows the first fact only. feat(canvas): share a canvas at a link, and download it as a PDF (ent#554) #2623's sharepage renders
<span v-if="fresh.stale">from the samefreshness()this PR rewrote, and thefunction no longer returns that key — so the span can never render (Vue treats
undefinedas false;no error). That matches the accepted design. Widening the second fact onto that page was not
done on purpose: the share link can be scoped "anyone with the link, no sign-in", so putting an exact
last-run instant there is a materially larger disclosure than the rostered-client widening reviewed
above — a new product/security decision, not a rebase. Filed.
no-new-tooltip decision. Its sibling
CanvasChart.vue:57does satisfy it, so one canvas can show achart timestamp with absolute-on-hover and a header with two relative times without. Revisit only if
the owner reopens that question.
Follow-ups
Listed here, not filed — for the gate to raise:
CanvasChart.vue:58-61's hand-rolledmetric may be stalepill — a primitives-over-lookalikesviolation 30 lines from this header, on an agent-supplied per-series flag (a different feature).
schedule_executionsrowafter a canvas write, and the seeder has an API token, not DB access.
chat_execution_service.py:301-308does not passexecution_idintoExecutionContextwhiletask_execution_service.py:1686does — a standing asymmetry between the two writers.patch_canvaserases an existing stamp whenexecution_idis omitted, degrading Canvas published with audience: operator is silently invisible to roster/public-link viewers #2577'svisible_to_requester.PortalDeliverables,CanvasChart's "as of",FileSharingPanel,LibrarySkillsSection,portalUtils.relativeTime's callers) — one cross-cuttingUI issue, not four unrelated components here.
CanvasPanelsurface.MAX(completed_at) WHERE agent_name = ?— pre-existing, paid today on thevoice panel's ~3 s poll; this diff adds zero query cost, and an index would drag both migration
tracks into a copy fix.
empty_canvasreturnsNoneforcreated_at/updated_at, whichmodels.pydeclares asnon-Optional
str. It works only becauserouters/voice.py:167has noresponse_model; adding one500s. Shape parity is not model validity.
canvasUtils.spec.jsmakes the file binary to git — no 3-way merge, everyconcurrent edit is a hand-reconcile.
PortalVoiceCanvas.vue:93(an unrelated fetch-failure message); it passes today because that string is outside the panel,
but it is a fragile coupling.
SharedCanvas.vueand the Manage pill (see Known limits) — decide whether the share pageshould carry the second fact, and whether feat(canvas): delete, pin, search and a stated bound for the canvas pile (ent#553) #2619's pill outlives the header verdict.
.claude/agents/test-runner.mdcatalog row — edited but uncommitted (the submodule isdetached in this worktree).
recorded above.
🤖 Generated with Claude Code