Skip to content

fix(canvas): the canvas header renders two facts instead of a staleness verdict (#2734) - #2779

Merged
vybe merged 4 commits into
devfrom
vybe/issue-2734
Sep 14, 2026
Merged

vybe merged 4 commits into
devfrom
vybe/issue-2734

Conversation

@trinity-ability

Copy link
Copy Markdown
Contributor

Fixes #2734

Problem

The canvas header rendered Updated just now and may be out of date in the same
line. The two were not in conflict by accident: the verdict was derived from the same
timestamp it contradicted. stale asks "did a run of this agent finish after the
canvas 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 on
the reporting instance, 0 stamped, including the reported acme-scout/main).

The change

The verdict badge was removed by product decision, not deferred. The canvas header now renders two neutral facts — Updated 2h ago · agent last ran 40m ago — and Trinity derives no staleness verdict from them. This was decided by the product owner on 2026-09-14 after review challenged the issue's own framing: the reported contradiction ("Updated just now" beside "may be out of date") is not a bug inside the derived mark but a consequence of deriving one at all, since the writing run always completes after its own write and the evidence needed to exclude it — updated_by_execution_id — is optional and absent on most live canvases. The decision rewrites the issue's AC 1, AC 4 and AC 5, which required the mark to exist and specified its copy; the issue text is updated separately by the owner. AC 3 (the timestamp is always rendered) and AC 6 (one rendering layer) stand and are both proved here — AC 3 by construction (both facts are unconditional), AC 6 by census, stated at its true scope — one renderer for the canvas header's freshness line: CanvasPanel.vue is that single renderer, canvasUtils.freshness() the single rule, and the three surfaces (AgentCanvasTab.vue, PortalRailCanvas.vue, PortalVoiceCanvas.vue) are thin mounts differing only in the injected fetchDetail and viewer. Two nearby surfaces are named rather than absorbed into that claim: CanvasChart.vue:52-62 renders its own per-series freshness line and metric may be stale pill inside a canvas (a different, agent-supplied feature — deliberately untouched), and PortalVoiceCanvas.vue:26-30 renders a Canvas may be out of date fetch-failure message in the column chrome above the panel (an error path, deliberately untouched). No stamp-dependent logic was built (OPEN-2 = (i)) and no tooltip was added (OPEN-3 = (A)). The agent's last-run time now reaches rostered Workspace clients as an exact timestamp where they previously received a boolean over the same read — an accepted widening, since that fact is the feature.

That census is from before this branch's base moved. #2619 and #2623 landed on dev
while 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_at on the payload. Server-derived from the MAX(schedule_executions.completed_at)
    read decorate() already issues once per agent — zero new queries, on the same routes at the
    same 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-2ce62c6b shape) failed quiet in a lexicographic compare but
    would be parsed by the browser as local time once rendered.
  • Declared on CanvasSummary, not merely set on the dict. Every operator canvas route carries
    response_model=, which silently drops an undeclared key — the field would have shipped on the
    voice panel and the portal (plain dicts) and vanished on Agent Detail, reading as a frontend bug.
  • stale is kept, computed exactly as today, and is no longer read by the header. It is retained
    so the derivation stays recoverable, not because it is endorsed.
  • The header renders Updated {relative} · agent last ran {relative} in one <span> on every
    surface 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.
  • Absence is omission, never narration (design-system-contract.md:47). 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 the second fact is dropped rather than asserting either. The gate is Date.parse, not
    truthiness: this module's relativeTime answers "at an unknown time", which would be a narrated
    non-fact.
  • A 60 s tick drives now, cleared in onBeforeUnmount (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-full puts the line on its own row. The sibling h3 is flex: 1 1 0% and contributes
    basis 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:

Decision Consequence carried here
OPEN-1 → accept the review's challenge — render two neutral facts, no verdict AC 1/4/5 rewritten; the badge, its :title and the note paragraph are deleted
OPEN-2 → (i) behave as today No stamp-dependent logic was added. updated_by_execution_id keeps its existing single consumer (#2577's visible_to_requester); (ii)–(vi) were not built
OPEN-3 → (A) keep it No new tooltip. The header carries no title attribute at all, which is why design-system principle 22 (absolute time on hover) is deliberately unsatisfied here — a named exception, not an oversight
stale → (A) keep it computed as today Zero change to any API or MCP response shape; the field keeps its writer-includes-itself semantics, unread by the header
Voice-written canvas → (i) leave it The voice column shows what the data says. During a drawing turn the agent's own run is what makes agent last ran read just now; that is accurate, and no special case suppresses it

A malformed stored timestamp resolves to null with a warning log, never a fabricated
or 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 now immediately followed by the
amber may be out of date pill, plus a note paragraph below the header repeating it — the
writer's own run marking the canvas it had just written.

After, the same canvas (proof PNGs in .plan/proof-2734/, Agent Detail and the Workspace
rail, 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 ago in muted text. No pill, no
note 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, relative
to an updated_at they already held. They now receive the raw time, and it is pollable:
db/canvas.py maxes over all schedule_executions rows for the agent, and a row is
created 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 from
schedule_executions, never from blocks) and is rendered as Vue text interpolation, so it
is DOM-escaped. No new endpoint, auth gate, input path or credential. /cso --diff returned
CLEAN 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 in client_portal/agent_page.py — recorded as
an available option, not a proposal.

Tests

Red on base first. Backend: 8 new tests, 8 failed / 54 passed before the source change
(KeyError: 'agent_last_run_at'). Frontend: 8 new, 8 failed / 139 passed. Both independently
reproduced by a second reviewer.

After (on the rebased tip):

Command Result
pytest unit/test_ent438_agent_canvas.py unit/test_2577_canvas_visibility_hint.py unit/test_ent536_canvas_prompt_guidance.py 118 passed
pytest unit/test_ent553_… unit/test_ent554_… unit/test_ent555_… unit/test_ent537_… (dev's canvas suites) 106 passed
npm run test:unit (incl. the raw-color and loading-gate ratchets) 2853 passed, 128 files
raw-color baseline, CanvasPanel.vue raw_gray 62 → 58, measured with scan-raw-colors.mjs; one entry edited in place

verify-local --skip-agent on the pre-rebase tip df564aa6 (project trinity-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 own
image): agent_last_run_at present in OpenAPI on all three models with its Field(description=…);
a deliberately naive stored row (2026-09-02 10:05:00) reached the payload as
2026-09-02T10:05:00.000000Z while is_stale still received the raw value; both failure arms
(a raising read, an unparseable row) degraded to None without an exception, each logged;
GET /api/agents/{name}/canvas returned 200 through the real response_model filter with the
field intact, and 404 — the enumeration-safe uniform one — without an ownership row.

The six standing is_stale assertions are left passing on purpose. The function is retired in
place
, 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 case
at 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_executions row, so on an actively-used
agent the second fact may read just now on 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 are
correct — but if the second fact reads just now on most canvas renders in practice, the fact to
render instead is "N runs since this was written": still neutral, still no verdict.

Rebase note

Rebased from 2c5cfe0e onto 61e9453c over #2619 (ent#553 delete/pin/search), #2623 (ent#554 share
link + PDF) and #2628 (ent#555 open-canvas context), which rewrote the same header region. Six
conflicts, all textual — re-applied, never re-decided:

  • CanvasPanel.vue header — the badge and the note paragraph deleted (ours), dev's PDF and Share
    buttons kept untouched, and the single freshness span placed after the buttons: a basis-full
    span sitting straight after the h3 pushes 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 to raw_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, a
    pre-existing safeLabel control-character fixture on dev), so there is no 3-way merge. Hand-
    reconciled: dev's file, plus our one import and our describe block appended at the end, byte-for-
    byte from the pre-rebase commit. The NUL fixture is untouched.
  • models.py — dev's pinned and our agent_last_run_at both kept, in that order.
  • agent-canvas.md (user doc) — dev's rewritten bullets kept; our freshness sentence replaced the
    old "may be out of date" sentence inside them.
  • canvas_service.empty_canvas — dev's pinned: False and our agent_last_run_at: None both 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-threshold
rejection 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 staleness
bullet, keeping the MAX-not-a-window sentence verbatim), docs/user-docs/agents/agent-canvas.md
(user-facing copy), mcp-server/src/tools/canvas.ts (both execution_id descriptions were false —
they promised a mark that no longer renders — plus one clause on get_canvas/list_canvases saying
what the returned stale is, since that was the only text that ever explained the field to an agent;
third-surface sync, Invariant #13, descriptions only), canvas_service.py module docstring,
PortalRailCanvas.vue header comment, canvasUtils.js::freshness docstring, and the CanvasPanel.vue
template comment.

Known limits

  • [I1] The rendered run time cannot advance without a canvas rewrite, on any surface. selected
    prefers the detail fetch, and ent#475's watch keys on updated_at, so a completed run that touches
    no 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.
  • The Manage row still renders a stale pill. feat(canvas): delete, pin, search and a stated bound for the canvas pile (ent#553) #2619 added a per-canvas pill in CanvasPanel.vue's
    Manage 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 share
    page renders <span v-if="fresh.stale"> from the same freshness() this PR rewrote, and the
    function no longer returns that key — so the span can never render (Vue treats undefined as 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.
  • Design-system principle 22 (absolute time on hover) is deliberately unsatisfied, per the
    no-new-tooltip decision. Its sibling CanvasChart.vue:57 does satisfy it, so one canvas can show a
    chart 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:

  1. CanvasChart.vue:58-61's hand-rolled metric may be stale pill — a primitives-over-lookalikes
    violation 30 lines from this header, on an agent-supplied per-series flag (a different feature).
  2. An e2e proving the second fact renders a real value — needs a completed schedule_executions row
    after a canvas write, and the seeder has an API token, not DB access.
  3. chat_execution_service.py:301-308 does not pass execution_id into ExecutionContext while
    task_execution_service.py:1686 does — a standing asymmetry between the two writers.
  4. patch_canvas erases an existing stamp when execution_id is omitted, degrading Canvas published with audience: operator is silently invisible to roster/public-link viewers #2577's
    visible_to_requester.
  5. The relative-time freeze on every other surface (PortalDeliverables, CanvasChart's "as of",
    FileSharingPanel, LibrarySkillsSection, portalUtils.relativeTime's callers) — one cross-cutting
    UI issue, not four unrelated components here.
  6. [I1] widen the metadata refresh to every CanvasPanel surface.
  7. No covering index for MAX(completed_at) WHERE agent_name = ? — pre-existing, paid today on the
    voice panel's ~3 s poll; this diff adds zero query cost, and an index would drag both migration
    tracks into a copy fix.
  8. empty_canvas returns None for created_at/updated_at, which models.py declares as
    non-Optional str. It works only because routers/voice.py:167 has no response_model; adding one
    500s. Shape parity is not model validity.
  9. Design-system principle 22 left unsatisfied by decision (above).
  10. The NUL byte in canvasUtils.spec.js makes the file binary to git — no 3-way merge, every
    concurrent edit is a hand-reconcile.
  11. The e2e negative assertion substring-matches copy that also lives at PortalVoiceCanvas.vue:93
    (an unrelated fetch-failure message); it passes today because that string is outside the panel,
    but it is a fragile coupling.
  12. SharedCanvas.vue and the Manage pill (see Known limits) — decide whether the share page
    should 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.
  13. One owed .claude/agents/test-runner.md catalog row — edited but uncommitted (the submodule is
    detached in this worktree).
  14. For the human: update bug(canvas): "Updated just now" and "may be out of date" shown together — the run that wrote the canvas marks its own output stale #2734's AC 1 / AC 4 / AC 5 text to the accepted design, per the decision
    recorded above.

🤖 Generated with Claude Code

trinity-ability and others added 3 commits September 14, 2026 12:18
…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
@vybe
vybe marked this pull request as ready for review September 14, 2026 13:16
…) — 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
@vybe

vybe commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

merge-train note — pushed one mechanical commit (2f945e642) to this branch. The body says widening agent_last_run_at onto the share link was not decided, but public_view_payload returned decorate()'s canvas unfiltered, so a public-scope link answered a stranger with the exact last-run instant (routers/public.py GET /canvas/{token} has no response_model). canvas_share_service.public_view_payload now drops the field, pinned by test_the_shared_payload_does_not_carry_the_agents_last_run_time (30/30 pass). Also noting for a follow-up, not changed: CanvasDocument.vue:22 still reads the retired fresh.stale (same inert branch as SharedCanvas.vue:44, which the body names).

@vybe vybe left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

merge-train: batch validated on train/20260914-1332

@vybe
vybe merged commit 8e70e20 into dev Sep 14, 2026
30 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants