Skip to content

feat(schedules): a schedule that names a user runs as that seat — reads and writes their memory (abilityai/trinity-enterprise#637) - #2924

Merged
vybe merged 11 commits into
devfrom
feature/ent637-schedule-user-memory
Sep 22, 2026
Merged

vybe merged 11 commits into
devfrom
feature/ent637-schedule-user-memory

Conversation

@dolho

@dolho dolho commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

Fixes abilityai/trinity-enterprise#637 (R26). OSS-core, on the same line as ent#498 (§10.18) and MEM-001 — same as #498's gating.

What

A role companion's proactive brief is a scheduled run, and a scheduled run could not touch an individual user's memory: write_user_memory refused every schedule trigger. Now a schedule that names a user (ent#498's deliver_to_workspace_emailthe same field, no second address) runs as that seat.

The seat is read off the row, never sent. ent#498's resolve_and_stamp already writes source_channel='portal' + source_channel_client=<email> after the roster/block checks. services/schedule_seat_memory.seat_for_execution reads that back for a schedule-origin row (real schedule_id; cron / Run now / webhook — the operator who presses Run now is not the addressee) and nothing else; routers/public_memory.py accepts the write for exactly that case. source_user_email is deliberately not stamped: client_portal/work reads it as "work I started" and two stream-ownership checks key on it. No address → refused as before, now saying "names no one" (AC 5).

The run reads before it writes. The tool is whole-blob replace, so the internal dispatch composes the seat's MEM-001 memory block + a seat note into execute_task(system_prompt=…) on both branches, only after the stamp landed. The #1205 public persona prompt is not folded in.

One boundary, now with history (AC 2 + AC 3). db.write_public_user_memory_agent_notes replaces the notes and records public_user_memory_writes (previous + new, execution, trigger, schedule) in one transaction — the ent#419 screen lands there once and covers scheduled writes by construction; ent#419's rollback layer is this table. The Workspace agent details (Info tab) gain "What it remembers about you" (PortalAgentMemory.vue, GET /api/enterprise/client-portal/agents/{name}/memory) listing each change with schedule name + time + what it left, and Undo (POST …/memory/writes/{id}/undo) — latest-first (409 not_latest / already_undone named), uniform 404 for a foreign id, roster-gated, keyed on the principal so only the viewer's own memory is ever served.

One run, one seat (AC 4) by construction — the seat is the single source_channel_client on the row and memory is UNIQUE(agent_name, user_email).

Schema: public_user_memory_writes on both tracks (SQLite public_user_memory_writes_table + Alembic 0065_public_user_memory_writes), CASCADE in AGENT_REFS. MCP write_user_memory description + the platform prompt's memory guidance updated.

⚠️ Alembic: #2920 also adds a 0065_* off 0064. Whichever lands second must re-chain its down_revision (alembic-head-watch will flag the fork).

Acceptance criteria

  • AC 1 — addressed schedule writes that user's memory; no address writes none — test_an_addressed_scheduled_run_writes_the_seats_memory, test_a_scheduled_run_with_no_address_is_refused_as_before, test_every_way_a_schedule_fires_serves_the_same_seat
  • AC 2 — same gate as chat, not a back door — test_there_is_one_write_boundary_for_every_trigger (+ every trigger's write goes through the one writer)
  • AC 3 — the person sees what/when/which run and can undo — test_every_write_is_recorded_with_what_when_and_which_run, test_undo_restores_what_the_notes_were_before_that_write, test_undo_is_latest_first…, test_the_person_sees_a_run_now_fire_as_a_scheduled_run; portalAgentMemory.spec.js mounts the component and drives Undo + its refusal (Safety-critical UI logic keeps landing in the one tier with no executable coverage — and the stated reason is false #2918)
  • AC 4 — seats stay separate — test_a_companion_serving_two_seats_keeps_them_apart, test_undo_cannot_reach_another_persons_write
  • AC 5 — unchanged for schedules that name no user — refusal + system_prompt=None on both dispatch branches (test_the_seat_prompt_reaches_execute_task_on_both_dispatch_branches)

Verification

pytest unit/test_ent637_schedule_seat_memory.py            39 passed
pytest test_ent498 + schema_parity + alembic guards + auth wiring + models_centralized   162 passed
-k "cleanup|agent_refs|rename_cascade|portal_agent_page|…"  150 passed
full unit (xdist):  16732 passed, 28 skipped, 16 failed — the same 16 pre-existing on base (IPv6-mapped SSRF params, 2814 parity); none in scope
check_alembic_heads → 1 head; check_alembic_parity → PASS; SQLite migration applied twice on a fresh file (idempotent)
vitest: 142 files / 3242 pass (incl. portalAgentMemory.spec.js 5, ratchets); check:tokens OK; vite build OK
mcp-server: tsc clean, 439 pass
/verify-local --skip-unit --skip-agent → PASS (build, boot+health, integration)

Live (dev stack, PostgreSQL, agent sidekick, this branch mounted): Alembic 0065_public_user_memory_writes applied at boot; created a disabled schedule addressed to my Workspace email; Run now → scheduler → /api/internal/execute-task: the row is stamped portal + seat + schedule_id (PG), the failed terminal landed in my Main (the model turn itself failed — sidekick's OAuth token is expired on this instance, unrelated). Then, with sidekick's own MCP key, POST /api/agents/sidekick/user-memory for that execution (what the tool does) → success, memory written for the seat, write_id returned; an unaddressed run → 422 naming why. GET …/memory → notes + one write (scheduled_run · ent637 seat brief probe, undoable). Workspace Info tab, light + dark: section renders, Undo click → notes revert to empty, row marked undone (PG: undone_by = me), no Undo left. Found live and fixed: a Run-now write was labelled "In a conversation" (kind keyed on triggered_by); now keyed on schedule_id + pinned by test.

Docs

docs/memory/requirements/scheduling.md §10.19 (new), public-access.md MEM-001 pointer, feature-flows/schedule-workspace-delivery.md (seat section, schema, testing), architecture/{database,workspace,backend,api-endpoints}.md.

🤖 Generated with Claude Code

dolho and others added 2 commits September 21, 2026 10:48
…name (#2914)

Assigning a library skill whose name matched a `.claude/skills/<name>/` the
agent wrote itself overwrote the agent's copy, gitignored and untracked the
directory, and buried an `unmanaged_dir_overwritten` warning in the assignment
response. The library and the agent's repo share one flat namespace on the
agent side, so a name match is not proof of the same skill — the library
silently won.

The inject path now refuses a directory that exists without the platform's
`.trinity-skill.json` marker before a byte is staged: no archive, no restore,
no `.gitignore` line, no untracking, not listed under CLAUDE.md Platform
Skills. `force` (manual Sync) does not override it; a platform-managed dir
keeps upgrading in place; an unreadable probe keeps the pre-existing fail-open
direction. The per-skill status is `conflict` — neither `injected` nor
`failed` (overall `success` stays true so the fleet re-inject does not alarm on
a standing conflict every auto-sync); `skills_conflict` + `conflicts[]` carry
it, and `deliver_assigned` reports `conflict` / `partial` + `conflicts[]`.

The verdict is recorded on the assignment row (`agent_skills.delivery_status`,
SQLite `agent_skills_delivery_status` + Alembic `0065`), carried across the
bulk-replace PUT for retained names, cleared by the next injection where the
name lands, and gone on unassign. The Skills tab reads it off the rows so the
badge, the explanation (which skill, the agent's own copy is what runs) and an
inline "Unassign library skill" action show on a fresh load; `inject()` re-reads
the rows so a resolved conflict clears without a reload. MCP passes the
delivery block through, `sync_agent_skills` names conflicts on its success
branch, and `get_agent_skills` carries `delivery_status` per row.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@dolho dolho added priority-p1 Critical path type-feature New functionality theme-ui-ux Theme: UI/UX labels Sep 21, 2026
@dolho

dolho commented Sep 21, 2026

Copy link
Copy Markdown
Contributor Author

Screenshots — Workspace › sidekick › Info rail (local dev, PostgreSQL, PR branch mounted)

image

1 · Before Undo — "What it remembers about you" shows the notes a seat run wrote (open loops / commitments / context, Updated just now); under Changes the latest entry reads Scheduled run · ent637 seat brief probe · just now with Undo; earlier entries are marked undone and carry no button (latest-first).

2 · After Undo — notes revert to what they were before that write (nothing, here → "Nothing yet. When it learns something about you — in a chat, or in a scheduled run addressed to you — it shows here."), the entry reads · undone, no Undo left. PG: undone_at set, undone_by = the viewer.

Dark theme — same section on the dark rail.

Capture note: the seat run's write_user_memory call was issued with sidekick's own MCP key against the run's execution id (what the MCP tool does), because the instance's model credential is expired; the row stamp, the endpoint, the history row and Undo are the real path. A header that truncated the time / undone mark was fixed in b9015ed after the first capture.

…; delivery copy and draft fixes (#2914)

Merge-train review on #2920: marker-absence was read as "the agent authored
this" and refused, but the platform itself wrote marker-less directories on
two live paths — the pre-#384 legacy fallback (SKILL.md only) and a restore
that dropped the meta member — so each would have turned its own package
into a permanent, unrepairable conflict on the next sync (AC#6 violated).

Both are closed at the write: `_legacy_fallback` writes the marker beside
SKILL.md (manifest = what was actually written), and `_restore_skill` writes
the marker back directly when the agent's `restored` list lacks it
(`marker_written_directly`). If the marker cannot be written the injection
is reported `failed` (`marker_not_written`; the legacy write is rolled back)
rather than a half-managed directory. Five tests cover the two paths, their
failure arms, and the legacy-marker round trip that closes AC#6.

Also from the review: `deliveryText` grammar ("its own a skill") with a real
plural arm; the `not_delivered` arm now names conflicts beside the failure
it retries; the Skills tab resets its draft only when the assigned SET
changes, so a Sync (which re-reads the rows for the verdict) no longer
wipes unsaved ticks; the conflict copy says "a directory the platform did
not create — usually a skill it authored" rather than asserting authorship.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…md (#2914)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@dolho
dolho requested a review from vybe September 21, 2026 11:37
dolho and others added 5 commits September 21, 2026 14:44
…ds and writes their memory (Abilityai/trinity-enterprise#637)

A role companion's proactive brief is a scheduled run, and a scheduled run
could not touch an individual user's memory: `write_user_memory` refused every
`schedule` trigger, so a brief could not carry the open loops and commitments
that make the next brief better than the last. That gap is what forced "one
agent per seat"; operator ruling R26 removed it.

The seat is ent#498's address, read off the execution row, never sent. The
ent#498 stamp already writes `source_channel='portal'` +
`source_channel_client=<email>` after the roster and block checks;
`services/schedule_seat_memory.seat_for_execution` reads it back for a
`schedule`-triggered row and nothing else, and `routers/public_memory.py`
accepts the write for exactly that case. `source_user_email` is deliberately
not stamped — `client_portal/work` reads it as "work I started" and two
stream-ownership checks key on it. No address → refused as before, now saying
"names no one".

The run reads before it writes: the tool is whole-blob replace, so the
internal dispatch composes the seat's MEM-001 memory block plus a seat note
into `execute_task(system_prompt=…)` on both branches, only after the stamp
landed. The public persona prompt is not folded in.

One write boundary, now with history: `db.write_public_user_memory_agent_notes`
replaces the notes and records `public_user_memory_writes` (previous + new,
execution, trigger, schedule) in one transaction — ent#419's screen lands
there once, and its rollback layer is this table. The Workspace agent details
gain "What it remembers about you" (`PortalAgentMemory.vue`,
`GET /agents/{name}/memory`) listing the writes with schedule name and time,
and Undo (`POST …/memory/writes/{id}/undo`) — latest-first, a named 409 for
a later write, uniform 404 for a foreign id. Dual-track migration (SQLite
`public_user_memory_writes_table` + Alembic `0065`), CASCADE in AGENT_REFS.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…erve the same seat (Abilityai/trinity-enterprise#637)

The manual trigger stamps triggered_by='manual' with the operator's own
source_user_email (#1970); the brief is still addressed to the seat, so the
seat resolver keys on a real schedule_id + the ent#498 portal stamp across
all three fire paths and never consults source_user_email.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…bilityai/trinity-enterprise#637)

Found live: the Workspace projection keyed the write's kind on
triggered_by == 'schedule', and a Run-now fire is 'manual', so the seat's
own write rendered as "In a conversation". The boundary records schedule_id
only for a seat run, so that is the key. Pinned with the projection test
and the named-refusal test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…the time and the undone mark (Abilityai/trinity-enterprise#637)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…065 — the three 2026-09-21 schema PRs are stacked (Abilityai/trinity-enterprise#637)

Three open PRs each added a 0065_* off 0064, which alembic-heads reads as
three heads and `upgrade head` would apply nothing. #2924 now stacks on
#2920 (base branch fix/2914-skill-name-conflict) and its revision is
0066 off 0065_agent_skills_delivery_status. The migrations.py append
collision is resolved by keeping both entries, #2920's first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@dolho
dolho force-pushed the feature/ent637-schedule-user-memory branch from b9015ed to 7610a12 Compare September 21, 2026 11:45
@dolho
dolho changed the base branch from dev to fix/2914-skill-name-conflict September 21, 2026 11:45
@dolho

dolho commented Sep 21, 2026

Copy link
Copy Markdown
Contributor Author

merge-train prep (mechanical): rebased onto fix/2914-skill-name-conflict and retargeted to it — the three 2026-09-21 schema PRs (#2920#2924#2927) are now a stack so dev never carries two Alembic heads off 0064. This revision is 0066_public_user_memory_writes off 0065_agent_skills_delivery_status (7610a1206); the migrations.py list-tail collision was resolved keep-both, #2920's first. Merge order: after #2920. Retarget to dev once it lands.

dolho added a commit that referenced this pull request Sep 21, 2026
vybe pushed a commit that referenced this pull request Sep 21, 2026
…s-wide caplog, stale release allowlist (#2935)

* fix(tests): three reds that keep CI honest-but-wrong — a guard race, a process-wide caplog, and a stale release allowlist

Three failure classes have been redding `backend-unit-test` on dev and the
regression diff on unrelated PRs (#2920, #2924, #2927, the 09-18 train):

1. `test_subprocess_pgroup` — `signal_guard.guarded_killpg` walked the
   group's members, then read each member's cgroup; the harness parent
   exiting in between (that IS the scenario under test) made `_cgroup_of`
   answer None, which read as "outside the session cgroup", and a kill of
   a group that was entirely ours a millisecond earlier was refused.
   Membership is now decided per LIVE member: a pid that vanished (or is a
   zombie) is not a member. A live pid whose cgroup cannot be read stays
   foreign — fail closed, unchanged. Two tests pin both halves; reverting
   the guard turns the race test red.

2. `test_2789…test_retry_budget_is_logged_with_its_cause` asserted over
   EVERY caplog record in the process, so a background task left by an
   earlier test (order-dependent under a random seed) logging an unrelated
   ERROR read as `['ERROR', 'WARNING'] == ['WARNING']`. It now asserts over
   the module's own logger.

3. `test_2814_workflow_trigger_parity` — every ACCEPTED_UNTIL_RELEASE entry
   is declared on `main` since the v0.9.5 cut and the guard has said
   "prune these" on every run since. Pruned, as the guard was designed to
   demand.

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

* fix(tests): finish the caplog narrowing — six process-wide reads survived

`mine()` was introduced to stop a background task's unrelated record from
reading as this test's own, and applied at four sites. Six reads of the
process-wide `caplog.records` survived in the same function, after the last
`mine()` call — including `:523`, the exact shape the fix was written for,
and two `assert not caplog.records` that any stray record from any logger
reddens.

Verified by negative control: with an unrelated ERROR emitted inside the
third phase's `caplog.at_level` window, the pre-fix assertions fail at
`assert "30s already spent" in caplog.records[0].message`; with `mine()`
they pass. `caplog.records` now appears once, in `mine()` itself.

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

* fix(tests): _is_gone fails closed on an unreadable status, per its docstring

`_is_gone` returned True for every OSError, so a LIVE pid whose
`/proc/<pid>/status` cannot be read (EACCES under a `hidepid=` mount, a
malformed line) was classified as gone, dropped from `live`, and the
`killpg` proceeded. The docstring two lines above states the opposite
contract: "a LIVE pid whose cgroup we cannot read is treated as foreign
(fail closed)".

Only the vanished-pid case is `gone` — FileNotFoundError. Every other
OSError/IndexError now keeps the pid in `live` so the cgroup check can
refuse the kill. This guard replaces os.kill/os.killpg for the whole unit
suite and exists because a mis-fire SIGKILLed a developer's desktop twice.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: sim <sim@example.com>
@vybe

vybe commented Sep 22, 2026

Copy link
Copy Markdown
Contributor

Migration numbering heads-up (orchestrator, 2026-09-22): PR #2942 (ent#477 declared-metric registry) lands ahead of this PR by operator ruling and carries 0066_metric_definitions ← 0065_agent_skills_delivery_status. This PR's Alembic revision must renumber behind it before merge — 0067_public_user_memory_writes ← 0066_metric_definitions for #2924 and 0068_agent_role_readiness ← 0067_public_user_memory_writes for #2927 — and run scripts/ci/check_alembic_heads.py src/backend/migrations/versions on the merged graph (the head-watch job is advisory). Recorded on ent#476.

@vybe

vybe commented Sep 22, 2026

Copy link
Copy Markdown
Contributor

Correction to the numbering note above (orchestrator, 2026-09-22): ent#478 (0067_metric_points) lands right behind ent#477 as part of the same stacked chain, so this PR renumbers behind 0067, not 0066 — #29240068_public_user_memory_writes ← 0067_metric_points, #29270069_agent_role_readiness ← 0068_public_user_memory_writes. Same pre-merge check: scripts/ci/check_alembic_heads.py src/backend/migrations/versions on the merged graph. Sorry for the two-step.

sim and others added 2 commits September 22, 2026 13:02
…e-user-memory

# Conflicts:
#	docs/memory/learnings.md
#	src/backend/db/migrations.py
… — mechanical, per the merge-train note on the PR

An agent-scoped key resolves to its owner carrying the owner's role, so
`assert_agent_access` alone let a sibling agent under the same owner name
this agent's finished seat run and replace that person's notes (cso --diff
F1, 2026-09-22). Mirrors `reminders._self_gate`; human principals unaffected.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@vybe

vybe commented Sep 22, 2026

Copy link
Copy Markdown
Contributor

merge-train (2026-09-22) — what was pushed to this branch, and why

Validated at lane C + schema (/validate-pr, /review, /cso --diff): no CRITICAL, every changed path executed by tests. Two mechanical commits pushed here so they survive to the squash:

  1. f2b60e9db merge of origin/dev — the base (fix/2914-skill-name-conflict) had already squash-merged as fix(skills): refuse to overwrite an agent-authored skill of the same name (#2914) #2920, so migrations.py and learnings.md echo-conflicted on fix(skills): refuse to overwrite an agent-authored skill of the same name (#2914) #2920's own text. Resolved to dev's text plus this PR's 0066 append; learnings.md delta vs dev is now empty (this PR never touched it). Retargeted to dev.
  2. bfc2a04d5 agent-self gate on POST /api/agents/{name}/user-memory/cso --diff F1 (MEDIUM 8/10, verified): an agent-scoped key resolves to its owner carrying the owner's role, so assert_agent_access alone let a sibling agent under the same owner name this agent's finished seat run and replace that person's notes. Pre-existing MEM-001 gap that this PR widened to seats who never opened a chat. Three lines mirroring reminders._self_gate, plus test_a_sibling_agents_key_cannot_write_this_agents_seat_memory; human principals unaffected.

Alembic numbering: the 0068/0069 renumber ruled earlier is not applicable yet — #2942 / #2948 are still drafts and dev's head is 0065, so this lands as 0066 ← 0065 and the metric-registry pair re-chains behind it. Merged graph checked: 1 head.

Left for the author (need intent, not blocking): (a) a finished seat run's execution_id stays a permanent write capability — liveness belongs to the open ent#629 ruling; (b) write_user_memory_agent_notes reads previous_notes without a row lock, so two concurrent writes on PostgreSQL can record the same previous_notes (.with_for_update() would close it); (c) build_seat_caller_prompt returning None on an unreadable memory does not stop the write it was guarding against; (d) update_public_user_memory_agent_notes / update_user_memory_agent_notes no longer have a caller. Body patched to Fixes abilityai/trinity-enterprise#637; status-in-dev on ent#637 will be set by hand after merge.

@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 (2026-09-22): validated at lane C + schema (/validate-pr, /review, /cso --diff), no CRITICAL; dev merged on the branch, self-gate landed, all checks green on bfc2a04.

@vybe
vybe merged commit 5b1e426 into dev Sep 22, 2026
30 checks passed
vybe pushed a commit that referenced this pull request Sep 22, 2026
…th metric freshness, readiness (Abilityai/trinity-enterprise#527) (#2927)

* fix(skills): refuse to overwrite an agent-authored skill of the same name (#2914)

Assigning a library skill whose name matched a `.claude/skills/<name>/` the
agent wrote itself overwrote the agent's copy, gitignored and untracked the
directory, and buried an `unmanaged_dir_overwritten` warning in the assignment
response. The library and the agent's repo share one flat namespace on the
agent side, so a name match is not proof of the same skill — the library
silently won.

The inject path now refuses a directory that exists without the platform's
`.trinity-skill.json` marker before a byte is staged: no archive, no restore,
no `.gitignore` line, no untracking, not listed under CLAUDE.md Platform
Skills. `force` (manual Sync) does not override it; a platform-managed dir
keeps upgrading in place; an unreadable probe keeps the pre-existing fail-open
direction. The per-skill status is `conflict` — neither `injected` nor
`failed` (overall `success` stays true so the fleet re-inject does not alarm on
a standing conflict every auto-sync); `skills_conflict` + `conflicts[]` carry
it, and `deliver_assigned` reports `conflict` / `partial` + `conflicts[]`.

The verdict is recorded on the assignment row (`agent_skills.delivery_status`,
SQLite `agent_skills_delivery_status` + Alembic `0065`), carried across the
bulk-replace PUT for retained names, cleared by the next injection where the
name lands, and gone on unassign. The Skills tab reads it off the rows so the
badge, the explanation (which skill, the agent's own copy is what runs) and an
inline "Unassign library skill" action show on a fresh load; `inject()` re-reads
the rows so a resolved conflict clears without a reload. MCP passes the
delivery block through, `sync_agent_skills` names conflicts on its success
branch, and `get_agent_skills` carries `delivery_status` per row.

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

* docs(learnings): #2914 — warnings on unread responses are not protection; the #2662 class inside BaseButton (#2921)

* fix(skills): the platform never leaves a marker-less directory behind; delivery copy and draft fixes (#2914)

Merge-train review on #2920: marker-absence was read as "the agent authored
this" and refused, but the platform itself wrote marker-less directories on
two live paths — the pre-#384 legacy fallback (SKILL.md only) and a restore
that dropped the meta member — so each would have turned its own package
into a permanent, unrepairable conflict on the next sync (AC#6 violated).

Both are closed at the write: `_legacy_fallback` writes the marker beside
SKILL.md (manifest = what was actually written), and `_restore_skill` writes
the marker back directly when the agent's `restored` list lacks it
(`marker_written_directly`). If the marker cannot be written the injection
is reported `failed` (`marker_not_written`; the legacy write is rolled back)
rather than a half-managed directory. Five tests cover the two paths, their
failure arms, and the legacy-marker round trip that closes AC#6.

Also from the review: `deliveryText` grammar ("its own a skill") with a real
plural arm; the `not_delivered` arm now names conflicts beside the failure
it retries; the Skills tab resets its draft only when the assigned SET
changes, so a Sync (which re-reads the rows for the verdict) no longer
wipes unsaved ticks; the conflict copy says "a directory the platform did
not create — usually a skill it authored" rather than asserting authorship.

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

* test(ent332): the legacy fallback now writes the marker beside SKILL.md (#2914)

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

* feat(schedules): a schedule that names a user runs as that seat — reads and writes their memory (Abilityai/trinity-enterprise#637)

A role companion's proactive brief is a scheduled run, and a scheduled run
could not touch an individual user's memory: `write_user_memory` refused every
`schedule` trigger, so a brief could not carry the open loops and commitments
that make the next brief better than the last. That gap is what forced "one
agent per seat"; operator ruling R26 removed it.

The seat is ent#498's address, read off the execution row, never sent. The
ent#498 stamp already writes `source_channel='portal'` +
`source_channel_client=<email>` after the roster and block checks;
`services/schedule_seat_memory.seat_for_execution` reads it back for a
`schedule`-triggered row and nothing else, and `routers/public_memory.py`
accepts the write for exactly that case. `source_user_email` is deliberately
not stamped — `client_portal/work` reads it as "work I started" and two
stream-ownership checks key on it. No address → refused as before, now saying
"names no one".

The run reads before it writes: the tool is whole-blob replace, so the
internal dispatch composes the seat's MEM-001 memory block plus a seat note
into `execute_task(system_prompt=…)` on both branches, only after the stamp
landed. The public persona prompt is not folded in.

One write boundary, now with history: `db.write_public_user_memory_agent_notes`
replaces the notes and records `public_user_memory_writes` (previous + new,
execution, trigger, schedule) in one transaction — ent#419's screen lands
there once, and its rollback layer is this table. The Workspace agent details
gain "What it remembers about you" (`PortalAgentMemory.vue`,
`GET /agents/{name}/memory`) listing the writes with schedule name and time,
and Undo (`POST …/memory/writes/{id}/undo`) — latest-first, a named 409 for
a later write, uniform 404 for a foreign id. Dual-track migration (SQLite
`public_user_memory_writes_table` + Alembic `0065`), CASCADE in AGENT_REFS.

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

* feat(schedules): Run-now and webhook fires of an addressed schedule serve the same seat (Abilityai/trinity-enterprise#637)

The manual trigger stamps triggered_by='manual' with the operator's own
source_user_email (#1970); the brief is still addressed to the seat, so the
seat resolver keys on a real schedule_id + the ent#498 portal stamp across
all three fire paths and never consults source_user_email.

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

* fix(workspace): a Run-now seat write is labelled as a scheduled run (Abilityai/trinity-enterprise#637)

Found live: the Workspace projection keyed the write's kind on
triggered_by == 'schedule', and a Run-now fire is 'manual', so the seat's
own write rendered as "In a conversation". The boundary records schedule_id
only for a seat run, so that is the key. Pinned with the projection test
and the named-refusal test.

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

* fix(workspace): the memory change header wraps instead of truncating the time and the undone mark (Abilityai/trinity-enterprise#637)

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

* chore(migrations): chain 0066_public_user_memory_writes off #2920's 0065 — the three 2026-09-21 schema PRs are stacked (Abilityai/trinity-enterprise#637)

Three open PRs each added a 0065_* off 0064, which alembic-heads reads as
three heads and `upgrade head` would apply nothing. #2924 now stacks on
#2920 (base branch fix/2914-skill-name-conflict) and its revision is
0066 off 0065_agent_skills_delivery_status. The migrations.py append
collision is resolved by keeping both entries, #2920's first.

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

* feat(workspace): the role card in Agent details — role, objectives with metric freshness, readiness (Abilityai/trinity-enterprise#527)

When a companion has a role (Tandem, ent#497), the Info rail shows a Role
card: the role it fills, the objectives it owns or supports with each
metric's value / target / freshness, the viewer's relationship, and its
readiness. Framework §8's organisation UI shrunk to one agent — files are
truth, the card is a projection. The Role + Readiness half; the
relationship line reads ent#500's assignment when it lands and states "no
assignment recorded" until then.

Files are truth, read through the platform, never a second store:
`client_portal/role_card.py` reads template.yaml (`x-role`, `x-canon`),
`<canon>/roles/<id>.yaml`, `<canon>/objectives/*.yaml` and the agent's own
`/api/metrics` through the agent door on every request; ids and paths that
reach a read are validated, text is bounded, and every failure is named
(`role.error`, `unavailable`) — no `x-role` means no card at all. A metric
is stale when its value is missing, unstamped, or older than the
framework's 30-day bound, and stale renders as stale, never as current.

Readiness is the agent owner's stamp (#663, ruled 2026-09-20).
`x-role.status` is agent-writable, so `agent_role_readiness` (dual-track:
SQLite + Alembic 0065, CASCADE in AGENT_REFS) holds the state, when it
changed and who flipped it; a template that says `ready` with no stamp is
shown as calibrating with the note that no owner stamped it. The flip is
`POST …/role/readiness`, gated on the platform's owner of the agent record
(named 403 for everyone else, no route for the agent), behind a confirm
that says it does not switch any schedule on. While calibrating the card
shows the viewer's own walkthrough count (asks in their Main, capped at
ten, and their thumbs-down).

`PortalAgentRole.vue` is mounted-tested (#2918); the backend card is
driven through a fake agent door; the stamp against a real SQLite file.

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

* merge-train: mark the role card's 30-day freshness rule interim (#2927) — mechanical, per the merge-train note on the PR

The PM ruling of 2026-09-21 (recorded on ent#476) allows shipping the card
as-is only with the 30-day rule marked interim in the feature flow, pointing
at the ent#479 shared metrics read that replaces it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: sim <sim@example.com>
webmixgamer added a commit that referenced this pull request Sep 22, 2026
…d after #2920/#2924/#2927

Content of both tracks is unchanged; only the Alembic parent moves so the
version line resolves to one head again (Invariant #3, #2068). It was chained
on 0064 while 0065–0067 were still open PRs; they have all landed on dev.

Refs Abilityai/trinity-enterprise#549

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
vybe pushed a commit that referenced this pull request Sep 22, 2026
…ai/trinity-enterprise#549) (#2936)

* fix(files): a shared file is for the person the turn was for (Abilityai/trinity-enterprise#549)

`agent_shared_files` was scoped by agent alone, so the Workspace Files tab
listed every active share of an agent -- download link included -- to everyone
on its roster: a file made in one person's chat appeared in another person's
tab. Third occurrence of one class (asks, reports, now files): a table scoped
by an owning entity gains a per-person dimension, and a reader written before
the column cannot be neutral about it.

A share now has an addressee. Three nullable columns on both migration tracks
(`addressed_to_email`, `addressed_to_channel` -- display only --,
`audience_source`); NULL email and NULL channel is the owner only, which is
also what every existing row becomes, so there is no backfill.

The platform decides the addressee; the agent does not choose.
`services/turn_audience.py` keeps two questions apart. Given a turn, who is it
for -- pure, table-driven over columns every entry path already stamps, with an
allow-list of channels. And which turn did this call come from -- the hard one:
an MCP call carries the agent's key and nothing about the turn, so the only
link is an execution id the agent types, and a resumed session cites ids out of
its own history (observed live: a turn 22 minutes old). The rule therefore
needs positive evidence: only the agent's own key; the cited id must be this
agent's; an agent-to-agent child is nobody; otherwise the id proves the
CONVERSATION and never the person, and that conversation's running direct turns
must agree on who it is. Every "could not tell" is the owner only, so the
failure direction is under-share. The shortcut that must not come back is
"the agent has exactly one running turn, so take it": a web-terminal session
holds the agent's key, has no execution row, and would hand an operator's file
to whichever client happened to be mid-conversation.

On a channel turn the resolver reads `source_channel_client`, which the router
now stamps only for a verified speaker in a one-to-one chat. In a group the
verified email is the unlocker's, set once per group and not per speaker.

- One override: `audience_email`, checked with the reader's own roster
  predicate; an off-roster address is refused by name and nothing is stored.
- The addressee joins the effect key, so re-addressing a file within one turn
  is a second share rather than a silent replay; the key is hashed and the
  replay snapshot carries no address, so `idempotency_keys` never holds one.
- The Workspace Files tab narrows in the query to the viewer, plus the
  owner-only rows for the agent's owner; an unidentifiable viewer matches
  nothing. The owner's Sharing panel lists everything, says who each file is
  for, and withholds the addressee from key-authenticated callers.
- WhatsApp media and voice notes are addressed by the platform code that
  already holds the recipient.
- `share_file` returns `visible_to_requester` / `visibility_note` (the names
  `set_canvas` uses) and never an address the platform resolved; the guidance
  lives in the tool description, since the prompt section is dropped at the
  minimal tier.

Unchanged and stated: the `?sig=` link remains a bearer credential -- the
audience governs the listing, not the download.

Tests: 87 in tests/unit/test_ent549_file_audience.py, built on real-SQLite
write-to-read round trips (every earlier share test mocked the persist step and
every listing test seeded by INSERT, so nothing crossed write to read), plus
mcp-server and frontend helper tests. 33 call-site mutations, all red, run in
isolated scratch copies; the battery is what found a vacuous test
(`column == None` compiles to `IS NULL`, and the table was empty).

Fixes Abilityai/trinity-enterprise#549

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(files): name the platform-injected id slot so CodeQL does not read it as a secret

CodeQL's sensitive-data heuristic classifies any identifier containing
"trusted" as secret material. The unused slot for a platform-injected execution
id was named `trusted_execution_id`, and that value reaches two existing log
lines in `idempotency_service.resolve_and_validate_execution` that print an
execution id -- so the first push raised two HIGH
`py/clear-text-logging-sensitive-data` alerts on a file this change never
touched. An execution id is not a secret; the alerts were true to the naming
heuristic and false to the data.

Renamed to `platform_execution_id` (and the local to `platform_turn`), which is
also what the docs already call it. No behaviour change. A comment at the
declaration says why, so the next reader does not name it back.

Refs Abilityai/trinity-enterprise#549

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs(learnings): an identifier named trusted_* is a CodeQL secret source

A new data flow into an existing log line raises the alert on a file the PR
never touched; the fix is a rename at the source, not a dismissal.

Refs Abilityai/trinity-enterprise#549

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(migrations): chain 0068 onto 0067_agent_role_readiness, dev's head after #2920/#2924/#2927

Content of both tracks is unchanged; only the Alembic parent moves so the
version line resolves to one head again (Invariant #3, #2068). It was chained
on 0064 while 0065–0067 were still open PRs; they have all landed on dev.

Refs Abilityai/trinity-enterprise#549

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs(learnings): move the two ent#549 lessons into fragments (#2945 convention)

dev now takes new ledger entries as one file per lesson under
docs/memory/learnings/, folded into learnings.md at the release cut, so
same-day PRs stop conflicting on the ledger's tail. The two entries this
branch had appended to learnings.md move there unchanged; learnings.md is
byte-identical to dev again.

Refs Abilityai/trinity-enterprise#549

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

priority-p1 Critical path theme-ui-ux Theme: UI/UX type-feature New functionality

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants