Skip to content

feat(canvas): the open canvas is shared context for the turn (ent#555) - #2628

Merged
vybe merged 39 commits into
devfrom
feature/555-open-canvas-context
Sep 14, 2026
Merged

vybe merged 39 commits into
devfrom
feature/555-open-canvas-context

Conversation

@dolho

@dolho dolho commented Sep 8, 2026 •

Copy link
Copy Markdown
Contributor

Fixes abilityai/trinity-enterprise#555

What

When a user with a canvas on screen says "add a column to this", the agent now knows which canvas they mean.

Before this the turn carried the message and nothing about the surface around it, so the agent asked, guessed, or minted a new canvas beside the one being looked at.

Stacked on feature/554-canvas-share-export (ent#554), which is stacked on feature/553-canvas-lifecycle (ent#553).

How

A per-turn context field — schedule_executions.open_canvas_id, the same shape as the source_channel* columns beside it — stamped at dispatch and read back by the tools and the prompt.

It is context, never authority. Two independent halves keep it there:

half decides cannot
client_portal.service.validated_open_canvas what may be stamped grant anything — it only ever narrows a client-supplied id
canvas_service.effective_canvas_id what a tool acts on widen reach — every read/write still passes the ownership and audience gates

The id is client-supplied, so it is validated at the boundary against the agent's own canvases and against what that caller can see:

  • an operator-only canvas is invisible to an external client — otherwise the field is an existence oracle for canvases the agent keeps privately
  • another agent's canvas is refused outright
  • malformed / nonexistent → nothing open

Every failure degrades to "nothing open". Never an error, never a wider reach.

Precedence, stated once so all three tools agree:

explicit canvas_id  >  the canvas the user has open  >  the default canvas

effective_canvas_id returns why as well as which, because with nothing named the agent has to be able to say which canvas it wrote to — "I updated the canvas" is not good enough when there are eight and the user is looking at one (AC #7).

Two delivery paths, both required. The MCP tools resolve a missing canvas_id through GET /api/agents/{name}/canvas/context (declared above /{canvas_id} — Invariant #4, since context is a valid id shape), and the turn prompt names the open canvas. A tool default handles a call that omits an id — but an agent must READ a canvas before editing it, and cannot read what it cannot name. The prompt line rides the same prefix as the file manifest, so it is present on a resumed turn too: the open canvas changes between turns while the session's memory of it does not.

A canvas deleted mid-conversation resolves to nothing, re-checked at read time rather than trusted from the stamp. ent#553 made deleting one click, and a surviving id would have the agent's next write CREATE a canvas under it — silently resurrecting something a person deleted.

Voice (AC #5) is satisfied by construction, not by wiring. ent#440 submits a spoken utterance through the same deliver() a typed one takes, so the stamp is already on it; a second voice path would be a second thing to keep in sync. The canvas tools in services/gemini_voice.py are deliberately untouched — that is VOICE-001's ephemeral display panel, a different surface with no persisted id and nothing addressable by canvas_id.

Verified

validation (what may be stamped)
  client, roster canvas   -> open-items
  client, OPERATOR canvas -> None      (invisible to a client)
  platform, operator one  -> secret
  ANOTHER agent's canvas  -> None      (cross-agent refused)
  nonexistent / malformed -> None

resolution (what a tool acts on)
  explicit id wins        -> ('q3-review', 'explicit')
  no id -> the open one   -> ('open-items', 'open')
  no execution -> default -> ('main', 'default')
  foreign execution       -> ('main', 'default')
  open canvas deleted     -> ('main', 'default')
  • tests/unit/test_ent555_open_canvas_context.py — 23 tests
  • canvas + portal + ent286 + ent287 backend suites: 641 passed
  • frontend: 2346 passed (105 files)
  • tsc --noEmit clean on the MCP server
  • check_alembic_heads.py — 61 revisions, 1 head (0060_execution_open_canvas)
  • raw-color and loading-gate ratchets clean

Migration

Dual-track: execution_open_canvas (SQLite) + 0060_execution_open_canvas (Alembic). The column is nullable, so every existing row and every un-updated caller reads as "nothing open" — additive, no backfill.

Related to Abilityai/trinity-enterprise#555

🤖 Generated with Claude Code

https://claude.ai/code/session_01CdxGmvuKuqaWJZ6pKUgaUJ

dolho and others added 6 commits September 8, 2026 15:21
An agent that uses its canvas the way ent#438 intends accumulates dozens: one
per report, per topic, per run. The Workspace could only ever ADD to that pile
— the client-portal surface had no delete at all, the only ordering was "newest
updated", and nothing bounded the table.

Two decisions, both by operator ruling 2026-09-08, recorded because each had a
plausible alternative:

**Deleting is owner-or-admin.** The answer ent#548 gives for files — the owner
deletes the shared artifact. This NARROWS the platform DELETE route, which
accepted any user with agent access; safe because no UI called it, so no
workflow depended on the wider gate. A canvas is one shared surface with no
per-user copy, so a non-owner has no "hide it from my list" middle ground: per
AC #2 they see no control at all rather than one that 403s. Agents keep
clearing their own (`clear_canvas`, the #918 self-gate).

**The bound is a per-agent CAP, not a retention window.** ent#438 recorded "no
retention window" because the composite key bounds rows per canvas — but
`canvas_id` is agent-chosen, so the COUNT was unbounded; the axis was missed,
not decided. `CANVAS_MAX_PER_AGENT` (100, env-tunable) is checked inside
`upsert_canvas`'s insert branch, in the same transaction as the INSERT, so it
is not a check-then-act race. Updating an existing canvas is NEVER refused — a
cap that froze updates would punish exactly the agent that reuses ids — and the
refusal is a named 409 telling the agent to retire one, never an eviction:
deleting a person's surfaces on a timer is the #1638 failure direction.

Both surfaces resolve permission through `db.can_user_share_agent`, the same
predicate `assert_agent_owner` uses, so Agent Detail and the Workspace cannot
disagree about who owns an agent. The Workspace learns it from
`PortalAgentCard.can_manage_canvases` — the portal's only capability channel
(#2128), since a portal principal cannot read `/api/settings/feature-flags` —
and it fails closed.

`pinned` (dual-track: `agent_canvases_pinned` + Alembic 0058, NOT NULL DEFAULT
0, no backfill) is written only by the human pin route and is deliberately
absent from every agent-facing tool: `audience` is the agent's decision about
who may read, `pinned` is the reader's about what they see first, and an agent
that could pin itself to the top would defeat the ordering. A pin survives the
agent rewriting the canvas.

Living with many is `CanvasPanel.vue`, shared by both surfaces (one rendering
layer, per ent#475): search once the list passes six, a height-bounded strip so
a long list does not cost the rail its other tabs, and a Manage mode giving
each row its age, stale mark, pin and delete. Decidable rules are pure in
`canvasUtils.js` — vitest runs `environment: 'node'` with no mount harness, so
a rule inside the SFC is one no test can reach.

Bulk delete is a POST, not a body-carrying DELETE (bodies on DELETE are
permitted-but-unreliable and this one is not optional), declared above the
parameterized routes on both routers (Invariant #4), and it reports the ids
that EXISTED rather than the ids requested so "3 of 5 removed" is sayable.

Three pre-existing guards failed and each was right to: `empty_canvas` was
missing the new field (a real bug in this change, fixed), the self-gate guard
needed to learn the new gate's name, and the positional-read guard needed its
synthetic row extended — that one exists precisely because `_row_to_summary`
reads by index.

Tests: 20 new backend cases (cap refusal + update-at-cap, pin ordering and
survival, bulk scoping, the permission matrix on both surfaces, route ordering,
dual-track migration parity, and that the MCP tools cannot pin) and ~24 vitest
cases for the pure rules. Verified against a real database: the cap refuses the
4th of 3, updates still succeed at the cap, a pin outranks recency and survives
a rewrite, and bulk delete returns only the ids that existed.

Related to Abilityai/trinity-enterprise#553

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CdxGmvuKuqaWJZ6pKUgaUJ
…ment the lifecycle

Closes the three acceptance criteria the first commit left open:

* AC #1 asked for the deletion to be audited and only the BULK route was —
  the single-canvas route is the one a person actually clicks. Logged only
  when something was removed, since the route is idempotent and a repeat
  click would otherwise fill the trail with events where nothing happened.
* AC #8: deleting the DEFAULT canvas is allowed, comes back empty on the
  next write, and frees a slot against the cap. `main` is the id both the
  MCP tools and the voice panel fall back to, so it is the one most likely
  to be deleted by accident and the one whose deletion must strand nobody.
* AC #9: the user doc gains a "Removing canvases" section stating the
  permission rule, the cap, and that nothing is ever deleted to make room.

Also records a latent pre-existing mismatch found while testing:
`empty_canvas` returns None timestamps while `models.Canvas` requires
strings, so `Canvas(**empty_canvas(...))` raises. Not live — its only
caller declares no `response_model` — but adding one there would turn the
voice teardown poll into a 500. Left as a comment where the next person to
reach for that will meet it, rather than fixed out of scope.

Related to Abilityai/trinity-enterprise#553

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CdxGmvuKuqaWJZ6pKUgaUJ
A canvas is where an agent's real output lives, and until now it could not
leave the Workspace: no share link, no export, nothing in the tree that renders
a PDF.

**Sharing never widens the audience by accident.** Two scopes, and the default
is the narrow one: `authorized` makes the link a DEEP link — opening it
requires signing in and the server re-checks `can_user_access_agent`, so it
reaches "the people who could already see it" and nobody else. `public` is an
explicit, separate, audited choice. Failing narrow is enforced in five
independent places (column default, Pydantic default, `normalize_scope`'s
fallback, the order of `SHARE_SCOPES`, the radio the dialog preselects),
because a link that reaches further than the sharer understood is the one
failure this feature must not have.

**`agent_canvas_shares` is deliberately its own table.** `agent_public_links`
has a `type` column that looks purpose-built for this, and reusing it would
have been a real vulnerability: nothing in that table's read path filters on
type — `get_public_link_by_token`, `is_link_valid` and
`routers/public.py::_validate_public_link` all resolve a token whatever it is —
so a canvas row there would ALSO be a working public-CHAT token, and anyone
sent a canvas could talk to the agent. (`type='site'` is the same trap already
laid; it is unexploited only because nothing creates those rows today.) A
separate table makes the isolation structural instead of dependent on every
consumer remembering to check.

**Live, and it says so** (AC #3, operator ruling): the link renders the canvas
as it is now, carrying its `updated_at` and stale mark, and the page states it
is not a copy taken at share time. That follows ent#438's model — a canvas is a
surface an agent keeps current — and means a share stores nothing. The cost is
drift after sharing; the mitigation is revocation, not freezing.

**Revocation keeps the row.** `revoked_at` is stamped, never deleted, because a
revoked link has to be able to SAY it was revoked (AC #2), which it cannot do
once the row is gone. The status vocabulary splits along disclosure: `revoked`
and `expired` are returned only for a token that MATCHED a row — whoever holds
such a link was already told the canvas exists — while an unknown token and a
canvas deleted out from under a link both collapse into one `not_found`, so a
stranger guessing tokens learns nothing from the difference. An unparseable
`expires_at` reads as expired: a lifetime we cannot read is one we cannot
promise is live.

**PDF is print-first**, the path the issue recommended: a print stylesheet plus
the browser's own PDF. No headless service to run, and — the deciding reason —
no second renderer to keep in step with `CanvasBlock`. A server-side renderer
was the stated fallback and was not needed: pagination (`break-inside: avoid`
per block) and fidelity both fall out of the same markup the screen uses.
`CanvasDocument.vue` is the one printable form, rendered by both the shared
page and every authenticated surface, so AC #7's "identical from every canvas
surface" is true by construction rather than by three surfaces agreeing. It
carries title, agent and generation date, forces the light rendering under
`@media print`, and when `window.print` is unavailable the control says so and
the share link still works.

`ent#425` (hosted deliverable pages) is still open, so per this issue's own
boundary rule this ships the narrower share link and #425 adopts it later.

Verified against a real database, not stubs: the full resolution matrix
(public/anonymous → ok, authorized/anonymous → sign-in, authorized/owner → ok,
authorized/stranger → refused, unknown → not-found, revoked, expired,
unparseable expiry → expired, canvas deleted → not-found), views counted only
on a successful render, cross-agent revoke refused, and a second revoke keeping
the first revocation time.

23 backend tests, 20 vitest cases for the pure rules.

Related to Abilityai/trinity-enterprise#554

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CdxGmvuKuqaWJZ6pKUgaUJ
…he test imports it

Two ent#553 tests passed in isolation and failed in a full-suite run:
`test_the_cap_refuses_a_new_canvas_by_name` and
`test_deleting_the_default_canvas_frees_a_slot_against_the_cap`.

Order-dependence, not a defect in the feature. They patched
`db.canvas.CANVAS_MAX_PER_AGENT` via a fresh `import db.canvas`. Some earlier
test in the suite evicts that module from `sys.modules`, so the fresh import
hands back a NEW module object while the live `db._canvas_ops` is still an
instance of the OLD class — whose `upsert_canvas` reads the OLD module's
globals. The patch lands somewhere nothing consults, the cap stays at its
default of 100, and the "refuses the 4th of 3" assertions fail.

`_set_cap` patches the bound method's own `__globals__`, which is whichever
module dict the running code actually closes over — correct whether or not an
eviction happened, so it does not depend on knowing which test pollutes.

Same failure and same fix as #2589, where the identical shape bit
`mark_stale_activities_failed`. Worth noting the class: a monkeypatch on a
module attribute is only as good as the assumption that the live object came
from that module object, and in a suite that evicts modules that assumption is
not free.

The feature is unchanged — this touches only the test file.

Related to Abilityai/trinity-enterprise#553

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CdxGmvuKuqaWJZ6pKUgaUJ
Reported from hands-on testing: pressing PDF offered to export the entire page.
Correct report — the print stylesheet only STYLED the document and never hid
anything else, so `window.print()` printed the nav bar, the tabs, the
on-screen panel AND the print copy. That is not "one clean column" by any
reading (AC #4), and it is the first thing anyone pressing the button hits.

Two halves, both required:

* a print rule that hides every `body` child except `.canvas-print-root`;
* the print copy TELEPORTED to <body>, so it is a body child and the rule can
  spare it. Nested inside the app the rule would hide its ancestor and print
  nothing at all — worse than the bug.

`body > *` rather than a class on the app root: it needs no knowledge of how
the app is mounted and works identically on the standalone shared page, which
keeps AC #7's "identical from every surface" true rather than approximately
true. The copy is rendered only while printing (`v-if="printing"` + a
`nextTick` flush before `print()`, since printing a not-yet-rendered teleport
yields a blank sheet), so the DOM carries no permanent hidden duplicate.

`canvasPrintIsolation.spec.js` pins all three structural facts. Nothing
automated can inspect a print preview, which is exactly why the bug shipped —
so the guard asserts the mechanism instead: the hiding rule exists, the root is
teleported to body, and the document mounts before print() is called.
Mutation-tested: removing the hiding rule fails it.

Also fixes a design-system violation the ratchet caught in the same file:
`SharedCanvas` gated its skeleton on a bare `v-if="loading"`. Now
`viewState()` — loading means "no data yet", never "a fetch is in flight"
(#1927, design-system p13-p15). The page fetches once today, so this is the
rule holding rather than a bug fixed; it stays correct if a refresh is added.
Baselining my own new violation was the alternative and would have been the
wrong one.

Related to Abilityai/trinity-enterprise#554

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CdxGmvuKuqaWJZ6pKUgaUJ
When a user with a canvas on screen says "add a column to this", the agent now
knows which canvas they mean. Before this the turn carried the message and
nothing about the surface around it, so the agent asked, guessed, or minted a
new canvas beside the one being looked at.

The mechanism is a per-turn context field — `schedule_executions.open_canvas_id`,
the same shape as the `source_channel*` columns beside it — stamped at dispatch
and read back by the tools and the prompt.

It is CONTEXT, never AUTHORITY, and two independent halves keep it there:

  - `validated_open_canvas` decides what may be STAMPED. The id is
    client-supplied, so it is checked against the agent's own canvases and
    against what that caller can see: an operator-only canvas is invisible to an
    external client (otherwise the field is an existence oracle for canvases the
    agent keeps privately), and another agent's canvas is refused outright.
    Every failure degrades to "nothing open" — never an error, never a wider
    reach.
  - `effective_canvas_id` decides what a tool ACTS on, and cannot widen
    anything: every read and write still passes the existing ownership and
    audience gates.

Precedence is stated once so all three tools agree:
`explicit canvas_id > the canvas the user has open > the default canvas`. It
returns WHY as well as WHICH, because with nothing named the agent has to be
able to say which canvas it wrote to — "I updated the canvas" is not good enough
when there are eight and the user is looking at one.

Both delivery paths are needed, not one. The MCP tools resolve a missing
`canvas_id` through `GET /api/agents/{name}/canvas/context` (declared above
`/{canvas_id}` — Invariant #4, since "context" is a valid id shape), AND the
turn prompt names the open canvas: a tool default handles a call that omits an
id, but an agent must READ a canvas before editing it and cannot read what it
cannot name. The prompt line rides the same prefix as the file manifest, so it
is present on a resumed turn too — the open canvas changes between turns while
the session's memory of it does not.

A canvas deleted mid-conversation resolves to nothing, re-checked at read time
rather than trusted from the stamp: ent#553 made deleting one click, and a
surviving id would have the agent's next write CREATE a canvas under it,
silently resurrecting something a person deleted.

Voice inherits this by construction — ent#440 submits a spoken utterance through
the same `deliver()` a typed one takes. The `canvas` tools in `gemini_voice.py`
are deliberately untouched: that is VOICE-001's ephemeral display panel, a
different surface with no persisted id.

Dual-track migration (`execution_open_canvas` + Alembic `0060`); the column is
nullable, so every existing row and every un-updated caller reads as "nothing
open".

Related to Abilityai/trinity-enterprise#555

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CdxGmvuKuqaWJZ6pKUgaUJ
@dolho
dolho requested a review from vybe September 9, 2026 08:13
dolho and others added 5 commits September 9, 2026 11:15
…eachable

Two review items from #2619.

**Alembic head fork (#2068 class).** `0058_agent_canvases_pinned` shared
`down_revision = 0057` with #2608's `0058_portal_file_dismissals`, which has
since landed on `dev` — two heads, and `alembic upgrade head` resolves its
single target before applying anything, so EVERY revision merged since the
fork stops arriving, not just one. Re-parented onto
`0058_portal_file_dismissals` and renumbered to `0059` so the prefix keeps
being a usable ordering cue; the id is not applied anywhere yet, so the
rename costs nothing. `check_alembic_heads.py` reports 1 head.

**`CANVAS_MAX_PER_AGENT` was inert (#1039 class).** The refusal message names
the number, but the variable was read only from `os.getenv` in `models.py` and
appeared in no compose file — so an operator following the refusal's own advice
would raise a lever that never reaches the container. Wired into
`docker-compose.yml`, `.prod.yml` and `.hosted.yml` (the last two launch
standalone, no base merge / no `env_file`) plus `.env.example`.

Related to #2619

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

# Conflicts:
#	src/backend/client_portal/service.py
#	src/backend/models.py
#	src/backend/routers/canvas.py
#	src/backend/services/canvas_service.py
…t#553

The raw-colour ratchet became enforceable on dev while this branch was
open (#2605/#2609), and the merge brings it here: this PR's delete/pin/
search chrome takes `components/canvas/CanvasPanel.vue` from 25 to 46
`raw_gray`, so `tests/unit/rawColorRatchet.spec.js` fails the frontend
build.

That growth is the honest kind. The design-system contract SPELLS the
neutral ink ladder as `gray-N` — surfaces gray-50/100/800/900, borders
gray-200/300/700/800, ink gray-300/400/500/600 — and there is no
semantic token for a neutral, which is exactly why the spec's own
comment says gray is ratcheted but never held to zero for new files.
The rule it does hold new code to is `raw_nongray`, and this file stays
at **0**.

Re-frozen in its OWN commit with the increase named in the baseline's
`refrozen` block, which is what the ratchet's error message asks for —
not absorbed silently into the feature diff. The entry is hand-edited
rather than regenerated so #2605's provenance block survives; no other
file's ceiling moves (verified: nothing grew, nothing is stale, no
un-baselined file carries `raw_nongray`).

Related to #553

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bd71qsYbFodvofba8P69eP
… on ownership (ent#553)

Three review findings, all in the same direction — the backend was right and
the user-facing half did not arrive — plus the two smaller ones.

1. **The stated bound now reaches the user.** `canvasLimit` was a `ref(0)`
   nothing ever assigned, so `canvasHeadroom(n, 0)` returned `{label: null}` and
   the early warning could not render at any count. The ceiling rides
   `GET /api/settings/feature-flags` as `canvas_max_per_agent` — the established
   home for a value the browser needs to render a surface, and where
   `platform_default_model` / `install_source` already set the precedent for a
   non-boolean. Not a new route (Invariant #13 would owe three surfaces for one
   integer) and not an envelope around the canvas list (the MCP tool and the
   Workspace both read it as a bare array). It is a CONSTANT, not per-agent
   state, and the client already holds the count. `0` still means "not told" and
   still renders nothing, so an older backend is unchanged.

2. **The Workspace canvas writes are audited.** The three portal routes recorded
   nothing while their operator twins have logged since they shipped, and
   `docs/user-docs/agents/agent-canvas.md` tells users deletion is audited — so
   the claim was false for exactly the client-facing surface. `_audit_canvas_change`
   is the shared helper; the actor is `actor_email` (the documented #848
   inline-auth path) rather than a fabricated `User`, which is honest because
   `_require_canvas_manager` is platform-only and owner-or-admin, so a real
   Trinity user is always behind it. Ids and counts only (G-04). The three
   routes become `async def` to await it, matching their operator twins, which
   already call the same sync db functions from an async handler.

   Pinning is audited too, on BOTH surfaces — the operator route was the one
   recording nothing. A pin decides which canvas an entire roster sees first, so
   it is an administrative act on a shared surface, not a per-viewer preference.

3. **`canManage` comes from the parent.** It was hardcoded `true` on the
   argument that the server decides. It does — but a merely-shared user was then
   shown Manage → Delete / Pin and got a 403, which is the failing-control
   problem `can_manage_canvases` exists to prevent on the Workspace. Agent
   Detail passes `agent.can_share`, the same predicate `ReportsPanel` five lines
   above already reads and the same one `_gate_human_removal` enforces. The prop
   defaults FALSE, so a caller that forgets it hides an affordance rather than
   offering one that refuses.

4. **`get_agent_card` resolves `can_manage_canvases`.** It omitted it, so the
   same owner read `true` in the sidebar and `false` on the agent's own page —
   the disagreement #2160's own docstring says that function exists to prevent.

5. **An agent genuinely cannot pin its own canvas now.** The user doc said so;
   `_gate_human_removal` allowed it (right for delete — an agent tidying up
   after itself — and wrong for pin), and "no MCP tool exposes it" is a property
   of the client, not of the route. `_gate_pin` is humans-only, which makes the
   documented sentence true rather than aspirational.

Tests: the audit guard now walks the portal routes as well as `routers.canvas`
(it only ever inspected the latter, which is why three unaudited routes passed
it), plus pin-audit parity, the humans-only pin gate beside the still-permitted
agent self-delete, the feature-flags constant being the same object the refusal
is raised from, the agent-card/roster agreement, and four frontend wiring cases.

1025 backend / 2538 frontend tests green.

Related to Abilityai/trinity-enterprise#553

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

dolho commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

Re-review. The design holds up under reading — the context/authority split is real and not just asserted:

  • validated_open_canvas narrows a client-supplied id at the boundary against the agent's own canvases and the caller's audience, so an operator-only canvas cannot be echoed back to an external client (the field would otherwise be an existence oracle) and another agent's id is refused outright. Every failure degrades to "nothing open" rather than to an error or a wider reach.
  • open_canvas_for_execution gates on resolve_and_validate_execution(execution_id, agent_name) before it reads the column, so a foreign execution_id yields nothing, and it re-reads the canvas at use time rather than trusting the stamp — which is the detail I'd have expected to be missed: without it a canvas deleted mid-turn (a one-click act since ent#553) would have the agent mint a new one under that id and silently resurrect it.
  • effective_canvas_id returns (id, source) so the agent can say which canvas it wrote to. With eight canvases and one on screen, "I updated the canvas" is not an answer, and that field is what makes AC security: Fix token logging and add HTML reports to gitignore #7 achievable rather than aspirational.

Dual-track is complete (schema.py / tables.py / db/migrations.py / Alembic), and the column sits beside the source_channel* ones it is shaped after.

Blocker, inherited rather than introduced here. This is stacked on #2623, which is stacked on #2619 — and both are built on #2619's pre-renumber state. #2619 has since moved 0058_agent_canvases_pinned → 0059_agent_canvases_pinned; #2623 still chains off the old id, and this PR chains off #2623. Merging the current #2619 into this branch conflicts, and the resulting graph fails the repo's own required guard:

$ python3 scripts/ci/check_alembic_heads.py src/backend/migrations/versions
alembic-heads: FAIL — resolves to 2 heads across 62 revision(s); exactly 1 is required.
  • 0059_agent_canvases_pinned
  • 0060_execution_open_canvas

alembic upgrade head is singular and resolves its target before applying anything, so that graph applies zero revisions — open_canvas_id never lands on PostgreSQL and neither does anything else since the fork. Details and the fix are on #2623; the three have to be re-based as one stack, bottom-up, and each branch's own CI is structurally unable to see it (every tree has one head; only the union has two).

Nothing else blocking from me.

dolho and others added 15 commits September 10, 2026 12:44
… (ent#553)

Found re-reviewing my own audit fix. Adding the rows was right; the attribution
was wrong, and a row that lands under the wrong actor is worse than the missing
row it replaced — nothing fails, so the wrong answer is believed.

`_audit_canvas_change` passed `actor_email` only. But
`platform_audit_service._resolve_actor` derives `actor_type` from
`actor_user` / `actor_agent_name` / `mcp_scope` / `mcp_key_id` and never from
the email, so an email-only call falls through to its last branch:

    _resolve_actor(None, None, None, None) -> ("system", "trinity-system", None)

So every Workspace canvas delete and pin was recorded as `actor_type="system"`,
`actor_id="trinity-system"` — a named operator's action attributed to the
platform, invisible to any `actor_type=user` query and to the audit UI's
per-actor filter. Verified against the real resolver, not by reading the call.

The `actor_email`-only path I cited (#848 inline auth) is right where the caller
genuinely has no `users` row. That is not this route: `_require_canvas_manager`
is platform-only and resolves through `db.can_user_share_agent`, so a row exists
by construction. It now resolves that row and passes `actor_user`, producing the
same `("user", <id>, <email>)` shape the operator twin has always written —
which is the point, since auditing the two surfaces differently buys little more
than auditing one of them.

Best-effort by construction: the action has already happened, so a lookup that
raises or misses must not drop the row. It falls back to the email-only call
with a WARNING, since a miss would mean the gate admitted someone the user table
does not know.

Tests: the regression is pinned against the REAL `_resolve_actor` (both the
shape the fix must not return to and the shape it produces now), plus a source
guard that the helper resolves a row, passes `actor_user`, keeps the email as a
fallback and cannot raise. Removing `actor_user=` reds it.

31 passed on the ent#553 file; 953 across canvas / portal / audit.

Related to Abilityai/trinity-enterprise#553

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RSjVEjay9ztC1oDXXkh9uN
Resolve src/frontend/raw-color-baseline.json: keep dev's #2662 notes, totals
patched to the merged tree's real values (per-file entries unchanged on both
sides; scanner + ratchet test verified).

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

Resolve the ent#403 (model pick) vs ent#555 (open canvas) overlap: both are
additive per-turn fields, so every conflict is a union — models.py, the two
router call sites, the six service.py sites (`_precreate_sync_execution` takes
`resolved_model` positionally, `open_canvas_id` by keyword), the store's two
turn actions, the composer's two dispatch paths, and the ent#286 stub.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015owqMKD5QDjzZrUTF2Joht
…d revision

ent#553 renamed its revision 0058_agent_canvases_pinned -> 0059 when it
absorbed dev's 0058_portal_file_dismissals; this revision still pointed at
the old id, so after the merge the directory resolved to two heads and
`alembic upgrade head` would have applied nothing. Renumbered to 0060 as
well so the numeric prefix stays a unique ordering cue.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015owqMKD5QDjzZrUTF2Joht
The share/PDF controls add gray chrome copied from the panel's existing
header; the branch predates the #2605 ratchet, so the guard first bit when
dev was merged in. Scoped to this one entry, in its own commit, as the
guard's own message prescribes.

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

Follows the ent#554 renumber so the chain reads 0059 pinned -> 0060 shares
-> 0061 open-canvas with unique prefixes and a single head.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015owqMKD5QDjzZrUTF2Joht
The two cap tests imported the class from `db.canvas` while `_set_cap` already
patches the cap through `upsert_canvas.__globals__` — because an earlier test
can evict and re-import the module. The same eviction gives the test a
different class object than the one the live code raises, and `pytest.raises`
then reports the correct refusal as an unexpected exception. Seen once in a
full local run after the dev merge (both tests pass in isolation and under
CI's three seeds); resolve the class from the same globals the cap comes from.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015owqMKD5QDjzZrUTF2Joht
…atch (ent#553 review)

`CanvasPanel.vue` gated the chip strip on `visible.length > 1 || manage`,
where `visible` is the FILTERED list. Searching down to exactly one canvas
hid the strip while the previously selected canvas stayed on screen, and the
auto-select watcher — keyed off the unfiltered `props.canvases` — never
selected the match. Proven by execution: 7 canvases, query "Topic 3" → strip
false, no-match message false. The one canvas the user just searched for was
unreachable.

Fix:
- `canvasSelectorVisible({visible, manage, query})` — with a query, any hit
  shows the strip; without one, a single canvas is no choice (unchanged).
- `canvasAutoSelect(visible, selectedId, query)` — while a query is active
  the selection follows the matches; no-op with no query or when the current
  selection already matches.
- `CanvasPanel.vue` consumes both: `v-if="selectorVisible"` and a watcher on
  `[visible, query]`.

Tests:
- `canvasUtils.spec.js`: the two pure rules.
- `canvasPanelSelectorGate.spec.js`: slices the `selectorVisible` computed out
  of the SFC and RUNS it against the ejection's numbers; pins that the
  template reads the computed, not a re-derived length test, and that the
  watcher calls `canvasAutoSelect`.
- `test_ent553_canvas_lifecycle.py`: the second review ask — the per-agent
  cap reaches the wire as a 409 through the real router → service → db
  chain (only the Redis rate limiter stubbed), names the remedy, and the same
  PUT against an existing id stays an update.

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

`query` has exactly one writer — the search input's `v-model` — and that
input was `v-if="showSearch"` with `showSearch = ordered.length > 6`.
Seven canvases, type "Topic 3", delete the one match: six canvases, the
box unmounts, `visible` still filters on the stale query, the strip
collapses, and the panel says *No canvas matches "Topic 3"* with no
control left to clear it. Every remaining canvas is unreachable via the
chips until navigation. Also reachable with no operator action: the
agent's own `clear_canvas` plus a rail refresh while a query is typed.

The rule is pure — `canvasSearchVisible(count, threshold, query)` — and
keeps the box while a query is active regardless of the count: the typed
intent survives the shrink, and the no-match line keeps the one control
that clears it. Resetting `query` when the box would flip off was the
other option and was rejected: it erases a search the user was mid-way
through because a sibling canvas went away.

The gate spec that pinned the previous ejection drove `visible`/`query`
in isolation from `showSearch`, which is why it could not see this one.
It now slices the real `showSearch` computed out of the SFC and RUNS it
against the ejection's own numbers (7 → 6 with "Topic 3" typed → box
stays; 6 with no query → box gone), and pins that the input is gated on
that computed and is the sole writer of `query`. Mutation-checked:
reverting the gate to the old length test reds three cases.

Four mechanical items from the same review ride along:

- requirements/core-agent.md: the ent#438 "deliberately no retention
  window: bounded by construction" line now says why that reasoning was
  wrong (rows are bounded per canvas, the count was not) and what bounds
  it instead; FR-18..FR-22 record delete / bulk / cap / pin / search,
  which had no requirements entries at all.
- raw-color-baseline.json: the `_ent553_note` naming CanvasPanel.vue's
  25 → 46 raw_gray was added in 2794388 and dropped by the dev merge
  aa248f7; re-added so the growth is named in the file.
- routers/canvas.py `# mcp:` header now says pin and bulk-delete are
  unexposed on purpose, and why — Invariant #13's deliberate-vs-forgotten
  signal.
- feature-flows/agent-canvas.md: the two search-state rules and the
  defect class they close.

Verified: vitest 2696 passed (121 files); canvas backend suites 101
passed; raw-colour ratchet and loading-gate ratchet unchanged.

Related to Abilityai/trinity-enterprise#553

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RS8FBA7oEtAAv6GamZKong
…to feature/554-canvas-share-export

# Conflicts:
#	src/frontend/raw-color-baseline.json
… into feature/555-open-canvas-context

# Conflicts:
#	src/backend/client_portal/service.py
@dolho

dolho commented Sep 13, 2026

Copy link
Copy Markdown
Contributor Author

Re-based on the current #2623 (b4289fd2b), which carries #2619's 2026-09-11 fix and dev. One conflict, in client_portal/service.py::portal_chat: #2694 (now on dev) puts the voice delta_prefix at the head of a resumed turn, and this PR puts canvas_prefix there. Resolved as delta → canvas → manifest → message on a resumed turn — the delta is conversation the session never heard, so it reads as history, and the canvas on screen sits where it does on the cold arm (history → canvas → manifest → message, which already carries the delta rows in the replay, so a cold retry cannot double-send them). The source-pin test (test_the_prompt_context_survives_a_resumed_turn) now asserts both arms.

check_alembic_heads.py on this tree: 62 revisions, 1 head (0061_execution_open_canvas ← 0060_agent_canvas_shares ← 0059_agent_canvases_pinned). vitest 2803 passed (128 files); portal/canvas/workspace-scoped backend suites 1180 passed (incl. test_2694_voice_thread_window, ent#358/#457/#553/#554/#555); full unit tier left to CI.

🤖 Generated with Claude Code

https://claude.ai/code/session_01RS8FBA7oEtAAv6GamZKong

dolho and others added 3 commits September 14, 2026 10:07
…ecycle

Conflict: src/frontend/raw-color-baseline.json (the `totals` block only).

The two sides moved different counters for unrelated reasons, so the
resolution takes both rather than choosing a side:

- dev (#2718) fixed a scanner false positive — a `#` followed by hex
  digits in rendered copy (issue references like `(#526)`) was read as a
  colour — dropping hardcoded_colors 456 -> 447 across 8 files. Nothing
  on this branch touches those files.
- this branch adds the canvas delete/pin/search chrome, which raises
  semantic_tokens 4020 -> 4030.

Merged totals are therefore hardcoded_colors 447 (dev's) and
semantic_tokens 4030 (this branch's); raw_nongray, raw_gray and
files_with_violations were identical on both sides and merged cleanly.

`totals` is informational — the ratchet compares PER-FILE counts — but it
is kept honest anyway. Verified with the gate itself:
`npx vitest run tests/unit/rawColorRatchet.spec.js` → 14 passed.

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

Carries the dev merge resolved in ent#553 down the stack.

Conflict: src/frontend/raw-color-baseline.json, in two places, both from
the same cause — this branch re-froze the baseline before dev's #2718
landed, so the two sides edited neighbouring lines for unrelated reasons.

- `refrozen` notes: union, not a choice. dev's `_2718_note` (the scanner
  false-positive fix) and this branch's `ent554` note (CanvasPanel
  raw_gray 46 -> 62 for the Share / Download PDF chrome) both survive,
  with the shared `_2616_note` between them.
- `totals`: raw_gray 8760 from this branch — 8744 already counted
  CanvasPanel at 46, and ent#554 adds the +16 that the per-file entry
  (which merged cleanly at 62) records. hardcoded_colors 447 from dev's
  scanner fix. raw_nongray, semantic_tokens and files_with_violations
  merged cleanly.

Verified with the gate itself:
`npx vitest run tests/unit/rawColorRatchet.spec.js` → 14 passed.

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

Carries the dev merge resolved in ent#553 and the baseline union resolved
in ent#554 down the stack.

Conflict: src/backend/client_portal/service.py — `portal_chat`'s signature
tail, where the two branches each appended a keyword argument to the same
line:

- ent#551 (on the 554 side) added `voice_call_id`, which attributes a
  turn's CHAT MESSAGE rows to the voice call that dispatched it.
- ent#555 (this branch) added `open_canvas_id`, which is stamped on the
  EXECUTION row so the agent's canvas tools default to what the user has
  on screen.

Different rows, different writers, no shared state — so the resolution is
the union, ordered voice_call_id then open_canvas_id. Both are threaded
end to end after the merge, and each keeps its only caller:
`gemini_voice._portal_chat_call` passes `voice_call_id`;
`start_portal_turn` and the router's `POST .../chat` pass `open_canvas_id`
(`start_portal_turn` carries no voice id by design — a voice turn does not
enter through the streaming path).

Verified: 194 passed across test_ent553_canvas_lifecycle,
test_ent554_canvas_share, test_ent555_open_canvas_context,
test_ent551_voice_background_tasks and both test_2694_voice_* suites.

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

dolho commented Sep 14, 2026

Copy link
Copy Markdown
Contributor Author

Review: /review + /validate-pr

Reviewed against the merge-base with feature/554-canvas-share-export (67a3b9357), so this is only the open-canvas-context work. Recommendation: APPROVE — no critical findings. The cleanest of the three.

⚠️ Inherited blocker. This stacks on #2623, which has a confirmed critical finding (an agent-scoped key can mint a public canvas share link). Nothing in this diff is affected by it, but it lands in the same history — so #2623 must be fixed before this merges.

/review — structural

Critical: none.

The trust boundary here is that open_canvas_id is client-supplied and ends up in a column an agent later reads and in a system prompt. It is handled correctly, and the reasoning is written down where the next person will find it.

  • Validated at the boundary that stamps it, not at the point of use — client_portal/service.py::validated_open_canvas checks three things and degrades to None on every failure: the id matches CANVAS_ID_RE; it names a canvas of this agent; and the caller can see it under the audience rules. I verified the audience argument exists end-to-end (db.get_agent_canvas(agent, id, audience) → CanvasOperations.get_canvas(..., audience=None) → canvas_audience_for(is_platform) returning roster for a portal client), because the surrounding except Exception would otherwise have masked a signature mismatch and silently disabled the whole feature.
  • Prompt injection is closed by ordering, not by escaping. service.py interpolates the id into f"[Client Portal] The user has the canvas '{open_canvas_id}' open on screen." — safe only because CANVAS_ID_RE (^[A-Za-z0-9._-]{1,64}$) has already run, so no quote or newline can survive to break out of the sentence. Worth keeping those two facts adjacent if either moves.
  • Context, never authority — open_canvas_for_execution's docstring states it and the code honours it: every read and write still goes through the ordinary audience and ownership gates, and test_an_agent_cannot_read_another_agents_open_canvas pins it.
  • Re-checked at read time, not trusted from the stamp. The nicest detail in the diff: a canvas deleted mid-turn resolves to None rather than being handed to the agent, because pointing at a missing id would have the agent create a new canvas under it — silently resurrecting something a person had just deleted with feat(canvas): delete, pin, search and a stated bound for the canvas pile (ent#553) #2619's one-click delete. Pinned by test_a_canvas_deleted_mid_turn_does_not_get_resurrected.
  • Both turn paths carry it — synchronous portal_chat and portal_chat_stream, which is the rule this file already states for new_thread and model ("a flag honoured by only one path brings the bug back exactly when streaming fails"). Pinned by test_both_turn_paths_carry_the_selection.
  • Route ordering (Invariant fix: add missing logging_config.py to backend Dockerfile #4) — GET /{name}/canvas/context at line 213, above /{name}/canvas/{canvas_id} at 335. context is a valid canvas-id shape, so this matters.
  • Column plumbing — open_canvas_id is read by NAME with an in row_keys guard, never positionally, so it does not repeat the _SUMMARY_COLUMNS index hazard that ent#537 and ent#553 both had to document.
  • effective_canvas_id returns (id, source) so the agent can say which canvas it wrote to when nobody named one. That is the difference between "I updated the canvas" and a usable answer when there are eight of them.

Informational

[I1] The validation's broad except Exception degrades silently. (Confidence: 6/10)
client_portal/service.py::validated_open_canvas — a failure in core_db.get_agent_canvas logs a WARNING and returns None, which means "nothing open". That is the right failure direction for a per-turn context hint, and it is logged, so this is a note rather than a defect. But the whole feature degrades to "never works" under that branch and the only symptom is a log line nobody tails — if this ever gets a health signal, that is the branch to hang it on.

/validate-pr — Lane B+schema

Not Lane C: no auth, credential, parser, image or CI path in the diff.

Category Status Notes
Base branch ✅ feature/554-canvas-share-export — correct for a stacked PR
Closing keyword ⚠️ none present. Cross-tracker, so the automation would not fire anyway, but rephrase to Fixes abilityai/trinity-enterprise#555 for the convention and as a reminder to set status-in-dev by hand
Dual-track migration ✅ SQLite execution_open_canvas + Alembic 0061_execution_open_canvas
Additive column ✅ nullable TEXT, no backfill — existing rows and agents undisturbed
Alembic single head ✅ 62 revisions, 1 head (0061), chained off 0060 — no fork (#2068)
Enterprise gitlink ✅ a4198127d resolves on the private remote — not an unpushed pointer
Security greps ✅ clean; no host paths, no mode/symlink change
Docs ✅ feature-flows/agent-canvas.md (+58)
Third surface (Invariant #13) ✅ backend route, mcp-server/src/client.ts and tools/canvas.ts all moved together; test_the_mcp_tools_resolve_rather_than_defaulting_to_main pins it
Merge gate ✅ all checks green
Test adequacy ✅ 18 tests, and they cover the cases that matter rather than the easy ones — operator-only canvas refused to a client, another agent's canvas refused, deleted-mid-turn, prompt survives a resumed turn
ui label ⚠️ touches PortalConversation.vue, CanvasPanel.vue and stores/clientPortal.js; no label, so frontend-e2e did not run against it

🤖 Generated with Claude Code · https://claude.ai/code/session_01Q19uRCksdn4DiRAJ55rfpZ

dolho and others added 6 commits September 14, 2026 11:01
…nt#554 review)

`create_canvas_share`'s docstring said "Owner-or-admin and human-only via
`_gate_human_removal`". That gate is not human-only — its own docstring, one
screen above, says an agent-scoped key may act on its own agent, which is
correct for `clear_canvas` ("an agent tidying up after itself") and wrong for
every verb that decides what someone OTHER than the agent may see.

So a prompt-injected agent could POST /api/agents/<self>/canvas/<id>/share
{"scope": "public"} with the TRINITY_MCP_API_KEY already in its container and
publish its own canvas at an unauthenticated URL. Three things make that worse
than it first reads:

* the share is LIVE, not a snapshot, so one link is a self-updating channel
  rather than a one-time disclosure;
* the agent is the only writer of canvas blocks, so anything it can read it
  can copy into a canvas and publish;
* `audience` is not consulted on the share path, so ent#438's fail-closed "a
  canvas reaches a client only because the agent said so" would not have
  applied — the agent would have been choosing for itself.

`list_canvas_shares` had the same gate and returns the TOKEN, which is the
capability itself; `revoke_canvas_share` too, so an agent could also turn off
a person's link.

The fix is the grant-vs-use line (Invariant #8): the endpoint that USES a
capability may be agent-callable, the one that GRANTS one is human-only.

* `_gate_human_only(current_user, name, *, agent_detail)` is factored out of
  `_gate_pin` — the predicate was always right, only its NAME described one
  caller. A gate named for a verb ("removal") is one a fourth caller reaches
  past by accident; a gate named for its rule is not. `_gate_pin` and the new
  `_gate_share` both delegate to it, with per-caller refusal text because an
  agent reads that message to decide what to do next.
* The three share routes now call `_gate_share`.
* The delete routes deliberately KEEP `_gate_human_removal`, and a test guards
  that boundary in the other direction — the first attempt at this fix swept
  `clear_canvas` into the human-only gate, because one `str.replace` matched
  both bodies. That would have broken a real MCP tool for every agent: a
  security fix breeding the next bug, the /review §4.14 class.

Six regression tests; four of them fail against the previous commit (the other
two are the over-correction guards, which must pass both ways by design). The
23 tests already here covered scope defaults, expiry, revocation and
enumeration, but none used an agent principal on any share route — which is how
this shipped.

Docs: the user doc now states that sharing is the owner's alone and that the
routes refuse an agent's own key, beside the same sentence for pin; the flow
doc records the decision, the blast radius, and why the delete routes stay
permissive.

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

The header comment lists which canvas routes are deliberately NOT exposed as
MCP tools and why. ent#554 added three that qualify — minting, listing and
revoking a share link — and the list did not grow with them.

Worth more than a comment here: the ent#553 entry states the rule the share
routes then failed to follow ("no tool exposes it" is a property of the
client), so a reader consulting this header to decide a fourth route's gate
would have found the reasoning but not the precedent.

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

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

dolho commented Sep 14, 2026

Copy link
Copy Markdown
Contributor Author

Re-review — the inherited blocker is cleared

My first review flagged this PR as clean on its own terms but carrying #2623's critical finding through its base branch. That finding is now fixed on feature/554-canvas-share-export and merged down into this branch (f16e66ae1), so the caveat is lifted — APPROVE stands without it.

What arrived here

Three commits merged down from the base, none of which touch ent#555's own surface:

  • 1005bd2d9 — the three canvas share routes move from _gate_human_removal (which admits an agent-scoped key acting on its own agent) to a new _gate_share, so an agent can no longer mint, list or revoke a share link for its own canvas. Six regression tests, four of which fail against the parent commit.
  • f5a39e1a4 — the # mcp: header records the share routes as deliberately not exposed.
  • 6d77d988f — the durable lesson in docs/memory/learnings.md.

The delete routes (clear_canvas, bulk_delete_canvases) deliberately keep the permissive gate, so nothing an agent legitimately does through the MCP canvas tools changed.

Verification on this branch

check result
-k canvas across tests/unit (after the merge) 315 passed, 2 skipped
CI on this branch 11/11 pass, 0 fail
Merge clean — the fix touches routers/canvas.py gates, tests/unit/test_ent554_canvas_share.py and docs; ent#555 touches canvas_service.py, client_portal/*, the execution column and the MCP client. No overlap.

Nothing changed in this PR's own findings

The original review stands as posted: no critical findings, one informational ([I1] — the broad except Exception in validated_open_canvas degrades silently to "nothing open", which is the right failure direction and is logged, but the whole feature's failure mode lives on that branch). The two ⚠️ from /validate-pr are also unchanged:

  • no closing keyword — add Fixes abilityai/trinity-enterprise#555; cross-tracker, so status-in-dev needs a manual bump either way.
  • no ui label — touches PortalConversation.vue, CanvasPanel.vue and stores/clientPortal.js; frontend-e2e has not run against them.

Merge order

Bottom-up, and each step needs one intermediate: #2619 → #2623 → this. Because dev squash-merges, after #2619 lands this branch's merge-base goes stale — merge origin/dev into the base branch and push before merging the next one, or its PR diff re-shows the previous PR's work.

🤖 Generated with Claude Code · https://claude.ai/code/session_01Q19uRCksdn4DiRAJ55rfpZ

@vybe vybe added the ui PR touches the frontend UI — triggers Playwright e2e tests label Sep 14, 2026
#2619 (ent#553) and #2623 (ent#554) both squash-landed on dev, so the stacked
base is gone. Conflicts were the parent-squash echo in the seven files this
branch touches on top of them (branch already carried both parents' final
trees) plus two docs files this branch never edits, taken from dev. The
enterprise submodule pointer is repinned to dev's commit — the branch carried
an older one from before the ent#190 bump. Tree vs dev is exactly the ent#555
delta (22 files, +685/-25).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LAFj2RWjsQWPgnq1vNT9Xm
@vybe
vybe changed the base branch from feature/554-canvas-share-export to dev September 14, 2026 10:34

@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.

Validated (/validate-pr, Lane C+schema — Lane C only via the enterprise submodule pointer, which is repinned to dev's commit): client-supplied open_canvas_id validated at the portal boundary against the agent's own canvases and the caller's audience, tools re-check ownership on read; dual-track migration 0061, single Alembic head; non-happy-path tests for external client / operator-only / other-agent. Merged dev in (both parent-squash echoes resolved; tree vs dev is exactly the ent#555 delta), retargeted to dev; full pytest matrix, CodeQL, e2e green.

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.

2 participants