feat(schedules): a schedule that names a user runs as that seat — reads and writes their memory (abilityai/trinity-enterprise#637) - #2924
Conversation
…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>
…; 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>
…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>
b9015ed to
7610a12
Compare
|
merge-train prep (mechanical): rebased onto |
…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>
|
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 |
|
Correction to the numbering note above (orchestrator, 2026-09-22): ent#478 ( |
…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>
|
merge-train (2026-09-22) — what was pushed to this branch, and why Validated at lane C + schema (
Alembic numbering: the 0068/0069 renumber ruled earlier is not applicable yet — #2942 / #2948 are still drafts and Left for the author (need intent, not blocking): (a) a finished seat run's |
…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>
…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>
…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>

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_memoryrefused everyscheduletrigger. Now a schedule that names a user (ent#498'sdeliver_to_workspace_email— the same field, no second address) runs as that seat.The seat is read off the row, never sent. ent#498's
resolve_and_stampalready writessource_channel='portal'+source_channel_client=<email>after the roster/block checks.services/schedule_seat_memory.seat_for_executionreads that back for a schedule-origin row (realschedule_id; cron / Run now / webhook — the operator who presses Run now is not the addressee) and nothing else;routers/public_memory.pyaccepts the write for exactly that case.source_user_emailis deliberately not stamped:client_portal/workreads 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_notesreplaces the notes and recordspublic_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_undonenamed), 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_clienton the row and memory isUNIQUE(agent_name, user_email).Schema:
public_user_memory_writeson both tracks (SQLitepublic_user_memory_writes_table+ Alembic0065_public_user_memory_writes), CASCADE inAGENT_REFS. MCPwrite_user_memorydescription + the platform prompt's memory guidance updated.0065_*off0064. Whichever lands second must re-chain itsdown_revision(alembic-head-watch will flag the fork).Acceptance criteria
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_seattest_there_is_one_write_boundary_for_every_trigger(+ every trigger's write goes through the one writer)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.jsmounts 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)test_a_companion_serving_two_seats_keeps_them_apart,test_undo_cannot_reach_another_persons_writesystem_prompt=Noneon both dispatch branches (test_the_seat_prompt_reaches_execute_task_on_both_dispatch_branches)Verification
Live (dev stack, PostgreSQL, agent
sidekick, this branch mounted): Alembic0065_public_user_memory_writesapplied at boot; created a disabled schedule addressed to my Workspace email; Run now → scheduler →/api/internal/execute-task: the row is stampedportal+ 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-memoryfor that execution (what the tool does) →success, memory written for the seat,write_idreturned; 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 markedundone(PG:undone_by= me), no Undo left. Found live and fixed: a Run-now write was labelled "In a conversation" (kind keyed ontriggered_by); now keyed onschedule_id+ pinned by test.Docs
docs/memory/requirements/scheduling.md§10.19 (new),public-access.mdMEM-001 pointer,feature-flows/schedule-workspace-delivery.md(seat section, schema, testing),architecture/{database,workspace,backend,api-endpoints}.md.🤖 Generated with Claude Code