Skip to content

Feature/vector log retention#3

Merged
vybe merged 3 commits into
mainfrom
feature/vector-log-retention
Jan 7, 2026
Merged

Feature/vector log retention#3
vybe merged 3 commits into
mainfrom
feature/vector-log-retention

Conversation

@oleksandr-korin

Copy link
Copy Markdown
Contributor

No description provided.

oleksandr-korin and others added 3 commits January 5, 2026 18:22
Add automated log retention, rotation, and archival system for Vector logs
to address SOC2/ISO27001 compliance requirements.

## Problem
Vector logging (introduced in 0ec3a7f) captures all container logs but lacked:
- Log rotation (files grow indefinitely)
- Retention policy (no automated cleanup)
- Archival capabilities (manual clearing loses history)
- Long-term storage (no S3/GCS integration)

This creates compliance gaps for SOC2/ISO27001 which require:
- Defined retention policies
- Secure archival of audit trails
- Prevention of log data loss

## Solution
### 1. Daily Log Rotation
Vector now writes to date-stamped files instead of single growing files:
- platform-2026-01-05.json (not platform.json)
- agents-2026-01-05.json (not agents.json)

### 2. Automated Archival Service
Backend APScheduler runs nightly (default: 3 AM UTC) to:
- Find log files older than retention period (default: 90 days)
- Compress with gzip (achieves ~90% size reduction)
- Verify integrity with SHA256 checksums
- Upload to S3-compatible storage (optional)
- Delete originals after successful archive

### 3. Admin API Endpoints
- GET /api/logs/stats - Log file statistics and sizes
- GET /api/logs/retention - Current retention configuration
- PUT /api/logs/retention - Update retention (runtime)
- POST /api/logs/archive - Manually trigger archival
- GET /api/logs/health - Service health check

### 4. S3-Compatible Storage
Optional integration with:
- AWS S3
- MinIO (self-hosted)
- Cloudflare R2
- Any S3-compatible service

Gracefully disabled by default - works without S3 configuration.

## Configuration
Add to .env (all optional, have sensible defaults):
```bash
LOG_RETENTION_DAYS=90        # Days to keep logs
LOG_ARCHIVE_ENABLED=true     # Enable automated archival
LOG_CLEANUP_HOUR=3           # Hour (UTC) to run nightly job
LOG_S3_ENABLED=false         # Upload archives to S3
LOG_S3_BUCKET=               # S3 bucket name
LOG_S3_ACCESS_KEY=           # S3 access key
LOG_S3_SECRET_KEY=           # S3 secret key
LOG_S3_ENDPOINT=             # Custom endpoint (for MinIO, R2)
LOG_S3_REGION=us-east-1      # AWS region
```

## Files Changed
New:
- src/backend/services/log_archive_service.py (313 lines)
- src/backend/services/s3_storage.py (177 lines)
- src/backend/routers/logs.py (177 lines)
- tests/test_log_archive.py (629 lines, 27 tests)

Modified:
- config/vector.yaml - Date-based file paths
- docker-compose.yml - Env vars + trinity-archives volume
- docker/backend/Dockerfile - Added boto3 dependency
- src/backend/main.py - Router + scheduler integration
- tests/requirements-test.txt - Test dependencies
- docs/memory/changelog.md - Detailed changelog entry
- docs/memory/feature-flows/vector-logging.md - Retention docs

## Testing
All 27 tests passing:
- 5 authentication tests
- 7 configuration tests
- 3 compression/integrity tests
- 3 manual archive tests
- 2 S3 integration tests
- 2 integration workflow tests
- 2 validation tests
- 3 error handling tests

## Impact
- No breaking changes to existing Vector setup
- Disk space stabilizes after retention period
- Compliance-ready audit trail with secure archival
- Production-ready with comprehensive test coverage

## Compliance
Addresses SOC2 CC7.2 (System Monitoring) and ISO27001 A.12.4.1
(Event Logging) requirements for log retention and archival.

🤖 Generated with Claude Sonnet 4.5
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Add uplot npm package to fix import error in SparklineChart.vue component.
This dependency is required for the telemetry sparkline charts feature.

Error fixed:
- Failed to resolve import "uplot" from "src/components/SparklineChart.vue"

Changes:
- src/frontend/package-lock.json - Added uplot package and dependencies
- tests/test_log_archive.py - Whitespace cleanup
Architectural change following feedback to maintain full data sovereignty.
All logs and archives now stay within operator-controlled infrastructure.

Changes:
- Created pluggable ArchiveStorage interface with LocalArchiveStorage
- Removed external S3/boto3 dependency entirely
- Fixed scheduler restart bug in retention update endpoint
- Updated docs with sovereign backup strategies (NAS, rsync, volumes)
- All 19 API tests passing

Breaking: S3 configuration no longer supported (by design)
Non-breaking: Archives still stored in trinity-archives volume

Files:
+ src/backend/services/archive_storage.py (new storage abstraction)
- src/backend/services/s3_storage.py (deleted)
M docker-compose.yml (removed S3 vars, added LOG_ARCHIVE_PATH)
M docker/backend/Dockerfile (removed boto3)
M src/backend/routers/logs.py (fixed responses + scheduler)
M src/backend/services/log_archive_service.py (uses interface)
M tests/requirements-test.txt (removed boto3)
M tests/test_log_archive.py (removed S3 tests)
@vybe
vybe merged commit 3a9563a into main Jan 7, 2026
vybe added a commit that referenced this pull request Jan 7, 2026
- Add Phase 13-14 requirements for scalability & process orchestration
- Document simplified role model (Executor/Monitor/Informed)
- Add human approval steps concept for business processes
- Enhance demo-analyst and demo-researcher templates
- Improve create-demo-agent-fleet command
- Fix replay mode activity & context simulation in network.js
- Merge conflict resolution for PR #3 and #4 changelog entries

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
pavshulin pushed a commit that referenced this pull request Mar 31, 2026
- requirements.md: Added SLACK-FILES requirement (§15.1b-iii)
- feature-flows/slack-file-sharing.md: Full flow document
- feature-flows.md: Index updated with new flow
- task_execution_service.py: Move start_time before try block (review item #3)
- message_router.py: Sanitize session_id in upload path (review item #4)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
vybe added a commit that referenced this pull request Mar 31, 2026
…container (#222)

* feat: Bidirectional file sharing — Slack to agent (inbound) (#222)

Enable Slack users to upload files that agents can process. Images are
embedded as base64 data URIs for Claude vision. Text files (CSV, JSON,
TXT, etc.) are copied into per-session container directories via Docker
put_archive API.

Security:
- Filename sanitization (path traversal prevention, hidden file rejection)
- Per-user file upload rate limiting (5 files/min)
- Size caps: 5MB per image, 10MB per file, 10MB total inline images
- Max 10 files per message
- Unsupported formats rejected with user message (PDF, archives, video, audio)
- Per-session upload dirs cleaned up after execution (all exit paths)

Architecture:
- FileAttachment model + files field on NormalizedMessage (channel-agnostic)
- adapter.download_file() — each channel implements its own download auth
- files:read OAuth scope added for Slack workspace installs
- container_put_archive() async wrapper in docker_utils
- Task execution logging: timeout, HTTP status, elapsed time

Tests: 36 unit tests covering sanitization, type routing, size limits,
       extraction, rate limiting, session directories

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

* docs: Add requirements, feature flow for Slack file sharing (#222)

- requirements.md: Added SLACK-FILES requirement (§15.1b-iii)
- feature-flows/slack-file-sharing.md: Full flow document
- feature-flows.md: Index updated with new flow
- task_execution_service.py: Move start_time before try block (review item #3)
- message_router.py: Sanitize session_id in upload path (review item #4)

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

---------

Co-authored-by:  Pavlo <pash@pashs-MBP.home>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: vybe <me@evyborov.com>
vybe added a commit that referenced this pull request Apr 21, 2026
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
vybe pushed a commit that referenced this pull request Jul 2, 2026
* fix(security): credential encryption key rotation (#267)

CSO finding #3: CREDENTIAL_ENCRYPTION_KEY (AES-256-GCM master for all
.credentials.enc + 8 DB token columns, Invariant #12) had no rotation path —
changing it bricked every existing secret.

Adds an online, zero-downtime rotation mechanism:
- CredentialEncryptionService gains an optional decrypt-only secondary key
  (CREDENTIAL_ENCRYPTION_KEY_SECONDARY = previous key): old-key ciphertext keeps
  opening while new writes use the primary. Single-key deploys unchanged.
- rewrap(envelope) re-encrypts onto the primary (works for DB tokens AND v2
  .credentials.enc archives — same envelope).
- scripts/deploy/rotate-credential-key.py sweeps the 8 DB secret columns
  (dry-run default, --apply to write; legacy-plaintext rows skipped, not
  corrupted; idempotent).
- Runbook docs/migrations/CREDENTIAL_KEY_ROTATION.md; compose forwards the
  secondary var; .env.example documents it.

Deferred (separate issues, as scoped): moving the key off a plaintext env var
(Docker/Swarm secrets / external manager) and an in-container .credentials.enc
re-encryption pass (those migrate on next credential op via the secondary key).

Related to #267

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

* fix(ci): rename SECRET_COLUMNS to satisfy CodeQL clear-text-logging (#267)

The list name SECRET_COLUMNS and the 'AS secret' query alias tripped
CodeQL's sensitive-data-source name heuristic, tainting the loop vars
(table/pk/col) so every progress print() flagged py/clear-text-logging
(3 high-severity FPs). No secret value was ever logged. Rename to
ENCRYPTED_COLUMNS / enc_value to break the heuristic.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
vybe pushed a commit that referenced this pull request Jul 2, 2026
…ts (#1205) (#1337)

* feat(sharing): per-agent custom instructions for public & channel chats (#1205)

Owners can attach extra system-prompt instructions that apply to public-facing
conversations ONLY — public links, channel chats (Slack/Telegram/WhatsApp), and
x402 paid chat — without changing the agent's behavior in their own
authenticated chats, scheduled runs, loops, or agent-to-agent calls. The
text-surface counterpart of voice_system_prompt.

- schema/migration: agent_ownership.public_channel_system_prompt TEXT (schema.py
  + tables.py Core object + versioned migration, Invariant #3)
- db: get/set_public_channel_system_prompt (set strips, empty clears) + facade
  delegation
- models: PublicChannelPrompt / PublicChannelPromptUpdate (4000-char cap)
- api: owner-only GET/PUT /api/agents/{name}/public-prompt (sharing.py),
  mirroring the voice-prompt endpoints
- injection: platform_prompt_service.build_public_channel_caller_prompt folds the
  fragment with the MEM-001 memory block (public fragment first); wired into the
  three public-facing sites — message_router (channels), public.py (sync+async),
  paid.py (x402). Authenticated chat / schedules / loops / a2a never call it, so
  the scope exclusion holds by construction. Unset = strict no-op; a DB error
  degrades to the memory block (never blocks a chat)
- ui: Additional Instructions textarea (save/clear, char counter) in
  SharingPanel.vue via two agents-store methods
- tests: db get/set/clear/isolation + helper composition (4 combos + db-error
  degradation), SQLite + Postgres; schema-parity + migrations suites green
- docs: requirements section 44, architecture endpoint + column

Related to #1205

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(db): add Alembic revision for agent_ownership.public_channel_system_prompt (#1205)

The PR predates Alembic adoption (#1183) and shipped only the SQLite migration.
Add the paired Postgres revision (0005, chained after 0004_agent_ownership_voice_name)
so the dual-track migration invariant holds — ADD COLUMN IF NOT EXISTS, no-op when
0001_baseline already created the column.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Eugene Vyborov <eugene@beingluminous.com>
vybe pushed a commit that referenced this pull request Jul 6, 2026
…der-filtered memory (#903) (#1458)

* fix(slack): scope channel chat session to thread, not channel (#903)

Slack channel sessions were keyed team_id:sender_id:channel_id, so every
@mention and thread reply from one user in one channel shared a single
channel-wide transcript. Two concurrent threads cross-contaminated and a
fresh top-level @mention inherited the channel's last-10-turn history.

get_session_identifier now branches on is_dm: DMs keep the sender+channel
key (one continuous per-user convo, no threads); channel messages key on
team_id:channel_id:thread_id. sender_id is dropped for channels so
multi-participant threads share context — per-speaker attribution comes
from the #350 identity prefix. thread_id is already populated by the
parsers (_parse_mention mints thread_ts or ts, so a top-level mention gets
a fresh id for free). Opaque session key: zero schema change; old
channel-keyed rows orphan naturally.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(slack): thread-scope session + per-speaker attribution + sender-filtered memory (#903)

Slack channel sessions were keyed team:sender:channel, collapsing every
@mention and thread reply in a channel into one transcript — concurrent
threads cross-contaminate and a fresh @mention inherits stale history. The
single opaque session key was overloaded: it silently encoded conversation
scope, speaker attribution, and per-user memory source, which a single-user
session kept accidentally aligned.

Decompose the three axes (plan Option C):
- Key: channels use team:channel:thread (sender_id dropped so multi-participant
  threads share context; DMs keep team:sender:channel). F-TEAMID: `or "unknown"`.
- Attribution: new nullable public_chat_messages.sender_label — build_context_prompt
  replays each turn as `Alice:`/`Bob:` instead of a flat `User:` (role fallback
  when null). Set from the #350 enrich metadata on the channel user turn.
- Per-user memory: new nullable sender_email — the MEM-001 summarizer filters
  get_recent_public_chat_messages by the current user's email so a shared thread
  never feeds one user's turns into another's durable memory (F-MEM). Web path
  stamps sender_email too so the filter is a no-op there.

F-RACE: thread-scoping newly lets two different users race the select-then-insert
on a brand-new thread key; wrap the INSERT in a savepoint + IntegrityError
re-SELECT so the loser converges on the winner's row instead of 500ing.

Dual-track schema (Invariant #3): schema.py DDL + tables.py MetaData +
db/migrations.py SQLite ALTER + Alembic 0013 + db_models.PublicChatMessage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(slack): record thread-scoped session + attribution + sender-filtered memory (#903)

- requirements/public-access.md: SLACK-002 §15.1b-ii key feature + sender columns
- architecture.md: message_router line — history attributed per stored sender,
  MEM-001 summarization sender-filtered
- feature-flows/slack-channel-routing.md: session-identifier (DM vs thread),
  per-speaker attribution & memory section, updated test checklist + unit refs
- feature-flows.md: Recent Updates row (trim oldest to keep the ~20 cap)
- learnings.md: overloaded-session-key pattern (split scope/attribution/memory)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(memory): note sender-filtered summarizer read in public-agent-links (#903)

The every-5-message MEM-001 summarizer now reads
get_recent_public_chat_messages(session_id, sender_email=user_email); reflect
the #903 filter in the shared memory-summarization flow doc.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(slack): stamp assistant turn for all single-participant DMs + sanitize speaker label (#903)

Two follow-ups to the #903 sender-filtered MEM-001 memory work:

1. Assistant-turn memory parity across ALL channel DMs. The summarizer reads
   get_recent_public_chat_messages(sender_email=user_email); a single-participant
   session must stamp the assistant reply with the recipient's email too, or its
   replies vanish from that user's durable memory. The first pass keyed on the
   Slack-only `is_dm` metadata key, which regressed Telegram DMs (they set
   `is_group`, never `is_dm`) and ALL WhatsApp chats (DM-only; sets neither) —
   the exact pre-#903 behavior the fix meant to preserve. Extract
   `_assistant_sender_email()` combining each adapter's own signal (Slack `is_dm`,
   group `is_group`) so every DM stamps and only a Slack channel thread / group
   chat stays null. Web sync + background paths stamp unconditionally (always
   single-participant).

2. Sanitize the channel-controlled speaker label (`_sanitize_label`) — collapse
   newlines/control chars before the label lands in the replayed transcript's
   structural `{speaker}:` position, so a crafted display name can't forge an
   `Assistant:` line (defence in depth; a no-op for legitimate names).

Regression tests: per-channel assistant-stamp matrix (Slack DM/channel, Telegram
DM/group, WhatsApp DM), single-participant assistant-in-feed parity, and label
newline stripping. Docs (slack-channel-routing, public-agent-links) generalized
from "Slack DMs" to "any channel DM".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Andrii Pasternak <anpast31@gmail.com>
Co-authored-by: Eugene Vyborov <eugene@beingluminous.com>
vybe pushed a commit that referenced this pull request Jul 7, 2026
…+ confirm-re-read (#1450) (#1495)

* test(canary): unblock canary_invariants fixture broken by #1472 merge

Two pre-existing breakages landed via #1472 (367a5e1, merged today) in
root-level tests/test_canary_invariants.py — a file the CI unit job
(tests/unit/ only) does not run, so they merged undetected:

1. Duplicate `CREATE TABLE agent_schedules` in the `canary_db` fixture DDL
   — the E-06 work added a second definition next to the pre-existing one,
   so `executescript` raised "table agent_schedules already exists" and
   every fixture-dependent canary test errored. Removed the older, unused
   block (its `message`/`owner_id` columns have no consumer; `_add_schedule`
   and the E-06/L-03 collectors use only the kept block's columns).
2. `test_run_invariants_all` expected registry set omitted "E-06" (added to
   the invariants registry by the same PR). Added E-06 to the expected set
   and asserted it is green on a clean platform.

Restores a green baseline (92 passed) so the #1450 B-01 work can be verified.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(canary): B-01 queue-status coherence — backend-consistent Side B + confirm-re-read (#1450)

B-01 compared two reads that were neither temporally atomic nor
backend-consistent (both latent while the canary is default-OFF and SQLite
makes the two reads hit one file):

(a) Temporal non-atomicity — a concurrent enqueue/backlog-drain landing
    between the two reads produced a transient count mismatch → spurious
    critical + green→red Slack alert.
(b) Backend divergence (#300/#1093) — Side A (`db.get_queued_count`) honors
    `get_engine()`/DATABASE_URL; Side B read raw sqlite3 at DB_PATH. On
    Postgres those are two different databases, so B-01 compared Postgres
    truth to a stale/absent SQLite file (fatal under the Postgres direction +
    SQLite EOL, #1278).

Production-side residue of #1446 (PR #1452), which fixed only the test-harness
`sys.modules` leak and deferred these two gaps.

Fix (localized to B-01):
- New `_collect_queued_ids_via_engine` reads B-01's Side B through the SAME
  `get_engine()` seam as the accessor, on a dedicated `queued_ids_via_engine`
  snapshot field. Independent code path (SELECT id/literal 'queued' vs
  COUNT(*)/the QUEUED enum) — shares a database, not a code path, so a
  cache/status-filter regression still surfaces (non-tautology, AC #3). No
  cache / second count of the queue (AC #4).
- The collector performs one confirm-re-read on a mismatch: a transient race
  self-heals; a persistent drift survives and fires (AC #2). An engine-read or
  unconfirmable confirm degrades to a B-01 skip — it never compares an engine
  count against the raw-sqlite id-set (the blocker the reviews surfaced).
- `queued_exec_ids` (raw sqlite) stays untouched for B-02/E-02, so blast
  radius is B-01-only and the sibling #1077 merge stays clean.

The running-side / known-agents reads remain raw-sqlite (half-migrated
collector); the collector-wide migration + a dark-canary tripwire are a filed
follow-up.

Tests (test_canary_invariants.py): retarget the synthetic B-01 tests at
`queued_ids_via_engine`; add the AC #5 regression net — a diverged-backend
proxy (raw ≠ engine temp files) that false-fires pre-fix and is green
post-fix, a transient-race confirm-absorb + persistent-drift-still-fires pair,
and an engine-read-failure→skip test. New split fixtures (`canary_db_split` /
`reload_canary_split`) model the diverged backend without a live PG. Convert
the file to the sanctioned `_STUBBED_MODULE_NAMES` + `_restore_sys_modules`
escape hatch so its import-time stubs no longer leak (the #1446 mechanism) and
the reimport `del`s are lint-clean.

Docs: architecture.md Canary B-01 row updated for the engine-backed read path
+ confirm-re-read.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(feature-flows): add #1450 canary B-01 Recent Updates row

Sync-feature-flows: canary-internal change, no dedicated flow doc
(architecture.md is canonical); append one Recent Updates row.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Eugene Vyborov <eugene@beingluminous.com>
vybe added a commit that referenced this pull request Jul 8, 2026
* fix(agent): make agent /tmp tmpfs size configurable via AGENT_TMP_SIZE (#1231) (#1233)

Agent containers mounted /tmp as a hardcoded 100 MB noexec,nosuid RAM-backed
tmpfs. It fills easily — e.g. `gh` CLI install artifacts (~38 MB) that hardcode
/tmp and bypass the #1098 TMPDIR redirect — after which every /tmp write fails
with "No space left on device", including git's commit scratch, so autonomous
scheduled runs "complete" but silently fail to persist. The size being a
literal meant operators couldn't tune it without a code change + base-image
rebuild.

- capabilities.py: AGENT_TMPFS_MOUNT size now read from AGENT_TMP_SIZE (env on
  the backend service, which builds the agent mount spec), default 512m,
  validated `^\d+[mg]$` with empty/invalid → default. noexec,nosuid stay
  hardcoded — only size is configurable, and it stays bounded (counts against
  the agent memory cgroup). Single source of truth, so create (crud.py) and
  recreate (lifecycle.py) can't drift.
- Wire AGENT_TMP_SIZE=${AGENT_TMP_SIZE:-512m} on the backend service in both
  docker-compose.yml and docker-compose.prod.yml; document in .env.example.
- architecture.md Container Security: note the now-configurable size.
- tests/unit/test_1231_agent_tmp_size.py: default, valid m/g, case-fold,
  invalid→default, and the security flags are never dropped.

Mount specs are creation-time: existing agents pick up a new size on recreate,
not restart. Builds on #1098 (TMPDIR redirect) — closes the gap for tools that
hardcode /tmp. The agent-side git-sync silent-persist-failure is a separate
issue in the abilities repo, per the ticket.

Related to #1231

Co-authored-by: Eugene Vyborov <1073874+vybe@users.noreply.github.com>

* feat(ui): per-schedule performance scorecards on Agent Detail (#1115) (#1149)

Surface per-schedule performance on the Overview tab and the Schedules tab,
both from a SINGLE compact aggregate (no N per-schedule round-trips) — extends
#1107 (Overview) and generalises #868 (per-schedule deep analytics).

Backend:
- db `get_agent_schedules_summary(agent, hours)` — one rollup row per
  non-deleted schedule (zero-run schedules included): terminal success_rate
  (success / (success + failed[incl. error]); None when zero terminal),
  NULL-skipping avg_duration_ms, cost_total, context_avg, tool_call_total
  (parsed over newest 5,000 rows agent-wide, tool_calls_sampled flag), and
  last-run outcome. Cheap grouped SQL; iso_cutoff window (Invariant #16).
- GET /api/agents/{name}/schedules/analytics-summary?window=7d|14d|30d
  (AuthorizedAgent). Declared BEFORE /{schedule_id} in routers/schedules.py
  so the literal segment isn't captured as a schedule_id (Invariant #4) —
  putting it in analytics.py would be shadowed (schedules_router mounts first).
- models: ScheduleSummaryRow + AgentSchedulesSummaryResponse (Invariant #14).

Frontend (single fetch, two consumers — Invariant #7):
- executions.js fetchSchedulesSummary, cached per ${name}:${window} like
  fetchAgentAnalytics.
- OverviewPanel: "Schedules performance" section, honors the existing 7/14/30d
  window selector, each row deep-links to the Schedules tab; hidden at zero.
- SchedulesPanel: inline mini-stats per row (success rate, avg duration, runs,
  last-run dot) — badge style, no new chart/modal — from the same call.

Tests: tests/unit/test_1115_schedules_summary.py (6) — terminal success rate,
NULL-skip avg, tool-call total, zero-run-still-appears, soft-delete excluded,
out-of-window excluded. Full analytics suites green (30 passed). Frontend
prod build clean; endpoint verified live across 7/14/30d windows.

Related to #1115

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Eugene Vyborov <1073874+vybe@users.noreply.github.com>

* feat(ui): in-app bug reporting from the floating Help widget (#1116) (#1283)

* docs(readme): document the Trinity Ops Agent and PostgreSQL backend/migration (#1290)

Adds a top-of-README callout recommending PostgreSQL for production (SQLite remains the zero-config dev default, opt-in via DATABASE_URL, #300), links the public Trinity Ops Agent (trinity-ops-public) for instance operations, and documents migrating existing SQLite instances via its /migrate-to-postgres skill. Also adds a Database section, a DATABASE_URL env row, and an ops-agent entry in the docs index.

Co-authored-by: Eugene Vyborov <eugene@beingluminous.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(reliability): unify the SUB-003 auth-class failure classifier into one shared module (#1088) (#1297)

The `is_auth_failure` + `AUTH_INDICATORS` + `NON_AUTH_KILL_MARKERS` (#904)
logic was duplicated inline in `subscription_auto_switch.py` and
`scheduler/service.py`, kept in sync by a hand-written "keep these lists in
sync" comment — exactly how the #904 kill-marker bug class re-appears.

Consolidate into one canonical module:

- New `src/backend/services/failure_classifier.py` — canonical, pure-stdlib
  classifier (55 lines). `subscription_auto_switch.py` now re-exports
  `is_auth_failure` unchanged, so existing importers
  (`routers/chat.py`, `services/task_execution_service.py`) and their test
  patch targets keep working.
- New `src/scheduler/failure_classifier.py` — byte-identical vendored mirror.
  The scheduler runs in a separate container and cannot import
  `backend.services`; it uses the classifier for log-labelling only (picks the
  `logger.error` wording, never gates a switch). The agent-runtime classifier
  in `error_classifier` is intentionally NOT merged — it diverges semantically
  and stays kill-safe by `_classify_signal_exit` precedence (D4).
- Byte-identity is enforced by
  `tests/unit/test_904_sigkill_no_false_auth.py::TestBackendSchedulerParity`;
  the re-export is pinned by `TestBackendReExportGuard`. No hand-sync.

Pure structural refactor, no behavioral change (verified by SHA-256 equality
of the two copies and line-for-line comparison vs the deleted code). The test
rewrite also drops the prior `exec(compile(...))` source-slicing in favour of
`importlib` path-loading, removing the only injection primitive in scope.

Tests: 19/19 pass in `test_904_sigkill_no_false_auth.py`.
CSO --diff: CLEAR (docs/security-reports/cso-diff-2026-06-21.md).

Refs #1088

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(orchestration): pull-pilot routing for agent→agent MCP chat behind default-OFF flag (#946) (#1293)

* feat(orchestration): pull-pilot routing for agent→agent MCP chat behind default-OFF flag (#946)

Phase 2 PoC for pull/work-stealing (Epic #1045, umbrella #1081). When
MCP_AGENT_CHAT_PULL_ENABLED is ON, a sequential agent→agent (scope='agent',
non-self) chat_with_agent is routed by the MCP server through the durable async
/task path instead of the synchronous held /chat; the caller gets an immediate
{accepted|queued, execution_id} receipt and polls get_execution_result.
scope='user', self-tasks, and parallel=true are unchanged. Default OFF — flag
flip + MCP routing revert is the whole rollback.

- config.py: canonical MCP_AGENT_CHAT_PULL_ENABLED registry entry (both services
  read the SAME env key, so a single-.env deploy can't drift).
- settings.py: surface mcp_agent_chat_pull_enabled in /api/settings/feature-flags
  (auth-gated, observability-only — not a UI surface).
- chat.py: release the idempotency claim on the two /task dispatch-breaker-open
  (CircuitOpen) deny paths, mirroring /chat and CapacityFull (T5 fix) — without
  it a breaker-open reject silently blocks same-key retries for 24h.
- mcp-server: scope-based pull routing + D8 dispatch-mode idempotency token so a
  flag flip can't replay a wrong-shape snapshot across endpoints; startup log of
  the routing mode for the soak's control/treatment window.
- tests: chat.test.ts (routing fork + key behavior), test_946_task_idempotency_on_deny.py
  (deny-path claim release), feature-flag exposure tests.
- docs: ACTOR_MODEL_POSTCARD (#945 resolved), PULL_PILOT_946_SOAK harness +
  go/no-go record, CSO diff audit (CLEAR), TARGET_ARCHITECTURE/architecture updates.

Refs #946

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

* docs(feature-flows): document pull-pilot routing (#946) + AGENT_TMP_SIZE tmpfs (#1231)

Sync feature-flow docs with recent changes:
- agent-to-agent-collaboration.md: new Pull-Pilot Routing (#946) section —
  flag-gated MCP routing fork to the durable async /task path, poll-for-result
  receipt contract, D8 idempotency route token, feature-flag exposure, and the
  T5 /task dispatch-breaker-open deny-path claim release.
- container-capabilities.md: refresh stale tmpfs facts — agent /tmp size is now
  operator-configurable via AGENT_TMP_SIZE (default 512m, noexec,nosuid fixed),
  plus the TMPDIR=/home/developer/.tmp heavy-scratch redirect (#1098).
- feature-flows.md: add Recent Updates index rows for #946 and #1231.

Refs #946

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Eugene Vyborov <eugene@beingluminous.com>

* feat(agent): runtime data_paths with portable export/import (#1169) (#1294)

* feat(agent): runtime data_paths with portable export/import (#1169)

Declare an agent's runtime data (SQLite DBs, datasets) under data/ on the
already-durable home volume — no separate volume, no platform schema change.

- template.yaml `data_paths:` surfaced by template_service (github + local)
  and materialized at creation by crud.py -> git_service.materialize_data_paths:
  writes ~/.trinity/data-paths.yaml and appends data/ + entries to the agent's
  own .gitignore (idempotent). Opt-in; empty list is a no-op.
- S4 persistent-state and data_paths now share one extracted heredoc/list
  primitive (materialize_trinity_yaml_list / _read_trinity_yaml_list).
- New routers/agent_data.py: POST /data/export (stream | base64, 413 over cap,
  manifest-only tar when data/ missing) and POST /data/import (proxies to the
  agent-server restore primitive; data/** allowlist + traversal guard;
  Idempotency-Key). Both serialized per agent by a cross-worker Redis op lock.
- MCP tools export_agent_data / import_agent_data (Invariant #13).
- Validation checks DP-001..DP-005 in agent-validation-spec.
- Docs: architecture, requirements, feature-flows index + agent-data-volumes
  flow, agent guide; CSO diff audit report (0 critical/high).

Tests: ~30 unit + TestClient tests (export/import endpoints, allowlist,
gitignore, template surface). PR2 (scheduled snapshots + pre-snapshot
quiesce hook + retention/cascade) deferred.

Closes #1169

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

* test(agent-data): satisfy sys.modules pollution lint in #1169 tests

The two new data_paths test files copied the baselined `patch.dict` +
bare `del sys.modules[...]` loader from test_persistent_state_allowlist.py,
which the sys.modules pollution lint flags as NEW (non-baselined) violations.

Adopt the blessed snapshot/restore exception (precedent:
test_telegram_webhook_backfill.py): declare a top-level
`_STUBBED_MODULE_NAMES` list + an autouse `_restore_sys_modules` fixture, and
install the stubs / evict the cached module directly (the fixture owns
restoration). Removes the bare `del sys.modules[...]` entirely rather than
hiding it. Drop the now-unused `patch` import from the gitignore test.

Lint passes (no new violations); both files' 16 tests still green; no
cross-file leakage.

Refs #1169

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

* docs(feature-flows): sync recent changes — #1231 tmpfs, #1115/#1231 index rows

Fix container-capabilities.md for the now-configurable agent /tmp tmpfs
(#1231): default 100m → 512m via AGENT_TMP_SIZE, and correct the stale
full_capabilities ternary excerpts to the shared AGENT_TMPFS_MOUNT constant
(noexec,nosuid always applied, both modes). Add Recent Updates index rows for
the per-schedule performance scorecards (#1115) and the tmpfs-size fix (#1231);
the #1169 and #1116 rows already shipped in-commit.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Eugene Vyborov <eugene@beingluminous.com>

* fix(security): authenticate the in-container agent server on the shared agent network (#1159) (#1292)

* fix(security): authenticate the in-container agent server on the shared agent network (#1159)

The in-container agent server (:8000) had zero inbound auth on
trinity-agent-network: any agent could read a sibling's .env secrets or
run arbitrary Claude on it. Every backend->agent call now carries a
per-agent X-Trinity-Agent-Token = HMAC-SHA256(AGENT_AUTH_SECRET,
"trinity-agent-auth:v1:"+name), verified by a pure-ASGI middleware on all
HTTP and WebSocket routes (constant-time compare; only /health exempt).

- Derive-don't-store: the master AGENT_AUTH_SECRET lives only in the
  backend env (auto-generated by start.sh like SECRET_KEY); each container
  receives only its own token, so a compromised agent cannot compute a
  sibling's. Fail-closed -- derive raises on an empty secret.
- Callers route through services/agent_auth.py (agent_httpx_client /
  build_agent_auth_headers / merge_auth_headers); a static guard test
  fails any new raw agent-{name}:8000 caller that bypasses them.
- Removed the dead, unauthenticated /ws/chat route (ran arbitrary Claude)
  and the agent server's wildcard CORS (internal-only).
- Grace path for old images: empty TRINITY_AGENT_AUTH_TOKEN -> allow;
  check_agent_auth_token_env_matches forces a one-pass recreate to inject.
- Retired the unused src/scheduler/agent_client.py.

Tests: unit (matcher, middleware, header guard, derivation) + security
isolation test. CSO diff audit in docs/security-reports/cso-diff-2026-06-20.md.

Closes #1159

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

* docs(feature-flows): document agent-server authentication (#1159) + sync recent flows

Add a feature-flow doc for the in-container agent-server inbound auth shipped
in this PR, and sync the index with two recent merged changes.

- New feature-flows/agent-server-authentication.md: end-to-end trace of the
  derived X-Trinity-Agent-Token (HMAC over AGENT_AUTH_SECRET), the pure-ASGI
  middleware enforcing it on every HTTP/WS route, fail-closed vs grace path,
  recreate reconciliation, and the migrated callers + static guard. Added to
  the Authentication & Security catalog in the index.
- container-capabilities.md: refresh the stale /tmp tmpfs size (was a hardcoded
  100m) to the configurable AGENT_TMP_SIZE (default 512m, #1231).
- Recent Updates rows for #1159, #1231, and the previously-missing #1115.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Eugene Vyborov <eugene@beingluminous.com>

* docs(voip): genericize moved-issue reference in feature-flow

#1039 (configurable data-retention) moved to the private enterprise
tracker; replace the now-private issue number in voip-telephony.md with a
generic description so the public doc doesn't deep-link a private issue.

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

* feat(agents): server-side compatibility validation with auto-fix (#668)

Run ~100 best-practice checks (11 categories) against a running agent's
workspace, surfaced non-blocking in the Overview tab with one-click
auto-fix for the 10 gitignore checks, plus an MCP tool.

- services/compatibility/ package (spec/collector/static_checks/ai_checks/fixes):
  ONE docker exec -> in-container python -> JSON snapshot (secret files
  existence-only, size/binary caps); pure STATIC checks (HARD-only) +
  category-batched AI checks (Haiku, iterate-expected, fail-open, capped at
  SOFT, secret-redacted); runtime-aware (claude-only checks skipped for
  Codex/Gemini).
- GET/POST endpoints (read AuthorizedAgentByName; fix OwnedAgentByName, gitignore
  only, per-agent Redis lock, atomic write, uncommitted until next sync;
  include_ai path rate-limited). agent_compatibility_results table (dual-track
  SQLite + Alembic) persists the latest snapshot; cascade/rename via AGENT_REFS.
- CompatibilityPanel.vue (two-phase fetch, grouped checklist, per-check fix,
  re-run) in OverviewPanel; get_agent_compatibility_report MCP tool.
- 35 fixture-driven unit tests; spec sync-tested against docs/agent-validation-spec.md.

Persistence departs from the issue's "no DB table" note so AI verdicts show
without re-spend + enable fleet aggregation (see requirements section 41).

Fixes #668

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

* fix(compat): remove polynomial-ReDoS in secret-assignment regex (#668)

CodeQL py/polynomial-redos (high): `_ASSIGN_RE` captured the value as
`[ \t]*(.+?)[ \t]*$`. The lazy `.+?` and the surrounding `[ \t]*` can both
match a tab, giving polynomial backtracking when `_redact()` runs the pattern
over up to 48 KB of agent-supplied file text.

Capture the value greedily to end-of-line (`(.*)$`) and let the callers
strip — both `_looks_placeholder()` callers already `.strip()`, so secret
detection and redaction are behaviourally identical (verified: `=`, `:`,
`export`, and indented forms still match). 35 unit tests pass.

* feat(sso): OSS gated surface for enterprise SSO (OIDC) (#32)

Companion to trinity-enterprise#36. OSS carries only the entitlement-gated
surface; all SSO logic lives in the private submodule.

- Login.vue: "Sign in with <IdP>" buttons (shown only when the `sso` feature is
  entitled and a provider is enabled), plus OIDC callback-fragment handling
  (`/login#sso=ok|mfa|error`) — reuses the existing 2FA challenge UI when the
  IdP login still requires a local second factor.
- stores/auth.js: completeSsoLogin() (reuses _finalizeLogin / _setMfaChallenge)
  + fetchSsoProviders() (empty in OSS-only builds — endpoint 404s).
- Settings.vue: admin-gated "SSO" tab → SsoPanel.vue (provider CRUD + test +
  policy). Gated by enterpriseStore.isEntitled('sso'), same as the 2FA tab.
- Bump enterprise submodule to the SSO module commit.
- docs: architecture enterprise-modules row + requirements §40 (SSO/OIDC).

No new backend dependency (python-jose + httpx already in the image) and no
OSS Python changes — the mint/whitelist/mfa seams already exist.

Stacked on feat/5-2fa-totp (reuses the OSS mfa_gate + 2FA challenge surface).

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

* chore(sso): bump enterprise submodule to OIDC hardening (#32 review)

Pulls in the email_verified / issuer-pinning / login-CSRF fixes
(trinity-enterprise 87c8f97). OSS gated surface unchanged.

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

* docs(planning): note incubating goal-directed direction; reword voip flow

Add an "Incubating Directions (Not Yet Decided)" section to
TARGET_ARCHITECTURE.md capturing the goal-directed control-surface idea
(Objective + policies + roster + externally-measured evals), explicitly
bounded by CLAUDE.md §8 and sequenced after the pull migration + #300.
Incubating in trinity-enterprise#27.

Reword the voip-telephony flow note to drop a stale #1039 reference in
favor of describing the LOG_* data-retention no-op class directly.

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

* docs(security): add CSO 2026-06-21 posture report

Routine /cso full-audit posture report (Phases 0–14, daily 8/10 gate).
Follows the docs/security-reports/ convention; no real secrets reproduced.

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

* docs(user-docs): video library + per-page links, v0.6.1 What's New, sync dev features

- Add videos.md (35 published videos, newest-first by topic) and a README Watch section
- Add 'Watch' callouts to 32 feature pages linking the most relevant, newest videos
- Add user-facing whats-new/v0.6.1.md (translated from release notes; no issue numbers)
- Document dev-only features: agent runtimes (Claude Code/Codex/Gemini CLI, #1187),
  agent data paths + export/import (#1169), compatibility validation (#668),
  in-app bug reporting (#1116), subscription hot-reload (#1089), pull-pilot routing (#946),
  configurable AGENT_TMP_SIZE (#1231), Postgres migration-runner groundwork (#1160)
- Index agent-runtimes, agent-data, and the previously-orphaned agent-session pages

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

* feat(setup): first-run operator intake + admin email login (abilityai/trinity-enterprise#38, #82)

Capture an optional operator email/company at first-run setup with an explicit,
unchecked-by-default opt-in to "occasionally receive important security & product
updates", submitted once to a new /v1/operator-intake endpoint on #1116's
Cloudflare intake app. The same email binds as the admin's sign-in identity so
the operator can log in with email + password — no verification email is sent (a
fresh install has no Resend key; the email is bound, not code-verified). The
code-based email second factor stays Phase 2 on the existing mfa_gate seam.

- backend: operator_intake_service (fire-and-forget, at-most-once via a
  system_settings marker, DO_NOT_TRACK aware, owns installation_id); setup
  endpoint captures profile + binds admin email; authenticate_user resolves the
  admin by username OR registered email (password guard blocks code-only users);
  PUT /api/users/me/email for the existing-admin transition
- frontend: SetupPassword email/company + consent checkbox; Login "username or
  email" field; Settings -> General "Admin sign-in email" card
- config: OPERATOR_INTAKE_ENABLED / OPERATOR_INTAKE_URL (+ .env.example)
- docs: requirements section 43, architecture catalog, first-time-setup feature flow
- tests: 16 unit tests (intake idempotency/guards, email-login resolution, setup)

Fixes abilityai/trinity-enterprise#38
Fixes #82

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

* docs(feature-flows): index row for first-run intake + admin email login (trinity-enterprise#38, #82)

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

* fix(setup): de-ambiguate email regex to clear CodeQL polynomial-ReDoS (#82)

The _EMAIL_RE pattern duplicated into setup.py and users.py had two
[^@\s]+ atoms around the literal \. that both also match '.', giving the
engine many ways to place the dot and backtracking polynomially on
user-controlled email input (CodeQL alerts #211, #212).

Constrain only the final segment to [^@\s.]+ (no dot) so the trailing \.
can align with exactly one position -> linear matching. Behaviour is
unchanged: multi-subdomain addresses still validate; an 80k-char
pathological input now resolves in ~2ms.

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

* feat(credentials): curated credential file-type injection (SA keys, certs, SSH, binary) (#1305)

* feat(credentials): curated credential file-type injection — SA keys, certs, SSH, binary (enterprise#11)

Widens CRED-002 injection from the fixed 3-path exact allowlist
(.env/.credentials.enc/.mcp.json) to a curated set of credential file *types*,
without reopening the arbitrary-path RCE surface (#183/#590/#598).

- New services/credential_paths.py — single-source policy: ALLOW (.config/gcloud/**,
  .kube/config, *.pem/*.key/*.crt/*.cert/*.p12/*.pfx, .ssh/id_*, + existing exact set)
  with deny-precedence over anything executed/sourced at startup (shell rc,
  CLAUDE.md/AGENTS.md/.claude/**, .mcp.json.template, .ssh/authorized_keys/config,
  .git*, bin/**) and `..`/absolute traversal. Vendored byte-identically into the
  agent image (Invariant #5) with a parity test.
- Agent-server hardening: the inject + update file loops now enforce the policy AND
  a resolve-under-home traversal guard the original write path lacked; parent-dir
  creation + chmod 0o600 preserved. New GET /api/credentials/list for export discovery.
- Binary-safe: inject carries files_b64 (base64); agent writes via write_bytes.
  .credentials.enc gains a v2 {files, files_b64} envelope (legacy flat archives still
  decrypt); encrypt/decrypt stay flat for the single-secret callers (SIEM/2FA/SSO).
- Export now captures the FULL injected set (via /list) + binary, not just the 2 defaults.
- Three surfaces in sync (Invariant #13): MCP inject_credentials gains files_b64;
  frontend CredentialsPanel gains a file-upload affordance (text vs base64 auto-detected).
- Tests: allowlist test now exercises the REAL policy (newly-allowed + still-blocked),
  + credential_paths parity test + binary archive round-trip test. 61 pass.
- docs/memory/architecture.md: credential-path policy documented.

Related to Abilityai/trinity-enterprise#11. Loosens a deliberately-tight boundary —
run /cso on the diff before merge.

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

* fix(credentials): close /cso findings on the injection widening (#11 review)

Security review of the widening surfaced one HIGH regression + hardening items;
all fixed here.

#1 (HIGH, RCE): the #598 .mcp.json content-validation guard was bypassable via
the new files_b64 (binary) channel — validate_mcp_config only checked `files`,
so `files_b64={".mcp.json": base64(<stdio-command MCP server>)}` skipped it and
configured an RCE MCP server on the target agent. Fix: .mcp.json may only arrive
as TEXT (files), where it is validated; rejected in files_b64 at the backend
inject router AND the agent-server write helper.

#2 (defense-in-depth): import/auto-import wrote decrypted archives via the
agent-server /inject layer only. Added validate_credential_set() (curated path
policy + .mcp.json content + no-binary-.mcp.json) on the backend import boundary
so enforcement is dual-layer as the issue mandates. (Archives are AES-GCM with
the server key, so a forged archive wasn't practical — but the layer belongs.)

#3: .ssh/ is now locked to id_* only — a stray *.key/*.pem under .ssh is no
longer accepted (policy was previously broader than the "SSH keys = id_*" intent).

#4 (noted): .config/gcloud/** can hold a google-auth executable credential_source;
only honored under non-default GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES=1.
Documented in credential_paths.py.

+6 regression tests (169 pass). CSO report: docs/security-reports/cso-2026-06-22-11-diff.md.

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

* fix(credentials): exclude vendor dirs from cert globs + accurate export count (#11 live test)

Found while testing PR #1305 against a real local instance:

1. Over-capture: the broad *.pem/*.key/*.crt globs matched bundled CA files
   (e.g. .local/.../site-packages/certifi/cacert.pem), so export's /list walk
   swept vendored cert material into .credentials.enc. Added node_modules,
   site-packages, .local, .venv/venv, .cache, go/pkg to the deny-list (both
   root and nested forms) so cert globs only catch real credential files.

2. export's files_exported count re-read just the 2 default files (reported 1
   while the archive actually held 5). export_to_agent now returns the true
   captured count; dropped the redundant stale read.

Verified end-to-end on a live agent: allowed types inject (text+binary, 0600,
parent dirs), blocked paths 400 (incl. .ssh non-id_*, .mcp.json-via-files_b64,
weaponized .mcp.json text), and binary round-trips through export→import with
matching sha256. +5 regression tests (72 pass).

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(security): annotate CSO 2026-06-21 findings as remediated; drop stale voip hunk

Two review-driven fixes ahead of the v0.7.0 cut:

- Annotate the CSO posture report (.md + .json) with post-audit remediation
  status. The report audited `main` pre-cut and listed F1/F2/F3 as open
  VERIFIED findings; they are already remediated on `dev` and ship in v0.7.0:
    - F1 (unauth agent-server) -> #1159 X-Trinity-Agent-Token middleware
    - F2 (fastmcp -> hono/undici) -> #1255, #1289; fastmcp ^4.3.0
    - F3 (form-data CRLF via axios) -> #1254
    - F4/F5/F8 exploit path closed by #1159 (auth gate)
  Adds a top-of-report banner, per-row status tags, per-finding notes, and a
  machine-readable `remediation_status` block in the JSON. Avoids publishing a
  stale "open CRITICAL + exploit" to a PUBLIC repo without its fix context.

- Drop the voip-telephony.md reword: it is superseded by already-merged #1301,
  which made the identical `#1039` -> `LOG_*` change on `dev`. Restoring to
  merge-base removes the redundant/conflicting hunk from this PR.

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

* chore: set version to 0.7.0

* feat: streamline first-time setup wizard (abilityai/trinity-enterprise#49)

Drop the log-copied setup token, require an admin email, and rebuild the
first-run page as a welcoming animated welcome screen.

Backend (routers/setup.py, main.py):
- Remove the setup-token machinery entirely (ensure_setup_token /
  clear_setup_token / Redis-shared token + the main.py startup emission).
  Setup no longer depends on Redis — the admin write goes straight to SQLite.
- Make admin email REQUIRED (sign-in identity): missing -> 422 at the model
  layer; blank/typo -> 400, validated before any write so setup never
  half-completes. Password complexity (OWASP ASVS 2.1) still enforced.
- get_setup_status keeps setup_available:true for frontend back-compat.

Frontend (SetupPassword.vue):
- Full redesign: dark branded hero with an animated orbiting fleet
  constellation (Trinity mark core + agent nodes on three rings), split
  layout (stacks on mobile), prefers-reduced-motion aware.
- No setup-token field; email required; order email -> password (+confirm)
  -> company -> updates opt-in. Removed the Redis-wait panel + polling.

Security tradeoff (chosen: accept + document): removing the token leaves the
unauthenticated first-run window with no proof-of-control. Documented as an
operator responsibility (deploy behind a tunnel/VPN until setup completes) in
docs/DEPLOYMENT.md Security Recommendations; endpoint still self-disables
after first success. See docs/security-reports/cso-diff-2026-06-23.md (F1).

Docs: DEPLOYMENT.md security note, architecture.md, requirements.md
(§15.2/§43), feature-flows/first-time-setup.md.

Tests: remove obsolete test_1165_setup_token_shared.py; update test_setup.py
(no token, email required) and test_setup_operator_profile.py (email
required, model-layer + blank/invalid rejection). 7 operator-profile unit
tests pass; new contract verified live (422/400 negative paths).

Fixes abilityai/trinity-enterprise#49

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

* fix(setup): full-bleed setup screen via normal flow, not position:fixed

The redesigned first-run page used `position:fixed; inset:0` for its root.
On wider viewports this left a band of the light `#app` (bg-gray-100)
background showing through on the right/bottom — a fixed root is clipped to
the nearest transformed/contained ancestor instead of the viewport, so its
coverage isn't guaranteed.

Switch the root to the original component's proven normal-flow approach
(`position:relative; width:100%; min-height:100vh`), which fills the
full-width `#app`, and make the decorative aurora/grid `position:absolute`
within it. Verified covering the full viewport at 2560x1440 (light mode, the
repro case) and stacking correctly at 430px.

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

* fix(tests): defer routers.setup import so unit collection can't be corrupted

The CI backend-unit regression gate runs `cd tests && pytest unit/` (the whole
unit suite). test_setup_operator_profile.py imported `routers.setup` at module
(collection) time; that import — pulling in database/dependencies/services and
their many `utils.*` leaves — failed/perturbed sys.modules during collection and
INTERRUPTED the entire `unit/` collection (head collected ~2 of 2734 → the diff
gate flagged it as a new failure).

Defer the `import routers.setup` to a cached `_get_setup()` accessor used inside
the tests, so module collection imports only stdlib/pytest/fastapi/pydantic and
can never corrupt the suite. `_get_setup()` also spec-preloads the backend
`utils.*` leaves (helpers/errors/credential_sanitizer/password_validation/
url_validation/image_optimize) the same way conftest preloads `utils.helpers`,
without touching `sys.modules["utils"]`, so the import resolves cleanly at run
time regardless of harness utils state.

Verified with the exact CI command (`cd tests && pytest unit/ --co`): the full
suite now collects 2734 items with no interruption, and the 7 setup tests pass.
No conftest changes.

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

* fix(tests): drop sys.modules preload — plain lazy import (passes #762 lint)

The previous commit's `_get_setup()` spec-preloaded backend utils leaves via
`sys.modules[...] = …` / `.pop`, which tests/lint_sys_modules.py (#762) bans
outside conftest. It's also unnecessary: in the backend-unit gate
(`cd tests && pytest unit/`), tests/unit/conftest.py already installs
src/backend/utils as the canonical `utils` package, so a plain lazy
`import routers.setup` resolves the backend `utils.*` leaves natively.

Simplify `_get_setup()` to a cached plain lazy import — no sys.modules
mutation. Verified: lint clean (no new violations), `pytest unit/ --co`
collects 2734 with no interruption, and the 7 setup tests pass.

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

* fix(tests): update #858 guards for setup-token removal (#49)

Removing the setup token (trinity-enterprise#49) deleted
`routers/setup.py::ensure_setup_token` and the lifespan token emission, so two
#858 regression guards asserted gone behavior and failed in the backend-unit
gate:
  - test_ensure_setup_token_logs_token_via_logger_warning
  - test_lifespan_emits_setup_token_via_logger_before_event_bus

The #858 invariant itself is intact: the lifespan still emits the first-run
notice via `logger.warning` (not print), after setup_logging() and before
event_bus.start(). Replace the token-specific guard with one that matches the
new FIRST-TIME SETUP warning by content + ordering, drop the now-obsolete
ensure_setup_token guard, and remove the unused BACKEND_SETUP constant. The
Dockerfile PYTHONUNBUFFERED parity checks and the no-print-in-lifespan guard are
unchanged. 4 passed.

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

* feat(whatsapp): deliver ChannelResponse.files as Twilio MediaUrl (#1315)

WhatsApp agents can now send files to users. send_response delivers
ChannelResponse.files as Twilio MediaUrl attachments (one message per file,
text first), reaching parity with the Slack adapter.

- New create_share_from_bytes() persists in-memory bytes through the FILES-001
  pipeline (MIME-blocklist/quota/disk/DB) and mints a public ?sig= URL; both it
  and create_share now share the extracted _persist_and_register helper.
- Per-agent file_sharing_enabled gate; 1h share TTL (cleanup reaper purges).
- Caps (image/audio/video ~5MB, documents ~16MB) on the detected MIME; graceful
  text-link fallback when public_chat_url is unset/non-HTTPS, the MIME is
  unsupported, or the file is oversized — never silently dropped.
- Per-file isolation: a rejected/failed file never aborts the text or siblings.
- 42 unit tests; requirements.md + whatsapp-integration.md updated.

Fixes #1315

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

* fix(whatsapp): webhook routes to backend, not frontend (#1281) (#1316)

The WhatsApp panel's deployment-prerequisite notice told operators to route
/api/whatsapp/webhook/* to the "frontend service". That path is a backend
FastAPI route (Twilio HMAC-verified); pointing tunnel ingress at the static
SPA silently drops inbound messages. Corrected to the backend service
(http://backend:8000), matching the cited PUBLIC_EXTERNAL_ACCESS_SETUP.md.

Related to #1281

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(ui): loading skeletons for Dashboard graph & timeline (#1266) (#1312)

The initial fleet/metrics load can take 20s+ on 10+ agent fleets (#1265);
until now the Dashboard rendered the "No agents" empty state (or blank
timeline) during that wait, so the UI looked frozen/broken.

- New reusable `SkeletonLoader.vue` (dark-mode aware, accessible
  role=status/aria-busy, reserves space to avoid layout shift) with `rows`
  (timeline/list) and `nodes` (collaboration graph) variants.
- `stores/network.js`: add `loading` (defaults true so the first paint is a
  skeleton, not the empty state) + `loadError` (distinct failed-load state),
  toggled in `fetchAgents` (finally-cleared so a failure never shows an
  infinite skeleton).
- `Dashboard.vue`: graph canvas and timeline now render skeleton → error →
  empty → content off those flags. Loading shows immediately on nav; error
  states offer a Retry (reuses `refreshAll`).

Frontend-only; pairs with the backend perf work in #1265. `vite build` passes.

Related to #1266

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(db): set SQLite end-of-support to September 1, 2026 + Postgres migration notes (#1278) (#1314)

Records the firm SQLite end-of-support date and the SQLite → PostgreSQL
migration announcement/guidance. Documentation/decision only — SQLite code
removal stays with the migration work (#300/#1183/#746).

- docs/migrations/SQLITE_TO_POSTGRES.md (new): authoritative guide — EOL date,
  what changes and when, switching a fresh deployment (DATABASE_URL + postgres
  profile), migrating an existing deployment (backup-first; no turnkey data-copy
  tool yet — honest cutover options), verification, and release-notes copy.
- docs/releases/v0.6.2.md (new, draft): EOL announcement section linking the
  guide, seeding the next release notes.
- docs/planning/TARGET_ARCHITECTURE.md + docs/memory/architecture.md (Invariant #3):
  reference the EOL date so it's discoverable outside the release.
- Cross-links the in-repo reminder companion (#1279).

Related to #1278

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: stop disclosing enterprise functionality in public docs (trinity-enterprise#45) (#1311)

* docs: stop disclosing enterprise functionality in public docs (trinity-enterprise#45)

The public repo documented the full design, feature catalog, and gating
strategy of the paid enterprise tier — a free blueprint of what we monetize
and how it's built. This removes that competitive content and keeps only the
generic open-core seam public.

- Delete 4 strategy/design docs (OSS_ENTERPRISE_SPLIT_RESEARCH,
  ENTERPRISE_ARCHITECTURE, feature-flows/enterprise-modules, ENTERPRISE_LOCAL_DEV)
- architecture.md "Enterprise Modules" table -> neutral seam pointer
  (no paid-feature catalog, no enterprise_* table DDL, no per-module detail)
- requirements.md §35 -> abstract EntitlementService seam (drop the enumerated
  module list + dead links to the deleted strategy docs)
- audit-trail.md: neutralize the lone enterprise-pillar mention
- CLAUDE.md: standing rule — enterprise designs live only in trinity-enterprise
- CI: enterprise-docs-guard.yml fails the build if live public docs reintroduce
  paid-feature / private-schema tokens

Content is preserved (relocated to the private trinity-enterprise repo, see the
companion PR). Git-history scrub of the deleted files + point-in-time historical
docs (archive/, releases/, security-reports/) tracked as a follow-up.

Related to Abilityai/trinity-enterprise#45

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

* ci(enterprise-docs-guard): add least-privilege permissions block

Clears CodeQL actions/missing-workflow-permissions (medium). The guard only
checks out and greps, so contents: read is sufficient.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(releases): add 0.7.0 release notes

* fix(#1115): port get_agent_schedules_summary to SQLAlchemy Core (Postgres-safe)

The #300 SQLAlchemy migration dropped the get_db_connection import from
db/schedules.py but left get_agent_schedules_summary (#1115) calling it,
so the /schedules/analytics-summary endpoint raised NameError at runtime.
Surfaced for the first time by the v0.7.0 release-PR full-suite run (dev
pushes only lint).

Port the method to get_engine() Core queries like its siblings, and
replace the SQLite-only bare-column-with-MAX last-run query with a
portable ROW_NUMBER() window so it works on PostgreSQL too.

Also refresh the test_login_rate_limit_split config stub, which went
stale when auth.py grew a PUBLIC_ACCESS_REQUESTS_ENABLED dependency
(trinity-enterprise#10) — 8 collection errors under HEAD.

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

* feat(voip): per-agent VoIP config panel + persisted voice (abilityai/trinity-enterprise#28)

Add the missing per-agent VoIP config UI (agent Settings/Sharing tab) and a
persisted per-agent Gemini voice. Shipped as plain OSS gated on the existing
voip_available platform flag — NOT entitlement-gated (a UI gate over a
money-spending OSS backend would be cosmetic; deliberate simplification of the
issue's original "entitlement-gated" framing).

Backend:
- agent_ownership.voice_name (default Kore) via dual-track migration
  (SQLite db/migrations.py + Alembic 0004 + schema.py/tables.py). db
  get/set_voice_name with read-path fallback to Kore for unset/invalid values.
- GET/PUT /api/agents/{name}/voice/name (PUT owner-only, validated against
  GEMINI_VOICE_NAMES). _get_voice_name and voip_service now read the persisted
  voice instead of the two hardcoded "Kore" sites.
- PUT /api/agents/{name}/voip/enabled toggle (owner-only, 404 when no binding);
  create_binding upsert no longer forces enabled=1 so re-saving credentials
  preserves a disabled state (call path already refuses disabled bindings).

Frontend:
- VoipChannelPanel.vue (modeled on WhatsAppChannelPanel) mounted in SharingPanel
  under voip_available; shared src/constants/voices.js (drift-guarded vs backend);
  AgentWorkspace picker defaults to the persisted voice; sessions store surfaces
  voip_available.

Tests: tests/unit/test_28_voip_voice_config.py — voice fallback/roundtrip/
invalid->default, enable toggle + re-PUT-preserves-disabled (H3), and the
frontend/backend voice-list drift guard. Schema-parity + voip-db guards green.

Refs abilityai/trinity-enterprise#28

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

* test(voip): HTTP-level endpoint tests for /voice/name + /voip/enabled (#28 review I1)

Pre-landing /review flagged that the new endpoints were covered only at the DB
layer. Add FastAPI TestClient tests (mount real routers, override auth deps, stub
db/voip_service) asserting:
- PUT /voice/name: owner-gated (403), 400 on unknown voice, empty clears to
  default, valid voice persists; GET returns voice_name + available_voices.
- PUT /voip/enabled: owner-gated (403), 404 when no binding / when voip flag off,
  200 reflecting state with no auth_token leaked.

Also capture a durable learning (docs/memory/learnings.md): the schema-parity
test is blind to db/tables.py drift — a missing Column there passes parity but
breaks at runtime; guard it with a db-accessor unit test that executes a live
select on the new column.

Refs abilityai/trinity-enterprise#28

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

* refactor(docs): agent-readable zero-to-value onboarding (#1280) (#1336)

* refactor(docs): agent-readable zero-to-value onboarding (#1280)

Make the repo's entry points machine-first so an autonomous agent can self-orient and reach a useful result without a human translating context.

- AGENTS.md: add a "Using this file" machine-contract header (declares it the authoritative agent entry point, states the AGENTS/CLAUDE/README boundary, explains how to traverse). Rebuild Route-by-task with an explicit "Done when" zero-to-value signal per persona.
- CLAUDE.md: cross-link to AGENTS.md and frame CLAUDE.md as the contributor working agreement (auto-loaded by Claude Code), not the agent landing page.
- Fixes from a context-free agent onboarding test (AC#5 validation): AGENTS.md deploy verify no longer assumes an undefined $TOKEN (leads with `trinity agents list`, shows token derivation); deploy section states the running-instance prerequisite; README CLI example adds the `trinity agents list` verify step; docs/CLI.md leads with `pip install trinity-cli` (PyPI) and marks `-e src/cli/` as the from-source/dev variant.

Validated by an agent performing a zero-to-value deploy task using only repo files, no human context: self-oriented in 2 hops (README -> AGENTS.md) to a correct deploy+verify answer; friction items above are its findings, folded back in.

Repo-root AGENTS.md is hand-authored; the CLAUDE.md->AGENTS.md mirror (#1187) is per-agent-container (startup.sh), so these edits are conflict-free.

Related to #1280

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(templates): machine-readable starter-template catalog (#1280)

Affordance sweep for agent self-selection. Previously an agent had to `ls`
config/agent-templates/ (24 dirs, 7 of them test fixtures) and open each
template.yaml to find a starting point.

- config/agent-templates/README.md: catalog grouping the 17 real templates
  (single-purpose: scout/sage/scribe/demo-*/trinity-system; the dd-* due-
  diligence suite) with one-line affordances, how-to-use, and an explicit
  "not starting points" list for the test/canary fixtures
- AGENTS.md: link the catalog from the Deploy-an-agent section so the
  zero-to-value path is "pick a ready-made template", not "author from scratch"

Related to #1280

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* fix(tests): un-quarantine and fix the 15 unmasked unit failures (#1103) (#1338)

Removes the @pytest.mark.skip quarantines added in #300 and fixes the
underlying issues. Each group fixed at root cause, not by matching assertions
to current behavior.

Environmental (git identity):
- tests/unit/conftest.py: set GIT_AUTHOR/COMMITTER_NAME/EMAIL process-wide so
  in-test `git commit` works on a CI runner with no global git identity
  ("Author identity unknown"). Fixes test_reset_preserve_state_guardrails (3)
  and the test_git_pull_branch end-to-end setups.

Test-setup bug (production code was correct):
- test_git_pull_branch.py: the repos did `git push -u origin main` but `git init`
  defaults to `master` (no init.defaultBranch), so origin/main never existed and
  _get_pull_branch correctly fell back to the working branch — the assertions
  expecting "main" failed. Force `git init -b main` (local + bare). Fixes all 5
  (TestGetPullBranch 2 + TestGitPullFromMainEndToEnd 3).

Test-isolation bug (assertions were correct):
- test_orphaned_execution_recovery.py: shared module-level mocks were reset with
  plain reset_mock(), which keeps return_value/side_effect — so one test's
  get_agent_container.side_effect bled into later tests under random ordering,
  skewing recovery counts ("assert 3 == 2"). Reset with
  reset_mock(return_value=True, side_effect=True). Stable across 5 seeds.

Real lint findings:
- docker/base-image/startup.sh: shellcheck now exits 0. Converted the 4 fragile
  file-iteration loops to `find -print0 | while read` (SC2010/SC2045/SC2044),
  hardened 8 `cd` with `|| exit 1` (SC2164), split the SC2155 export, and
  documented-disabled SC2001 on the two regex `sed` lines that ${//} can't
  express. `bash -n` clean. Un-skips test_startup_sh_shellcheck_clean.

backlog (3) and 929 (1) were already un-quarantined on dev (db_harness schema),
so no change needed there.

Verified: the 11 un-skipped tests pass across multiple random seeds; full unit
suite shows no regressions from these changes (the unrelated pre-existing
test_1115_schedules_summary / test_admin_email_login failures fail on dev too).

Related to #1103

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* chore(deps-dev): bump happy-dom (#1326)

Bumps the patch-and-minor group in /tests/git-sync with 1 update: [happy-dom](https://github.com/capricorn86/happy-dom).


Updates `happy-dom` from 20.10.5 to 20.10.6
- [Release notes](https://github.com/capricorn86/happy-dom/releases)
- [Commits](https://github.com/capricorn86/happy-dom/compare/v20.10.5...v20.10.6)

---
updated-dependencies:
- dependency-name: happy-dom
  dependency-version: 20.10.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: patch-and-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps-dev): bump @types/node in /src/mcp-server (#1327)

Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 25.9.3 to 26.0.0.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 26.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump fastmcp (#1325)

Bumps the patch-and-minor group in /src/mcp-server with 1 update: [fastmcp](https://github.com/punkpeye/fastmcp).


Updates `fastmcp` from 4.3.0 to 4.3.2
- [Release notes](https://github.com/punkpeye/fastmcp/releases)
- [Commits](https://github.com/punkpeye/fastmcp/compare/v4.3.0...v4.3.2)

---
updated-dependencies:
- dependency-name: fastmcp
  dependency-version: 4.3.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: patch-and-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump the patch-and-minor group (#1329)

Bumps the patch-and-minor group in /src/frontend with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [axios](https://github.com/axios/axios) | `1.18.0` | `1.18.1` |
| [@playwright/test](https://github.com/microsoft/playwright) | `1.61.0` | `1.61.1` |
| [autoprefixer](https://github.com/postcss/autoprefixer) | `10.5.0` | `10.5.1` |
| [@rollup/rollup-darwin-arm64](https://github.com/rollup/rollup) | `4.62.0` | `4.62.2` |
| [@rollup/rollup-linux-arm64-musl](https://github.com/rollup/rollup) | `4.62.0` | `4.62.2` |


Updates `axios` from 1.18.0 to 1.18.1
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.18.0...v1.18.1)

Updates `@playwright/test` from 1.61.0 to 1.61.1
- [Release notes](https://github.com/microsoft/playwright/releases)
- [Commits](https://github.com/microsoft/playwright/compare/v1.61.0...v1.61.1)

Updates `autoprefixer` from 10.5.0 to 10.5.1
- [Release notes](https://github.com/postcss/autoprefixer/releases)
- [Changelog](https://github.com/postcss/autoprefixer/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/autoprefixer/compare/10.5.0...10.5.1)

Updates `@rollup/rollup-darwin-arm64` from 4.62.0 to 4.62.2
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.62.0...v4.62.2)

Updates `@rollup/rollup-linux-arm64-musl` from 4.62.0 to 4.62.2
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.62.0...v4.62.2)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.18.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: patch-and-minor
- dependency-name: "@playwright/test"
  dependency-version: 1.61.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: patch-and-minor
- dependency-name: autoprefixer
  dependency-version: 10.5.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: patch-and-minor
- dependency-name: "@rollup/rollup-darwin-arm64"
  dependency-version: 4.62.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: patch-and-minor
- dependency-name: "@rollup/rollup-linux-arm64-musl"
  dependency-version: 4.62.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: patch-and-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* feat(executions): propagate cancelled terminal status end-to-end (#679) (#1333)

Defense-in-depth follow-up to #671. Make the agent task-runner aware that an
operator cancel happened and surface a third terminal outcome — `cancelled` —
alongside success/failed, so a cancel is never recorded as a billable success
or an agent failure.

Agent server:
- ProcessRegistry records a `_terminated[execution_id]` marker on a successful
  SIGINT send; `was_terminated()` (read-only, 300s lazy TTL, cleared on
  register) lets the sync chat handler and async result callback relabel a
  graceful-exit-0 / SIGKILL->504 turn as cancelled.
- `record_task_finish` accepts a neutral finish (success=None): a cancel
  neither resets nor increments the dispatch-breaker failure counter (#526).

Backend:
- 3-way status map (success->SUCCESS, cancelled->CANCELLED, else->FAILED) in
  the async callback (routers/agents.py) and the sync applier
  (task_execution_service). An auth/rate terminal is never reclassified as a
  cancellation — guarded at the backend trust boundary too (CSO finding 2).
- Consumers (message_router, chat, paid, public, validation_service) treat
  cancelled as non-delivery; paid no longer settles on cancel (money bug).
- terminate writes CANCELLED only when it actually stopped a running turn; on
  already-finished the agent's real terminal stands (Issue 7).

Tests: 9 new unit suites (64 cases) + execution-termination integration
additions; 85 unit tests pass locally. CSO diff audit: CLEAR.

Refs #679

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(ui): unify Chat + Session into one Chat tab with a session-mode toggle (#1112) (#1340)

Collapse the redundant Chat + Session tabs on Agent Detail into a single "Chat"
tab carrying a "Session mode" toggle (default ON), keeping the legacy stateless
surface as a first-class user-selectable mode rather than dead code.

- AgentDetail.vue: single `{ id: 'chat' }` tab (drop the separate Session entry).
  New `chatMode` ref ('session'|'legacy', default 'session') persisted per-user in
  localStorage['trinity.chatMode']. `sessionAvailable` = feature flag on AND
  runtime has --resume (not Codex); `effectiveChatMode` forces legacy when the
  Session surface is unavailable and hides the toggle. The toggle swaps
  SessionPanel ↔ ChatPanel in-place (v-if). isFullscreenTab keys on the single
  'chat' id; `?tab=session` aliases to 'chat' (hinting session mode).
- Execution-resume: ExecutionDetail "continue as chat" (?tab=chat&resumeSessionId)
  forces legacy ChatPanel (which owns resume) via a transient, non-persisted
  routeForcedMode — without rewriting the user's saved preference.
- No backend change (session_tab_enabled already exists). MobileAdmin unaffected:
  its openChat is a self-contained mobile chat overlay, not an AgentDetail
  deep-link, so there is nothing to repoint.
- docs: architecture Session Tab block + requirements §5.8 note.

Related to #1112

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* feat(access): Access tab — manage Trinity operators per agent (trinity-enterprise#17) (#1317)

New Access tab on Agent Detail that manages Trinity operators (platform users)
with access to an agent, distinct from the Sharing tab's external channel
clients. Draws the operator-vs-client line on the read path.

Backend:
- db.get_agent_operator_access(): outer-joins agent_sharing × users on the
  grantee email (lower-cased, engine-based → PG+SQLite). Resolved → active
  operator (username/role/last_active); unresolved → pending invite.
- GET /api/agents/{name}/access (AgentOperatorAccess model). Add/remove reuse
  the existing /share + /share/{email}.

Frontend:
- AccessPanel.vue: operator roster (status + role badges, last-active), add by
  email, remove. Access tab wired into AgentDetail (owner-gated).
- SharingPanel.vue: Team Sharing allow-list removed (moves to Access); dead
  share-management script + stale "Team Sharing below" copy cleaned/repointed.
- stores/agents.js: getAgentAccess().

Tests: active-vs-pending classification + agent scoping. vite build passes.

Note: the strict client-vs-operator split (non-user emails → a dedicated client
roster) is deferred to the Sharing-side redesign (#18/#20); removing them here
now would orphan those grants, so all allow-list entries stay visible on Access.

Related to Abilityai/trinity-enterprise#17

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(ui): slim the Overview executions-by-type bars

The "Executions by type" stacked bars rendered full-width with a 1px
gap, so a busy agent's week read as a solid wall of color. Cap each
bar at 56px and center it inside its (still full-width) hover column,
and soften the top corner. The column stays flex-1 so spacing/tooltips
are unchanged and wider windows (14d/30d) thin naturally.

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

* feat(voip): add Gacrux to the Gemini Live voice picker

Adds the "Gacrux — Mature" prebuilt voice to the per-agent VoIP voice
selector (and the shared AgentWorkspace per-session picker, which reads
the same list). Updated in lockstep across the three mirrored sources so
the frontend↔backend parity test stays green:

- src/frontend/src/constants/voices.js — single frontend source of truth
- src/backend/config.py GEMINI_VOICE_NAMES — write-validation allowlist +
  read-path fallback
- tests/unit/test_28_voip_voice_config.py — hardcoded parity tuple

Follow-up to #1323 (per-agent VoIP config panel + persisted voice).

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

* feat(ui): reframe Sharing tab to external-client sharing via channels (#1347)

Part of the Access/Sharing redesign (Epic trinity-enterprise#16). The Access
tab (#17) already owns Trinity operators; this scopes the Sharing tab to the
operator → external-client surface.

- Google-Docs-style "Share this agent" framing; operator language removed
  (operators live on the Access tab).
- External access policy collapsed into one **Restricted ↔ Open** segmented
  control over require_email/open_access (Restricted = approval-gated, Open =
  anyone verified; identity proof always on for external sharing).
- Pending requests kept, reframed as external clients awaiting approval.
- Channels rendered as compact collapsible summary rows (new
  ChannelDisclosure.vue). Detailed config stays reachable inside the expanded
  row as a non-regressing interim seam — #19 replaces the body with a modal
  dialog. No channel functionality removed.
- Outbound file sharing + public links nudged into a separate "Distribution"
  section (distribution, not client access).

Frontend-only; no API changes. SharingPanel prop/emit contract unchanged.
Verified: both SFCs compile (vue/compiler-sfc), design-token check passes.

Related to abilityai/trinity-enterprise#18

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(architecture): trim architecture.md under the 150k-char context limit (#1344)

architecture.md had grown to ~156.7k chars (on dev), past the 150k
soft limit Claude Code warns about when auto-loading it each session
(it's `@`-imported by CLAUDE.md). Over the limit the file risks silent
truncation and eats a large slice of the context window every session.

Compressed the densest Cross-Cutting Subsystem narratives, the migration
and non-root-container invariants (#3/#17), a few catalog/endpoint rows,
and the longest frontend UI prose — preferring summary + pointer where a
dedicated `feature-flows/` doc already owns the deep detail (the doc's own
editorial rule). Result: 156.7k → 149.3k chars (~4.8% smaller).

No facts dropped: every issue tag, field name, default, and error-string
is preserved; protected SQLite DDL (tracked by /validate-schema) untouched;
heading/code-fence/table counts unchanged; all 12 added flow-doc links
resolve.

Related to the over-limit warning surfaced in Claude Code.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(deps): bump js-yaml from 4.2.0 to 5.1.0 in /src/frontend (#1330)

Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 4.2.0 to 5.1.0.
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/4.2.0...5.1.0)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 5.1.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* fix(ci): guard the Alembic (Postgres) track in schema-parity (#1342) (#1345)

* fix(ci): guard the Alembic (Postgres) track in schema-parity (#1342)

The schema-parity required check validated only the SQLite track
(migrations.py ↔ schema.py). A schema change that ships the SQLite
migration but omits the Alembic revision under
src/backend/migrations/versions/ passed every required check green yet
broke PostgreSQL — init_database() runs alembic_runner.upgrade_to_head(),
which applies revision files only and does not autogenerate from
tables.py. Two PRs reached "green CI but PG-broken" and had to be held by
hand.

Add a cross-track guard, folded into the existing required schema-parity
job (no new required-check to manage):

- scripts/ci/check_alembic_parity.py — fails a PR that ADDS schema DDL to
  db/{migrations,schema,tables}.py without a net-new revision file under
  src/backend/migrations/versions/. Pure stdlib, PR-only (diffs base...head).
- Heuristic / false-positive guard: the signal is a DDL keyword on an
  *added, non-comment* line (SQL: CREATE/ALTER/ADD COLUMN/…; SQLAlchemy:
  Column(/Table(/Index(/…). Comment edits, data-only and down migrations
  carry no DDL keyword, so they don't trip it. Documented in the script
  docstring and the workflow header.
- tests/unit/test_alembic_parity_guard.py — 20 tests incl. the acceptance
  fixtures (SQLite-only change fails; dual-tracked passes; comment/data-only
  pass). Wired into the parity pytest run.

Also notes the enforcement in architecture.md Invariant #3.

Verified locally: 24 tests pass; end-to-end smoke across clean / SQLite-only
(exit 1) / paired-revision (exit 0) scenarios behaves correctly.

Related to #1342

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

* fix(ci): tighten Alembic guard to require a new MIGRATIONS entry (#1342)

Verifying against real repo history surfaced a false positive: the
migration-runner refactor #1263 (_atomic_rebuild table rebuilds) re-emits
CREATE TABLE / CREATE INDEX for *existing* tables in a rename-swap but adds
no actual schema and no new MIGRATIONS entry — yet the original "any added
DDL keyword" heuristic flagged it, violating AC #4 (non-schema edits must
not trip).

Tighten the signal to two conjuncts: a schema change must (1) register a
net-new ("name", _migrate_fn) entry in the MIGRATIONS list AND (2) carry a
DDL keyword. Runner refactors / table rebuilds add no entry → exempt;
data-only new migrations carry no DDL → exempt; real column/table adds do
both → caught.

Validated against real commits:
  • #740 agent_loops, #526 agent_ownership column → FAIL (correctly blocked)
  • #668 compat, voice_name (both shipped an Alembic revision) → PASS
  • #1263 runner refactor → PASS (false positive fixed)
A full post-Alembic history scan finds 0 outstanding missing revisions, so
no backfill is owed; the pre-Alembic columns are already in 0001_baseline.

28 tests pass (added MIGRATIONS-entry detection + the #1263 rebuild case).

Related to #1342

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Eugene Vyborov <eugene@beingluminous.com>

* docs(dependab…
obasilakis added a commit that referenced this pull request Jul 9, 2026
…hase 0)

Dual-track (SQLite db/migrations.py + Alembic) nullable columns on
schedule_executions:
- claim_token, lease_expires_at, claimed_by_worker (Alembic 0015)
- redelivery_count, distinct from #678 retry_count (Alembic 0016)

DDL mirrored in db/schema.py + db/tables.py + db_models.py so fresh builds
stay correct (invariant #3). All columns nullable and unread until later
phases wire them, so this is a true no-op for the existing fleet.

Alembic revisions renumbered 0015/0016 during rebase (re-parented onto dev
head 0014_agent_schedules_webhook_auth). The original PG1 env.py version_num
widening is dropped — superseded by dev's #1420 (0008a_widen_alembic_version).
vybe pushed a commit that referenced this pull request Jul 10, 2026
…04 (Phase 4) (#1497)

* test(canary): unblock canary_invariants fixture broken by #1472 merge

Two pre-existing breakages landed via #1472 (367a5e1, merged today) in
root-level tests/test_canary_invariants.py — a file the CI unit job
(tests/unit/ only) does not run, so they merged undetected:

1. Duplicate `CREATE TABLE agent_schedules` in the `canary_db` fixture DDL
   — the E-06 work added a second definition next to the pre-existing one,
   so `executescript` raised "table agent_schedules already exists" and
   every fixture-dependent canary test errored. Removed the older, unused
   block (its `message`/`owner_id` columns have no consumer; `_add_schedule`
   and the E-06/L-03 collectors use only the kept block's columns).
2. `test_run_invariants_all` expected registry set omitted "E-06" (added to
   the invariants registry by the same PR). Added E-06 to the expected set
   and asserted it is green on a clean platform.

Restores a green baseline (92 passed) so the #1450 B-01 work can be verified.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(canary): B-01 queue-status coherence — backend-consistent Side B + confirm-re-read (#1450)

B-01 compared two reads that were neither temporally atomic nor
backend-consistent (both latent while the canary is default-OFF and SQLite
makes the two reads hit one file):

(a) Temporal non-atomicity — a concurrent enqueue/backlog-drain landing
    between the two reads produced a transient count mismatch → spurious
    critical + green→red Slack alert.
(b) Backend divergence (#300/#1093) — Side A (`db.get_queued_count`) honors
    `get_engine()`/DATABASE_URL; Side B read raw sqlite3 at DB_PATH. On
    Postgres those are two different databases, so B-01 compared Postgres
    truth to a stale/absent SQLite file (fatal under the Postgres direction +
    SQLite EOL, #1278).

Production-side residue of #1446 (PR #1452), which fixed only the test-harness
`sys.modules` leak and deferred these two gaps.

Fix (localized to B-01):
- New `_collect_queued_ids_via_engine` reads B-01's Side B through the SAME
  `get_engine()` seam as the accessor, on a dedicated `queued_ids_via_engine`
  snapshot field. Independent code path (SELECT id/literal 'queued' vs
  COUNT(*)/the QUEUED enum) — shares a database, not a code path, so a
  cache/status-filter regression still surfaces (non-tautology, AC #3). No
  cache / second count of the queue (AC #4).
- The collector performs one confirm-re-read on a mismatch: a transient race
  self-heals; a persistent drift survives and fires (AC #2). An engine-read or
  unconfirmable confirm degrades to a B-01 skip — it never compares an engine
  count against the raw-sqlite id-set (the blocker the reviews surfaced).
- `queued_exec_ids` (raw sqlite) stays untouched for B-02/E-02, so blast
  radius is B-01-only and the sibling #1077 merge stays clean.

The running-side / known-agents reads remain raw-sqlite (half-migrated
collector); the collector-wide migration + a dark-canary tripwire are a filed
follow-up.

Tests (test_canary_invariants.py): retarget the synthetic B-01 tests at
`queued_ids_via_engine`; add the AC #5 regression net — a diverged-backend
proxy (raw ≠ engine temp files) that false-fires pre-fix and is green
post-fix, a transient-race confirm-absorb + persistent-drift-still-fires pair,
and an engine-read-failure→skip test. New split fixtures (`canary_db_split` /
`reload_canary_split`) model the diverged backend without a live PG. Convert
the file to the sanctioned `_STUBBED_MODULE_NAMES` + `_restore_sys_modules`
escape hatch so its import-time stubs no longer leak (the #1446 mechanism) and
the reimport `del`s are lint-clean.

Docs: architecture.md Canary B-01 row updated for the engine-backed read path
+ confirm-re-read.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(feature-flows): add #1450 canary B-01 Recent Updates row

Sync-feature-flows: canary-internal change, no dedicated flow doc
(architecture.md is canonical); append one Recent Updates row.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(canary): fix duplicate agent_schedules DDL + reconcile E-06 in registry test

The canary_db fixture had two `CREATE TABLE agent_schedules` statements in one
executescript (a #1472 merge artifact), so it raised `table already exists` and
reddened the whole file. Merge them into one definition carrying every column
the canary reads (next_run_at/enabled/deleted_at + agent_name). Add
duration_ms/queued_at/backlog_metadata to schedule_executions and extend
_add_execution for the #1077 E-03/E-04 collector work. Reconcile E-06 (already
registered) into test_run_invariants_all's expected key-set.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(canary): terminal-row collector for E-03/G-03 (#1077)

Add _collect_terminal_rows(window_seconds) + Snapshot.terminal_rows. Windowed on
started_at (not completed_at, so E-03 can see NULL-completed_at rows), scoped to
success/failed/cancelled via a local _E03_TERMINAL_STATUSES subset that excludes
skipped (which legitimately has no completed_at/duration_ms). PRAGMA guard skips
the source entirely when completed_at/duration_ms are absent (column-absent !=
value-NULL) rather than false-firing. Window = max per-agent timeout + 300s;
bounded ORDER BY started_at DESC LIMIT 5000 with a logged sampled flag (no
(status,started_at) index over 90-day retention; tripwire, not backfill audit).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(canary): register E-03 (completed_at populated) + G-03 (clock sanity) (#1077)

E-03 (A/major): terminal rows must have completed_at NOT NULL. Predicate is
completed_at-only — the catalog's + duration_ms clause false-fires on healthy
queue-terminated rows (cancel/fail/expire set completed_at but never
duration_ms). G-03 (A/minor): started_at <= completed_at with a ~1s cross-worker
clock-skew tolerance, UTC-aware parsing (E-06 _to_utc shape) so a #1474 mixed
naive/Z pair compares without raising. Both are leading-edge tripwires over the
shared terminal-row collector and catch all producers incl. the standalone
scheduler's raw-SQL writers a unit test never exercises.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(canary): E-03/G-03 synthetic + collector + end-to-end coverage (#1077)

Per-invariant synthetic tests (holds-clean, fires-on-violation), collector tests
(started_at window in/out, NULL-completed_at still collected, skipped-status
excluded, column-absent DDL -> unavailable, LIMIT cap + sampled flag), and
end-to-end collect_snapshot tests through the real collector: the C1
cancelled-from-queue holds-clean guard (completed_at set, duration_ms NULL ->
zero E-03), half-written fires E-03, bad-clock fires G-03, sub-second skew does
not, and the #1474 naive-vs-Z compare-without-raising case.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(canary): document E-03/G-03 Phase 4 invariants (#1077)

architecture.md canary table gains E-03/G-03 rows + Phase 4 lookup-key line;
requirements/infrastructure.md §31 gains a Phase 4 bullet (and reconciles the
stale 'Phase 2 deferred' note now that #882/#1472 shipped);
orchestration-invariant-catalog.md annotates E-03/G-03 as shipped with explicit
registry-id mapping, notes the E-03 completed_at-only predicate deviation and
G-03 started_at<=completed_at reduction, and flags the catalog-id vs registry-id
E-06 drift (catalog #129 != registry #1472).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(feature-flows): index row for canary Phase 4 E-03/G-03 (#1077)

Canary is an internal invariant harness (no user-facing flow / dedicated flow
doc); follows the #1446 precedent of a Recent Updates row pointing at
architecture.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(canary): queued-row metadata collector for E-04/G-04 (#1077)

Capture queued_at + backlog_metadata for status='queued' rows in the
existing _collect_executions query, keyed by execution_id in a new
AgentSnapshot.queued_meta map. Both columns are PRAGMA-guarded (added by
BACKLOG-001): when either is absent on an older/minimal DDL the map is
left empty so E-04/G-04 skip those eids (older-image fail-open). Scoped
STRICTLY to queued rows — never terminal — so #1449's deferred
terminal-row backlog_metadata NULL-out cannot make E-04 false-fire.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(canary): register E-04 (queued metadata) + G-04 (no creds in metadata) (#1077)

E-04 (Tier A, major): every status='queued' row has queued_at NOT NULL
AND a non-NULL, JSON-parseable backlog_metadata — the
backlog_service.drain_next replay contract. A malformed blob raises
JSONDecodeError and stalls the FIFO. Reports only the failed-predicate
reason code + ids, never the raw metadata (may carry credentials;
violations persist to canary_violations).

G-04 (Tier A, critical): a queued row's backlog_metadata matches no
known secret prefix (sk-/ghp_/gho_/ghs_/ghu_/github_pat_/xoxb-/xoxp-/
AKIA/AIza/sk_live_), word-boundary anchored so common substrings don't
false-fire. Rides E-04's collected bytes. Reports only the matched
pattern NAME + ids, one violation per row (stops at first match) — never
the secret, surrounding bytes, or raw metadata.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(canary): E-04/G-04 synthetic + collector + end-to-end coverage (#1077)

Collector test proves queued_meta is populated for queued rows only (a
terminal row carrying backlog_metadata is excluded — #1449-safe). E-04:
holds on valid rows; fires with the right reason code on NULL queued_at,
NULL backlog_metadata, and non-JSON metadata; skips an eid absent from
queued_meta (older-image fail-open); e2e over a real temp DB. G-04:
holds on benign metadata (incl. "task-" substring that must not
false-fire); fires on github_pat / openai / slack / aws exemplars; skips
NULL metadata (E-04 owns it). Every G-04/E-04 test asserts the secret /
raw metadata bytes appear NOWHERE in the persisted violation record. The
runner test now expects E-04 + G-04 in the registered invariant set.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(canary): document E-04/G-04 Phase 4 invariants (#1077)

architecture.md lookup-key table gains E-04 + G-04 rows and the Phase 4
line now lists all four (E-04/G-04 stacked on #1450). requirements
infrastructure.md Phase 4 bullet expands to the full four-predicate set
with the credential-safety note. Catalog flips E-04/G-04 from
"gated on #1450" to SHIPPED, records the json.loads-vs-json_valid
implementation note, the queued-only scope, older-image fail-open, and
the report-reason/pattern-name-only security discipline; G-04 notes the
implemented check covers the backlog half of the title (log-line
scanning out of scope for #1077).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(feature-flows): index row for canary Phase 4 E-04/G-04 (#1077)

Also corrects the header/separator ordering left malformed by the
earlier E-03/G-03 index-row commit (a data row had slipped above the
|---| separator).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Eugene Vyborov <eugene@beingluminous.com>
vybe pushed a commit that referenced this pull request Jul 14, 2026
…k) + pilot (#1081) (#1550)

* docs(pull-migration): consolidate handoff/orchestration/testing docs

Fold the ephemeral session docs into two durable status docs and keep the
wire-contract specs separate:

- docs/planning/PULL_MIGRATION_STATUS.md  <- ORCHESTRATOR_SESSION_CONTEXT.md
  + PULL_MIGRATION_SESSION_HANDOFF_2026-07-07.md + PRD.json
- docs/testing/PULL_MIGRATION_TESTING.md  <- PULL_MIGRATION_TEST_PLAN.md
  + PULL_MIGRATION_RECONCILED_TESTING.md

MESSAGE_ENVELOPE_SCHEMA.md (#945) and ACTOR_MODEL_POSTCARD.md stay as
separate cross-linked reference docs. Removes root-level PRD.json /
ORCHESTRATOR_SESSION_CONTEXT.md clutter. Docs only.

* feat(pull-migration): dark schema for claim/lease/redelivery (#1081 Phase 0)

Dual-track (SQLite db/migrations.py + Alembic) nullable columns on
schedule_executions:
- claim_token, lease_expires_at, claimed_by_worker (Alembic 0015)
- redelivery_count, distinct from #678 retry_count (Alembic 0016)

DDL mirrored in db/schema.py + db/tables.py + db_models.py so fresh builds
stay correct (invariant #3). All columns nullable and unread until later
phases wire them, so this is a true no-op for the existing fleet.

Alembic revisions renumbered 0015/0016 during rebase (re-parented onto dev
head 0014_agent_schedules_webhook_auth). The original PG1 env.py version_num
widening is dropped — superseded by dev's #1420 (0008a_widen_alembic_version).

* feat(pull-migration): pull coordination core — claim/CAS, lease-reaper, capacity meter (#1081 Phase 1+3)

Dark backend pull path (no production caller; push path unchanged):

- GET /api/internal/next-task: atomic claim of the oldest queued row for an
  agent, stamps claim_token/lease_expires_at/claimed_by_worker, status->running.
- POST /api/internal/tasks/{id}/result: CAS-applies a terminal (status
  precondition + claim_token match); idempotent replay.
- Dual-auth on both seams: X-Internal-Secret OR the agent's own scoped MCP key
  (authorize_heartbeat) — no master secret in an agent container (#1159/#307).
- lease-reaper (services/lease_reaper_service.py + cleanup _sweep_expired_leases):
  under MAX_REDELIVERY re-queue the SAME execution_id (redelivery_count++), at
  cap FAIL + park to the operator queue. Re-delivery preserves execution_id so
  effect_guard/#525 keep deduping (#1402 invariant).
- lease_expires_at IS NULL exclusion on 6 non-reaper selectors so leased rows
  are owned exclusively by the reaper; park/requeue registered in the #1082
  status-guard.
- capacity shadow meter (count_active_leased_by_agent -> CapacityManager
  get_all_states/get_slot_state): read-only; admission stays on the ZSET.

Fixes C1 (found on real PG): claim_next_queued double-claimed -> double-RAN a
row up to N x under READ COMMITTED (uncorrelated InitPlan subquery + no outer
status re-check). Adds FOR UPDATE SKIP LOCKED + outer status='queued' re-check;
real-PG multi-process race 0/25 double-claims (was 25/25). Also keeps
claim_token on park/requeue so a late SUCCESS can CAS-overwrite (B2/B5) and
creates the operator alert before the park write (B3).

MAX_REDELIVERY=3 and PULL_MODE_PILOT_AGENTS (default empty) in config.py.

* feat(pull-migration): agent worker pool + PULL_MODE pilot gating (#1081 Phase 2)

- agent_server/services/pull_worker.py (wired in agent_server/main.py): bounded
  pool (N = max_parallel_tasks) that short-polls next-task, runs the turn, POSTs
  the result with claim_token; decorrelated-jitter backoff mirroring #1083
  result_callback. Persists the completed terminal to
  ~/.trinity/pending-pull-results/<eid>.json before delivery, re-sends leftovers
  on startup, and drains on shutdown so an already-billed turn is never dropped
  (B6).
- services/agent_service/pull_mode.py: injects TRINITY_PULL_MODE /
  TRINITY_MAX_PARALLEL_TASKS ONLY for agents in the PULL_MODE_PILOT_AGENTS
  allowlist, at create (crud.py) + recreate (lifecycle.py). Default empty
  allowlist => no agent opts in => proven no-op.
- B1: recreate pops PULL_MODE_ENV_KEYS before re-applying so a de-piloted agent
  clears the baked flag; the claim seam's agent-key path is allowlist-gated.
- G2: recreate setdefaults TRINITY_BACKEND_URL so a legacy agent opted into pull
  keeps a backend URL (matches the #1098 TMPDIR idiom).

Worker auths with Bearer ${TRINITY_MCP_API_KEY}; no master secret injected.

* feat(pull-migration): canary S-01 lease-awareness (#1081)

Exclude leased rows (lease_expires_at IS NOT NULL) from the S-01 slot-row
bijection via an additive snapshot.running_lease_expires_at, so a pull pilot
does not false-fire in_sql_only. A genuine slot-row mismatch on a non-pull
row still fires. E-05/E-01 are the same class and remain open (see
docs/testing/PULL_MIGRATION_TESTING.md T3.6/T3.7 — apply before opt-in or
retire with the ZSET at Phase 5).

* chore(pull-migration): forward PULL_MODE_PILOT_AGENTS to backend (#1081 G1)

The backend service uses an explicit environment: list (not env_file), so the
documented pilot opt-in (set PULL_MODE_PILOT_AGENTS in .env + restart) was a
no-op. Forward PULL_MODE_PILOT_AGENTS=${PULL_MODE_PILOT_AGENTS:-} so the flag
reaches the backend process from the tracked compose alone.

* docs(pull-migration): rollback runbook (#1081)

Tiered rollback plan for the pull-coordination change:
- Tier 0: empty PULL_MODE_PILOT_AGENTS + restart (instant off-switch for a
  misbehaving pilot; result-report path stays open so in-flight results land).
- Tier 1: redeploy previous image for a push-fleet regression; leave the
  additive nullable columns (expand/contract-safe, no DB step).
- Tier 2: DB downgrade rarely/never needed.
Plus pre-deploy checklist, detection signals (incl. the G3 canary-on-PG blind
spot), scenario->response table, and the Phase-5 point-of-no-return note.
Linked from PULL_MIGRATION_STATUS.md.

* test(pull-migration): committed C1 concurrency regression + document dark schema columns (#1081)

Review follow-ups (I1/I2):
- I1: add TestClaimConcurrencyC1 — a Postgres-only, multi-thread regression for
  the C1 double-claim fix. N workers race N queued rows through the REAL
  claim_next_queued; a correct claim is an exact N-way partition, so removing
  the FOR UPDATE SKIP LOCKED + outer status='queued' guards (all N would claim
  the head row) fails the bijection. Skips on SQLite / when TEST_POSTGRES_URL is
  unset. Replaces the torn-down scratch harness; verified passing on real PG.
  The prior suite only proved SEQUENTIAL no-double-claim, which passes with or
  without the fix.
- I2: document the 4 dark pull columns (claim_token / lease_expires_at /
  claimed_by_worker / redelivery_count) in architecture.md's schedule_executions
  schema — they ship to every instance, so the DDL block was stale.

* docs(learnings): claim-subquery InitPlan double-claim race (#1081 review)

Durable bug class from the /review of #1550: an UPDATE ... WHERE id = (scalar
subquery) compiles to a once-evaluated InitPlan, so under Postgres READ
COMMITTED every concurrent updater re-applies to the same id unless the mutated
predicate is re-checked in the OUTER WHERE and/or FOR UPDATE SKIP LOCKED is
used. Also records why a sequential claim test cannot catch it.

* chore(pull-migration): wire pilot flags into prod compose + document dark surfaces (#1081)

Address /validate-pr #1550 findings:
- W1 (config packaging, #1056 class): PULL_MODE_PILOT_AGENTS + MAX_REDELIVERY
  were read by the backend (config.py) but the .env levers didn't reach a prod
  deploy — prod compose launches standalone (no env_file merge). Wire both into
  docker-compose.prod.yml + docker-compose.yml backend.environment and document
  them in .env.example, so the pilot opt-in is actually settable on prod (still
  default-empty = fully dark).
- W2 (architecture doc): catalog the dark pull surfaces — the two internal
  claim/result seams on pull_router, the additive lease-reaper in the Cleanup
  Service, and the capacity shadow meter (metering-not-admission).

* fix(pull-migration): uniform 404 on the result seam — close execution-existence oracle (#1081)

CSO finding (LOW): POST /api/internal/tasks/{execution_id}/result returned 404
for a missing execution but 403 for an existing-but-not-owned one — a
cross-tenant existence oracle reachable by any valid agent-scoped MCP key,
against all executions, even with pull-mode OFF (the endpoint's auth is inline,
not allowlist-gated). Violated the self-uniform enumeration-safety invariant
(#186) and deviated from the #1083 callback (agents.py:1045) it claims to
mirror. Bounded to LOW by 128-bit token_urlsafe(16) execution_ids (not
enumerable), but it ships live.

Fold the missing-and-not-owned cases into a uniform 404. Adds a #186 regression
test asserting a not-owned and an unknown execution are indistinguishable (same
status + body).

* docs(security): CSO --diff audit report for the pull-migration surface (#1081)

Point-in-time security audit of PR #1550's new attack surface (pull seams,
worker, reaper, config). 1 LOW finding (execution-existence enumeration oracle
on the result seam) — fixed in 4294488 — plus clean phases + the
scoped-key-over-master-secret improvement. Historical record under
docs/security-reports/ (out of the enterprise-docs-guard scope).

---------

Co-authored-by: Eugene Vyborov <eugene@beingluminous.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
obasilakis pushed a commit that referenced this pull request Jul 17, 2026
…recovery (#1664, #1665, #1667) (#1666)

* fix(cleanup): stop the orphan-volume sweep destroying a renamed agent's live home volume (#1664)

Rename keeps the agent's Docker data volumes — Docker can rename neither a
volume nor its immutable `trinity.agent-name` label — so after a rename both
name and label permanently describe an agent that no longer exists. The #1581
orphan sweep read that self-description as ownership truth, found no agent by
that name, and force-removed what is actually the LIVE agent's
`/home/developer`. Only Docker's in-use 409 stood in the way; during any
container recreate (guardrails / resources / shared-folder change, operator
rebuild) the volume is briefly unattached, and a sweep landing in that window
silently and unrecoverably destroyed the agent's #1169 `data_paths` data.

Ownership is now a DB question, not a volume-label question:

- `agent_ownership.volume_base_name` (NULL = `agent_name`, so no backfill)
  pins the volume identity, written in the same statement as the rename and
  frozen across re-renames. `db.is_volume_base_reserved` replaces
  `is_agent_name_reserved` as the sweep's orphan predicate; soft-deleted rows
  still match, so the recovery window holds for renamed agents too.
- The purge path resolves the base BEFORE deleting the row that holds it, so a
  renamed agent's real volumes are the ones reclaimed (they leaked before).
- Agents renamed before the column existed are healed at boot from Docker's own
  mount table (`_heal_renamed_volume_bases`, one-shot, idempotent, ahead of the
  startup sweep) — the pin can never overwrite an existing one.

Two further fail-safe gates, since this sweep force-removes durable user data
(#1638: cleanup must fail safe):

- A volume mounted by ANY container is skipped. Fail-closed: if the mount table
  can't be read, the whole cycle is skipped rather than reclaiming blind.
- A candidate must be seen unattached for 3 consecutive cycles (~15 min) — the
  recreate gap is seconds, so one unattached sighting is not evidence of an
  orphan. Any attached sighting resets the streak.

Verified against a real Docker daemon (four scenarios): the pre-#1664 ownership
answer destroys the volume; with the pin it survives 8 unattached cycles; a
mounted-but-unowned volume survives; a genuine orphan is still reclaimed, but
only after the streak.

Dual-track migration per invariant #3 (SQLite `agent_ownership_volume_base_name`
+ Alembic 0024). 23 new tests; full unit suite green.

Related to #1664

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

* fix(db): expose the #1664 volume-identity methods on the DatabaseManager facade

`_sweep_orphan_agent_volumes`, the purge path and the boot heal all call
`db.is_volume_base_reserved` / `get_volume_base_name` / `set_volume_base_name`,
but the facade had no pass-throughs — every one would have raised
AttributeError at runtime, and the sweep swallows exceptions per-cycle, so the
reclaim would have silently degraded to a no-op forever.

Caught by the `test_database_facade_delegation` guard (the unit tests mock `db`,
so they cannot see a facade gap).

Related to #1664

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

* fix(cleanup): a renamed agent owns volumes under BOTH bases, not just the pin (#1664)

Self-review catch: my own predicate had the same data-loss shape it was meant
to close, one layer over.

`get_public_volume_name` / `get_shared_volume_name` name off the LIVE agent
name, so a renamed agent creates `agent-{new}-public` while its workspace keeps
`agent-{old}-workspace` — it legitimately owns volumes under two bases. The
predicate matched only the pin, so `is_volume_base_reserved("new-name")` was
False for a live agent, and the public volume — which is unmounted whenever
file-sharing is toggled off, defeating the attached-check — was reclaimed after
the 3-cycle streak. Verified against a real daemon: the live agent's public
volume was deleted.

Ownership is now the UNION of both identities (agent_name OR volume_base_name),
which is also strictly safer than the pre-#1664 predicate it replaces
(`is_agent_name_reserved` is exactly the first branch), so it can only protect
more, never less. A purged row matches neither, so genuine orphans are still
reclaimed — re-verified end-to-end.

The purge path had the mirror gap: removing only the pinned base leaked
`agent-{new}-public` forever. It now removes both (deduped, so an un-renamed
agent is one call as before).

Related to #1664

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

* docs(architecture): the volume-ownership predicate is a union of both bases (#1664)

Related to #1664

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

* docs(learnings): identity-by-self-description vs identity-by-ownership (#1664)

Related to #1664

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

* fix(agents): refuse to create an agent whose data volumes still belong to another (#1664)

Rename frees the NAME while the agent keeps its volumes under the old base
(Docker can rename neither a volume nor its immutable label). `crud.py`'s volume
block is get-then-create — an existing volume is REUSED, not rejected — so a new
agent created under the freed name silently booted on the renamed agent's
`/home/developer`: its `.env`, its `.credentials.enc`, its workspace, with both
containers writing the same disk. The two owners need not be the same person, so
this is a cross-tenant credential disclosure, not just corruption.

Verified against a real daemon before the fix: a second agent created under the
freed name read the first agent's ANTHROPIC_API_KEY out of its `.env`.

The create gate now also consults `db.is_volume_base_reserved` (the #1664
primitive) and 409s before any container or volume work.

Partial: this closes the rename-driven case, where a live row still claims the
base. A volume orphaned by a failed/pending reclaim has no row at all, so the
predicate says "free" and the get-then-create reuse still applies — the create
path needs to refuse-or-adopt an existing volume explicitly. Filed separately.

Related to #1664

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

* fix(cleanup): never purge a volume base another agent still claims (#1664)

Second self-review catch, and a regression from this branch's own both-bases
purge. `volume_base_name` carries no unique constraint, and installs predating
the create-time gate can hold a collision the gate now prevents: agent `new`
pinned to base `old`, PLUS a live agent literally named `old` — both on the same
volumes (that shared-volume state is exactly the bug the create gate closes, so
it exists in the field wherever a freed name was reused).

Purging `new` resolved base `old` and removed `agent-old-*` — the LIVE `old`
agent's home volume. Before the both-bases change, purge touched only
`agent-new-*`, so this branch widened the blast radius rather than inheriting it.

The purged row is already gone by then, so a still-True `is_volume_base_reserved`
means a DIFFERENT row claims the base: its data is live and not ours to drop.
Skip it (WARNING) and let the orphan sweep reclaim it if it ever goes unowned.

Verified against a real DB + daemon: the purge completes, removes 0 volumes, and
the live agent's volume survives.

Also drops the now-dead `and_` import the union-predicate edit left behind.

Related to #1664

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

* fix(agents): exempt ghosts from the volume-base gate; stub it in the fork tests

CI regression-diff caught the create gate 409ing all 9 test_fork_to_own tests:
that module mocks the whole `database` module, and an unstubbed MagicMock
attribute is truthy — so the new `db.is_volume_base_reserved` call read as "base
taken" and refused every create in the module. Test-harness gap, not a
production one: fork destinations, deploy pre-populate (#950) and versioned
redeploy all use fresh names with no owning row. Stubbed explicitly, with the
trap noted for the next person to add a db-gated check.

Also exempts ephemeral agents from the gate. Ghosts are volume-less by
construction (the volume block is `if not config.ephemeral`), so there is
nothing for them to collide with, and gating them put a DB read on the
burst-spawn path for no reason.

Related to #1664

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

* fix(agents): recovery must mount a renamed agent's real workspace volume (#1665)

Rename keeps the agent's volumes under the pre-rename base (Docker can rename
neither a volume nor its immutable label), so for a renamed agent the CURRENT
name names no volume. `recreate_missing_container` (#1559 soft-delete recovery)
f-stringed that name — and `containers.run` CREATES a missing named volume
rather than failing, so recovery silently rebuilt the agent on an empty
`/home/developer` while its real data (incl. #1169 `data_paths`) sat
unreferenced under the old base. Silent, not loud: the agent comes back looking
healthy, just empty.

Verified against a real DB + daemon, before: recovery mounts
`agent-{new}-workspace` -> "<volume does not exist -> docker CREATES it empty>".
After: mounts `agent-{old}-workspace` -> the agent's real data.

Three sites, one rule — resolve through the ownership row, never the name:

- `recreate_missing_container`: the data-loss one, now via the new
  `_workspace_volume_name` (fail-safe: a DB error falls back to the agent name,
  the pre-#1665 behavior and correct for every un-renamed agent).
- `_read_template_yaml_from_volume`: same trap one level down — it read nothing
  for a renamed agent, so recovery rebuilt on DEFAULT agent-type/runtime instead
  of the committed ones. Now recovers `researcher`/`codex` correctly.
- `recreate_container_with_updated_config` carried a DEAD `agent_volume_name =
  f"agent-{agent_name}-workspace"` assignment that read as though it were the
  mount in use (it isn't — mounts are carried forward from the old container,
  which is what keeps a renamed agent on its volume across a recreate). Removed
  and replaced with a note, since a live-looking wrong name is how this bug
  class spreads.

`deploy.py`'s `agent-{version_name}-workspace` is correct by construction (a
fresh name, no row, no rename in its past) and is annotated as audited rather
than changed.

Also stubs `get_volume_base_name` in the #1559 recreate test: it mocks the whole
database module, and an unstubbed MagicMock attribute is truthy — it would be
f-stringed straight into the volume name (same trap that broke test_fork_to_own
in #1666).

Related to #1665

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

* fix(agents): adopting a pre-existing workspace volume is a decision, not a fallthrough (#1667)

The create path provisioned the home volume with get-then-create and no branch:
an existing `agent-{name}-workspace` was silently mounted as the new agent's
`/home/developer`. Whatever the previous holder of that name left behind — its
`.env`, its `.credentials.enc`, its workspace — resurfaced inside a different
agent, possibly a different owner's. Verified against a real daemon in #1666:
the new agent read the old one's ANTHROPIC_API_KEY.

#1664's gate covers a volume some ROW still claims (the rename case). This
covers the volume NOTHING claims: a purge whose removal hit an in-use 409 and
was skipped, a crash between `volume_create` and the ownership INSERT (creation
writes the volume first — the reason the orphan sweep carries a 1h creation
grace), or a restored backup.

Emptiness cannot be the discriminator — the fact that reshaped this fix:
deploy-local (#950), the ONE legitimate adopter, PRE-POPULATES the volume with
the template before calling create, so a valid adopt is non-empty. (Docker also
auto-populates a named volume from the image on first mount, so "empty" would
not even identify a crashed create.) So the adopter declares itself:
`adopt_existing_workspace` — a function kwarg, mirroring `skip_name_sanitization`,
deliberately NOT a field on AgentConfig, where a caller could set it and re-open
the disclosure. deploy passes True; everyone else gets a 409 naming the volume
and how to clear it. An adopt is also logged now, so it is never silent again.

Raised BEFORE the docker try-block: that block catches Exception, rolls back and
re-raises as a generic 500, so a 409 inside it would lose both its status and
its message (the same reason fork_to_own raises its destination 409 before the
block). Nothing is built at that point, so there is nothing to roll back either.
Fail-open on a probe error, and ghosts skip it (volume-less by construction).

Unaffected by design: `system_agent_service` builds its recycled-name container
directly rather than through `create_agent_internal`; the Cornelius seeder is
first-run-only behind a durable flag.

Also fixes the fork test harness, which stubbed `volume_get` as a bare AsyncMock
— i.e. "yes, that volume exists" to every probe, the opposite of reality for the
fresh names those tests create. NotFound is the truthful default (raised as the
harness's own `_NotFound`, since it rebinds `docker.errors.NotFound`).

Related to #1667

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

* fix(agents): gate rename on volume-base ownership — one row per base, at both producers (#1671)

Review catch (@obasilakis on #1666): #1664 established that one ownership row
claims one volume base and gated CREATE on it, but rename is the other producer
of `volume_base_name` and was left ungated. An ordinary ops swap mints the
collision the create gate refuses:

    rename `prod` -> `prod-old`    (row keeps pin `prod`)
    rename `staging` -> `prod`     (name free, base NOT free)  <- was allowed

Two rows then claim base `prod`. `get_public_volume_name` names off the LIVE
name (deliberate, per the is_volume_base_reserved docstring), so the new `prod`
get-then-creates onto the old agent's `agent-prod-public` — the #1667
silent-adopt disclosure through the ungated path. It also strands both bases
forever: with two claimants the purge guard skips them and the orphan sweep
never reclaims them, so this incompleteness weakened THIS PR's own guard, which
assumes a single claimant.

Gated at both layers, per the reviewer's stronger suggestion and the #1445
pattern: `db.rename_agent` re-checks inside its transaction (the chokepoint —
closes the check-then-write gap and covers future callers), and the router gates
before the container is stopped/renamed so a refusal leaves nothing half-done
and returns an actionable 409 rather than that path's generic 500.

`is_volume_base_reserved` grows `exclude_agent`: "does anyone ELSE claim this
base?". The reviewer's suggested one-liner would have refused a LEGITIMATE
rename-back — an agent renamed `B`->`A` keeps pin `B`, so renaming it to `B`
must be allowed (it owns that base; the result has one claimant), and the naive
gate told the owner their own volumes belonged to someone else. Verified against
a real DB: the swap is refused, exactly one row claims the base afterwards, and
`B`->`A`->`B` still works.

Also fixes the migration docstring the review flagged: the boot heal is
`CleanupService._heal_renamed_volume_bases` (services/cleanup_service.py), not
`heal_renamed_volume_bases` (db/agents.py) — that docstring is exactly where
someone goes to understand why the backfill is a no-op.

Closes #1671
Related to #1664

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
vybe pushed a commit that referenced this pull request Jul 17, 2026
…#1676)

* feat(agents): editable display label with an immutable slug (ent#181)

An agent had exactly one name — its slug — so "rename" meant the heavyweight
identity change: `PUT /rename` stops the container, rewrites ~20 tables, clears
every per-agent Redis keyspace, renames avatar files, and STILL leaves the
agent's volumes under the old base, because Docker can rename neither a volume
nor its immutable `trinity.agent-name` label. That path is the root of
#1664/#1665/#1667/#1669/#1671. Yet "call it Marketing Bot" is the common case,
and it should not touch any of that.

Adds a display label that is rendered, never resolved. The slug stays the
identity everything machine-facing keys on; the label is presentation.

- `agent_ownership.display_label TEXT`, nullable — NULL means "render the slug",
  so no backfill and every existing agent is unchanged until someone sets one;
  clearing reverts to the slug rather than blanking a name. Dual-track migration
  (Invariant #3): SQLite `agent_ownership_display_label` + Alembic 0025, plus
  schema.py / tables.py.
- `DisplayLabelMixin` (Invariant #2 — new setting, new mixin), with a batched
  reader for the fleet list so the hottest endpoint stays one query, and the
  four facade pass-throughs (the #1666 lesson: mocked tests can't see a facade
  gap).
- `GET`/`PUT /api/agents/{name}/label`, owner-only. Read never coerces `label`
  to the slug — the UI must tell "no label" from "label equals slug". WS
  `agent_label_changed` broadcast.
- Frontend: one resolution helper (`utils/agentName.js`) — no per-site
  `label || name`, which would show one agent under two names (§1.3.1 FR-3).
  The header pencil edits the label and shows the slug as secondary text (FR-4);
  list/tile surfaces render the label with the slug in the tooltip. The store
  goes through the shared axios client (Invariant #7).
- The slug rename is demoted, not removed (FR-5): a separate "Rename the id
  instead…" affordance with copy stating what it does (restart, re-key, volumes
  stay under the old id) — owners who need it keep it; it stops being the
  default gesture.

Requirements §1.3.1 written before the code (rule #1). Verified end-to-end on
the live stack: label set → slug untouched (container intact, `/api/agents/{slug}`
still 200, nothing restarted), blank and null both clear back to the slug.
28 tests.

Decision (maintainer): OSS-core; demote the slug rename; label everywhere.

Closes trinity-enterprise#181

* fix(agents): carry the label on the detail endpoint + make the WS broadcast live (ent#181)

Self-review (/review) caught two consistency gaps I'd introduced.

1. `GET /api/agents/{name}` — the endpoint AgentHeader loads on page open —
   enriched the agent dict with owner/is_owner/can_share but NOT display_label.
   So on a fresh load or refresh the header showed the SLUG; the label only
   appeared after an edit round-tripped through the store. Reproduced live
   (detail returned display_label=None for a labelled agent), fixed by adding
   the one read, re-verified live. §1.3.1 FR-3 — the surfaces must agree.

2. The `agent_label_changed` broadcast was dead: it set only `type`, but the
   frontend WS client switches on `event` (both are the convention, per
   agent_started et al.), and no handler existed — so a label change on one
   client never reached others. Added `event` + the `data` shape the other
   agent_* events use, and a handler that updates the cached agent (the slug
   never moves, so it's a pure re-render).

Endpoint test updated to assert the new broadcast shape.
vybe pushed a commit that referenced this pull request Jul 19, 2026
#1296) (#1694)

* docs(requirements): agent self-reminders §10.14 (#1296)

Requirements-first per Rule of Engagement #1: document the new
agent self-reminders capability (durable one-shot deferred
self-trigger) before implementing it — AC 1-7, storage fork,
scheduler fire home, at-least-once delivery, autonomy hold, and
retention.

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

* feat(db): agent_reminders table — 5-track migration (#1296)

Durable one-shot deferred self-trigger storage. Byte-consistent DDL
across all five coordinated edits (Invariant #3):
- db/schema.py: TABLES entry + two indexes (agent + partial active)
- db/migrations.py: _migrate_agent_reminders_table + MIGRATIONS tail
- migrations/versions/0028_agent_reminders (off head 0027)
- db/tables.py: SQLAlchemy Core Table (byte-matched to DDL)
- db/agent_cleanup.py: AGENT_REFS CASCADE (rename cascade + purge +
  soft-delete filter; source_agent_name left unregistered per the
  agent_loops precedent)

schema-parity, alembic-parity, and cleanup-parity all green.

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

* feat(executions): wire triggered_by="reminder" into all three trigger constants (#1296)

A new triggered_by value must hit all three sites or reminder
executions silently vanish from the Overview chart, fail to filter,
or misclassify their failure alert:
- _TRIGGER_BUCKETS + _BUCKET_ORDER (db/schedules.py) — first-class
  "Reminders" bucket (mirrors the #1150 loops precedent)
- _AUTONOMOUS_TRIGGERS (task_execution_service.py) — a reminder
  fires unwatched, so a FAILED reminder earns an operator alert
- _VALID_TRIGGERS (routers/executions.py) — the fleet Executions
  ?triggered_by= filter accepts it

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

* feat(reminders): backend create/list/cancel surface (#1296)

Three-layer backend for agent self-reminders (Invariants #1/#2/#8/#14/#18):
- models.py: ReminderCreate (fire_at XOR delay_seconds validator) +
  Reminder/ReminderSummary + env-tunable abuse-bound constants
- db/reminders.py: RemindersOperations (SQLAlchemy Core, tenant-scoped
  by-id ops, CAS cancel, pending/daily counts, retention prune+count)
- database.py: facade delegation
- services/reminder_service.py: thin create — resolved-window bound,
  timeout clamp to agent cap (#929 parity), pending + durable daily
  caps, provenance, relative→absolute, insert
- services/idempotency_service.py: derive_reminder_key over RAW input
- routers/reminders.py: self-only gate (current_user.agent_name==name)
  + _reject_connector_principal; create-idempotency (header wins else
  raw-input key; terminal rows excluded from replay; fail() only
  pre-insert); tenant-scoped cancel (200/409/404); main.py registration

Self-only auth mirrors reports; connector keys stay off the auth-entry
allowlist AND are explicitly rejected. Verified against a real migrated
SQLite DB; models-centralized + enumeration-uniformity green.

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

* feat(scheduler): arm/fire/reconcile agent self-reminders (#1296)

The standalone scheduler (single-instance) owns the reminder fire path
— a near-clone of the RETRY-001 one-shot DateTrigger machinery:
- models.py: Reminder dataclass (naive-UTC fire_at/firing_at)
- database.py: committed CAS methods (claim_reminder_firing pending→
  firing, mark_fired/failed, release_to_pending, set_execution) +
  get_active_reminders (pending∪firing JOIN agent_ownership, filters
  deleted_at + autonomy_enabled=1) + _row_to_reminder via
  parse_scheduler_ts
- service.py: _schedule_reminder_job (DateTrigger), _execute_reminder
  (at-least-once: CAS claim → create real execution row (__manual__,
  triggered_by=reminder) → dispatch via _call_backend_execute_task →
  timeout=assume-dispatched no-force-FAILED / clean-failure=status-
  guarded FAILED + bounded retry), _reconcile_reminders (fail-open,
  arms pending + reclaims stale firing) wired into initialize() (own
  try after _recover_pending_retries), _sync_schedules() (own try,
  NOT under the cron try — Codex C5), and reload_schedules() (Codex C6)
- config.py: MAX_REMINDER_FIRE_ATTEMPTS (default 3)

All 207 existing scheduler tests green.

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

* feat(mcp): set_reminder/list_reminders/cancel_reminder tools (#1296)

Third surface (Invariant #13) mirroring loops.ts 1:1:
- tools/reminders.ts: createReminderTools → {setReminder, listReminders,
  cancelReminder}; reuses the inline getClient + resolveAgentName
  self-scoping (an agent-scoped key defaults to its bound name); Zod
  carries the abuse bounds (message.max(4000), delay 60..2592000, fire_at
  ISO string). Idempotency is enforced server-side over the raw input,
  so a naive retry dedupes without a client-supplied key.
- client.ts: setReminder/listReminders/cancelReminder thin request wrappers
- server.ts: import + register in the tools array

No agent-server mirror (backend-only scheduling primitive). tsc build clean.

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

* feat(retention): agent_reminders retention sweep + #1644 guard wiring (#1296)

A durable status table needs a retention story (the AGENT_REFS CASCADE
only cleans on agent delete; terminal rows on a LIVE agent accumulate).
Decision: wire the FULL #1644 blast-radius guard (lightweight, mirrors
agent_reports exactly, honors #1638/#1644 discipline):
- settings_service.py: agent_reminders_retention_days in OPS_SETTINGS_
  DEFAULTS (90, wide/safe), OPS_SETTINGS_DESCRIPTIONS, and
  RETENTION_OPS_KEYS (surfaced at GET /api/settings/retention, protected
  from /ops/reset, logged at boot). NOT a community-floor key.
- cleanup_service.py: _sweep_agent_reminders_retention — DELETE terminal
  (fired/cancelled/failed) rows past the window (pending/firing never
  deleted), chunked, gated via _guard_allows + _after_guarded_prune
  (default MAX_ROWS_PER_SWEEP floor); CleanupReport.agent_reminders_pruned.

retention-guard suite green.

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

* test(reminders): backend + scheduler coverage (#1296)

Backend (tests/unit/test_1296_reminders.py, 33 tests): migration +
tables.py live-select accessor guard (the guard schema-parity misses),
the three trigger constants (parametrized), ReminderCreate XOR,
derive_reminder_key raw-input stability, db round-trip + tenant-scope +
cancel CAS states, retention (terminal pruned / pending+firing kept),
reminder_service bounds (min/max window, timeout>cap, pending 429,
daily 429), and router self-gate ATTACK (sibling 403, connector 403),
tenant-scope 404, idempotency edges (dup replay, in-flight 409,
cancel-then-recreate-fresh), cancel outcomes, list default-pending.

Scheduler (tests/scheduler_tests/test_1296_reminders.py, 17 tests):
committed single-fire CAS + multi-connection contention (exactly one
wins, Codex C8), _execute_reminder outcomes (claim-loss / success /
timeout=assume-dispatched-no-force-FAILED / clean-failure-retry /
bounded-failed), _reconcile (arm-once, past-due, stale-firing reclaim,
Z-suffix no-raise, missing-table no-op, soft-delete/autonomy-off
filtered), reload path (Codex C6).

Scheduler suite 224 green; backend reminder+parity+guard sweep 124 green.

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

* docs(architecture): agent self-reminders subsystem (#1296)

- New 'Agent Self-Reminders (#1296)' subsystem section (sibling to
  Sequential Agent Loops): storage fork, scheduler fire home,
  at-least-once delivery, self-only auth + abuse bounds, autonomy hold
  + AGENT_REFS cascade + retention
- agent_reminders DB schema block (dual-track migration, CASCADE)
- router/service catalog entries (reminders.py, reminder_service.py)
- MCP tools table row (reminders.ts × 3)
- Scheduler Service + Cleanup Service background-service rows updated
- API Endpoints section for the 3 reminder endpoints

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

* docs(feature-flows): agent-self-reminders flow doc + index (#1296)

New feature-flow doc mirroring run-agent-loop.md: the two design forks,
MCP/backend/scheduler layers, single-fire + at-least-once delivery
semantics, the 5-track migration, retention, and the three trigger
constants. Adds a Recent Updates row + a Core Agent Features category
row to the index (per the always-add-an-index-row rule).

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

* chore(config): wire agent-reminder tunables into compose + .env.example (#1296)

The six REMINDER_*/MAX_*REMINDER* caps are backend os.getenv() levers with
working code defaults, so the feature ships functional — but without compose
wiring the .env levers are inert on deploy (the #1056/#1039 packaging class),
mirroring how REPORT_RATE_LIMIT (#918) is wired into all three files. Adds the
block to .env.example, docker-compose.yml, and docker-compose.prod.yml
backend.environment: with defaults matching models.py/routers.

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

* fix: resolve .env.example conflict left in the previous merge commit (keep both #1296 REMINDER_* and #1632 OPERATOR_QUEUE_* blocks)

---------

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants