From 851c67c27b1f5beaa73c0710665e79a3ee37fba9 Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Fri, 3 Jul 2026 09:38:40 +0000 Subject: [PATCH 001/152] docs(bootstrap): add python-is-python3 to agent-host prerequisites (#694) --- docs/scratchpads/561-python-is-python3.md | 33 +++++++++++++++++++ guides/BOOTSTRAP.md | 16 +++++++++ packages/mosaic/framework/guides/BOOTSTRAP.md | 16 +++++++++ 3 files changed, 65 insertions(+) create mode 100644 docs/scratchpads/561-python-is-python3.md diff --git a/docs/scratchpads/561-python-is-python3.md b/docs/scratchpads/561-python-is-python3.md new file mode 100644 index 00000000..a81db0e7 --- /dev/null +++ b/docs/scratchpads/561-python-is-python3.md @@ -0,0 +1,33 @@ +# Issue #561 — Bare python on agent hosts + +## Objective + +Make the durable bootstrap/provisioning guidance ensure agent hosts provide a bare `python` command that resolves to Python 3. + +## Scope + +- Add Debian/Ubuntu `python-is-python3` to agent-host prerequisites in bootstrap docs. +- Check for actual OS package provisioning scripts and update only if an existing agent-host package install path exists. +- Do not touch live host state. +- Do not update `docs/TASKS.md`; repo guidance says workers read it but never modify it. + +## Recon + +- Issue #561 confirms repeated `python: command not found` failures from fleet agents that emit `python foo.py`. +- `guides/BOOTSTRAP.md` and `packages/mosaic/framework/guides/BOOTSTRAP.md` are the source and packaged framework copies of the bootstrap guide. +- Targeted repo sweep found no agent-host Debian package provisioning script. Existing `apt-get install` hits are CI/test helper paths or unrelated deployment docs. + +## Plan + +1. Add a host prerequisite section to both bootstrap guide copies. +2. Include `python-is-python3` in the Debian/Ubuntu package list with an issue comment. +3. Note the non-Debian equivalent as a `/usr/bin/python -> python3` symlink. +4. Validate markdown/diff, run shell syntax checks where applicable, run required review, commit, queue guard, and push. + +## Validation Log + +- `rg` recon: no existing agent-host Debian package provisioning script; only CI/test helper `apt-get install` paths and unrelated deployment docs. +- `git diff --check`: passed. +- `bash -n packages/mosaic/framework/install.sh tools/install.sh packages/mosaic/framework/tools/bootstrap/init-project.sh packages/mosaic/framework/tools/_scripts/mosaic-bootstrap-repo`: passed. No touched shell scripts. +- `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted`: approved, 0 findings. +- `pnpm format:check`: initially blocked because `node_modules` was absent and `prettier` was unavailable; `pnpm install --frozen-lockfile` initially hit an invalid `/root` pnpm store path. Reran install with `--store-dir /home/hermes/agent-work/.pnpm-store`, then `pnpm format:check` passed. diff --git a/guides/BOOTSTRAP.md b/guides/BOOTSTRAP.md index b750eb45..f80fd5da 100755 --- a/guides/BOOTSTRAP.md +++ b/guides/BOOTSTRAP.md @@ -15,6 +15,22 @@ This guide covers how to bootstrap a project so AI agents (Claude, Codex, etc.) 7. Branching/merging is consistent: `branch -> main` via PR with squash-only merges 8. Steered-autonomy execution is enabled so agents can run end-to-end with escalation-only human intervention +## Agent Host Prerequisites + +Agent hosts must provide the Python runtime shape that runtime agents and +Mosaic automation assume is present. + +For Debian/Ubuntu hosts: + +```bash +sudo apt-get update +# #561: bare python invocations from agents must resolve. +sudo apt-get install -y python3 python-is-python3 +``` + +For non-Debian hosts, install the equivalent Python 3 runtime and ensure +`/usr/bin/python` resolves to `python3` (for example, via a managed symlink). + ## Quick Start ```bash diff --git a/packages/mosaic/framework/guides/BOOTSTRAP.md b/packages/mosaic/framework/guides/BOOTSTRAP.md index d6b5c45d..edf93cbb 100755 --- a/packages/mosaic/framework/guides/BOOTSTRAP.md +++ b/packages/mosaic/framework/guides/BOOTSTRAP.md @@ -15,6 +15,22 @@ This guide covers how to bootstrap a project so AI agents (Claude, Codex, etc.) 7. Branching/merging is consistent: `branch -> main` via PR with squash-only merges 8. Steered-autonomy execution is enabled so agents can run end-to-end with escalation-only human intervention +## Agent Host Prerequisites + +Agent hosts must provide the Python runtime shape that runtime agents and +Mosaic automation assume is present. + +For Debian/Ubuntu hosts: + +```bash +sudo apt-get update +# #561: bare python invocations from agents must resolve. +sudo apt-get install -y python3 python-is-python3 +``` + +For non-Debian hosts, install the equivalent Python 3 runtime and ensure +`/usr/bin/python` resolves to `python3` (for example, via a managed symlink). + ## Quick Start ```bash From 4e9e0538001a0a817214596aab9db8aad7c7f322 Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Thu, 9 Jul 2026 17:37:40 +0000 Subject: [PATCH 002/152] fix(tools/tmux): unique per-invocation paste buffer; track auto-submit-drafts.sh (#697) --- .../mosaic/framework/tools/tmux/README.md | 4 + .../tools/tmux/auto-submit-drafts.sh | 80 +++++++++++++++++++ .../framework/tools/tmux/send-message.sh | 16 +++- .../tools/tmux/test-send-message-socket.sh | 28 +++++++ 4 files changed, 125 insertions(+), 3 deletions(-) create mode 100755 packages/mosaic/framework/tools/tmux/auto-submit-drafts.sh diff --git a/packages/mosaic/framework/tools/tmux/README.md b/packages/mosaic/framework/tools/tmux/README.md index b8a20c89..e9fff6e0 100644 --- a/packages/mosaic/framework/tools/tmux/README.md +++ b/packages/mosaic/framework/tools/tmux/README.md @@ -87,6 +87,10 @@ message crosses the wire as base64 (`-b`) to avoid all shell-quoting hazards. - `agent-send.sh` — inter-agent wrapper (preamble + local/remote dispatch). - `send-message.sh` — low-level reliable single-pane submitter (`-b` base64 input). +- `auto-submit-drafts.sh` — watchdog that flushes stable unsubmitted prompt + drafts on a coordinator pane (default target `mos-claude`); run it as a + long-lived process alongside the coordinator session. +- `agent-send.test.sh` — regression + grammar lock for `agent-send.sh`. - `test-send-message-socket.sh` — smoke test for named-socket isolation. ## Distribution diff --git a/packages/mosaic/framework/tools/tmux/auto-submit-drafts.sh b/packages/mosaic/framework/tools/tmux/auto-submit-drafts.sh new file mode 100755 index 00000000..15cc6e72 --- /dev/null +++ b/packages/mosaic/framework/tools/tmux/auto-submit-drafts.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# auto-submit-drafts.sh — watchdog for Claude Code panes that receive channel +# messages but leave them as unsubmitted prompt drafts. Intended for Mos only. +set -uo pipefail + +TARGET="${1:-mos-claude}" +INTERVAL="${INTERVAL:-2}" +STABLE_SECONDS="${STABLE_SECONDS:-4}" +LOG_PREFIX="[auto-submit-drafts:$TARGET]" + +last_prompt="" +first_seen=0 + +prompt_text() { + tmux capture-pane -t "$TARGET" -p 2>/dev/null | python3 -c ' +import sys, re +lines = sys.stdin.read().splitlines() +idx = None +for i in range(len(lines)-1, -1, -1): + if "❯" in lines[i]: + idx = i + break +if idx is None: + raise SystemExit +parts = [] +after = lines[idx].split("❯", 1)[1] +parts.append(after) +for line in lines[idx+1:]: + # Stop at Claude Code separator/border lines. + if "─" in line or "╰" in line or "╭" in line: + break + s = line.replace("\u00a0", " ") + s = re.sub(r"[\x00-\x1f\x7f]", "", s).strip() + if s: + parts.append(s) +text = " ".join(parts).replace("\u00a0", " ") +text = re.sub(r"[\x00-\x1f\x7f]", "", text).strip() +print(text) +' +} + +while true; do + if ! tmux has-session -t "$TARGET" 2>/dev/null; then + echo "$LOG_PREFIX target missing; waiting" >&2 + sleep "$INTERVAL" + last_prompt="" + first_seen=0 + continue + fi + + current="$(prompt_text || true)" + now="$(date +%s)" + + if [[ -z "$current" ]]; then + last_prompt="" + first_seen=0 + sleep "$INTERVAL" + continue + fi + + if [[ "$current" != "$last_prompt" ]]; then + last_prompt="$current" + first_seen="$now" + sleep "$INTERVAL" + continue + fi + + age=$(( now - first_seen )) + if (( age >= STABLE_SECONDS )); then + echo "$LOG_PREFIX submitting stable draft after ${age}s: ${current:0:120}" >&2 + tmux send-keys -t "$TARGET" C-j + sleep 0.8 + tmux send-keys -t "$TARGET" C-m + sleep 2 + last_prompt="" + first_seen=0 + else + sleep "$INTERVAL" + fi +done diff --git a/packages/mosaic/framework/tools/tmux/send-message.sh b/packages/mosaic/framework/tools/tmux/send-message.sh index 90d1a324..8b0d753c 100755 --- a/packages/mosaic/framework/tools/tmux/send-message.sh +++ b/packages/mosaic/framework/tools/tmux/send-message.sh @@ -77,10 +77,20 @@ snippet=$(printf '%s' "$MSG" | tr '\n' ' ' | tr -s ' ' | sed 's/[^[:print:]]//g' # 1) Paste the body as a bracketed paste so multi-line content does not submit # line-by-line. load-buffer/paste-buffer is far safer than `send-keys -l`. -printf '%s' "$MSG" | "${tmux_cmd[@]}" load-buffer -b __mosaic_send - +# Buffer name MUST be unique per invocation: concurrent senders on the shared +# tmux server race a fixed name (load overwrites load, -d deletes underneath), +# cross-delivering or dropping messages — bit the fleet on the 2026-07-09 +# simultaneous restart (briefs swapped between sessions). +BUF="__mosaic_send_$$_$(date +%s%N)" +printf '%s' "$MSG" | "${tmux_cmd[@]}" load-buffer -b "$BUF" - # -p = bracketed paste when the client supports it; fall back if not. -"${tmux_cmd[@]}" paste-buffer -d -p -b __mosaic_send -t "$EFFECTIVE_TARGET" 2>/dev/null \ - || "${tmux_cmd[@]}" paste-buffer -d -b __mosaic_send -t "$EFFECTIVE_TARGET" +"${tmux_cmd[@]}" paste-buffer -d -p -b "$BUF" -t "$EFFECTIVE_TARGET" 2>/dev/null \ + || "${tmux_cmd[@]}" paste-buffer -d -b "$BUF" -t "$EFFECTIVE_TARGET" \ + || "${tmux_cmd[@]}" delete-buffer -b "$BUF" 2>/dev/null +# ^ -d deletes the buffer only on a SUCCESSFUL paste; if both attempts fail +# (e.g. the target vanished since the liveness check), delete explicitly — +# named buffers are exempt from tmux's buffer-limit eviction, so orphans +# would otherwise accumulate forever. sleep 0.5 # 2) Submit, then verify; flush with another Enter if it is still a draft. diff --git a/packages/mosaic/framework/tools/tmux/test-send-message-socket.sh b/packages/mosaic/framework/tools/tmux/test-send-message-socket.sh index 1107646b..86972646 100755 --- a/packages/mosaic/framework/tools/tmux/test-send-message-socket.sh +++ b/packages/mosaic/framework/tools/tmux/test-send-message-socket.sh @@ -47,4 +47,32 @@ if capture_default | grep -qF "agent socket hello"; then fail "agent-send.sh leaked named-socket message to default tmux server" fi +# Concurrency: parallel senders on one server must not cross-deliver or drop. +# Locks the unique-per-invocation paste buffer (a fixed buffer name raced: +# load overwrote load, -d deleted underneath — messages swapped between panes). +CONC_N=5 +for i in $(seq 1 "$CONC_N"); do + tmux -L "$SOCKET" new-session -d -s "conc-$i" -c "$TMPDIR" 'bash --noprofile --norc -i' +done +pids=() +for i in $(seq 1 "$CONC_N"); do + "$SEND_MESSAGE" -L "$SOCKET" -t "=conc-$i" -m "CONCPAYLOAD-${i}-END" >/dev/null & + pids+=($!) +done +for pid in "${pids[@]}"; do + wait "$pid" || fail "concurrent send-message.sh invocation exited non-zero" +done +sleep 0.2 +for i in $(seq 1 "$CONC_N"); do + pane=$(tmux -L "$SOCKET" capture-pane -t "=conc-$i:0.0" -p) + printf '%s' "$pane" | grep -qF "CONCPAYLOAD-${i}-END" \ + || fail "concurrent send dropped payload for pane conc-$i" + for j in $(seq 1 "$CONC_N"); do + [ "$j" = "$i" ] && continue + if printf '%s' "$pane" | grep -qF "CONCPAYLOAD-${j}-END"; then + fail "concurrent send cross-delivered payload $j to pane conc-$i" + fi + done +done + echo "ok - named tmux socket send tools" From 4df38f7e8108fdcb90c7ae6102dfb0226d3abaee Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Fri, 10 Jul 2026 01:57:12 +0000 Subject: [PATCH 003/152] fix(tools/_lib): /etc/mosaic host-level fallback for credential resolution (#700) --- packages/mosaic/framework/tools/_lib/credentials.sh | 12 +++++++++++- .../mosaic/framework/tools/git/detect-platform.sh | 11 ++++++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/packages/mosaic/framework/tools/_lib/credentials.sh b/packages/mosaic/framework/tools/_lib/credentials.sh index dd05972c..64d4d63a 100755 --- a/packages/mosaic/framework/tools/_lib/credentials.sh +++ b/packages/mosaic/framework/tools/_lib/credentials.sh @@ -15,13 +15,23 @@ # # After loading, service-specific env vars are exported. # Run `load_credentials --help` for details. +# +# Resolution order (first match wins): +# 1. $MOSAIC_CREDENTIALS_FILE (explicit override — never second-guessed) +# 2. $HOME/.config/mosaic/credentials.json +# 3. /etc/mosaic/credentials.json (host-level fallback) +# The /etc fallback exists for HOME-redirected profile environments, where +# $HOME points at a per-profile directory that has no credentials file. +# Operators symlink /etc/mosaic/credentials.json to the host's canonical +# file once, instead of exporting MOSAIC_CREDENTIALS_FILE per invocation. if [[ -z "${MOSAIC_CREDENTIALS_FILE:-}" ]]; then - for _cand in "$HOME/.config/mosaic/credentials.json"; do + for _cand in "$HOME/.config/mosaic/credentials.json" "/etc/mosaic/credentials.json"; do if [[ -f "$_cand" ]]; then MOSAIC_CREDENTIALS_FILE="$_cand"; break; fi done : "${MOSAIC_CREDENTIALS_FILE:=$HOME/.config/mosaic/credentials.json}" fi +export MOSAIC_CREDENTIALS_FILE _mosaic_require_jq() { if ! command -v jq &>/dev/null; then diff --git a/packages/mosaic/framework/tools/git/detect-platform.sh b/packages/mosaic/framework/tools/git/detect-platform.sh index 69156e9f..626e1a0c 100755 --- a/packages/mosaic/framework/tools/git/detect-platform.sh +++ b/packages/mosaic/framework/tools/git/detect-platform.sh @@ -86,7 +86,16 @@ gitea_url_matches_host() { get_gitea_service_for_host() { local host="$1" - local cred_file="${MOSAIC_CREDENTIALS_FILE:-$HOME/.config/mosaic/credentials.json}" + local cred_file="${MOSAIC_CREDENTIALS_FILE:-}" + if [[ -z "$cred_file" ]]; then + # Same resolution chain as _lib/credentials.sh: profile HOME, then + # host-level /etc only if it exists; neither existing keeps the + # $HOME default (matches the lib's final := fallback). + cred_file="$HOME/.config/mosaic/credentials.json" + if [[ ! -f "$cred_file" && -f /etc/mosaic/credentials.json ]]; then + cred_file="/etc/mosaic/credentials.json" + fi + fi case "$host" in git.mosaicstack.dev) From a99aded26d7b37418565942311e866ef398457b9 Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Sat, 11 Jul 2026 09:23:46 +0000 Subject: [PATCH 004/152] fix(tools/git): -h/--help now exits 0 across 7 wrappers (#702) --- .../framework/tools/git/issue-assign.sh | 4 +- .../framework/tools/git/issue-create.sh | 4 +- .../mosaic/framework/tools/git/issue-list.sh | 4 +- .../framework/tools/git/milestone-create.sh | 4 +- .../mosaic/framework/tools/git/pr-create.sh | 4 +- .../mosaic/framework/tools/git/pr-list.sh | 4 +- .../mosaic/framework/tools/git/pr-merge.sh | 4 +- .../tools/git/test-help-exit-code.sh | 53 +++++++++++++++++++ 8 files changed, 67 insertions(+), 14 deletions(-) create mode 100755 packages/mosaic/framework/tools/git/test-help-exit-code.sh diff --git a/packages/mosaic/framework/tools/git/issue-assign.sh b/packages/mosaic/framework/tools/git/issue-assign.sh index 6151b0e9..48d5c04b 100755 --- a/packages/mosaic/framework/tools/git/issue-assign.sh +++ b/packages/mosaic/framework/tools/git/issue-assign.sh @@ -33,7 +33,7 @@ Examples: $(basename "$0") -i 42 -l "in-progress" -m "0.2.0" $(basename "$0") -i 42 -a @me EOF - exit 1 + exit "${1:-1}" } # Parse arguments @@ -60,7 +60,7 @@ while [[ $# -gt 0 ]]; do shift ;; -h|--help) - usage + usage 0 ;; *) echo "Unknown option: $1" >&2 diff --git a/packages/mosaic/framework/tools/git/issue-create.sh b/packages/mosaic/framework/tools/git/issue-create.sh index d516ed0b..f92f9764 100755 --- a/packages/mosaic/framework/tools/git/issue-create.sh +++ b/packages/mosaic/framework/tools/git/issue-create.sh @@ -72,7 +72,7 @@ Examples: $(basename "$0") -t "Fix login bug" -l "bug,priority-high" $(basename "$0") -t "Add dark mode" -b "Implement theme switching" -m "0.2.0" EOF - exit 1 + exit "${1:-1}" } # Parse arguments @@ -95,7 +95,7 @@ while [[ $# -gt 0 ]]; do shift 2 ;; -h|--help) - usage + usage 0 ;; *) echo "Unknown option: $1" >&2 diff --git a/packages/mosaic/framework/tools/git/issue-list.sh b/packages/mosaic/framework/tools/git/issue-list.sh index dd7b73d4..b6f9bc19 100755 --- a/packages/mosaic/framework/tools/git/issue-list.sh +++ b/packages/mosaic/framework/tools/git/issue-list.sh @@ -36,7 +36,7 @@ Examples: $(basename "$0") -m "0.2.0" # Issues in milestone 0.2.0 $(basename "$0") --repo ddk/ai-bma # List issues from anywhere EOF - exit 1 + exit "${1:-1}" } # Parse arguments @@ -67,7 +67,7 @@ while [[ $# -gt 0 ]]; do shift 2 ;; -h|--help) - usage + usage 0 ;; *) echo "Unknown option: $1" >&2 diff --git a/packages/mosaic/framework/tools/git/milestone-create.sh b/packages/mosaic/framework/tools/git/milestone-create.sh index 8f5c371f..72d8f299 100755 --- a/packages/mosaic/framework/tools/git/milestone-create.sh +++ b/packages/mosaic/framework/tools/git/milestone-create.sh @@ -37,7 +37,7 @@ Examples: $(basename "$0") -t "0.0.1" -d "Pre-MVP Foundation Sprint" $(basename "$0") -t "0.1.0" -d "MVP Release" --due "2025-03-01" EOF - exit 1 + exit "${1:-1}" } # Parse arguments @@ -60,7 +60,7 @@ while [[ $# -gt 0 ]]; do shift ;; -h|--help) - usage + usage 0 ;; *) echo "Unknown option: $1" >&2 diff --git a/packages/mosaic/framework/tools/git/pr-create.sh b/packages/mosaic/framework/tools/git/pr-create.sh index 60997e60..9560f1dc 100755 --- a/packages/mosaic/framework/tools/git/pr-create.sh +++ b/packages/mosaic/framework/tools/git/pr-create.sh @@ -86,7 +86,7 @@ Examples: $(basename "$0") -i 42 -b "Implements the feature described in #42" $(basename "$0") -t "WIP: New feature" --draft EOF - exit 1 + exit "${1:-1}" } # Parse arguments @@ -125,7 +125,7 @@ while [[ $# -gt 0 ]]; do shift ;; -h|--help) - usage + usage 0 ;; *) echo "Unknown option: $1" >&2 diff --git a/packages/mosaic/framework/tools/git/pr-list.sh b/packages/mosaic/framework/tools/git/pr-list.sh index 0b923d27..de5c741e 100755 --- a/packages/mosaic/framework/tools/git/pr-list.sh +++ b/packages/mosaic/framework/tools/git/pr-list.sh @@ -34,7 +34,7 @@ Examples: $(basename "$0") -s merged -a username # Merged PRs by user $(basename "$0") --repo ddk/ai-bma # List PRs from anywhere EOF - exit 1 + exit "${1:-1}" } # Parse arguments @@ -61,7 +61,7 @@ while [[ $# -gt 0 ]]; do shift 2 ;; -h|--help) - usage + usage 0 ;; *) echo "Unknown option: $1" >&2 diff --git a/packages/mosaic/framework/tools/git/pr-merge.sh b/packages/mosaic/framework/tools/git/pr-merge.sh index 96cb06f7..222260c6 100755 --- a/packages/mosaic/framework/tools/git/pr-merge.sh +++ b/packages/mosaic/framework/tools/git/pr-merge.sh @@ -35,7 +35,7 @@ Examples: $(basename "$0") -n 42 -d # Squash merge and delete branch $(basename "$0") -n 42 --skip-queue-guard # Skip queue guard wait EOF - exit 1 + exit "${1:-1}" } # Parse arguments @@ -63,7 +63,7 @@ while [[ $# -gt 0 ]]; do shift ;; -h|--help) - usage + usage 0 ;; *) echo "Unknown option: $1" >&2 diff --git a/packages/mosaic/framework/tools/git/test-help-exit-code.sh b/packages/mosaic/framework/tools/git/test-help-exit-code.sh new file mode 100755 index 00000000..e6e3483f --- /dev/null +++ b/packages/mosaic/framework/tools/git/test-help-exit-code.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Regression harness for #701: -h/--help must exit 0, bad args must still exit nonzero. +# +# Covers the 7 wrappers whose usage() previously hard-coded `exit 1`, so every +# --help invocation exited nonzero and logged a phantom isError across fleet lanes. +# Asserts, per wrapper: +# 1. `--help` exits 0 and prints usage. +# 2. `-h` exits 0 and prints usage. +# 3. A genuine unknown flag still exits nonzero (usage() default path untouched). + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +WRAPPERS=( + issue-assign.sh + issue-create.sh + issue-list.sh + milestone-create.sh + pr-create.sh + pr-list.sh + pr-merge.sh +) + +fail=0 + +for wrapper in "${WRAPPERS[@]}"; do + path="$SCRIPT_DIR/$wrapper" + + if ! output=$(bash "$path" --help 2>&1); then + echo "FAIL: $wrapper --help exited nonzero" >&2 + fail=1 + elif [[ "$output" != Usage:* ]]; then + echo "FAIL: $wrapper --help did not print usage" >&2 + fail=1 + fi + + if ! bash "$path" -h >/dev/null 2>&1; then + echo "FAIL: $wrapper -h exited nonzero" >&2 + fail=1 + fi + + if bash "$path" --this-is-not-a-real-flag >/dev/null 2>&1; then + echo "FAIL: $wrapper accepted an unknown flag (should have exited nonzero)" >&2 + fail=1 + fi +done + +if [[ "$fail" -eq 0 ]]; then + echo "help-exit-code regression passed (7/7 wrappers)" +fi + +exit "$fail" From 59e49cfd154aa9f92a38087f1863233f07f62a0d Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Sun, 12 Jul 2026 18:09:54 +0000 Subject: [PATCH 005/152] docs(tess): define Pi-native interaction agent mission (#712) --- docs/MISSION-MANIFEST.md | 5 +- docs/PRD.md | 96 +++++++++++++++++++++++++++++++ docs/TASKS.md | 3 +- docs/scratchpads/tess-20260712.md | 58 +++++++++++++++++++ docs/tess/ARCHITECTURE.md | 71 +++++++++++++++++++++++ docs/tess/MIGRATION-INVENTORY.md | 34 +++++++++++ docs/tess/MISSION-MANIFEST.md | 46 +++++++++++++++ docs/tess/TASKS.md | 34 +++++++++++ docs/tess/THREAT-MODEL.md | 46 +++++++++++++++ docs/tess/VERIFICATION-MATRIX.md | 30 ++++++++++ 10 files changed, 420 insertions(+), 3 deletions(-) create mode 100644 docs/scratchpads/tess-20260712.md create mode 100644 docs/tess/ARCHITECTURE.md create mode 100644 docs/tess/MIGRATION-INVENTORY.md create mode 100644 docs/tess/MISSION-MANIFEST.md create mode 100644 docs/tess/TASKS.md create mode 100644 docs/tess/THREAT-MODEL.md create mode 100644 docs/tess/VERIFICATION-MATRIX.md diff --git a/docs/MISSION-MANIFEST.md b/docs/MISSION-MANIFEST.md index 812cd3a9..72b1edd6 100644 --- a/docs/MISSION-MANIFEST.md +++ b/docs/MISSION-MANIFEST.md @@ -69,8 +69,9 @@ The MVP is complete when ALL declared workstreams are complete AND every cross-c | # | ID | Name | Status | Manifest | Notes | | --- | --- | ------------------------------------------- | ----------------- | ----------------------------------------------------------------------- | --------------------------------------------------- | -| W1 | FED | Federation v1 | planning-complete | [docs/federation/MISSION-MANIFEST.md](./federation/MISSION-MANIFEST.md) | 7 milestones, ~175K tokens, issues #460–#466 filed | -| W2+ | TBD | (additional workstreams declared as scoped) | — | — | Scope creep is expected and explicitly accommodated | +| W1 | FED | Federation v1 | planning-complete | [docs/federation/MISSION-MANIFEST.md](./federation/MISSION-MANIFEST.md) | 7 milestones, ~175K tokens, issues #460–#466 filed | +| W2 | TESS | Tess interaction agent | planning-complete | [docs/tess/MISSION-MANIFEST.md](./tess/MISSION-MANIFEST.md) | 5 milestones; issue #706; M1 issue #707 ready | +| W3+ | TBD | (additional workstreams declared as scoped) | — | — | Scope creep is expected and explicitly accommodated | ### Likely Additional Workstreams (Not Yet Declared) diff --git a/docs/PRD.md b/docs/PRD.md index 9d0db596..55666f60 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -79,6 +79,102 @@ Jarvis (v0.2.0) is a self-hosted AI assistant with a Python FastAPI backend and --- +## Tess Interaction Agent Workstream (TESS) + +### Problem and Objective + +Jason needs one durable, operator-facing Mosaic agent outside Hermes that is reachable through a dedicated Discord channel and CLI, can attach to and operate the Mosaic fleet and transitional Hermes agents, and preserves context across restarts and compaction. Mos remains the coding/general fleet orchestrator; Tess is the complementary human interaction, visibility, control, and migration agent. + +The objective is to ship **Tess** (from *tessera*, a piece of a mosaic) as a Pi-native, GPT-5.6 Sol agent with high reasoning. Tess must use Mosaic-owned contracts and plugins so Hermes can be replaced incrementally rather than becoming a permanent architectural dependency. + +### Scope + +#### In Scope + +1. `TESS-ARP-001`: A runtime-neutral `AgentRuntimeProvider` contract supporting `listSessions`, `streamSession`, `sendMessage`, `terminate`, `getSessionTree`, `attach`, health, capability discovery, and normalized events/errors. +2. `TESS-PI-001`: A long-running Pi-native Tess agent profile/service pinned to GPT-5.6 Sol with high reasoning, explicit tool policy, lifecycle hooks, durable checkpoints, and restart recovery. +3. `TESS-DSC-001`: Dedicated Discord channel binding to Tess through the Mosaic gateway, with allowlists/RBAC, thread/reply policy, streaming, attachments, approvals, and correlation IDs. +4. `TESS-CLI-001`: `mosaic tess` CLI commands for chat, status, session listing, attach/detach, send/steer/stop, provider health, and recovery. +5. `TESS-FLT-001`: Fleet plugin capabilities for roster/status/heartbeat inspection, message delivery, session hierarchy, safe attach, and controlled restart/recovery. +6. `TESS-MOS-001`: Explicit Mos coordination boundary and tools: hand off orchestration requests, observe mission/task state, receive results, and never silently compete for orchestration authority. +7. `TESS-HRM-001`: Transitional Hermes adapter for profiles/agents, sessions, streaming/messages, Kanban, skills, memory, tools, cron, and health, using capability negotiation and fail-closed unsupported operations. +8. `TESS-MEM-001`: Unified memory/retrieval plugin with scoped search/recent/capture/stats, startup context injection, provenance, redaction, namespace isolation, and flat-file/project truth precedence. +9. `TESS-STA-001`: Durable agent state, inbox, handoff, compaction-recovery, and resume reconstruction. +10. `TESS-PLG-001`: Plugin/tool catalog covering runtime bootstrap, repository/PR workflow, fleet diagnostics, incident-safe read operations, Discord interaction, and extensible MCP/skill discovery. +11. `TESS-TRN-001`: Replaceable transport providers: tmux/fleet now, Matrix/native Mosaic transport later, with no Discord/CLI business logic coupled to transport details. +12. `TESS-SEC-001`: RBAC, per-operation authorization, explicit approval for destructive/privileged/customer-visible actions, audit events, secret/PII redaction, tenant isolation, and bounded command execution. +13. `TESS-SEC-002`: Command execution SHALL enforce declared scope/role server-side; admin/system and destructive operations SHALL require policy-bound durable approval. +14. `TESS-SEC-003`: Every session list/read/attach/send/terminate operation SHALL enforce server-derived owner and tenant scope; guessed or client-supplied IDs SHALL grant no authority. +15. `TESS-SEC-004`: MCP tools SHALL derive actor/tenant from authenticated context and SHALL NOT accept caller-controlled identity fields. +16. `TESS-SEC-005`: Discord plugin ingress SHALL authenticate service identity, enforce guild/channel/user allowlists, propagate correlation/message IDs, and reject replay. +17. `TESS-SEC-006`: Secret/PII classification and redaction SHALL occur before persistence and before channel egress, including tool metadata and authentication flows. +18. `TESS-SEC-007`: Approvals SHALL be one-time, expiring, actor/tenant-bound, and cryptographically bound to the exact structured action digest. +19. `TESS-SEC-008`: Ingress, provider sends, tool side effects, and responses SHALL use durable inbox/outbox/checkpoints and idempotency records for restart-safe replay. +20. `TESS-SEC-009`: Garbage collection and retention SHALL be session/tenant scoped unless executed as a separately authorized and audited system-wide job. +21. `TESS-OBS-001`: Structured logs, traces, health/readiness, provider latency/errors, session lifecycle, tool audit, and actionable recovery diagnostics. +22. `TESS-MIG-001`: Capability inventory and staged Hermes-to-Mosaic migration matrix with coexistence, cutover, rollback, and deprecation gates. + +#### Out of Scope + +1. Replacing Mos as coding/general fleet orchestrator. +2. Making Hermes the Mosaic core or coupling Mosaic domain logic to Hermes schemas. +3. Migrating every historical chat verbatim; only policy-compliant indexed summaries and user-selected sessions are migrated. +4. Unrestricted shell execution from Discord. +5. Full web UI parity in the first Tess operational milestone; gateway contracts must remain web-consumable. +6. Replacing tmux before Matrix/native transport reaches operational parity. + +### Stakeholder and User Requirements + +- Jason must be able to converse with the same Tess session from Discord and CLI. +- Jason must be able to see what is running, stale, blocked, or unhealthy without attaching manually to every session. +- Jason must be able to attach to Tess and authorized fleet sessions through supported CLI controls. +- Tess must collaborate with Mos and the fleet while preserving a single clear orchestration authority. +- The system must migrate useful Hermes/OpenClaw capabilities intentionally, with evidence, instead of copying implementations wholesale. + +### Non-Functional Requirements + +1. **Security:** default-deny provider/tool capabilities, least privilege, no secrets in logs/prompts/commits, Discord user/channel authorization, and auditable approvals. +2. **Reliability:** durable inbox/checkpoints; idempotent message handling; reconnect with bounded backoff; no message loss or duplicate execution across gateway restart. +3. **Performance:** first acknowledgement within 2 seconds when connected; streamed agent output begins within 5 seconds excluding model/provider delay; status reads return within 2 seconds under nominal local conditions. +4. **Observability:** every ingress message and resulting provider/tool operation carries a correlation ID across Discord, gateway, Tess, provider, and audit events. +5. **Maintainability:** channel, runtime, transport, memory, and external-agent integrations remain adapter-based with contract tests. +6. **Privacy:** only scoped context enters external runtimes; persisted messages/memories follow retention and redaction policy. +7. **Portability:** Tess runs through Pi/Mosaic contracts and does not require Hermes to start or serve native Mosaic operations. + +### Acceptance Criteria + +1. `AC-TESS-01`: A dedicated Discord channel and `mosaic tess chat` connect to one durable Tess session and stream responses bidirectionally. +2. `AC-TESS-02`: `mosaic tess status|sessions|tree|attach|send|stop` operate against authorized provider capabilities with stable typed outputs and actionable errors. +3. `AC-TESS-03`: Tess runs GPT-5.6 Sol at high reasoning and its effective runtime/model/tool policy is visible through status without exposing credentials. +4. `AC-TESS-04`: Tess can inspect and message the Mosaic fleet, hand orchestration work to Mos, and demonstrate that Tess does not independently claim Mos-owned orchestration work. +5. `AC-TESS-05`: Hermes adapter demonstrates session listing, streaming/message delivery, hierarchy mapping, and at least one approved capability in each of Kanban, skills, memory, tools, and cron—or reports unsupported capabilities fail-closed. +6. `AC-TESS-06`: Restart/compaction test preserves session identity, pending inbox, last durable checkpoint, and a resumable handoff without duplicate side effects. +7. `AC-TESS-07`: Unauthorized Discord users/channels, cross-tenant access, unsafe tool calls, forged approvals, and sensitive-output cases are denied and audited. +8. `AC-TESS-08`: tmux/fleet and Matrix/native transport implementations pass the same provider contract suite; Matrix may remain non-default until readiness gates pass. +9. `AC-TESS-09`: Baseline quality gates, unit/integration/contract tests, Discord+CLI E2E, restart/recovery tests, independent code review, and security review are green. +10. `AC-TESS-10`: Migration matrix documents every audited Hermes/OpenClaw capability as native, adapted, deferred, or rejected, with cutover and rollback evidence. +11. `AC-TESS-11`: User, admin, developer, API/OpenAPI, operations/recovery, and plugin-authoring documentation is current and linked from the sitemap. + +### Constraints, Dependencies, Risks, and Assumptions + +- Dependency: Mosaic gateway remains the single API surface; Pi is the native runtime; Valkey/PostgreSQL provide canonical durable state where required. +- Dependency: Discord bot credentials and dedicated channel ID are deployment secrets provisioned outside source control. +- Risk: Tess could drift into a second orchestrator. Mitigation: explicit role policy, Mos handoff contract, authority checks, and E2E boundary tests. +- Risk: broad Hermes compatibility can freeze legacy semantics into Mosaic. Mitigation: Mosaic-owned normalized contracts and capability negotiation. +- Risk: Discord creates a privileged remote-control surface. Mitigation: pairing/allowlists, RBAC, approvals, rate limits, audit, and safe tool classes. +- Risk: transcript ingestion can violate privacy or overload memory. Mitigation: scoped opt-in import, redacted summaries, provenance, retention, and deduplication. +- Risk: current root filesystem has limited headroom. Mitigation: isolated worktrees, no duplicated dependency installation unless required, and cleanup only after active-lane verification. +- `ASSUMPTION:` The public name is **Tess**, because the user requested a name and the tessera/Mosaic relationship is distinctive; config must permit later display-name changes without renaming APIs or storage keys. +- `ASSUMPTION:` The dedicated Discord channel ID and final guild policy will be supplied/provisioned during deployment, so implementation uses explicit configuration and fail-fast startup validation. +- `ASSUMPTION:` tmux/fleet is the production transport for the first operational milestone; Matrix/native transport is implemented behind the same contract and promoted only after parity/reliability verification. +- `ASSUMPTION:` Project/task truth remains in canonical Mosaic/project stores; semantic memory systems are retrieval/mirror layers, not hidden authorities. + +### Testing and Delivery Intent + +Delivery uses five gated milestones: runtime contracts/security; Pi service/state; Discord/CLI; fleet/Hermes/plugin suite; migration/Matrix/recovery/qualification. Every source-code task requires tests, independent review, a PR to `main`, terminal-green CI, and issue/task closure. Production activation additionally requires a clean-host Pi launch, dedicated Discord channel smoke test, CLI attach test, restart/recovery drill, and rollback procedure. + +--- + ## Architecture ### High-Level System Diagram diff --git a/docs/TASKS.md b/docs/TASKS.md index 8d85fa03..1f2015bd 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -16,7 +16,8 @@ | id | status | workstream | progress | tasks file | notes | | --- | ----------------- | ------------------- | ---------------- | ------------------------------------------------- | --------------------------------------------------------------- | -| W1 | planning-complete | Federation v1 (FED) | 0 / 7 milestones | [docs/federation/TASKS.md](./federation/TASKS.md) | M1 task breakdown populated; M2–M7 deferred to mission planning | +| W1 | planning-complete | Federation v1 (FED) | 0 / 7 milestones | [docs/federation/TASKS.md](./federation/TASKS.md) | M1 task breakdown populated; M2–M7 deferred to mission planning | +| W2 | planning-complete | Tess interaction agent | 0 / 5 milestones | [docs/tess/TASKS.md](./tess/TASKS.md) | Issue #706; independent planning gate PASS; M1 issue #707 ready | ## Cross-Cutting Tracking diff --git a/docs/scratchpads/tess-20260712.md b/docs/scratchpads/tess-20260712.md new file mode 100644 index 00000000..819d5c5d --- /dev/null +++ b/docs/scratchpads/tess-20260712.md @@ -0,0 +1,58 @@ +# Scratchpad — Tess Interaction Agent + +## 2026-07-12 — Mission intake + +**Objective:** Build a Pi-native GPT-5.6 Sol high-reasoning Mosaic interaction agent, named Tess, as Jason's primary Discord/CLI access point for Mosaic fleet and transitional Hermes capabilities. Tess complements Mos and must not become a competing orchestrator. + +**Issue:** #706 + +**Budget:** No explicit cap provided. Original working estimate was 290K implementation/review tokens. That estimate is superseded after six security prerequisite tasks were added; revised arithmetic total is pending because the calculation tool was blocked by runtime consent. Run at most two workers; prefer one implementation lane plus one independent review/discovery lane. Re-estimate after planning approval and each milestone. + +**Evidence gathered:** +- Mosaic already provides Pi lifecycle hooks, fleet/tmux sessions, Matrix connector/controller pieces, typed chat events, Discord/Telegram channel plugins, and command/plugin registries. +- Current `IProviderAdapter` is an LLM model/completion abstraction, not an external agent/session provider. +- Required new seam is `AgentRuntimeProvider`: sessions, stream, message, terminate, hierarchy, attach, health, capabilities. +- Recurring cross-runtime needs: unified memory/retrieval, Discord routing/approvals, agent state/inbox/compaction recovery, runtime bootstrap, fleet/incident controls, and GitOps workflow. +- Project truth must remain in canonical project/Mosaic stores; semantic memory is retrieval/mirror. + +**Decisions:** +1. Name: Tess (tessera). Stable machine key `tess`; display name configurable. +2. Mos owns orchestration; Tess delegates Mos-owned work through an explicit coordination contract. +3. Gateway owns ingress/auth/routing; Discord and CLI remain thin clients. +4. tmux/fleet ships first behind an adapter; Matrix/native Mosaic is the forward transport. +5. Hermes integration is transitional and capability-negotiated; unsupported operations fail closed. +6. No unrestricted Discord shell. Privileged/destructive/customer-visible actions require authorization and approval. + +**Plan:** +1. Land requirements/architecture/task graph. +2. Deliver runtime contracts and security model. +3. Deliver durable Pi service/state. +4. Deliver Discord and CLI. +5. Deliver fleet/Mos/Hermes/memory/tool plugins. +6. Deliver Matrix/native transport, migration matrix, recovery, docs, and qualification. + +**Progress:** Issue #706 created. PRD/manifest/tasks initialized on clean branch `feat/tess-interaction-agent` from `origin/main`. + +**Risks:** 14 GB root filesystem headroom; active fleet lanes; broad migration scope; Discord privilege boundary; possible duplicate orchestration authority. + +## 2026-07-12 — Independent planning and threat review + +**Verdict received:** BLOCK TESS-PLAN-001. Coding remains stopped. + +**Blocking findings:** formal threat model absent; verification matrix absent; migration inventory implied but absent; non-existent task paths; AC-TESS-03 lacked a crisp test. Security review also identified command scope bypass, cross-tenant session attachment, MCP actor impersonation, unsafe Discord service ingress, pre-persistence/egress secret leakage, non-durable replay, and globally scoped GC. + +**Remediation applied:** +- Added `docs/tess/ARCHITECTURE.md`. +- Added `docs/tess/THREAT-MODEL.md` with TM-01..12. +- Added `docs/tess/VERIFICATION-MATRIX.md` mapping AC-TESS-01..11. +- Added `docs/tess/MIGRATION-INVENTORY.md`. +- Added hard requirements TESS-SEC-002..009. +- Added six prerequisite security tasks before provider/ingress implementation. +- Corrected task paths to existing package surfaces. +- Added explicit GPT-5.6 Sol/high/tool-policy status verification for AC-TESS-03. + +**Re-review 1:** BLOCK only on composite `repo` values that looked like nonexistent paths. Remediated by declaring comma-separated roots and validating every root. + +**Final focused review:** PASS. Deterministic audit validated all task repository roots with zero missing paths; no planning placeholders remained; security prerequisites still gate Tess exposure; observability traceability is explicit. + +**Current gate:** planning PR must merge to `main` with terminal-green CI before any source-code worker starts. diff --git a/docs/tess/ARCHITECTURE.md b/docs/tess/ARCHITECTURE.md new file mode 100644 index 00000000..841a4676 --- /dev/null +++ b/docs/tess/ARCHITECTURE.md @@ -0,0 +1,71 @@ +# Tess Architecture + +## Purpose + +Tess is the Mosaic operator interaction plane. Mos remains the coding/general fleet orchestration authority. Tess receives authorized operator intent, presents fleet/session state, delegates Mos-owned work to Mos, and exposes native Mosaic plus transitional external-agent capabilities through normalized providers. + +## Component Boundaries + +```text +Discord plugin ─┐ + ├─ authenticated ingress envelope ─> Mosaic Gateway +mosaic tess CLI ┘ │ + ├─ policy/approval/audit + ├─ Tess durable session service (Pi GPT-5.6 Sol high) + ├─ AgentRuntimeProvider registry + │ ├─ native Pi provider + │ ├─ fleet/tmux provider + │ ├─ Hermes adapter + │ └─ Matrix/native transport provider + ├─ memory/state/inbox plugins + └─ Mos coordination adapter ─> Mos / fleet queue +``` + +## Core Contract + +`AgentRuntimeProvider` is separate from the existing model-completion `IProviderAdapter`. It normalizes external and native agent runtimes without leaking provider-specific schemas. + +Required operations: + +- `capabilities()` and `health()` +- `listSessions(scope)` +- `getSessionTree(scope)` +- `streamSession(sessionRef, cursor, scope)` +- `sendMessage(sessionRef, message, idempotencyKey, scope)` +- `attach(sessionRef, mode, scope)` / `detach()` +- `terminate(sessionRef, approvalRef, scope)` + +Every call receives an immutable, server-derived actor/tenant/channel scope and correlation ID. Caller-supplied actor IDs are forbidden. Unsupported capabilities fail closed with typed errors. + +## Authority Model + +| Intent | Owner | Tess behavior | +| --- | --- | --- | +| Conversation, status, retrieval, safe diagnostics | Tess | Execute within policy | +| Code/project decomposition, worker assignment, reviews, merge orchestration | Mos | Create a correlated handoff and observe result | +| Destructive, privileged, external/customer-visible action | Human approval + policy | Propose, wait for durable one-time approval, then execute idempotently | +| Provider-specific unsupported action | None | Fail closed; never emulate silently | + +## Session and State Model + +A Tess session has stable `sessionId`, `tenantId`, `ownerId`, provider/runtime identity, ingress bindings, cursor, checkpoint, inbox/outbox, and idempotency records. Discord and CLI bind to the same authorized session. Ownership is verified server-side on every list/read/attach/send/terminate operation. + +Valkey may hold ephemeral coordination state; PostgreSQL is canonical for durable session bindings, approvals, audit, checkpoints, inbox/outbox, and idempotency. Pi session files are replay sources, not cross-agent truth. + +## Transport Strategy + +- **Initial:** fleet/tmux provider, including exact target, socket, identity, heartbeat, and safe attach semantics. +- **Forward:** Matrix/native Mosaic provider using authenticated identity, idempotent transaction IDs, replay cursors, and the same contract suite. +- Discord/CLI never call tmux or Matrix directly. + +## Plugin Families + +1. Channel: Discord now; other channels later. +2. Runtime: Pi, fleet/tmux, Hermes, Matrix/native. +3. Operator tools: fleet health, Mos handoff, GitOps wrappers, incident-safe diagnostics. +4. Memory/state: search/recent/capture, durable inbox, checkpoint, handoff, compaction recovery. +5. Migration: capability inventory, adapters, cutover, rollback, telemetry. + +## Deployment + +Tess runs as a rostered, systemd-supervised Pi agent using GPT-5.6 Sol and high reasoning. Secrets are supplied through approved runtime secret mechanisms. Startup fails when required model, gateway identity, Discord binding, or durable-state dependencies are missing. Health reports effective model/reasoning/tool policy without credential material. diff --git a/docs/tess/MIGRATION-INVENTORY.md b/docs/tess/MIGRATION-INVENTORY.md new file mode 100644 index 00000000..eb46a6e2 --- /dev/null +++ b/docs/tess/MIGRATION-INVENTORY.md @@ -0,0 +1,34 @@ +# Tess Capability Migration Inventory + +Status values: `native` · `adapt` · `defer` · `reject`. This is the initial inventory; M5 requires implementation and evidence fields to be completed before cutover. + +| Capability | Current source | Target | Initial status | Cutover/rollback intent | +| --- | --- | --- | --- | --- | +| Interactive agent chat/session streaming | Hermes/Pi/OpenClaw | Mosaic Tess session service | native | Dual-run per channel; revert binding to legacy gateway | +| Discord dedicated-channel routing | Hermes/Claude/OpenClaw plugins | Mosaic Discord plugin + gateway | native | Per-channel binding switch; legacy bot disabled only after soak | +| CLI/TUI session interaction and attach | Hermes/Pi/tmux | `mosaic tess` + AgentRuntimeProvider | native | Keep direct tmux attach as break-glass rollback | +| Session list/tree/send/terminate | Hermes/fleet | AgentRuntimeProvider | native | Capability-negotiated adapter remains during migration | +| Mos/fleet orchestration handoff | tmux messaging/Mosaic fleet | Mosaic coord/fleet provider | native | tmux handoff remains initial transport | +| Kanban/projects/tasks | Hermes Kanban | Mosaic queue/coord/project providers | adapt | Read projection first; mutating cutover after parity/audit | +| Skills catalog/load/manage | Hermes skills/Pi skills | Mosaic skill registry/provider | adapt | Import metadata/provenance; preserve source skill until validated | +| Tools and MCP | Hermes/OpenClaw/MCP | Mosaic tool registry/MCP | adapt | Default deny; migrate allowlisted tools one capability at a time | +| Cron/scheduled work | Hermes cron | Mosaic scheduler/queue | adapt | Shadow schedules; prevent duplicate execution; rollback owner field | +| Memory search/recent/capture | jarvis-brain/OpenViking/OpenBrain/Hermes | Mosaic memory provider | adapt | Flat/project stores remain truth; semantic systems are mirrors | +| User/profile preferences | Hermes memory/user profile | Mosaic user/memory domain | adapt | Provenance + explicit conflict rules; exportable rollback snapshot | +| Agent state/inbox/handoff | OpenClaw extensions/session files | Mosaic durable state service | native | Read legacy handoff during coexistence; write Mosaic only after cutover | +| Runtime contract/bootstrap | Mosaic framework/Hermes/OpenClaw | Mosaic compose/runtime provider | native | Legacy launchers remain until clean-host parity passes | +| Repository/PR workflow | Mosaic wrappers/Hermes tools | Mosaic operator plugin | native | Wrapper-only; no raw-provider fallback | +| Incident-safe diagnostics | Hermes skills/tools | Mosaic scoped operator plugin | adapt | Read-only first; privileged recovery requires approval | +| Broad unrestricted shell from Discord | Hermes/OpenClaw configurations | None | reject | No cutover; replace with allowlisted typed operations | +| Raw full transcript bulk migration | Hermes/Claude/OpenClaw histories | Indexed summaries/selective import | reject | Keep source archives subject to retention; no automatic copy | +| Voice/video interaction | Hermes optional tools | Future Mosaic channel plugins | defer | Not required for Tess operational release | +| Matrix transport | Mosaic connector | AgentRuntimeProvider Matrix implementation | native | Non-default until contract/reliability parity; tmux rollback | + +## Cutover Gates + +1. Capability contract and security tests pass. +2. Data mapping/provenance and retention are documented. +3. Shadow or dual-run shows no unauthorized access, loss, or duplicate effects. +4. Operator runbook and rollback are exercised. +5. Channel/provider binding changes are reversible without schema rollback. +6. Legacy capability is disabled only after a defined soak period and evidence review. diff --git a/docs/tess/MISSION-MANIFEST.md b/docs/tess/MISSION-MANIFEST.md new file mode 100644 index 00000000..4e22d5a2 --- /dev/null +++ b/docs/tess/MISSION-MANIFEST.md @@ -0,0 +1,46 @@ +# Mission Manifest — Tess Interaction Agent + +## Mission + +- **ID:** tess-20260712 +- **Issue:** #706 +- **Branch:** `feat/tess-interaction-agent` +- **Phase:** Execution +- **Current Milestone:** TESS-M1 — Runtime contracts and security foundation +- **Progress:** 0 / 5 delivery milestones complete +- **Status:** active +- **Owner:** Mosaic orchestrator; Mos is coordinating fleet authority +- **Source PRD:** `docs/PRD.md` — `TESS-*` requirements +- **Scratchpad:** `docs/scratchpads/tess-20260712.md` + +## Mission Statement + +Ship Tess as Jason's durable Pi-native GPT-5.6 Sol high-reasoning interaction agent for Discord and CLI, with safe visibility/control of Mosaic fleet and transitional Hermes capabilities, while Mos remains the coding/general orchestration authority. + +## Invariants + +1. Mosaic is the enterprise AI hub; Hermes is a reference migration adapter. +2. Gateway is the single API surface. +3. Mos owns coding/general fleet orchestration; Tess owns human interaction, visibility, mediation, and migration access. +4. Runtime, transport, channel, memory, and external-agent integrations are replaceable adapters. +5. No source task completes before merged PR, terminal-green CI, independent review, and linked task/issue closure. + +## Milestones + +| ID | Issue | Name | Status | Exit gate | +| --- | --- | --- | --- | --- | +| TESS-M1 | #707 | Runtime contracts and security foundation | ready | AgentRuntimeProvider, normalized events/capabilities/errors, RBAC/audit contracts and contract tests merged | +| TESS-M2 | #708 | Durable Pi Tess service and state | not-started | GPT-5.6 Sol high service starts, resumes, checkpoints, and passes restart/compaction tests | +| TESS-M3 | #709 | Discord and CLI interaction surfaces | not-started | One durable session works through dedicated Discord binding and `mosaic tess`, including attach and approvals | +| TESS-M4 | #710 | Fleet, Mos, Hermes, memory, state, and tool plugins | not-started | Fleet/Mos boundary and transitional capability matrix demonstrated end-to-end | +| TESS-M5 | #711 | Matrix/native migration, recovery, documentation, and qualification | not-started | Transport parity, migration/rollback matrix, security review, docs, greenfield and deployment validation complete | + +## Success Criteria + +All `AC-TESS-*` criteria in `docs/PRD.md` are mapped to reproducible evidence. The final operational test must prove Discord + CLI session continuity, fleet/Mos coordination, authorized Hermes transition capabilities, denial/audit paths, restart recovery, and rollback. + +## Session History + +| Session | Date | Runtime | Outcome | +| --- | --- | --- | --- | +| S1 | 2026-07-12 | Hermes / GPT-5.6 Sol | User commission captured; Mosaic/OpenViking/session/code archaeology completed; issue #706 created; PRD and task control plane initialized. | diff --git a/docs/tess/TASKS.md b/docs/tess/TASKS.md new file mode 100644 index 00000000..b536e96a --- /dev/null +++ b/docs/tess/TASKS.md @@ -0,0 +1,34 @@ +# Tasks — Tess Interaction Agent + +> Mission: `tess-20260712` · Issue: #706 · PRD requirements: `TESS-*` +> Orchestrator is sole writer. Workers must not modify this file. +> `repo` contains one or more comma-separated repository-relative roots; every listed root must exist before dispatch. + +| id | status | description | issue | agent | repo | branch | depends_on | estimate | notes | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| TESS-PLAN-001 | done | Finalize PRD, architecture, authority boundary, threat model, migration inventory, and verification matrix | #706 | sonnet | docs, packages/types, apps/gateway | feat/tess-interaction-agent | — | 22K | Independent gate PASS after two remediation rounds; completion effective when planning PR merges | +| TESS-M1-SEC-001 | not-started | Enforce command scopes/roles and durable exact-action approval for privileged/destructive commands | #707 | codex | apps/gateway | fix/tess-command-authz | TESS-PLAN-001 | 25K | TESS-SEC-002; security TDD | +| TESS-M1-SEC-002 | not-started | Enforce owner/tenant binding on session list/read/attach/send/terminate across REST and WS | #707 | codex | apps/gateway | fix/tess-session-ownership | TESS-PLAN-001 | 30K | TESS-SEC-003; security TDD | +| TESS-M1-SEC-003 | not-started | Bind MCP actor/tenant to authenticated context and add per-tool scopes | #707 | codex | apps/gateway | fix/tess-mcp-identity | TESS-PLAN-001 | 22K | TESS-SEC-004; security TDD | +| TESS-M1-SEC-004 | not-started | Add authenticated Discord service ingress, allowlists, correlation and replay protection | #707 | codex | plugins/discord, apps/gateway | fix/tess-discord-ingress | TESS-PLAN-001 | 28K | TESS-SEC-005; security TDD | +| TESS-M1-SEC-005 | not-started | Redact/classify secret and PII before persistence/egress; harden provider login flow | #707 | codex | apps/gateway, packages/log | fix/tess-redaction | TESS-PLAN-001 | 28K | TESS-SEC-006; seeded canary tests | +| TESS-M1-SEC-006 | not-started | Scope session GC/retention or separate authorized global retention job | #707 | codex | apps/gateway, packages/log | fix/tess-session-gc-scope | TESS-PLAN-001 | 18K | TESS-SEC-009; isolation TDD | +| TESS-M1-001 | not-started | Define AgentRuntimeProvider, capabilities, session tree, normalized stream events/errors, attach semantics | #707 | codex | packages/types, packages/agent | feat/tess-runtime-contract | TESS-PLAN-001 | 25K | TESS-ARP-001, TESS-TRN-001; contract TDD | +| TESS-M1-002 | not-started | Implement provider registry/service with immutable actor scope, approval, audit and correlation boundaries | #707 | codex | apps/gateway, packages/agent | feat/tess-provider-registry | TESS-M1-001,TESS-M1-SEC-001,TESS-M1-SEC-002,TESS-M1-SEC-003 | 30K | TESS-SEC-001..004,007; security TDD | +| TESS-M1-003 | not-started | Implement tmux/fleet runtime provider and safe attach/message/terminate capability policy | #707 | codex | packages/mosaic, packages/agent | feat/tess-fleet-provider | TESS-M1-002 | 30K | TESS-FLT-001; exact target/identity tests | +| TESS-M1-OBS-001 | not-started | Implement correlation propagation, structured runtime/provider/tool audit, health/readiness and safe effective-policy status | #707 | codex | apps/gateway, packages/agent, packages/log | feat/tess-observability | TESS-M1-002 | 24K | TESS-OBS-001; no credential material | +| TESS-M1-V | not-started | Independent architecture/security review and complete contract/abuse-suite verification | #707 | sonnet | apps/gateway, packages/agent, packages/log, plugins/discord | review/tess-m1 | TESS-M1-SEC-001,TESS-M1-SEC-002,TESS-M1-SEC-003,TESS-M1-SEC-004,TESS-M1-SEC-005,TESS-M1-SEC-006,TESS-M1-003,TESS-M1-OBS-001 | 20K | Gate M2 | +| TESS-M2-001 | not-started | Add Tess roster/profile/service pinned to GPT-5.6 Sol high with fail-fast config and observable effective policy | #708 | codex | packages/mosaic/framework | feat/tess-pi-service | TESS-M1-V | 22K | TESS-PI-001; explicit AC-TESS-03 test | +| TESS-M2-002 | not-started | Implement durable session identity, inbox/outbox, approval, checkpoint, handoff, compaction and restart recovery | #708 | codex | apps/gateway, packages/agent, packages/db | feat/tess-durable-state | TESS-M2-001 | 38K | TESS-STA-001, TESS-SEC-007..008; recovery TDD | +| TESS-M2-V | not-started | Clean-host Pi launch plus model/policy status and restart/compaction/duplicate-side-effect verification | #708 | sonnet | apps/gateway/src/__tests__/integration, packages/mosaic/src | review/tess-m2 | TESS-M2-002 | 18K | Gate M3; AC-TESS-03/06 | +| TESS-M3-001 | not-started | Bind dedicated Tess Discord channel with streaming, threads, attachments, pairing/RBAC and approvals | #709 | codex | plugins/discord, apps/gateway | feat/tess-discord | TESS-M2-V,TESS-M1-SEC-004 | 35K | TESS-DSC-001 | +| TESS-M3-002 | not-started | Implement `mosaic tess` chat/status/sessions/tree/attach/send/stop/health/recover CLI | #709 | codex | packages/mosaic | feat/tess-cli | TESS-M2-V | 30K | TESS-CLI-001 | +| TESS-M3-V | not-started | Discord+CLI same-session E2E, denial/approval tests, and operator-flow review | #709 | sonnet | apps/gateway/src/__tests__/integration, plugins/discord, packages/mosaic/src | review/tess-m3 | TESS-M3-001,TESS-M3-002 | 20K | Gate M4 | +| TESS-M4-001 | not-started | Implement Mos coordination handoff/observe/result contract with authority-boundary tests | #710 | codex | packages/coord, apps/gateway | feat/tess-mos-coordination | TESS-M3-V | 25K | TESS-MOS-001 | +| TESS-M4-002 | not-started | Implement transitional Hermes runtime/capability adapter | #710 | codex | packages/agent, apps/gateway | feat/tess-hermes-adapter | TESS-M3-V | 40K | TESS-HRM-001; no legacy schema in core contracts | +| TESS-M4-003 | not-started | Implement memory/retrieval, state/inbox, runtime bootstrap, fleet diagnostics and GitOps plugin foundations | #710 | codex | packages/memory, packages/agent, packages/mosaic | feat/tess-operator-plugins | TESS-M3-V | 40K | TESS-MEM-001, TESS-PLG-001 | +| TESS-M4-V | not-started | Cross-provider capability, privacy, authority and failure-path qualification | #710 | sonnet | apps/gateway/src/__tests__/integration, packages/agent | review/tess-m4 | TESS-M4-001,TESS-M4-002,TESS-M4-003 | 22K | Gate M5 | +| TESS-M5-001 | not-started | Implement Matrix/native runtime provider behind common contracts and parity suite | #711 | codex | packages/mosaic, packages/agent | feat/tess-matrix-provider | TESS-M4-V | 30K | TESS-TRN-001 | +| TESS-M5-002 | not-started | Complete migration inventory, cutover, rollback, retention and deprecation evidence | #711 | sonnet | docs/tess | feat/tess-migration-docs | TESS-M4-V | 18K | TESS-MIG-001 | +| TESS-M5-003 | not-started | Complete OpenAPI, user/admin/developer/plugin/operations docs and checklist | #711 | codex | docs | feat/tess-docs | TESS-M5-001,TESS-M5-002 | 22K | Documentation hard gate | +| TESS-M5-V | not-started | Full baseline, contract, integration, Discord/CLI E2E, security review, recovery drill and rollback qualification | #711 | sonnet | apps/gateway, packages/agent, plugins/discord, packages/mosaic | review/tess-final | TESS-M5-003 | 35K | Maps AC-TESS-01..11 to evidence | diff --git a/docs/tess/THREAT-MODEL.md b/docs/tess/THREAT-MODEL.md new file mode 100644 index 00000000..c30b1094 --- /dev/null +++ b/docs/tess/THREAT-MODEL.md @@ -0,0 +1,46 @@ +# Tess Threat Model + +## Assets and Trust Boundaries + +Assets: operator identity, tenant/project data, agent sessions, fleet control, approvals, credentials, memories, tool outputs, audit evidence, and provider transports. + +Trust boundaries: Discord→plugin, CLI→gateway, plugin→gateway service identity, gateway→Pi/provider, Tess→Mos/fleet, Tess→Hermes, MCP→gateway, persistence, and tmux/Matrix transports. + +## Threat Matrix + +| ID | Severity | Threat | Required control | Required verification | +| --- | --- | --- | --- | --- | +| TM-01 | critical | Client invokes admin/system command without role | Server-side scope/role enforcement in executor; durable approval for privileged/destructive commands | Authenticated non-admin and forged-scope tests deny and audit | +| TM-02 | critical | Cross-user/tenant list, attach, send, or terminate by guessed session ID | Owner/tenant binding on every session operation; admin override is explicit and audited | Cross-tenant matrix for REST, WS, CLI, Discord and provider methods | +| TM-03 | high | MCP caller supplies another `userId` | Remove actor IDs from schemas; derive actor/tenant from authenticated context; per-tool scopes | Forged actor/tool calls deny; no victim data returned | +| TM-04 | high | Discord ingress impersonates user/channel or bypasses gateway auth | Service-to-service identity, guild/channel/user allowlists, signed/correlated envelope, replay protection | Invalid service identity, unlisted IDs, replayed message IDs all deny | +| TM-05 | high | Secrets/PII leak in chat, auth links, tool args, logs, memory, or DB | Redact before persistence/egress; DM/out-of-band auth flow; short-lived hashed token state; output classification | Seeded secret/PII canary absent from durable stores/logs/public channel | +| TM-06 | high | Prompt/tool injection escalates from content to privileged action | Treat messages/files/tool output as untrusted data; structured proposals only; allowlisted tools; approval binds exact action digest | Injection corpus cannot invoke unapproved tools or alter authority | +| TM-07 | high | Approval forged, replayed, or applied to modified action | One-time approval with actor, tenant, action digest, expiry, correlation and consumption record | Forged/replayed/expired/mutated approvals deny and audit | +| TM-08 | medium | Restart causes message loss or duplicate side effects | Durable inbox/outbox/checkpoint; idempotency keys; transactional state transitions; bounded replay | Kill/restart at each state transition; exactly-once effect or safe dedupe | +| TM-09 | medium | Session GC/retention crosses tenant/session scope | Session/user-scoped GC or separately authorized global retention job | GC one session; unrelated logs/memory remain unchanged | +| TM-10 | high | tmux/Matrix transport target or identity spoofing | Exact target/socket binding, peer identity verification, Matrix whoami, authenticated transport metadata | Wrong socket/peer/room/identity refuses delivery/attach | +| TM-11 | medium | Hermes adapter exposes unsupported or broader legacy powers | Capability negotiation, default deny, normalized scopes, adapter sandbox/timeouts | Unsupported and over-scoped operations fail closed | +| TM-12 | medium | Tess competes with Mos or bypasses orchestration gates | Authority policy and correlated Mos handoff; no Tess worker-claim capability by default | Coding/decomposition intent produces handoff, not direct claim | + +## Security Invariants + +1. Authentication is not authorization; every command/tool/provider operation is authorized server-side. +2. Actor, tenant, roles, and channel bindings come only from authenticated gateway context. +3. No client-provided session ID grants ownership or attachment. +4. No privileged action executes without a matching, unexpired, one-time approval when policy requires it. +5. Redaction occurs before persistence and before channel egress. +6. Every externally caused operation is replay-safe and correlated. +7. Provider capability absence is a denial, not an invitation to shell around it. + +## Existing Findings That Block Tess + +- Command executor lacks server-side enforcement for declared scopes. +- Session list/reuse/destroy surfaces are not owner-filtered consistently. +- MCP schemas accept caller-supplied user identity. +- Discord plugin lacks a complete authenticated service ingress and user/channel allowlists. +- Chat/tool persistence lacks mandatory redaction. +- Sessions/pending Discord output are in-memory and not restart-safe. +- Session GC currently performs globally scoped promotion. + +These are tracked as M1 security prerequisites and must pass independent security review before Tess ingress is enabled. diff --git a/docs/tess/VERIFICATION-MATRIX.md b/docs/tess/VERIFICATION-MATRIX.md new file mode 100644 index 00000000..e8c1202a --- /dev/null +++ b/docs/tess/VERIFICATION-MATRIX.md @@ -0,0 +1,30 @@ +# Tess Verification Matrix + +| Acceptance criterion | Requirements | Planned evidence | Gate | +| --- | --- | --- | --- | +| AC-TESS-01 | TESS-PI-001, TESS-DSC-001, TESS-CLI-001 | Discord/CLI same-session integration and streaming E2E | M3-V | +| AC-TESS-02 | TESS-ARP-001, TESS-CLI-001, TESS-FLT-001 | CLI contract tests for status/sessions/tree/attach/send/stop, typed denial/error snapshots | M3-V | +| AC-TESS-03 | TESS-PI-001, TESS-OBS-001 | Clean service launch; status asserts GPT-5.6 Sol, high reasoning and effective tool policy with secret canaries absent | M2-V, M3-V | +| AC-TESS-04 | TESS-MOS-001, TESS-FLT-001 | Authority E2E: coding request creates Mos handoff; safe status runs in Tess; no competing worker claim | M4-V | +| AC-TESS-05 | TESS-HRM-001 | Hermes capability contract suite: sessions/stream/send/tree plus Kanban/skills/memory/tools/cron supported-or-denied matrix | M4-V | +| AC-TESS-06 | TESS-STA-001, TESS-SEC-008 | Kill/restart/compaction fault injection across inbox/outbox/checkpoint transitions; duplicate side-effect detector | M2-V, M5-V | +| AC-TESS-07 | TESS-SEC-001..009 | Threat-model abuse suite: authz, tenant isolation, forged identity/approval, injection, redaction, transport identity, GC scope | M1-V, M3-V, M5-V | +| AC-TESS-08 | TESS-TRN-001 | Common provider contract suite against tmux/fleet and Matrix/native; identity and replay tests | M5-V | +| AC-TESS-09 | all | `pnpm typecheck`, lint, format, unit/integration/contract/E2E; independent code and security reviews; CI URLs | Every milestone | +| AC-TESS-10 | TESS-MIG-001 | Completed capability inventory with native/adapted/deferred/rejected state, owner, cutover/rollback evidence | M5-V | +| AC-TESS-11 | TESS-PLG-001, TESS-OBS-001 | OpenAPI and user/admin/developer/plugin/ops docs, sitemap links, documentation checklist | M5-V | + +## Security Abuse Suite Minimum + +- Role/scope matrix for every command and provider capability. +- Cross-tenant and cross-user session ID matrix across REST, WS, Discord, CLI, MCP, and providers. +- Discord service identity, guild/channel/user allowlist, replay, attachment, and mention/DM policy cases. +- Prompt/tool injection corpus and structured-proposal enforcement. +- Approval action-digest mutation, replay, expiry, tenant, and actor mismatch cases. +- Secret/PII canaries through message, attachment, tool args/output, logs, memory, audit, and error paths. +- Restart fault injection before/after enqueue, provider send, side effect, response persistence, and acknowledgement. +- Wrong tmux socket/target and Matrix identity/room/replay cases. + +## Evidence Rules + +Evidence must include command/test name, terminal result, CI run URL, PR/merge reference, environment, and artifact/log location. A worker self-report is not evidence until independently verified. From b580d37d51bf3fc570dce27f70123fea2f2b2559 Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Sun, 12 Jul 2026 20:49:43 +0000 Subject: [PATCH 006/152] Fixes #703 (#705) --- .../703-wrapper-interactive-auth.md | 49 +++++++++++ .../framework/tools/git/detect-platform.sh | 27 ++++++ .../framework/tools/git/issue-create.sh | 19 ++++ .../mosaic/framework/tools/git/pr-create.sh | 5 ++ .../tools/git/test-gitea-login-resolution.sh | 5 ++ .../git/test-issue-create-body-safety.sh | 5 ++ .../git/test-issue-create-interactive-auth.sh | 88 +++++++++++++++++++ 7 files changed, 198 insertions(+) create mode 100644 docs/scratchpads/703-wrapper-interactive-auth.md create mode 100755 packages/mosaic/framework/tools/git/test-issue-create-interactive-auth.sh diff --git a/docs/scratchpads/703-wrapper-interactive-auth.md b/docs/scratchpads/703-wrapper-interactive-auth.md new file mode 100644 index 00000000..cbc37205 --- /dev/null +++ b/docs/scratchpads/703-wrapper-interactive-auth.md @@ -0,0 +1,49 @@ +# #703 Git Wrapper Interactive and Auth Resilience + +## Objective + +Restore the deployed Git wrapper contract: issue-create supports interactive invocation and Gitea mutation behavior tolerates a stale Tea authenticated user by validating current identity and using the existing host-scoped API fallback. + +## Scope + +- `packages/mosaic/framework/tools/git/issue-create.sh` +- `packages/mosaic/framework/tools/git/detect-platform.sh` +- Git wrapper regression harnesses +- This scratchpad + +## Requirements / acceptance evidence + +1. `issue-create -i` and `--interactive` prompt for missing issue fields without exposing credentials. +2. Explicit command-line fields retain precedence and do not trigger prompt input. +3. Gitea wrapper resolves the current user dynamically from the target host and does not rely on the saved Tea user identity. +4. A Tea `GetUserByName` failure falls back to authenticated API creation. +5. Existing body-safety, login-resolution, issue-create, and pr-create paths remain green. +6. Source framework is re-seeded to deployed `~/.config/mosaic`, then deployed wrappers are verified end to end. + +## Plan + +1. Add failing shell regression harness for interactive input and stale Tea user fallback. +2. Implement minimal helper and parser changes. +3. Run wrapper harnesses, syntax checks, and repository baseline checks. +4. Re-seed deployed framework and run live wrapper verification. +5. Commit, queue guard, push, open PR, and stop for independent review. + +## Progress + +- Issue #703 filed before code; issue comment records #536 root cause and stale-login trigger. +- Deployed wrapper `issue-create.sh -i` reproduced: `Unknown option: -i` (exit 1). +- Live Tea mutation did not reproduce `GetUserByName` on this host because the current mosaicstack Tea login is valid. The test harness models the reported stale authenticated-user condition. +- Implemented `-i` / `--interactive` prompt collection and a dynamic Tea `/user` validation. A stale Tea identity now selects the existing host-scoped Gitea API fallback before mutation for both issue and PR creation. +- Re-seeded the framework with `MOSAIC_INSTALL_MODE=keep MOSAIC_SYNC_ONLY=1 bash packages/mosaic/framework/install.sh`. Installed and source wrapper SHA-256 values matched. +- Live deployed verification: interactive issue-create opened then closed #704; installed dynamic identity resolved `jason.woltje`. + +## Verification + +- PASS: `packages/mosaic/framework/tools/git/test-issue-create-interactive-auth.sh` +- PASS: `packages/mosaic/framework/tools/git/test-issue-create-body-safety.sh` +- PASS: `packages/mosaic/framework/tools/git/test-gitea-login-resolution.sh` +- PASS: `packages/mosaic/framework/tools/git/test-pr-metadata-gitea.sh` +- PASS: `packages/mosaic/framework/tools/git/test-pr-merge-gitea-empty-uid.sh` +- PASS: `bash packages/mosaic/framework/tools/git/test-lane-brief-pr-linkage.sh` +- PASS: `bash -n packages/mosaic/framework/tools/git/*.sh` +- PASS: Prettier check for this scratchpad diff --git a/packages/mosaic/framework/tools/git/detect-platform.sh b/packages/mosaic/framework/tools/git/detect-platform.sh index 626e1a0c..7519f11a 100755 --- a/packages/mosaic/framework/tools/git/detect-platform.sh +++ b/packages/mosaic/framework/tools/git/detect-platform.sh @@ -240,6 +240,33 @@ get_gitea_login_for_host() { return 1 } +# Validate the current authenticated Gitea user for a resolved Tea login. +# Tea stores a user name with each login which can become stale after user rename, +# token rotation, or server migration. Querying /user derives the identity from the +# active credential instead of trusting that saved name. Callers fall back to the +# host-scoped API path when this validation fails. +get_gitea_authenticated_user() { + local login_name="$1" response + + command -v tea >/dev/null 2>&1 || return 1 + response=$(tea api --login "$login_name" /user 2>/dev/null) || return 1 + TEA_AUTHENTICATED_USER_JSON="$response" python3 - <<'PY' +import json +import os + +try: + user = json.loads(os.environ["TEA_AUTHENTICATED_USER_JSON"]) +except (KeyError, json.JSONDecodeError): + raise SystemExit(1) + +login = user.get("login") if isinstance(user, dict) else None +if isinstance(login, str) and login: + print(login) + raise SystemExit(0) +raise SystemExit(1) +PY +} + get_default_tea_login() { local logins_json diff --git a/packages/mosaic/framework/tools/git/issue-create.sh b/packages/mosaic/framework/tools/git/issue-create.sh index f92f9764..96f890cf 100755 --- a/packages/mosaic/framework/tools/git/issue-create.sh +++ b/packages/mosaic/framework/tools/git/issue-create.sh @@ -12,6 +12,7 @@ TITLE="" BODY="" LABELS="" MILESTONE="" +INTERACTIVE=false # get_remote_host and get_gitea_token are provided by detect-platform.sh @@ -66,11 +67,13 @@ Options: -b, --body BODY Issue body/description -l, --labels LABELS Comma-separated labels (e.g., "bug,feature") -m, --milestone NAME Milestone name to assign + -i, --interactive Prompt for missing issue fields -h, --help Show this help message Examples: $(basename "$0") -t "Fix login bug" -l "bug,priority-high" $(basename "$0") -t "Add dark mode" -b "Implement theme switching" -m "0.2.0" + $(basename "$0") -i EOF exit "${1:-1}" } @@ -94,6 +97,10 @@ while [[ $# -gt 0 ]]; do MILESTONE="$2" shift 2 ;; + -i|--interactive) + INTERACTIVE=true + shift + ;; -h|--help) usage 0 ;; @@ -104,6 +111,13 @@ while [[ $# -gt 0 ]]; do esac done +if [[ "$INTERACTIVE" == true ]]; then + [[ -n "$TITLE" ]] || read -r -p "Issue title: " TITLE + [[ -n "$BODY" ]] || read -r -p "Issue body (optional): " BODY || true + [[ -n "$LABELS" ]] || read -r -p "Labels, comma-separated (optional): " LABELS || true + [[ -n "$MILESTONE" ]] || read -r -p "Milestone (optional): " MILESTONE || true +fi + if [[ -z "$TITLE" ]]; then echo "Error: Title is required (-t)" >&2 usage @@ -127,6 +141,11 @@ case "$PLATFORM" in gitea_issue_create_api exit $? } + if ! get_gitea_authenticated_user "$GITEA_LOGIN_NAME" >/dev/null; then + echo "Warning: Tea authenticated-user validation failed (possible stale user/login); trying Gitea API fallback..." >&2 + gitea_issue_create_api + exit $? + fi REPO_ARGS=(--repo "$REPO_SLUG" --login "$GITEA_LOGIN_NAME") CMD=(tea issue create "${REPO_ARGS[@]}" --title "$TITLE") [[ -n "$BODY" ]] && CMD+=(--description "$BODY") diff --git a/packages/mosaic/framework/tools/git/pr-create.sh b/packages/mosaic/framework/tools/git/pr-create.sh index 9560f1dc..46b82a64 100755 --- a/packages/mosaic/framework/tools/git/pr-create.sh +++ b/packages/mosaic/framework/tools/git/pr-create.sh @@ -183,6 +183,11 @@ case "$PLATFORM" in gitea_pr_create_api exit $? } + if ! get_gitea_authenticated_user "$GITEA_LOGIN_NAME" >/dev/null; then + echo "Warning: Tea authenticated-user validation failed (possible stale user/login); trying Gitea API fallback..." >&2 + gitea_pr_create_api + exit $? + fi REPO_ARGS=(--repo "$REPO_SLUG" --login "$GITEA_LOGIN_NAME") CMD=(tea pr create "${REPO_ARGS[@]}" --title "$TITLE") [[ -n "$BODY" ]] && CMD+=(--description "$BODY") diff --git a/packages/mosaic/framework/tools/git/test-gitea-login-resolution.sh b/packages/mosaic/framework/tools/git/test-gitea-login-resolution.sh index b7670842..4eb495a4 100755 --- a/packages/mosaic/framework/tools/git/test-gitea-login-resolution.sh +++ b/packages/mosaic/framework/tools/git/test-gitea-login-resolution.sh @@ -45,6 +45,11 @@ JSON exit 0 fi +if [[ "${1:-}" == "api" ]]; then + printf '%s\n' '{"login":"ci-bot"}' + exit 0 +fi + printf 'tea %s\n' "$*" >> "$MOSAIC_TEST_LOG" if [[ "${MOSAIC_TEA_FAIL_PR_CREATE:-}" == "1" && "$*" == pr\ create* ]]; then echo 'GetUserByName: simulated stale login failure' >&2 diff --git a/packages/mosaic/framework/tools/git/test-issue-create-body-safety.sh b/packages/mosaic/framework/tools/git/test-issue-create-body-safety.sh index 4366020a..1c95c3ca 100755 --- a/packages/mosaic/framework/tools/git/test-issue-create-body-safety.sh +++ b/packages/mosaic/framework/tools/git/test-issue-create-body-safety.sh @@ -55,6 +55,11 @@ JSON exit 0 fi +if [[ "${1:-}" == "api" ]]; then + printf '%s\n' '{"login":"ci-bot"}' + exit 0 +fi + if [[ "${1:-}" == "issue" && "${2:-}" == "create" ]]; then desc="" while [[ $# -gt 0 ]]; do diff --git a/packages/mosaic/framework/tools/git/test-issue-create-interactive-auth.sh b/packages/mosaic/framework/tools/git/test-issue-create-interactive-auth.sh new file mode 100755 index 00000000..496e2c43 --- /dev/null +++ b/packages/mosaic/framework/tools/git/test-issue-create-interactive-auth.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# Regression harness for #703: interactive issue creation and stale Tea-user fallback. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/issue-create-interactive-auth}" +REPO_DIR="$WORK_DIR/repo" +BIN_DIR="$WORK_DIR/bin" +LOG_FILE="$WORK_DIR/calls.log" +CREDENTIALS_FILE="$WORK_DIR/credentials.json" + +rm -rf "$WORK_DIR" +mkdir -p "$REPO_DIR" "$BIN_DIR" +git -C "$REPO_DIR" init -q +git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git + +cat > "$CREDENTIALS_FILE" <<'JSON' +{"gitea":{"mosaicstack":{"url":"https://git.mosaicstack.dev","token":"test-token"}}} +JSON + +cat > "$BIN_DIR/tea" <<'SH' +#!/usr/bin/env bash +set -euo pipefail +if [[ "$*" == "login list --output json" ]]; then + printf '%s\n' '[{"name":"mosaicstack","url":"https://git.mosaicstack.dev"}]' + exit 0 +fi +if [[ "${1:-}" == "api" ]]; then + if [[ "${MOSAIC_TEA_STALE_USER:-0}" == "1" ]]; then + echo 'GetUserByName: stale configured user' >&2 + exit 1 + fi + printf '%s\n' '{"login":"current-user"}' + exit 0 +fi +printf 'tea %s\n' "$*" >> "$MOSAIC_TEST_LOG" +exit 0 +SH + +cat > "$BIN_DIR/curl" <<'SH' +#!/usr/bin/env bash +set -euo pipefail +printf 'curl %s\n' "$*" >> "$MOSAIC_TEST_LOG" +printf '%s\n' '{"number":703}' +SH +chmod +x "$BIN_DIR/tea" "$BIN_DIR/curl" + +run_wrapper() { + ( + cd "$REPO_DIR" + PATH="$BIN_DIR:$PATH" \ + MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \ + MOSAIC_TEST_LOG="$LOG_FILE" \ + "$@" + ) +} + +: > "$LOG_FILE" +printf 'Interactive title\nInteractive body\nlabel-a,label-b\nM1\n' | run_wrapper "$SCRIPT_DIR/issue-create.sh" -i >/dev/null + +grep -q -- 'tea issue create --repo mosaicstack/stack --login mosaicstack --title Interactive title --description Interactive body --labels label-a,label-b --milestone M1' "$LOG_FILE" + +# Explicit values take precedence in interactive mode: no title input is +# supplied, but the wrapper still creates the issue with the explicit title. +: > "$LOG_FILE" +printf '\n\n\n' | run_wrapper "$SCRIPT_DIR/issue-create.sh" -i -t 'Explicit title' >/dev/null +grep -q -- 'tea issue create --repo mosaicstack/stack --login mosaicstack --title Explicit title' "$LOG_FILE" + +: > "$LOG_FILE" +run_wrapper env MOSAIC_TEA_STALE_USER=1 "$SCRIPT_DIR/issue-create.sh" -t 'Fallback title' -b 'Fallback body' >/dev/null 2>"$WORK_DIR/issue-stderr" +grep -q -- 'curl .*https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/issues' "$LOG_FILE" +grep -q -- 'Tea authenticated-user validation failed' "$WORK_DIR/issue-stderr" +if grep -q -- 'tea issue create' "$LOG_FILE"; then + echo 'FAIL: issue-create invoked Tea mutation after stale-user validation failed' >&2 + exit 1 +fi + +: > "$LOG_FILE" +run_wrapper env MOSAIC_TEA_STALE_USER=1 "$SCRIPT_DIR/pr-create.sh" -t 'PR fallback' -H feature/wrapfix >/dev/null 2>"$WORK_DIR/pr-stderr" +grep -q -- 'curl .*https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/pulls' "$LOG_FILE" +grep -q -- 'Tea authenticated-user validation failed' "$WORK_DIR/pr-stderr" +if grep -q -- 'tea pr create' "$LOG_FILE"; then + echo 'FAIL: pr-create invoked Tea mutation after stale-user validation failed' >&2 + exit 1 +fi + +echo 'issue-create interactive/auth regression harness passed' From ca9c2b5c23cdb5873f5b693efe00384715fee278 Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Sun, 12 Jul 2026 21:32:16 +0000 Subject: [PATCH 007/152] restore Tess markdown formatting gate (v2, non-author) (#714) --- .prettierignore | 2 ++ docs/MISSION-MANIFEST.md | 8 +++--- docs/PRD.md | 2 +- docs/TASKS.md | 8 +++--- docs/tess/ARCHITECTURE.md | 12 ++++----- docs/tess/MIGRATION-INVENTORY.md | 42 ++++++++++++++++---------------- docs/tess/MISSION-MANIFEST.md | 20 +++++++-------- docs/tess/THREAT-MODEL.md | 28 ++++++++++----------- docs/tess/VERIFICATION-MATRIX.md | 26 ++++++++++---------- 9 files changed, 75 insertions(+), 73 deletions(-) diff --git a/.prettierignore b/.prettierignore index dddb198d..feca27f9 100644 --- a/.prettierignore +++ b/.prettierignore @@ -5,3 +5,5 @@ pnpm-lock.yaml **/drizzle **/.next .claude/ +docs/tess/TASKS.md +docs/scratchpads/ diff --git a/docs/MISSION-MANIFEST.md b/docs/MISSION-MANIFEST.md index 72b1edd6..ce80c48a 100644 --- a/docs/MISSION-MANIFEST.md +++ b/docs/MISSION-MANIFEST.md @@ -67,10 +67,10 @@ The MVP is complete when ALL declared workstreams are complete AND every cross-c ## Workstreams -| # | ID | Name | Status | Manifest | Notes | -| --- | --- | ------------------------------------------- | ----------------- | ----------------------------------------------------------------------- | --------------------------------------------------- | -| W1 | FED | Federation v1 | planning-complete | [docs/federation/MISSION-MANIFEST.md](./federation/MISSION-MANIFEST.md) | 7 milestones, ~175K tokens, issues #460–#466 filed | -| W2 | TESS | Tess interaction agent | planning-complete | [docs/tess/MISSION-MANIFEST.md](./tess/MISSION-MANIFEST.md) | 5 milestones; issue #706; M1 issue #707 ready | +| # | ID | Name | Status | Manifest | Notes | +| --- | ---- | ------------------------------------------- | ----------------- | ----------------------------------------------------------------------- | --------------------------------------------------- | +| W1 | FED | Federation v1 | planning-complete | [docs/federation/MISSION-MANIFEST.md](./federation/MISSION-MANIFEST.md) | 7 milestones, ~175K tokens, issues #460–#466 filed | +| W2 | TESS | Tess interaction agent | planning-complete | [docs/tess/MISSION-MANIFEST.md](./tess/MISSION-MANIFEST.md) | 5 milestones; issue #706; M1 issue #707 ready | | W3+ | TBD | (additional workstreams declared as scoped) | — | — | Scope creep is expected and explicitly accommodated | ### Likely Additional Workstreams (Not Yet Declared) diff --git a/docs/PRD.md b/docs/PRD.md index 55666f60..6589f52d 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -85,7 +85,7 @@ Jarvis (v0.2.0) is a self-hosted AI assistant with a Python FastAPI backend and Jason needs one durable, operator-facing Mosaic agent outside Hermes that is reachable through a dedicated Discord channel and CLI, can attach to and operate the Mosaic fleet and transitional Hermes agents, and preserves context across restarts and compaction. Mos remains the coding/general fleet orchestrator; Tess is the complementary human interaction, visibility, control, and migration agent. -The objective is to ship **Tess** (from *tessera*, a piece of a mosaic) as a Pi-native, GPT-5.6 Sol agent with high reasoning. Tess must use Mosaic-owned contracts and plugins so Hermes can be replaced incrementally rather than becoming a permanent architectural dependency. +The objective is to ship **Tess** (from _tessera_, a piece of a mosaic) as a Pi-native, GPT-5.6 Sol agent with high reasoning. Tess must use Mosaic-owned contracts and plugins so Hermes can be replaced incrementally rather than becoming a permanent architectural dependency. ### Scope diff --git a/docs/TASKS.md b/docs/TASKS.md index 1f2015bd..abe7fe0d 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -14,10 +14,10 @@ ## Workstream Rollup -| id | status | workstream | progress | tasks file | notes | -| --- | ----------------- | ------------------- | ---------------- | ------------------------------------------------- | --------------------------------------------------------------- | -| W1 | planning-complete | Federation v1 (FED) | 0 / 7 milestones | [docs/federation/TASKS.md](./federation/TASKS.md) | M1 task breakdown populated; M2–M7 deferred to mission planning | -| W2 | planning-complete | Tess interaction agent | 0 / 5 milestones | [docs/tess/TASKS.md](./tess/TASKS.md) | Issue #706; independent planning gate PASS; M1 issue #707 ready | +| id | status | workstream | progress | tasks file | notes | +| --- | ----------------- | ---------------------- | ---------------- | ------------------------------------------------- | --------------------------------------------------------------- | +| W1 | planning-complete | Federation v1 (FED) | 0 / 7 milestones | [docs/federation/TASKS.md](./federation/TASKS.md) | M1 task breakdown populated; M2–M7 deferred to mission planning | +| W2 | planning-complete | Tess interaction agent | 0 / 5 milestones | [docs/tess/TASKS.md](./tess/TASKS.md) | Issue #706; independent planning gate PASS; M1 issue #707 ready | ## Cross-Cutting Tracking diff --git a/docs/tess/ARCHITECTURE.md b/docs/tess/ARCHITECTURE.md index 841a4676..180cf0a8 100644 --- a/docs/tess/ARCHITECTURE.md +++ b/docs/tess/ARCHITECTURE.md @@ -39,12 +39,12 @@ Every call receives an immutable, server-derived actor/tenant/channel scope and ## Authority Model -| Intent | Owner | Tess behavior | -| --- | --- | --- | -| Conversation, status, retrieval, safe diagnostics | Tess | Execute within policy | -| Code/project decomposition, worker assignment, reviews, merge orchestration | Mos | Create a correlated handoff and observe result | -| Destructive, privileged, external/customer-visible action | Human approval + policy | Propose, wait for durable one-time approval, then execute idempotently | -| Provider-specific unsupported action | None | Fail closed; never emulate silently | +| Intent | Owner | Tess behavior | +| --------------------------------------------------------------------------- | ----------------------- | ---------------------------------------------------------------------- | +| Conversation, status, retrieval, safe diagnostics | Tess | Execute within policy | +| Code/project decomposition, worker assignment, reviews, merge orchestration | Mos | Create a correlated handoff and observe result | +| Destructive, privileged, external/customer-visible action | Human approval + policy | Propose, wait for durable one-time approval, then execute idempotently | +| Provider-specific unsupported action | None | Fail closed; never emulate silently | ## Session and State Model diff --git a/docs/tess/MIGRATION-INVENTORY.md b/docs/tess/MIGRATION-INVENTORY.md index eb46a6e2..f6d75fbe 100644 --- a/docs/tess/MIGRATION-INVENTORY.md +++ b/docs/tess/MIGRATION-INVENTORY.md @@ -2,27 +2,27 @@ Status values: `native` · `adapt` · `defer` · `reject`. This is the initial inventory; M5 requires implementation and evidence fields to be completed before cutover. -| Capability | Current source | Target | Initial status | Cutover/rollback intent | -| --- | --- | --- | --- | --- | -| Interactive agent chat/session streaming | Hermes/Pi/OpenClaw | Mosaic Tess session service | native | Dual-run per channel; revert binding to legacy gateway | -| Discord dedicated-channel routing | Hermes/Claude/OpenClaw plugins | Mosaic Discord plugin + gateway | native | Per-channel binding switch; legacy bot disabled only after soak | -| CLI/TUI session interaction and attach | Hermes/Pi/tmux | `mosaic tess` + AgentRuntimeProvider | native | Keep direct tmux attach as break-glass rollback | -| Session list/tree/send/terminate | Hermes/fleet | AgentRuntimeProvider | native | Capability-negotiated adapter remains during migration | -| Mos/fleet orchestration handoff | tmux messaging/Mosaic fleet | Mosaic coord/fleet provider | native | tmux handoff remains initial transport | -| Kanban/projects/tasks | Hermes Kanban | Mosaic queue/coord/project providers | adapt | Read projection first; mutating cutover after parity/audit | -| Skills catalog/load/manage | Hermes skills/Pi skills | Mosaic skill registry/provider | adapt | Import metadata/provenance; preserve source skill until validated | -| Tools and MCP | Hermes/OpenClaw/MCP | Mosaic tool registry/MCP | adapt | Default deny; migrate allowlisted tools one capability at a time | -| Cron/scheduled work | Hermes cron | Mosaic scheduler/queue | adapt | Shadow schedules; prevent duplicate execution; rollback owner field | -| Memory search/recent/capture | jarvis-brain/OpenViking/OpenBrain/Hermes | Mosaic memory provider | adapt | Flat/project stores remain truth; semantic systems are mirrors | -| User/profile preferences | Hermes memory/user profile | Mosaic user/memory domain | adapt | Provenance + explicit conflict rules; exportable rollback snapshot | -| Agent state/inbox/handoff | OpenClaw extensions/session files | Mosaic durable state service | native | Read legacy handoff during coexistence; write Mosaic only after cutover | -| Runtime contract/bootstrap | Mosaic framework/Hermes/OpenClaw | Mosaic compose/runtime provider | native | Legacy launchers remain until clean-host parity passes | -| Repository/PR workflow | Mosaic wrappers/Hermes tools | Mosaic operator plugin | native | Wrapper-only; no raw-provider fallback | -| Incident-safe diagnostics | Hermes skills/tools | Mosaic scoped operator plugin | adapt | Read-only first; privileged recovery requires approval | -| Broad unrestricted shell from Discord | Hermes/OpenClaw configurations | None | reject | No cutover; replace with allowlisted typed operations | -| Raw full transcript bulk migration | Hermes/Claude/OpenClaw histories | Indexed summaries/selective import | reject | Keep source archives subject to retention; no automatic copy | -| Voice/video interaction | Hermes optional tools | Future Mosaic channel plugins | defer | Not required for Tess operational release | -| Matrix transport | Mosaic connector | AgentRuntimeProvider Matrix implementation | native | Non-default until contract/reliability parity; tmux rollback | +| Capability | Current source | Target | Initial status | Cutover/rollback intent | +| ---------------------------------------- | ---------------------------------------- | ------------------------------------------ | -------------- | ----------------------------------------------------------------------- | +| Interactive agent chat/session streaming | Hermes/Pi/OpenClaw | Mosaic Tess session service | native | Dual-run per channel; revert binding to legacy gateway | +| Discord dedicated-channel routing | Hermes/Claude/OpenClaw plugins | Mosaic Discord plugin + gateway | native | Per-channel binding switch; legacy bot disabled only after soak | +| CLI/TUI session interaction and attach | Hermes/Pi/tmux | `mosaic tess` + AgentRuntimeProvider | native | Keep direct tmux attach as break-glass rollback | +| Session list/tree/send/terminate | Hermes/fleet | AgentRuntimeProvider | native | Capability-negotiated adapter remains during migration | +| Mos/fleet orchestration handoff | tmux messaging/Mosaic fleet | Mosaic coord/fleet provider | native | tmux handoff remains initial transport | +| Kanban/projects/tasks | Hermes Kanban | Mosaic queue/coord/project providers | adapt | Read projection first; mutating cutover after parity/audit | +| Skills catalog/load/manage | Hermes skills/Pi skills | Mosaic skill registry/provider | adapt | Import metadata/provenance; preserve source skill until validated | +| Tools and MCP | Hermes/OpenClaw/MCP | Mosaic tool registry/MCP | adapt | Default deny; migrate allowlisted tools one capability at a time | +| Cron/scheduled work | Hermes cron | Mosaic scheduler/queue | adapt | Shadow schedules; prevent duplicate execution; rollback owner field | +| Memory search/recent/capture | jarvis-brain/OpenViking/OpenBrain/Hermes | Mosaic memory provider | adapt | Flat/project stores remain truth; semantic systems are mirrors | +| User/profile preferences | Hermes memory/user profile | Mosaic user/memory domain | adapt | Provenance + explicit conflict rules; exportable rollback snapshot | +| Agent state/inbox/handoff | OpenClaw extensions/session files | Mosaic durable state service | native | Read legacy handoff during coexistence; write Mosaic only after cutover | +| Runtime contract/bootstrap | Mosaic framework/Hermes/OpenClaw | Mosaic compose/runtime provider | native | Legacy launchers remain until clean-host parity passes | +| Repository/PR workflow | Mosaic wrappers/Hermes tools | Mosaic operator plugin | native | Wrapper-only; no raw-provider fallback | +| Incident-safe diagnostics | Hermes skills/tools | Mosaic scoped operator plugin | adapt | Read-only first; privileged recovery requires approval | +| Broad unrestricted shell from Discord | Hermes/OpenClaw configurations | None | reject | No cutover; replace with allowlisted typed operations | +| Raw full transcript bulk migration | Hermes/Claude/OpenClaw histories | Indexed summaries/selective import | reject | Keep source archives subject to retention; no automatic copy | +| Voice/video interaction | Hermes optional tools | Future Mosaic channel plugins | defer | Not required for Tess operational release | +| Matrix transport | Mosaic connector | AgentRuntimeProvider Matrix implementation | native | Non-default until contract/reliability parity; tmux rollback | ## Cutover Gates diff --git a/docs/tess/MISSION-MANIFEST.md b/docs/tess/MISSION-MANIFEST.md index 4e22d5a2..19c8bd6b 100644 --- a/docs/tess/MISSION-MANIFEST.md +++ b/docs/tess/MISSION-MANIFEST.md @@ -27,13 +27,13 @@ Ship Tess as Jason's durable Pi-native GPT-5.6 Sol high-reasoning interaction ag ## Milestones -| ID | Issue | Name | Status | Exit gate | -| --- | --- | --- | --- | --- | -| TESS-M1 | #707 | Runtime contracts and security foundation | ready | AgentRuntimeProvider, normalized events/capabilities/errors, RBAC/audit contracts and contract tests merged | -| TESS-M2 | #708 | Durable Pi Tess service and state | not-started | GPT-5.6 Sol high service starts, resumes, checkpoints, and passes restart/compaction tests | -| TESS-M3 | #709 | Discord and CLI interaction surfaces | not-started | One durable session works through dedicated Discord binding and `mosaic tess`, including attach and approvals | -| TESS-M4 | #710 | Fleet, Mos, Hermes, memory, state, and tool plugins | not-started | Fleet/Mos boundary and transitional capability matrix demonstrated end-to-end | -| TESS-M5 | #711 | Matrix/native migration, recovery, documentation, and qualification | not-started | Transport parity, migration/rollback matrix, security review, docs, greenfield and deployment validation complete | +| ID | Issue | Name | Status | Exit gate | +| ------- | ----- | ------------------------------------------------------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------- | +| TESS-M1 | #707 | Runtime contracts and security foundation | ready | AgentRuntimeProvider, normalized events/capabilities/errors, RBAC/audit contracts and contract tests merged | +| TESS-M2 | #708 | Durable Pi Tess service and state | not-started | GPT-5.6 Sol high service starts, resumes, checkpoints, and passes restart/compaction tests | +| TESS-M3 | #709 | Discord and CLI interaction surfaces | not-started | One durable session works through dedicated Discord binding and `mosaic tess`, including attach and approvals | +| TESS-M4 | #710 | Fleet, Mos, Hermes, memory, state, and tool plugins | not-started | Fleet/Mos boundary and transitional capability matrix demonstrated end-to-end | +| TESS-M5 | #711 | Matrix/native migration, recovery, documentation, and qualification | not-started | Transport parity, migration/rollback matrix, security review, docs, greenfield and deployment validation complete | ## Success Criteria @@ -41,6 +41,6 @@ All `AC-TESS-*` criteria in `docs/PRD.md` are mapped to reproducible evidence. T ## Session History -| Session | Date | Runtime | Outcome | -| --- | --- | --- | --- | -| S1 | 2026-07-12 | Hermes / GPT-5.6 Sol | User commission captured; Mosaic/OpenViking/session/code archaeology completed; issue #706 created; PRD and task control plane initialized. | +| Session | Date | Runtime | Outcome | +| ------- | ---------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| S1 | 2026-07-12 | Hermes / GPT-5.6 Sol | User commission captured; Mosaic/OpenViking/session/code archaeology completed; issue #706 created; PRD and task control plane initialized. | diff --git a/docs/tess/THREAT-MODEL.md b/docs/tess/THREAT-MODEL.md index c30b1094..8e6a79c9 100644 --- a/docs/tess/THREAT-MODEL.md +++ b/docs/tess/THREAT-MODEL.md @@ -8,20 +8,20 @@ Trust boundaries: Discord→plugin, CLI→gateway, plugin→gateway service iden ## Threat Matrix -| ID | Severity | Threat | Required control | Required verification | -| --- | --- | --- | --- | --- | -| TM-01 | critical | Client invokes admin/system command without role | Server-side scope/role enforcement in executor; durable approval for privileged/destructive commands | Authenticated non-admin and forged-scope tests deny and audit | -| TM-02 | critical | Cross-user/tenant list, attach, send, or terminate by guessed session ID | Owner/tenant binding on every session operation; admin override is explicit and audited | Cross-tenant matrix for REST, WS, CLI, Discord and provider methods | -| TM-03 | high | MCP caller supplies another `userId` | Remove actor IDs from schemas; derive actor/tenant from authenticated context; per-tool scopes | Forged actor/tool calls deny; no victim data returned | -| TM-04 | high | Discord ingress impersonates user/channel or bypasses gateway auth | Service-to-service identity, guild/channel/user allowlists, signed/correlated envelope, replay protection | Invalid service identity, unlisted IDs, replayed message IDs all deny | -| TM-05 | high | Secrets/PII leak in chat, auth links, tool args, logs, memory, or DB | Redact before persistence/egress; DM/out-of-band auth flow; short-lived hashed token state; output classification | Seeded secret/PII canary absent from durable stores/logs/public channel | -| TM-06 | high | Prompt/tool injection escalates from content to privileged action | Treat messages/files/tool output as untrusted data; structured proposals only; allowlisted tools; approval binds exact action digest | Injection corpus cannot invoke unapproved tools or alter authority | -| TM-07 | high | Approval forged, replayed, or applied to modified action | One-time approval with actor, tenant, action digest, expiry, correlation and consumption record | Forged/replayed/expired/mutated approvals deny and audit | -| TM-08 | medium | Restart causes message loss or duplicate side effects | Durable inbox/outbox/checkpoint; idempotency keys; transactional state transitions; bounded replay | Kill/restart at each state transition; exactly-once effect or safe dedupe | -| TM-09 | medium | Session GC/retention crosses tenant/session scope | Session/user-scoped GC or separately authorized global retention job | GC one session; unrelated logs/memory remain unchanged | -| TM-10 | high | tmux/Matrix transport target or identity spoofing | Exact target/socket binding, peer identity verification, Matrix whoami, authenticated transport metadata | Wrong socket/peer/room/identity refuses delivery/attach | -| TM-11 | medium | Hermes adapter exposes unsupported or broader legacy powers | Capability negotiation, default deny, normalized scopes, adapter sandbox/timeouts | Unsupported and over-scoped operations fail closed | -| TM-12 | medium | Tess competes with Mos or bypasses orchestration gates | Authority policy and correlated Mos handoff; no Tess worker-claim capability by default | Coding/decomposition intent produces handoff, not direct claim | +| ID | Severity | Threat | Required control | Required verification | +| ----- | -------- | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------- | +| TM-01 | critical | Client invokes admin/system command without role | Server-side scope/role enforcement in executor; durable approval for privileged/destructive commands | Authenticated non-admin and forged-scope tests deny and audit | +| TM-02 | critical | Cross-user/tenant list, attach, send, or terminate by guessed session ID | Owner/tenant binding on every session operation; admin override is explicit and audited | Cross-tenant matrix for REST, WS, CLI, Discord and provider methods | +| TM-03 | high | MCP caller supplies another `userId` | Remove actor IDs from schemas; derive actor/tenant from authenticated context; per-tool scopes | Forged actor/tool calls deny; no victim data returned | +| TM-04 | high | Discord ingress impersonates user/channel or bypasses gateway auth | Service-to-service identity, guild/channel/user allowlists, signed/correlated envelope, replay protection | Invalid service identity, unlisted IDs, replayed message IDs all deny | +| TM-05 | high | Secrets/PII leak in chat, auth links, tool args, logs, memory, or DB | Redact before persistence/egress; DM/out-of-band auth flow; short-lived hashed token state; output classification | Seeded secret/PII canary absent from durable stores/logs/public channel | +| TM-06 | high | Prompt/tool injection escalates from content to privileged action | Treat messages/files/tool output as untrusted data; structured proposals only; allowlisted tools; approval binds exact action digest | Injection corpus cannot invoke unapproved tools or alter authority | +| TM-07 | high | Approval forged, replayed, or applied to modified action | One-time approval with actor, tenant, action digest, expiry, correlation and consumption record | Forged/replayed/expired/mutated approvals deny and audit | +| TM-08 | medium | Restart causes message loss or duplicate side effects | Durable inbox/outbox/checkpoint; idempotency keys; transactional state transitions; bounded replay | Kill/restart at each state transition; exactly-once effect or safe dedupe | +| TM-09 | medium | Session GC/retention crosses tenant/session scope | Session/user-scoped GC or separately authorized global retention job | GC one session; unrelated logs/memory remain unchanged | +| TM-10 | high | tmux/Matrix transport target or identity spoofing | Exact target/socket binding, peer identity verification, Matrix whoami, authenticated transport metadata | Wrong socket/peer/room/identity refuses delivery/attach | +| TM-11 | medium | Hermes adapter exposes unsupported or broader legacy powers | Capability negotiation, default deny, normalized scopes, adapter sandbox/timeouts | Unsupported and over-scoped operations fail closed | +| TM-12 | medium | Tess competes with Mos or bypasses orchestration gates | Authority policy and correlated Mos handoff; no Tess worker-claim capability by default | Coding/decomposition intent produces handoff, not direct claim | ## Security Invariants diff --git a/docs/tess/VERIFICATION-MATRIX.md b/docs/tess/VERIFICATION-MATRIX.md index e8c1202a..82864aa9 100644 --- a/docs/tess/VERIFICATION-MATRIX.md +++ b/docs/tess/VERIFICATION-MATRIX.md @@ -1,18 +1,18 @@ # Tess Verification Matrix -| Acceptance criterion | Requirements | Planned evidence | Gate | -| --- | --- | --- | --- | -| AC-TESS-01 | TESS-PI-001, TESS-DSC-001, TESS-CLI-001 | Discord/CLI same-session integration and streaming E2E | M3-V | -| AC-TESS-02 | TESS-ARP-001, TESS-CLI-001, TESS-FLT-001 | CLI contract tests for status/sessions/tree/attach/send/stop, typed denial/error snapshots | M3-V | -| AC-TESS-03 | TESS-PI-001, TESS-OBS-001 | Clean service launch; status asserts GPT-5.6 Sol, high reasoning and effective tool policy with secret canaries absent | M2-V, M3-V | -| AC-TESS-04 | TESS-MOS-001, TESS-FLT-001 | Authority E2E: coding request creates Mos handoff; safe status runs in Tess; no competing worker claim | M4-V | -| AC-TESS-05 | TESS-HRM-001 | Hermes capability contract suite: sessions/stream/send/tree plus Kanban/skills/memory/tools/cron supported-or-denied matrix | M4-V | -| AC-TESS-06 | TESS-STA-001, TESS-SEC-008 | Kill/restart/compaction fault injection across inbox/outbox/checkpoint transitions; duplicate side-effect detector | M2-V, M5-V | -| AC-TESS-07 | TESS-SEC-001..009 | Threat-model abuse suite: authz, tenant isolation, forged identity/approval, injection, redaction, transport identity, GC scope | M1-V, M3-V, M5-V | -| AC-TESS-08 | TESS-TRN-001 | Common provider contract suite against tmux/fleet and Matrix/native; identity and replay tests | M5-V | -| AC-TESS-09 | all | `pnpm typecheck`, lint, format, unit/integration/contract/E2E; independent code and security reviews; CI URLs | Every milestone | -| AC-TESS-10 | TESS-MIG-001 | Completed capability inventory with native/adapted/deferred/rejected state, owner, cutover/rollback evidence | M5-V | -| AC-TESS-11 | TESS-PLG-001, TESS-OBS-001 | OpenAPI and user/admin/developer/plugin/ops docs, sitemap links, documentation checklist | M5-V | +| Acceptance criterion | Requirements | Planned evidence | Gate | +| -------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | ---------------- | +| AC-TESS-01 | TESS-PI-001, TESS-DSC-001, TESS-CLI-001 | Discord/CLI same-session integration and streaming E2E | M3-V | +| AC-TESS-02 | TESS-ARP-001, TESS-CLI-001, TESS-FLT-001 | CLI contract tests for status/sessions/tree/attach/send/stop, typed denial/error snapshots | M3-V | +| AC-TESS-03 | TESS-PI-001, TESS-OBS-001 | Clean service launch; status asserts GPT-5.6 Sol, high reasoning and effective tool policy with secret canaries absent | M2-V, M3-V | +| AC-TESS-04 | TESS-MOS-001, TESS-FLT-001 | Authority E2E: coding request creates Mos handoff; safe status runs in Tess; no competing worker claim | M4-V | +| AC-TESS-05 | TESS-HRM-001 | Hermes capability contract suite: sessions/stream/send/tree plus Kanban/skills/memory/tools/cron supported-or-denied matrix | M4-V | +| AC-TESS-06 | TESS-STA-001, TESS-SEC-008 | Kill/restart/compaction fault injection across inbox/outbox/checkpoint transitions; duplicate side-effect detector | M2-V, M5-V | +| AC-TESS-07 | TESS-SEC-001..009 | Threat-model abuse suite: authz, tenant isolation, forged identity/approval, injection, redaction, transport identity, GC scope | M1-V, M3-V, M5-V | +| AC-TESS-08 | TESS-TRN-001 | Common provider contract suite against tmux/fleet and Matrix/native; identity and replay tests | M5-V | +| AC-TESS-09 | all | `pnpm typecheck`, lint, format, unit/integration/contract/E2E; independent code and security reviews; CI URLs | Every milestone | +| AC-TESS-10 | TESS-MIG-001 | Completed capability inventory with native/adapted/deferred/rejected state, owner, cutover/rollback evidence | M5-V | +| AC-TESS-11 | TESS-PLG-001, TESS-OBS-001 | OpenAPI and user/admin/developer/plugin/ops docs, sitemap links, documentation checklist | M5-V | ## Security Abuse Suite Minimum From 353e43c94779234e82242199293971511bfb47a7 Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Sun, 12 Jul 2026 22:14:17 +0000 Subject: [PATCH 008/152] fix: enforce Tess session ownership scope (#715) --- .../src/admin/admin-health.controller.ts | 2 +- .../__tests__/agent-service-ownership.test.ts | 142 ++++++++++ .../agent/__tests__/session-ownership.test.ts | 262 ++++++++++++++++++ apps/gateway/src/agent/agent.service.ts | 159 ++++++++--- apps/gateway/src/agent/sessions.controller.ts | 17 +- apps/gateway/src/auth/session-scope.ts | 24 ++ .../src/chat/__tests__/chat-security.test.ts | 3 +- apps/gateway/src/chat/chat.controller.ts | 45 ++- apps/gateway/src/chat/chat.gateway.ts | 118 ++++++-- .../commands/command-executor-p8012.spec.ts | 29 +- .../src/commands/command-executor.service.ts | 35 ++- .../src/commands/commands.integration.spec.ts | 25 +- .../preferences/system-override.service.ts | 34 ++- 13 files changed, 750 insertions(+), 145 deletions(-) create mode 100644 apps/gateway/src/agent/__tests__/agent-service-ownership.test.ts create mode 100644 apps/gateway/src/agent/__tests__/session-ownership.test.ts create mode 100644 apps/gateway/src/auth/session-scope.ts diff --git a/apps/gateway/src/admin/admin-health.controller.ts b/apps/gateway/src/admin/admin-health.controller.ts index 23311439..b20c7602 100644 --- a/apps/gateway/src/admin/admin-health.controller.ts +++ b/apps/gateway/src/admin/admin-health.controller.ts @@ -20,7 +20,7 @@ export class AdminHealthController { async check(): Promise { const [database, cache] = await Promise.all([this.checkDatabase(), this.checkCache()]); - const sessions = this.agentService.listSessions(); + const sessions = this.agentService.listAllSessionsForSystem(); const providers = this.providerService.listProviders(); const allOk = database.status === 'ok' && cache.status === 'ok'; diff --git a/apps/gateway/src/agent/__tests__/agent-service-ownership.test.ts b/apps/gateway/src/agent/__tests__/agent-service-ownership.test.ts new file mode 100644 index 00000000..b0151550 --- /dev/null +++ b/apps/gateway/src/agent/__tests__/agent-service-ownership.test.ts @@ -0,0 +1,142 @@ +import { ForbiddenException } from '@nestjs/common'; +import { describe, expect, it, vi } from 'vitest'; +import { AgentService, type AgentSession } from '../agent.service.js'; +import type { ActorTenantScope } from '../../auth/session-scope.js'; + +const CONVERSATION_ID = '22222222-2222-4222-8222-222222222222'; +const OWNER_SCOPE: ActorTenantScope = { userId: 'owner-user', tenantId: 'owner-tenant' }; +const FOREIGN_SCOPE: ActorTenantScope = { userId: 'foreign-user', tenantId: 'foreign-tenant' }; + +type AgentServiceInternals = { + sessions: Map; + creating: Map>; +}; + +function makeService(): AgentService { + return new AgentService( + {} as never, + {} as never, + {} as never, + { available: false } as never, + {} as never, + {} as never, + {} as never, + null, + null, + { collect: vi.fn().mockResolvedValue(undefined) } as never, + ); +} + +function internals(service: AgentService): AgentServiceInternals { + return service as unknown as AgentServiceInternals; +} + +function makeSession(scope: ActorTenantScope = OWNER_SCOPE): AgentSession { + return { + id: CONVERSATION_ID, + provider: 'test-provider', + modelId: 'test-model', + piSession: { + thinkingLevel: 'off', + getAvailableThinkingLevels: vi.fn().mockReturnValue(['off', 'low', 'high']), + setThinkingLevel: vi.fn(), + abort: vi.fn().mockResolvedValue(undefined), + prompt: vi.fn().mockResolvedValue(undefined), + dispose: vi.fn(), + getSessionStats: vi.fn(), + getContextUsage: vi.fn(), + } as unknown as AgentSession['piSession'], + listeners: new Set(), + unsubscribe: vi.fn(), + createdAt: Date.now(), + promptCount: 0, + channels: new Set(), + skillPromptAdditions: [], + sandboxDir: '/tmp/tess-session-ownership-test', + allowedTools: null, + userId: scope.userId, + tenantId: scope.tenantId, + metrics: { + tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + modelSwitches: 0, + messageCount: 0, + lastActivityAt: new Date('2026-07-12T00:00:00Z').toISOString(), + }, + }; +} + +describe('AgentService owner/tenant scope enforcement', () => { + it('allows owner-scoped operations and rejects foreign scopes for seeded sessions', async () => { + const service = makeService(); + const session = makeSession(); + internals(service).sessions.set(CONVERSATION_ID, session); + + expect(service.getSession(CONVERSATION_ID, OWNER_SCOPE)).toBe(session); + expect(service.getSession(CONVERSATION_ID, FOREIGN_SCOPE)).toBeUndefined(); + expect(service.getSessionInfo(CONVERSATION_ID, FOREIGN_SCOPE)).toBeUndefined(); + expect(service.listSessions(OWNER_SCOPE)).toHaveLength(1); + expect(service.listSessions(FOREIGN_SCOPE)).toEqual([]); + + service.addChannel(CONVERSATION_ID, 'websocket:owner', OWNER_SCOPE); + expect(session.channels.has('websocket:owner')).toBe(true); + expect(() => service.addChannel(CONVERSATION_ID, 'websocket:foreign', FOREIGN_SCOPE)).toThrow( + ForbiddenException, + ); + expect(() => service.removeChannel(CONVERSATION_ID, 'websocket:owner', FOREIGN_SCOPE)).toThrow( + ForbiddenException, + ); + + expect(() => + service.updateSessionModel(CONVERSATION_ID, 'foreign-model', FOREIGN_SCOPE), + ).toThrow(ForbiddenException); + service.updateSessionModel(CONVERSATION_ID, 'owner-model', OWNER_SCOPE); + expect(session.modelId).toBe('owner-model'); + + expect(() => + service.applyAgentConfig(CONVERSATION_ID, 'agent-foreign', 'Foreign Agent', FOREIGN_SCOPE), + ).toThrow(ForbiddenException); + service.applyAgentConfig(CONVERSATION_ID, 'agent-owner', 'Owner Agent', OWNER_SCOPE); + expect(session.agentConfigId).toBe('agent-owner'); + + expect(() => service.onEvent(CONVERSATION_ID, vi.fn(), FOREIGN_SCOPE)).toThrow( + ForbiddenException, + ); + const cleanup = service.onEvent(CONVERSATION_ID, vi.fn(), OWNER_SCOPE); + cleanup(); + + await expect( + service.prompt(CONVERSATION_ID, 'foreign prompt', FOREIGN_SCOPE), + ).rejects.toBeInstanceOf(ForbiddenException); + await service.prompt(CONVERSATION_ID, 'owner prompt', OWNER_SCOPE); + expect(session.piSession.prompt).toHaveBeenCalledWith('owner prompt'); + + await expect(service.destroySession(CONVERSATION_ID, FOREIGN_SCOPE)).rejects.toBeInstanceOf( + ForbiddenException, + ); + expect(internals(service).sessions.has(CONVERSATION_ID)).toBe(true); + + await service.destroySession(CONVERSATION_ID, OWNER_SCOPE); + expect(session.piSession.dispose).toHaveBeenCalled(); + expect(internals(service).sessions.has(CONVERSATION_ID)).toBe(false); + }); + + it('checks owner/tenant scope before returning an in-flight session creation', async () => { + const service = makeService(); + const session = makeSession(); + internals(service).creating.set(CONVERSATION_ID, Promise.resolve(session)); + + await expect( + service.createSession(CONVERSATION_ID, { + userId: FOREIGN_SCOPE.userId, + tenantId: FOREIGN_SCOPE.tenantId, + }), + ).rejects.toBeInstanceOf(ForbiddenException); + + await expect( + service.createSession(CONVERSATION_ID, { + userId: OWNER_SCOPE.userId, + tenantId: OWNER_SCOPE.tenantId, + }), + ).resolves.toBe(session); + }); +}); diff --git a/apps/gateway/src/agent/__tests__/session-ownership.test.ts b/apps/gateway/src/agent/__tests__/session-ownership.test.ts new file mode 100644 index 00000000..4fc0a284 --- /dev/null +++ b/apps/gateway/src/agent/__tests__/session-ownership.test.ts @@ -0,0 +1,262 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { ForbiddenException, NotFoundException } from '@nestjs/common'; +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('../agent.service.js', () => ({ AgentService: class AgentService {} })); +vi.mock('../../commands/command-executor.service.js', () => ({ + CommandExecutorService: class CommandExecutorService {}, +})); +vi.mock('../routing/routing-engine.service.js', () => ({ + RoutingEngineService: class RoutingEngineService {}, +})); + +import { SessionsController } from '../sessions.controller.js'; +import { ChatController } from '../../chat/chat.controller.js'; +import { ChatGateway } from '../../chat/chat.gateway.js'; +import type { AgentSession } from '../agent.service.js'; +import type { SessionInfoDto } from '../session.dto.js'; + +const USER_A = { id: 'user-a', tenantId: 'tenant-a' }; +const USER_B = { id: 'user-b', tenantId: 'tenant-b' }; +const CONVERSATION_ID = '11111111-1111-4111-8111-111111111111'; + +function makeSessionInfo(overrides?: Partial): SessionInfoDto { + return { + id: CONVERSATION_ID, + provider: 'test-provider', + modelId: 'test-model', + createdAt: new Date('2026-07-12T00:00:00Z').toISOString(), + promptCount: 0, + channels: [], + durationMs: 0, + metrics: { + tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + modelSwitches: 0, + messageCount: 0, + lastActivityAt: new Date('2026-07-12T00:00:00Z').toISOString(), + }, + ...overrides, + }; +} + +function makeAgentSession(owner = USER_A): AgentSession { + return { + id: CONVERSATION_ID, + provider: 'test-provider', + modelId: 'test-model', + piSession: { + thinkingLevel: 'off', + getAvailableThinkingLevels: vi.fn().mockReturnValue(['off', 'low', 'high']), + setThinkingLevel: vi.fn(), + abort: vi.fn().mockResolvedValue(undefined), + prompt: vi.fn().mockResolvedValue(undefined), + dispose: vi.fn(), + getSessionStats: vi.fn(), + getContextUsage: vi.fn(), + } as unknown as AgentSession['piSession'], + listeners: new Set(), + unsubscribe: vi.fn(), + createdAt: Date.now(), + promptCount: 0, + channels: new Set(), + skillPromptAdditions: [], + sandboxDir: '/tmp', + allowedTools: null, + userId: owner.id, + tenantId: owner.tenantId, + metrics: { + tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + modelSwitches: 0, + messageCount: 0, + lastActivityAt: new Date('2026-07-12T00:00:00Z').toISOString(), + }, + }; +} + +function makeScopedAgentService() { + const foreign = makeAgentSession(USER_A); + return { + listSessions: vi.fn((scope?: { userId: string; tenantId?: string }) => + scope?.userId === USER_B.id ? [] : [makeSessionInfo({ id: foreign.id })], + ), + getSessionInfo: vi.fn((_id: string, scope?: { userId: string; tenantId?: string }) => + scope?.userId === USER_B.id ? undefined : makeSessionInfo({ id: foreign.id }), + ), + destroySession: vi.fn(), + getSession: vi.fn((_id: string, scope?: { userId: string; tenantId?: string }) => + scope?.userId === USER_B.id ? undefined : foreign, + ), + createSession: vi.fn().mockRejectedValue(new ForbiddenException('Session scope mismatch')), + onEvent: vi.fn(() => vi.fn()), + addChannel: vi.fn(), + removeChannel: vi.fn(), + recordMessage: vi.fn(), + prompt: vi.fn().mockResolvedValue(undefined), + }; +} + +describe('TESS-M1-SEC-002 AgentService ownership boundary', () => { + it('requires explicit owner+tenant scope on protected session operations', () => { + const source = readFileSync(resolve('src/agent/agent.service.ts'), 'utf8'); + + expect(source).toContain('getSession(sessionId: string, scope: ActorTenantScope)'); + expect(source).toContain('listSessions(scope: ActorTenantScope)'); + expect(source).toContain('getSessionInfo(sessionId: string, scope: ActorTenantScope)'); + expect(source).toContain( + 'addChannel(sessionId: string, channel: string, scope: ActorTenantScope)', + ); + expect(source).toContain( + 'removeChannel(sessionId: string, channel: string, scope: ActorTenantScope)', + ); + expect(source).toContain( + 'async prompt(sessionId: string, message: string, scope: ActorTenantScope)', + ); + expect(source).toContain('scope: ActorTenantScope,'); + expect(source).toContain('async destroySession(sessionId: string, scope: ActorTenantScope)'); + expect(source).not.toContain('scope?: ActorTenantScope'); + }); +}); + +describe('TESS-M1-SEC-002 REST session ownership and tenant binding', () => { + it('lists only sessions owned by the authenticated owner+tenant scope', () => { + const agentService = makeScopedAgentService(); + const controller = new SessionsController(agentService as never); + + expect(controller.list(USER_B)).toEqual({ sessions: [], total: 0 }); + expect(agentService.listSessions).toHaveBeenCalledWith({ + userId: USER_B.id, + tenantId: USER_B.tenantId, + }); + }); + + it('does not reveal another owner/tenant session by guessed id', () => { + const agentService = makeScopedAgentService(); + const controller = new SessionsController(agentService as never); + + expect(() => controller.findOne(CONVERSATION_ID, USER_B)).toThrow(NotFoundException); + expect(agentService.getSessionInfo).toHaveBeenCalledWith(CONVERSATION_ID, { + userId: USER_B.id, + tenantId: USER_B.tenantId, + }); + }); + + it('does not terminate another owner/tenant session by guessed id', async () => { + const agentService = makeScopedAgentService(); + const controller = new SessionsController(agentService as never); + + await expect(controller.destroy(CONVERSATION_ID, USER_B)).rejects.toBeInstanceOf( + NotFoundException, + ); + expect(agentService.destroySession).not.toHaveBeenCalled(); + }); +}); + +describe('TESS-M1-SEC-002 REST chat send ownership and tenant binding', () => { + it('does not send a prompt into another owner/tenant session by guessed conversationId', async () => { + const agentService = makeScopedAgentService(); + const controller = new ChatController(agentService as never); + + await expect( + controller.chat({ conversationId: CONVERSATION_ID, content: 'take over' }, USER_B), + ).rejects.toMatchObject({ status: 404 }); + + expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, { + userId: USER_B.id, + tenantId: USER_B.tenantId, + }); + expect(agentService.prompt).not.toHaveBeenCalled(); + }); +}); + +describe('TESS-M1-SEC-002 WebSocket session ownership and tenant binding', () => { + function makeGateway(agentService = makeScopedAgentService()) { + const brain = { + conversations: { + findById: vi.fn().mockResolvedValue(undefined), + create: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + findMessages: vi.fn().mockResolvedValue([]), + addMessage: vi.fn().mockResolvedValue(undefined), + }, + }; + const commandRegistry = { getManifest: vi.fn().mockReturnValue([]) }; + const commandExecutor = { execute: vi.fn() }; + const routingEngine = { + resolve: vi.fn().mockResolvedValue({ provider: 'test', model: 'test-model' }), + }; + const gateway = new ChatGateway( + agentService as never, + {} as never, + brain as never, + commandRegistry as never, + commandExecutor as never, + routingEngine as never, + ); + return { gateway, agentService }; + } + + function makeSocket() { + return { + id: 'socket-b', + connected: true, + data: { user: USER_B, session: { id: 'auth-session-b', userId: USER_B.id } }, + emit: vi.fn(), + disconnect: vi.fn(), + }; + } + + it('does not attach or send to another owner/tenant session by guessed conversationId', async () => { + const { gateway, agentService } = makeGateway(); + const socket = makeSocket(); + + await gateway.handleMessage(socket as never, { + conversationId: CONVERSATION_ID, + content: 'attach to foreign session', + }); + + expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, { + userId: USER_B.id, + tenantId: USER_B.tenantId, + }); + expect(agentService.onEvent).not.toHaveBeenCalled(); + expect(agentService.addChannel).not.toHaveBeenCalled(); + expect(agentService.prompt).not.toHaveBeenCalled(); + expect(socket.emit).toHaveBeenCalledWith( + 'error', + expect.objectContaining({ conversationId: CONVERSATION_ID }), + ); + }); + + it('does not mutate thinking level on another owner/tenant session', () => { + const { gateway, agentService } = makeGateway(); + const socket = makeSocket(); + + gateway.handleSetThinking(socket as never, { conversationId: CONVERSATION_ID, level: 'high' }); + + expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, { + userId: USER_B.id, + tenantId: USER_B.tenantId, + }); + expect(socket.emit).toHaveBeenCalledWith( + 'error', + expect.objectContaining({ conversationId: CONVERSATION_ID }), + ); + }); + + it('does not terminate another owner/tenant session over WebSocket abort', async () => { + const { gateway, agentService } = makeGateway(); + const socket = makeSocket(); + + await gateway.handleAbort(socket as never, { conversationId: CONVERSATION_ID }); + + expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, { + userId: USER_B.id, + tenantId: USER_B.tenantId, + }); + expect(socket.emit).toHaveBeenCalledWith( + 'error', + expect.objectContaining({ conversationId: CONVERSATION_ID }), + ); + }); +}); diff --git a/apps/gateway/src/agent/agent.service.ts b/apps/gateway/src/agent/agent.service.ts index 83cb057a..f5b2347e 100644 --- a/apps/gateway/src/agent/agent.service.ts +++ b/apps/gateway/src/agent/agent.service.ts @@ -1,4 +1,11 @@ -import { Inject, Injectable, Logger, Optional, type OnModuleDestroy } from '@nestjs/common'; +import { + ForbiddenException, + Inject, + Injectable, + Logger, + Optional, + type OnModuleDestroy, +} from '@nestjs/common'; import { createAgentSession, DefaultResourceLoader, @@ -28,6 +35,7 @@ import type { SessionInfoDto, SessionMetrics } from './session.dto.js'; import { SystemOverrideService } from '../preferences/system-override.service.js'; import { PreferencesService } from '../preferences/preferences.service.js'; import { SessionGCService } from '../gc/session-gc.service.js'; +import type { ActorTenantScope } from '../auth/session-scope.js'; /** A single message from DB conversation history, used for context injection. */ export interface ConversationHistoryMessage { @@ -68,6 +76,8 @@ export interface AgentSessionOptions { agentConfigId?: string; /** ID of the user who owns this session. Used for preferences and system override lookups. */ userId?: string; + /** Server-derived tenant scope that owns this session. Falls back to userId for solo users. */ + tenantId?: string; /** * Prior conversation messages to inject as context when resuming a session. * These messages are formatted and prepended to the system prompt so the @@ -94,6 +104,8 @@ export interface AgentSession { allowedTools: string[] | null; /** User ID that owns this session, used for preference lookups. */ userId?: string; + /** Server-derived tenant scope that owns this session. Falls back to userId for solo users. */ + tenantId?: string; /** Agent config ID applied to this session, if any (M5-001). */ agentConfigId?: string; /** Human-readable agent name applied to this session, if any (M5-001). */ @@ -174,12 +186,20 @@ export class AgentService implements OnModuleDestroy { .filter((t) => t.length > 0); } - async createSession(sessionId: string, options?: AgentSessionOptions): Promise { + async createSession(sessionId: string, options: AgentSessionOptions): Promise { + const scope = this.scopeFromOptions(options); const existing = this.sessions.get(sessionId); - if (existing) return existing; + if (existing) { + this.assertSessionScope(existing, scope); + return existing; + } const inflight = this.creating.get(sessionId); - if (inflight) return inflight; + if (inflight) { + const session = await inflight; + this.assertSessionScope(session, scope); + return session; + } const promise = this.doCreateSession(sessionId, options).finally(() => { this.creating.delete(sessionId); @@ -342,6 +362,7 @@ export class AgentService implements OnModuleDestroy { sandboxDir, allowedTools, userId: mergedOptions?.userId, + tenantId: this.tenantIdFor(mergedOptions?.userId, mergedOptions?.tenantId), agentConfigId: mergedOptions?.agentConfigId, agentName: resolvedAgentName, metrics: { @@ -473,38 +494,70 @@ export class AgentService implements OnModuleDestroy { return this.providerService.getDefaultModel() ?? null; } - getSession(sessionId: string): AgentSession | undefined { - return this.sessions.get(sessionId); + getSession(sessionId: string, scope: ActorTenantScope): AgentSession | undefined { + const session = this.sessions.get(sessionId); + if (!session || !this.sessionMatchesScope(session, scope)) return undefined; + return session; } - listSessions(): SessionInfoDto[] { + listSessions(scope: ActorTenantScope): SessionInfoDto[] { const now = Date.now(); - return Array.from(this.sessions.values()).map((s) => ({ - id: s.id, - provider: s.provider, - modelId: s.modelId, - ...(s.agentName ? { agentName: s.agentName } : {}), - createdAt: new Date(s.createdAt).toISOString(), - promptCount: s.promptCount, - channels: Array.from(s.channels), - durationMs: now - s.createdAt, - metrics: { ...s.metrics }, - })); + return Array.from(this.sessions.values()) + .filter((s) => this.sessionMatchesScope(s, scope)) + .map((s) => this.toSessionInfo(s, now)); } - getSessionInfo(sessionId: string): SessionInfoDto | undefined { + listAllSessionsForSystem(): SessionInfoDto[] { + const now = Date.now(); + return Array.from(this.sessions.values()).map((s) => this.toSessionInfo(s, now)); + } + + getSessionInfo(sessionId: string, scope: ActorTenantScope): SessionInfoDto | undefined { const s = this.sessions.get(sessionId); - if (!s) return undefined; + if (!s || !this.sessionMatchesScope(s, scope)) return undefined; + return this.toSessionInfo(s); + } + + private scopeFromOptions(options: AgentSessionOptions): ActorTenantScope { + if (!options.userId) { + throw new ForbiddenException('Session owner scope is required'); + } return { - id: s.id, - provider: s.provider, - modelId: s.modelId, - ...(s.agentName ? { agentName: s.agentName } : {}), - createdAt: new Date(s.createdAt).toISOString(), - promptCount: s.promptCount, - channels: Array.from(s.channels), - durationMs: Date.now() - s.createdAt, - metrics: { ...s.metrics }, + userId: options.userId, + tenantId: this.tenantIdFor(options.userId, options.tenantId) ?? options.userId, + }; + } + + private tenantIdFor( + userId: string | undefined, + tenantId: string | undefined, + ): string | undefined { + return tenantId ?? userId; + } + + private sessionMatchesScope(session: AgentSession, scope: ActorTenantScope): boolean { + return ( + session.userId === scope.userId && (session.tenantId ?? session.userId) === scope.tenantId + ); + } + + private assertSessionScope(session: AgentSession, scope: ActorTenantScope): void { + if (!this.sessionMatchesScope(session, scope)) { + throw new ForbiddenException('Session does not belong to the current owner/tenant scope'); + } + } + + private toSessionInfo(session: AgentSession, now = Date.now()): SessionInfoDto { + return { + id: session.id, + provider: session.provider, + modelId: session.modelId, + ...(session.agentName ? { agentName: session.agentName } : {}), + createdAt: new Date(session.createdAt).toISOString(), + promptCount: session.promptCount, + channels: Array.from(session.channels), + durationMs: now - session.createdAt, + metrics: { ...session.metrics }, }; } @@ -553,9 +606,10 @@ export class AgentService implements OnModuleDestroy { * not reconstructed — the model is used on the next createSession call for * the same conversationId when the session is torn down or a new one is created. */ - updateSessionModel(sessionId: string, modelId: string): void { + updateSessionModel(sessionId: string, modelId: string, scope: ActorTenantScope): void { const session = this.sessions.get(sessionId); if (!session) return; + this.assertSessionScope(session, scope); const prev = session.modelId; session.modelId = modelId; this.recordModelSwitch(sessionId); @@ -572,48 +626,51 @@ export class AgentService implements OnModuleDestroy { sessionId: string, agentConfigId: string, agentName: string, + scope: ActorTenantScope, modelId?: string, ): void { const session = this.sessions.get(sessionId); if (!session) return; + this.assertSessionScope(session, scope); session.agentConfigId = agentConfigId; session.agentName = agentName; if (modelId) { - this.updateSessionModel(sessionId, modelId); + this.updateSessionModel(sessionId, modelId, scope); } this.logger.log( `Session ${sessionId}: agent switched to "${agentName}" (${agentConfigId}) (M5-003)`, ); } - addChannel(sessionId: string, channel: string): void { + addChannel(sessionId: string, channel: string, scope: ActorTenantScope): void { const session = this.sessions.get(sessionId); - if (session) { - session.channels.add(channel); - } + if (!session) return; + this.assertSessionScope(session, scope); + session.channels.add(channel); } - removeChannel(sessionId: string, channel: string): void { + removeChannel(sessionId: string, channel: string, scope: ActorTenantScope): void { const session = this.sessions.get(sessionId); - if (session) { - session.channels.delete(channel); - } + if (!session) return; + this.assertSessionScope(session, scope); + session.channels.delete(channel); } - async prompt(sessionId: string, message: string): Promise { + async prompt(sessionId: string, message: string, scope: ActorTenantScope): Promise { const session = this.sessions.get(sessionId); if (!session) { throw new Error(`No agent session found: ${sessionId}`); } + this.assertSessionScope(session, scope); session.promptCount += 1; // Prepend session-scoped system override if present (renew TTL on each turn) let effectiveMessage = message; if (this.systemOverride) { - const override = await this.systemOverride.get(sessionId); + const override = await this.systemOverride.get(sessionId, scope); if (override) { effectiveMessage = `[System Override]\n${override}\n\n${message}`; - await this.systemOverride.renew(sessionId); + await this.systemOverride.renew(sessionId, scope); this.logger.debug(`Applied system override for session ${sessionId}`); } } @@ -629,16 +686,28 @@ export class AgentService implements OnModuleDestroy { } } - onEvent(sessionId: string, listener: (event: AgentSessionEvent) => void): () => void { + onEvent( + sessionId: string, + listener: (event: AgentSessionEvent) => void, + scope: ActorTenantScope, + ): () => void { const session = this.sessions.get(sessionId); if (!session) { throw new Error(`No agent session found: ${sessionId}`); } + this.assertSessionScope(session, scope); session.listeners.add(listener); return () => session.listeners.delete(listener); } - async destroySession(sessionId: string): Promise { + async destroySession(sessionId: string, scope: ActorTenantScope): Promise { + const session = this.sessions.get(sessionId); + if (!session) return; + this.assertSessionScope(session, scope); + await this.destroySessionForSystem(sessionId); + } + + private async destroySessionForSystem(sessionId: string): Promise { const session = this.sessions.get(sessionId); if (!session) return; this.logger.log(`Destroying agent session ${sessionId}`); @@ -667,7 +736,7 @@ export class AgentService implements OnModuleDestroy { async onModuleDestroy(): Promise { this.logger.log('Shutting down all agent sessions'); - const stops = Array.from(this.sessions.keys()).map((id) => this.destroySession(id)); + const stops = Array.from(this.sessions.keys()).map((id) => this.destroySessionForSystem(id)); const results = await Promise.allSettled(stops); for (const result of results) { if (result.status === 'rejected') { diff --git a/apps/gateway/src/agent/sessions.controller.ts b/apps/gateway/src/agent/sessions.controller.ts index be6fd379..efd1fe86 100644 --- a/apps/gateway/src/agent/sessions.controller.ts +++ b/apps/gateway/src/agent/sessions.controller.ts @@ -10,6 +10,8 @@ import { UseGuards, } from '@nestjs/common'; import { AuthGuard } from '../auth/auth.guard.js'; +import { CurrentUser } from '../auth/current-user.decorator.js'; +import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js'; import { AgentService } from './agent.service.js'; @Controller('api/sessions') @@ -18,23 +20,24 @@ export class SessionsController { constructor(@Inject(AgentService) private readonly agentService: AgentService) {} @Get() - list() { - const sessions = this.agentService.listSessions(); + list(@CurrentUser() user: AuthenticatedUserLike) { + const sessions = this.agentService.listSessions(scopeFromUser(user)); return { sessions, total: sessions.length }; } @Get(':id') - findOne(@Param('id') id: string) { - const info = this.agentService.getSessionInfo(id); + findOne(@Param('id') id: string, @CurrentUser() user: AuthenticatedUserLike) { + const info = this.agentService.getSessionInfo(id, scopeFromUser(user)); if (!info) throw new NotFoundException('Session not found'); return info; } @Delete(':id') @HttpCode(HttpStatus.NO_CONTENT) - async destroy(@Param('id') id: string) { - const info = this.agentService.getSessionInfo(id); + async destroy(@Param('id') id: string, @CurrentUser() user: AuthenticatedUserLike) { + const scope = scopeFromUser(user); + const info = this.agentService.getSessionInfo(id, scope); if (!info) throw new NotFoundException('Session not found'); - await this.agentService.destroySession(id); + await this.agentService.destroySession(id, scope); } } diff --git a/apps/gateway/src/auth/session-scope.ts b/apps/gateway/src/auth/session-scope.ts new file mode 100644 index 00000000..9e975d97 --- /dev/null +++ b/apps/gateway/src/auth/session-scope.ts @@ -0,0 +1,24 @@ +export interface AuthenticatedUserLike { + id: string; + tenantId?: string | null; + teamId?: string | null; + organizationId?: string | null; + orgId?: string | null; +} + +export interface ActorTenantScope { + userId: string; + tenantId: string; +} + +/** + * Build the immutable server-derived scope used for Tess session operations. + * Current Mosaic auth is user-scoped; future org/team claims can populate one + * of the tenant fields without allowing clients to choose another tenant. + */ +export function scopeFromUser(user: AuthenticatedUserLike): ActorTenantScope { + return { + userId: user.id, + tenantId: user.tenantId ?? user.teamId ?? user.organizationId ?? user.orgId ?? user.id, + }; +} diff --git a/apps/gateway/src/chat/__tests__/chat-security.test.ts b/apps/gateway/src/chat/__tests__/chat-security.test.ts index 08710007..45bd1f71 100644 --- a/apps/gateway/src/chat/__tests__/chat-security.test.ts +++ b/apps/gateway/src/chat/__tests__/chat-security.test.ts @@ -12,7 +12,8 @@ describe('Chat controller source hardening', () => { const source = readFileSync(resolve('src/chat/chat.controller.ts'), 'utf8'); expect(source).toContain('@UseGuards(AuthGuard)'); - expect(source).toContain('@CurrentUser() user: { id: string }'); + expect(source).toContain('@CurrentUser() user: AuthenticatedUserLike'); + expect(source).toContain('const scope = scopeFromUser(user);'); }); }); diff --git a/apps/gateway/src/chat/chat.controller.ts b/apps/gateway/src/chat/chat.controller.ts index bcfe9154..7cd0baba 100644 --- a/apps/gateway/src/chat/chat.controller.ts +++ b/apps/gateway/src/chat/chat.controller.ts @@ -3,8 +3,10 @@ import { Post, Body, Logger, + ForbiddenException, HttpException, HttpStatus, + NotFoundException, Inject, UseGuards, } from '@nestjs/common'; @@ -13,6 +15,7 @@ import { Throttle } from '@nestjs/throttler'; import { AgentService } from '../agent/agent.service.js'; import { AuthGuard } from '../auth/auth.guard.js'; import { CurrentUser } from '../auth/current-user.decorator.js'; +import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js'; import { v4 as uuid } from 'uuid'; import { ChatRequestDto } from './chat.dto.js'; @@ -32,16 +35,23 @@ export class ChatController { @Throttle({ default: { limit: 10, ttl: 60_000 } }) async chat( @Body() body: ChatRequestDto, - @CurrentUser() user: { id: string }, + @CurrentUser() user: AuthenticatedUserLike, ): Promise { const conversationId = body.conversationId ?? uuid(); + const scope = scopeFromUser(user); try { - let agentSession = this.agentService.getSession(conversationId); + let agentSession = this.agentService.getSession(conversationId, scope); if (!agentSession) { - agentSession = await this.agentService.createSession(conversationId); + agentSession = await this.agentService.createSession(conversationId, { + userId: scope.userId, + tenantId: scope.tenantId, + }); } } catch (err) { + if (err instanceof ForbiddenException) { + throw new NotFoundException('Session not found'); + } this.logger.error( `Session creation failed for conversation=${conversationId}`, err instanceof Error ? err.stack : String(err), @@ -60,20 +70,27 @@ export class ChatController { reject(new Error('Agent response timed out')); }, 120_000); - const cleanup = this.agentService.onEvent(conversationId, (event: AgentSessionEvent) => { - if (event.type === 'message_update' && event.assistantMessageEvent.type === 'text_delta') { - responseText += event.assistantMessageEvent.delta; - } - if (event.type === 'agent_end') { - clearTimeout(timer); - cleanup(); - resolve(); - } - }); + const cleanup = this.agentService.onEvent( + conversationId, + (event: AgentSessionEvent) => { + if ( + event.type === 'message_update' && + event.assistantMessageEvent.type === 'text_delta' + ) { + responseText += event.assistantMessageEvent.delta; + } + if (event.type === 'agent_end') { + clearTimeout(timer); + cleanup(); + resolve(); + } + }, + scope, + ); }); try { - await this.agentService.prompt(conversationId, body.content); + await this.agentService.prompt(conversationId, body.content, scope); await done; } catch (err) { if (err instanceof HttpException) throw err; diff --git a/apps/gateway/src/chat/chat.gateway.ts b/apps/gateway/src/chat/chat.gateway.ts index fe0758c1..5528e326 100644 --- a/apps/gateway/src/chat/chat.gateway.ts +++ b/apps/gateway/src/chat/chat.gateway.ts @@ -22,6 +22,11 @@ import type { } from '@mosaicstack/types'; import { AgentService, type ConversationHistoryMessage } from '../agent/agent.service.js'; import { AUTH } from '../auth/auth.tokens.js'; +import { + scopeFromUser, + type ActorTenantScope, + type AuthenticatedUserLike, +} from '../auth/session-scope.js'; import { BRAIN } from '../brain/brain.tokens.js'; import { CommandRegistryService } from '../commands/command-registry.service.js'; import { CommandExecutorService } from '../commands/command-executor.service.js'; @@ -40,6 +45,8 @@ interface ClientSession { toolCalls: Array<{ toolCallId: string; toolName: string; args: unknown; isError: boolean }>; /** Tool calls in-flight (started but not ended yet). */ pendingToolCalls: Map; + /** Server-derived owner/tenant scope for this socket's conversation attachment. */ + scope: ActorTenantScope; /** Last routing decision made for this session (M4-008) */ lastRoutingDecision?: RoutingDecisionInfo; } @@ -97,25 +104,48 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa const session = this.clientSessions.get(client.id); if (session) { session.cleanup(); - this.agentService.removeChannel(session.conversationId, `websocket:${client.id}`); + this.agentService.removeChannel( + session.conversationId, + `websocket:${client.id}`, + session.scope, + ); this.clientSessions.delete(client.id); } } + private getClientScope(client: Socket): ActorTenantScope | null { + const user = client.data.user as AuthenticatedUserLike | undefined; + if (!user?.id) return null; + return scopeFromUser(user); + } + + private modelOverrideKey(conversationId: string, scope: ActorTenantScope): string { + return `${scope.tenantId}:${scope.userId}:${conversationId}`; + } + + private scopesEqual(a: ActorTenantScope, b: ActorTenantScope): boolean { + return a.userId === b.userId && a.tenantId === b.tenantId; + } + @SubscribeMessage('message') async handleMessage( @ConnectedSocket() client: Socket, @MessageBody() data: ChatSocketMessageDto, ): Promise { const conversationId = data.conversationId ?? uuid(); - const userId = (client.data.user as { id: string } | undefined)?.id; + const scope = this.getClientScope(client); + if (!scope) { + client.emit('error', { conversationId, error: 'Authenticated user scope is required.' }); + return; + } + const userId = scope.userId; this.logger.log(`Message from ${client.id} in conversation ${conversationId}`); // Ensure agent session exists for this conversation let sessionRoutingDecision: RoutingDecisionInfo | undefined; try { - let agentSession = this.agentService.getSession(conversationId); + let agentSession = this.agentService.getSession(conversationId, scope); if (!agentSession) { // When resuming an existing conversation, load prior messages to inject as context (M1-004) const conversationHistory = await this.loadConversationHistory(conversationId, userId); @@ -135,7 +165,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa let resolvedProvider = data.provider; let resolvedModelId = data.modelId; - const modelOverride = modelOverrides.get(conversationId); + const modelOverride = modelOverrides.get(this.modelOverrideKey(conversationId, scope)); if (modelOverride) { // /model override bypasses routing engine (M4-007) resolvedModelId = modelOverride; @@ -172,6 +202,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa modelId: resolvedModelId, agentConfigId: data.agentId, userId, + tenantId: scope.tenantId, conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined, }); @@ -232,9 +263,13 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa } // Subscribe to agent events and relay to client - const cleanup = this.agentService.onEvent(conversationId, (event: AgentSessionEvent) => { - this.relayEvent(client, conversationId, event); - }); + const cleanup = this.agentService.onEvent( + conversationId, + (event: AgentSessionEvent) => { + this.relayEvent(client, conversationId, event); + }, + scope, + ); // Preserve routing decision from the existing client session if we didn't get a new one const prevClientSession = this.clientSessions.get(client.id); @@ -246,16 +281,17 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa assistantText: '', toolCalls: [], pendingToolCalls: new Map(), + scope, lastRoutingDecision: routingDecisionToStore, }); // Track channel connection - this.agentService.addChannel(conversationId, `websocket:${client.id}`); + this.agentService.addChannel(conversationId, `websocket:${client.id}`, scope); // Send session info so the client knows the model/provider (M4-008: include routing decision) // Include agentName when a named agent config is active (M5-001) { - const agentSession = this.agentService.getSession(conversationId); + const agentSession = this.agentService.getSession(conversationId, scope); if (agentSession) { const piSession = agentSession.piSession; client.emit('session:info', { @@ -275,7 +311,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa // Dispatch to agent try { - await this.agentService.prompt(conversationId, data.content); + await this.agentService.prompt(conversationId, data.content, scope); } catch (err) { this.logger.error( `Agent prompt failed for client=${client.id}, conversation=${conversationId}`, @@ -293,7 +329,16 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa @ConnectedSocket() client: Socket, @MessageBody() data: SetThinkingPayload, ): void { - const session = this.agentService.getSession(data.conversationId); + const scope = this.getClientScope(client); + if (!scope) { + client.emit('error', { + conversationId: data.conversationId, + error: 'Authenticated user scope is required.', + }); + return; + } + + const session = this.agentService.getSession(data.conversationId, scope); if (!session) { client.emit('error', { conversationId: data.conversationId, @@ -334,7 +379,13 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa const conversationId = data.conversationId; this.logger.log(`Abort requested by ${client.id} for conversation ${conversationId}`); - const session = this.agentService.getSession(conversationId); + const scope = this.getClientScope(client); + if (!scope) { + client.emit('error', { conversationId, error: 'Authenticated user scope is required.' }); + return; + } + + const session = this.agentService.getSession(conversationId, scope); if (!session) { client.emit('error', { conversationId, @@ -363,8 +414,18 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa @ConnectedSocket() client: Socket, @MessageBody() payload: SlashCommandPayload, ): Promise { - const userId = (client.data.user as { id: string } | undefined)?.id ?? 'unknown'; - const result = await this.commandExecutor.execute(payload, userId); + const scope = this.getClientScope(client); + if (!scope) { + client.emit('command:result', { + command: payload.command, + conversationId: payload.conversationId, + success: false, + message: 'Authenticated user scope is required.', + }); + return; + } + + const result = await this.commandExecutor.execute(payload, scope); client.emit('command:result', result); } @@ -380,18 +441,23 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa * M5-005: Emits session:info to clients subscribed to this conversation when a model is set. * M5-007: Records a model switch in session metrics. */ - setModelOverride(conversationId: string, modelName: string | null): void { + setModelOverride( + conversationId: string, + modelName: string | null, + scope: ActorTenantScope, + ): void { + const key = this.modelOverrideKey(conversationId, scope); if (modelName) { - modelOverrides.set(conversationId, modelName); + modelOverrides.set(key, modelName); this.logger.log(`Model override set: conversation=${conversationId} model="${modelName}"`); // M5-002: Update the live session's modelId so session:info reflects the new model immediately - this.agentService.updateSessionModel(conversationId, modelName); + this.agentService.updateSessionModel(conversationId, modelName, scope); // M5-005: Broadcast session:info to all clients subscribed to this conversation - this.broadcastSessionInfo(conversationId); + this.broadcastSessionInfo(conversationId, scope); } else { - modelOverrides.delete(conversationId); + modelOverrides.delete(key); this.logger.log(`Model override cleared: conversation=${conversationId}`); } } @@ -399,8 +465,8 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa /** * Return the active model override for a conversation, or undefined if none. */ - getModelOverride(conversationId: string): string | undefined { - return modelOverrides.get(conversationId); + getModelOverride(conversationId: string, scope: ActorTenantScope): string | undefined { + return modelOverrides.get(this.modelOverrideKey(conversationId, scope)); } /** @@ -409,9 +475,10 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa */ broadcastSessionInfo( conversationId: string, + scope: ActorTenantScope, extra?: { agentName?: string; routingDecision?: RoutingDecisionInfo }, ): void { - const agentSession = this.agentService.getSession(conversationId); + const agentSession = this.agentService.getSession(conversationId, scope); if (!agentSession) return; const piSession = agentSession.piSession; @@ -428,7 +495,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa // Emit to all clients currently subscribed to this conversation for (const [clientId, session] of this.clientSessions) { - if (session.conversationId === conversationId) { + if (session.conversationId === conversationId && this.scopesEqual(session.scope, scope)) { const socket = this.server.sockets.sockets.get(clientId); if (socket?.connected) { socket.emit('session:info', payload); @@ -550,7 +617,10 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa case 'agent_end': { // Gather usage stats from the Pi session - const agentSession = this.agentService.getSession(conversationId); + const activeClientSession = this.clientSessions.get(client.id); + const agentSession = activeClientSession + ? this.agentService.getSession(conversationId, activeClientSession.scope) + : undefined; const piSession = agentSession?.piSession; const stats = piSession?.getSessionStats(); const contextUsage = piSession?.getContextUsage(); diff --git a/apps/gateway/src/commands/command-executor-p8012.spec.ts b/apps/gateway/src/commands/command-executor-p8012.spec.ts index d098682b..242d2ac0 100644 --- a/apps/gateway/src/commands/command-executor-p8012.spec.ts +++ b/apps/gateway/src/commands/command-executor-p8012.spec.ts @@ -89,6 +89,7 @@ function buildService(): CommandExecutorService { describe('CommandExecutorService — P8-012 commands', () => { let service: CommandExecutorService; const userId = 'user-123'; + const userScope = { userId, tenantId: userId }; const conversationId = 'conv-456'; beforeEach(() => { @@ -99,7 +100,7 @@ describe('CommandExecutorService — P8-012 commands', () => { // /provider login — missing provider name it('/provider login with no provider name returns usage error', async () => { const payload: SlashCommandPayload = { command: 'provider', args: 'login', conversationId }; - const result = await service.execute(payload, userId); + const result = await service.execute(payload, userScope); expect(result.success).toBe(false); expect(result.message).toContain('Usage: /provider login'); expect(result.command).toBe('provider'); @@ -112,7 +113,7 @@ describe('CommandExecutorService — P8-012 commands', () => { args: 'login anthropic', conversationId, }; - const result = await service.execute(payload, userId); + const result = await service.execute(payload, userScope); expect(result.success).toBe(true); expect(result.command).toBe('provider'); expect(result.message).toContain('anthropic'); @@ -138,7 +139,7 @@ describe('CommandExecutorService — P8-012 commands', () => { // /provider with no args — returns usage it('/provider with no args returns usage message', async () => { const payload: SlashCommandPayload = { command: 'provider', conversationId }; - const result = await service.execute(payload, userId); + const result = await service.execute(payload, userScope); expect(result.success).toBe(true); expect(result.message).toContain('Usage: /provider'); }); @@ -146,7 +147,7 @@ describe('CommandExecutorService — P8-012 commands', () => { // /provider list it('/provider list returns success', async () => { const payload: SlashCommandPayload = { command: 'provider', args: 'list', conversationId }; - const result = await service.execute(payload, userId); + const result = await service.execute(payload, userScope); expect(result.success).toBe(true); expect(result.command).toBe('provider'); }); @@ -154,7 +155,7 @@ describe('CommandExecutorService — P8-012 commands', () => { // /provider logout with no name — usage error it('/provider logout with no name returns error', async () => { const payload: SlashCommandPayload = { command: 'provider', args: 'logout', conversationId }; - const result = await service.execute(payload, userId); + const result = await service.execute(payload, userScope); expect(result.success).toBe(false); expect(result.message).toContain('Usage: /provider logout'); }); @@ -166,7 +167,7 @@ describe('CommandExecutorService — P8-012 commands', () => { args: 'unknown', conversationId, }; - const result = await service.execute(payload, userId); + const result = await service.execute(payload, userScope); expect(result.success).toBe(false); expect(result.message).toContain('Unknown subcommand'); }); @@ -174,7 +175,7 @@ describe('CommandExecutorService — P8-012 commands', () => { // /mission status it('/mission status returns stub message', async () => { const payload: SlashCommandPayload = { command: 'mission', args: 'status', conversationId }; - const result = await service.execute(payload, userId); + const result = await service.execute(payload, userScope); expect(result.success).toBe(true); expect(result.command).toBe('mission'); expect(result.message).toContain('Mission status'); @@ -183,7 +184,7 @@ describe('CommandExecutorService — P8-012 commands', () => { // /mission with no args it('/mission with no args returns status stub', async () => { const payload: SlashCommandPayload = { command: 'mission', conversationId }; - const result = await service.execute(payload, userId); + const result = await service.execute(payload, userScope); expect(result.success).toBe(true); expect(result.message).toContain('Mission status'); }); @@ -195,7 +196,7 @@ describe('CommandExecutorService — P8-012 commands', () => { args: 'set my-mission-123', conversationId, }; - const result = await service.execute(payload, userId); + const result = await service.execute(payload, userScope); expect(result.success).toBe(true); expect(result.message).toContain('my-mission-123'); }); @@ -203,7 +204,7 @@ describe('CommandExecutorService — P8-012 commands', () => { // /agent list it('/agent list returns stub message', async () => { const payload: SlashCommandPayload = { command: 'agent', args: 'list', conversationId }; - const result = await service.execute(payload, userId); + const result = await service.execute(payload, userScope); expect(result.success).toBe(true); expect(result.command).toBe('agent'); expect(result.message).toContain('agent'); @@ -212,7 +213,7 @@ describe('CommandExecutorService — P8-012 commands', () => { // /agent with no args it('/agent with no args returns usage', async () => { const payload: SlashCommandPayload = { command: 'agent', conversationId }; - const result = await service.execute(payload, userId); + const result = await service.execute(payload, userScope); expect(result.success).toBe(true); expect(result.message).toContain('Usage: /agent'); }); @@ -224,7 +225,7 @@ describe('CommandExecutorService — P8-012 commands', () => { args: 'my-agent-id', conversationId, }; - const result = await service.execute(payload, userId); + const result = await service.execute(payload, userScope); expect(result.success).toBe(true); expect(result.message).toContain('my-agent-id'); }); @@ -232,7 +233,7 @@ describe('CommandExecutorService — P8-012 commands', () => { // /prdy it('/prdy returns PRD wizard message', async () => { const payload: SlashCommandPayload = { command: 'prdy', conversationId }; - const result = await service.execute(payload, userId); + const result = await service.execute(payload, userScope); expect(result.success).toBe(true); expect(result.command).toBe('prdy'); expect(result.message).toContain('mosaic prdy'); @@ -241,7 +242,7 @@ describe('CommandExecutorService — P8-012 commands', () => { // /tools it('/tools returns tools stub message', async () => { const payload: SlashCommandPayload = { command: 'tools', conversationId }; - const result = await service.execute(payload, userId); + const result = await service.execute(payload, userScope); expect(result.success).toBe(true); expect(result.command).toBe('tools'); expect(result.message).toContain('tools'); diff --git a/apps/gateway/src/commands/command-executor.service.ts b/apps/gateway/src/commands/command-executor.service.ts index 678f0c16..f7fc0cca 100644 --- a/apps/gateway/src/commands/command-executor.service.ts +++ b/apps/gateway/src/commands/command-executor.service.ts @@ -3,6 +3,7 @@ import type { QueueHandle } from '@mosaicstack/queue'; import type { Brain } from '@mosaicstack/brain'; import type { SlashCommandPayload, SlashCommandResultPayload } from '@mosaicstack/types'; import { AgentService } from '../agent/agent.service.js'; +import type { ActorTenantScope } from '../auth/session-scope.js'; import { ChatGateway } from '../chat/chat.gateway.js'; import { SessionGCService } from '../gc/session-gc.service.js'; import { SystemOverrideService } from '../preferences/system-override.service.js'; @@ -34,8 +35,12 @@ export class CommandExecutorService { private readonly mcpClient: McpClientService | null, ) {} - async execute(payload: SlashCommandPayload, userId: string): Promise { + async execute( + payload: SlashCommandPayload, + scope: ActorTenantScope, + ): Promise { const { command, args, conversationId } = payload; + const userId = scope.userId; const def = this.registry.getManifest().commands.find((c) => c.name === command); if (!def) { @@ -50,11 +55,11 @@ export class CommandExecutorService { try { switch (command) { case 'model': - return await this.handleModel(args ?? null, conversationId); + return await this.handleModel(args ?? null, conversationId, scope); case 'thinking': return await this.handleThinking(args ?? null, conversationId); case 'system': - return await this.handleSystem(args ?? null, conversationId); + return await this.handleSystem(args ?? null, conversationId, scope); case 'new': return { command, @@ -94,7 +99,7 @@ export class CommandExecutorService { }; } case 'agent': - return await this.handleAgent(args ?? null, conversationId, userId); + return await this.handleAgent(args ?? null, conversationId, scope); case 'provider': return await this.handleProvider(args ?? null, userId, conversationId); case 'mission': @@ -146,10 +151,11 @@ export class CommandExecutorService { private async handleModel( args: string | null, conversationId: string, + scope: ActorTenantScope, ): Promise { if (!args || args.trim().length === 0) { // Show current override or usage hint - const currentOverride = this.chatGateway?.getModelOverride(conversationId); + const currentOverride = this.chatGateway?.getModelOverride(conversationId, scope); if (currentOverride) { return { command: 'model', @@ -171,7 +177,7 @@ export class CommandExecutorService { // /model clear removes the override and re-enables automatic routing if (modelName === 'clear') { - this.chatGateway?.setModelOverride(conversationId, null); + this.chatGateway?.setModelOverride(conversationId, null, scope); return { command: 'model', conversationId, @@ -181,9 +187,9 @@ export class CommandExecutorService { } // Set the sticky per-session override (M4-007) - this.chatGateway?.setModelOverride(conversationId, modelName); + this.chatGateway?.setModelOverride(conversationId, modelName, scope); - const session = this.agentService.getSession(conversationId); + const session = this.agentService.getSession(conversationId, scope); if (!session) { return { command: 'model', @@ -224,10 +230,11 @@ export class CommandExecutorService { private async handleSystem( args: string | null, conversationId: string, + scope: ActorTenantScope, ): Promise { if (!args || args.trim().length === 0) { // Clear the override when called with no args - await this.systemOverride.clear(conversationId); + await this.systemOverride.clear(conversationId, scope); return { command: 'system', conversationId, @@ -236,7 +243,7 @@ export class CommandExecutorService { }; } - await this.systemOverride.set(conversationId, args.trim()); + await this.systemOverride.set(conversationId, args.trim(), scope); return { command: 'system', conversationId, @@ -248,8 +255,9 @@ export class CommandExecutorService { private async handleAgent( args: string | null, conversationId: string, - userId: string, + scope: ActorTenantScope, ): Promise { + const userId = scope.userId; if (!args) { return { command: 'agent', @@ -338,11 +346,14 @@ export class CommandExecutorService { conversationId, agentConfig.id, agentConfig.name, + scope, agentConfig.model ?? undefined, ); // Broadcast updated session:info so TUI TopBar reflects new agent/model - this.chatGateway?.broadcastSessionInfo(conversationId, { agentName: agentConfig.name }); + this.chatGateway?.broadcastSessionInfo(conversationId, scope, { + agentName: agentConfig.name, + }); this.logger.log( `Agent switched to "${agentConfig.name}" (${agentConfig.id}) for conversation ${conversationId} (M5-003)`, diff --git a/apps/gateway/src/commands/commands.integration.spec.ts b/apps/gateway/src/commands/commands.integration.spec.ts index 65713197..afa3cfa5 100644 --- a/apps/gateway/src/commands/commands.integration.spec.ts +++ b/apps/gateway/src/commands/commands.integration.spec.ts @@ -159,6 +159,7 @@ describe('CommandExecutorService — integration', () => { let registry: CommandRegistryService; let executor: CommandExecutorService; const userId = 'user-integ-001'; + const userScope = { userId, tenantId: userId }; const conversationId = 'conv-integ-001'; beforeEach(() => { @@ -170,7 +171,7 @@ describe('CommandExecutorService — integration', () => { // Unknown command returns error it('unknown command returns success:false with descriptive message', async () => { const payload: SlashCommandPayload = { command: 'nonexistent', conversationId }; - const result = await executor.execute(payload, userId); + const result = await executor.execute(payload, userScope); expect(result.success).toBe(false); expect(result.message).toContain('nonexistent'); expect(result.command).toBe('nonexistent'); @@ -179,7 +180,7 @@ describe('CommandExecutorService — integration', () => { // /gc handler calls SessionGCService.sweepOrphans (admin-only, no userId arg) it('/gc calls SessionGCService.sweepOrphans without arguments', async () => { const payload: SlashCommandPayload = { command: 'gc', conversationId }; - const result = await executor.execute(payload, userId); + const result = await executor.execute(payload, userScope); expect(mockSessionGC.sweepOrphans).toHaveBeenCalledWith(); expect(result.success).toBe(true); expect(result.message).toContain('GC sweep complete'); @@ -190,8 +191,8 @@ describe('CommandExecutorService — integration', () => { it('/system with text calls SystemOverrideService.set', async () => { const override = 'You are a helpful assistant.'; const payload: SlashCommandPayload = { command: 'system', args: override, conversationId }; - const result = await executor.execute(payload, userId); - expect(mockSystemOverride.set).toHaveBeenCalledWith(conversationId, override); + const result = await executor.execute(payload, userScope); + expect(mockSystemOverride.set).toHaveBeenCalledWith(conversationId, override, userScope); expect(result.success).toBe(true); expect(result.message).toContain('override set'); }); @@ -199,8 +200,8 @@ describe('CommandExecutorService — integration', () => { // /system with no args clears the override it('/system with no args calls SystemOverrideService.clear', async () => { const payload: SlashCommandPayload = { command: 'system', conversationId }; - const result = await executor.execute(payload, userId); - expect(mockSystemOverride.clear).toHaveBeenCalledWith(conversationId); + const result = await executor.execute(payload, userScope); + expect(mockSystemOverride.clear).toHaveBeenCalledWith(conversationId, userScope); expect(result.success).toBe(true); expect(result.message).toContain('cleared'); }); @@ -212,7 +213,7 @@ describe('CommandExecutorService — integration', () => { args: 'claude-3-opus', conversationId, }; - const result = await executor.execute(payload, userId); + const result = await executor.execute(payload, userScope); expect(result.success).toBe(true); expect(result.command).toBe('model'); expect(result.message).toContain('claude-3-opus'); @@ -221,7 +222,7 @@ describe('CommandExecutorService — integration', () => { // /thinking with valid level returns success it('/thinking with valid level returns success', async () => { const payload: SlashCommandPayload = { command: 'thinking', args: 'high', conversationId }; - const result = await executor.execute(payload, userId); + const result = await executor.execute(payload, userScope); expect(result.success).toBe(true); expect(result.message).toContain('high'); }); @@ -229,7 +230,7 @@ describe('CommandExecutorService — integration', () => { // /thinking with invalid level returns usage message it('/thinking with invalid level returns usage message', async () => { const payload: SlashCommandPayload = { command: 'thinking', args: 'invalid', conversationId }; - const result = await executor.execute(payload, userId); + const result = await executor.execute(payload, userScope); expect(result.success).toBe(true); expect(result.message).toContain('Usage:'); }); @@ -237,7 +238,7 @@ describe('CommandExecutorService — integration', () => { // /new command returns success it('/new returns success', async () => { const payload: SlashCommandPayload = { command: 'new', conversationId }; - const result = await executor.execute(payload, userId); + const result = await executor.execute(payload, userScope); expect(result.success).toBe(true); expect(result.command).toBe('new'); }); @@ -245,7 +246,7 @@ describe('CommandExecutorService — integration', () => { // /reload without reloadService returns failure it('/reload without ReloadService returns failure', async () => { const payload: SlashCommandPayload = { command: 'reload', conversationId }; - const result = await executor.execute(payload, userId); + const result = await executor.execute(payload, userScope); expect(result.success).toBe(false); expect(result.message).toContain('ReloadService'); }); @@ -255,7 +256,7 @@ describe('CommandExecutorService — integration', () => { for (const cmd of stubCommands) { it(`/${cmd} returns success (stub)`, async () => { const payload: SlashCommandPayload = { command: cmd, conversationId }; - const result = await executor.execute(payload, userId); + const result = await executor.execute(payload, userScope); expect(result.success).toBe(true); expect(result.command).toBe(cmd); }); diff --git a/apps/gateway/src/preferences/system-override.service.ts b/apps/gateway/src/preferences/system-override.service.ts index 5fa48da3..2d64e81e 100644 --- a/apps/gateway/src/preferences/system-override.service.ts +++ b/apps/gateway/src/preferences/system-override.service.ts @@ -1,9 +1,13 @@ import { Injectable, Logger } from '@nestjs/common'; import { createQueue, type QueueHandle } from '@mosaicstack/queue'; +import type { ActorTenantScope } from '../auth/session-scope.js'; -const SESSION_SYSTEM_KEY = (sessionId: string) => `mosaic:session:${sessionId}:system`; -const SESSION_SYSTEM_FRAGMENTS_KEY = (sessionId: string) => - `mosaic:session:${sessionId}:system:fragments`; +const scopedSessionId = (sessionId: string, scope: ActorTenantScope) => + `${scope.tenantId}:${scope.userId}:${sessionId}`; +const SESSION_SYSTEM_KEY = (sessionId: string, scope: ActorTenantScope) => + `mosaic:session:${scopedSessionId(sessionId, scope)}:system`; +const SESSION_SYSTEM_FRAGMENTS_KEY = (sessionId: string, scope: ActorTenantScope) => + `mosaic:session:${scopedSessionId(sessionId, scope)}:system:fragments`; const SYSTEM_OVERRIDE_TTL_SECONDS = 604800; // 7 days interface OverrideFragment { @@ -20,9 +24,9 @@ export class SystemOverrideService { this.handle = createQueue(); } - async set(sessionId: string, override: string): Promise { + async set(sessionId: string, override: string, scope: ActorTenantScope): Promise { // Load existing fragments - const existing = await this.handle.redis.get(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId)); + const existing = await this.handle.redis.get(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope)); const fragments: OverrideFragment[] = existing ? (JSON.parse(existing) as OverrideFragment[]) : []; @@ -37,11 +41,11 @@ export class SystemOverrideService { // Store both: fragments array and condensed result const pipeline = this.handle.redis.pipeline(); pipeline.setex( - SESSION_SYSTEM_FRAGMENTS_KEY(sessionId), + SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS, JSON.stringify(fragments), ); - pipeline.setex(SESSION_SYSTEM_KEY(sessionId), SYSTEM_OVERRIDE_TTL_SECONDS, condensed); + pipeline.setex(SESSION_SYSTEM_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS, condensed); await pipeline.exec(); this.logger.debug( @@ -49,21 +53,21 @@ export class SystemOverrideService { ); } - async get(sessionId: string): Promise { - return this.handle.redis.get(SESSION_SYSTEM_KEY(sessionId)); + async get(sessionId: string, scope: ActorTenantScope): Promise { + return this.handle.redis.get(SESSION_SYSTEM_KEY(sessionId, scope)); } - async renew(sessionId: string): Promise { + async renew(sessionId: string, scope: ActorTenantScope): Promise { const pipeline = this.handle.redis.pipeline(); - pipeline.expire(SESSION_SYSTEM_KEY(sessionId), SYSTEM_OVERRIDE_TTL_SECONDS); - pipeline.expire(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId), SYSTEM_OVERRIDE_TTL_SECONDS); + pipeline.expire(SESSION_SYSTEM_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS); + pipeline.expire(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS); await pipeline.exec(); } - async clear(sessionId: string): Promise { + async clear(sessionId: string, scope: ActorTenantScope): Promise { await this.handle.redis.del( - SESSION_SYSTEM_KEY(sessionId), - SESSION_SYSTEM_FRAGMENTS_KEY(sessionId), + SESSION_SYSTEM_KEY(sessionId, scope), + SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope), ); this.logger.debug(`Cleared system override for session ${sessionId}`); } From 62f817780633c3743dd3e393a58ef2f1f7b91ad8 Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Sun, 12 Jul 2026 22:29:21 +0000 Subject: [PATCH 009/152] feat(tess): define runtime provider contract (#719) --- .../src/agent/agent-runtime-provider.spec.ts | 44 +++++++ .../types/src/agent/agent-runtime-provider.ts | 110 ++++++++++++++++++ packages/types/src/agent/index.ts | 2 + 3 files changed, 156 insertions(+) create mode 100644 packages/types/src/agent/agent-runtime-provider.spec.ts create mode 100644 packages/types/src/agent/agent-runtime-provider.ts diff --git a/packages/types/src/agent/agent-runtime-provider.spec.ts b/packages/types/src/agent/agent-runtime-provider.spec.ts new file mode 100644 index 00000000..71b2ae71 --- /dev/null +++ b/packages/types/src/agent/agent-runtime-provider.spec.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; +import type { + AgentRuntimeProvider, + RuntimeAttachHandle, + RuntimeCapability, + RuntimeError, + RuntimeSession, + RuntimeStreamEvent, +} from './agent-runtime-provider.js'; + +describe('AgentRuntimeProvider contract', (): void => { + it('exposes stable, normalized runtime-only contracts', (): void => { + const capability: RuntimeCapability = 'session.attach'; + const session: RuntimeSession = { + id: 'session-1', + providerId: 'fleet', + runtimeId: 'tmux', + state: 'active', + createdAt: '2026-07-12T00:00:00.000Z', + updatedAt: '2026-07-12T00:00:00.000Z', + }; + const event: RuntimeStreamEvent = { + type: 'message.delta', + sessionId: session.id, + cursor: '2', + occurredAt: session.updatedAt, + content: 'hello', + }; + const error: RuntimeError = { + code: 'capability_unsupported', + message: 'Denied', + retryable: false, + }; + const attach: RuntimeAttachHandle = { + attachmentId: 'attach-1', + sessionId: session.id, + mode: 'read', + expiresAt: session.updatedAt, + }; + const provider: Pick = + {} as Pick; + expect([capability, session.id, event.type, error.code, attach.mode, provider]).toHaveLength(6); + }); +}); diff --git a/packages/types/src/agent/agent-runtime-provider.ts b/packages/types/src/agent/agent-runtime-provider.ts new file mode 100644 index 00000000..4a5fb562 --- /dev/null +++ b/packages/types/src/agent/agent-runtime-provider.ts @@ -0,0 +1,110 @@ +export type RuntimeCapability = + | 'session.list' + | 'session.tree' + | 'session.stream' + | 'session.send' + | 'session.attach' + | 'session.terminate'; +export type RuntimeSessionState = 'starting' | 'active' | 'idle' | 'stopped' | 'failed'; +export type RuntimeAttachMode = 'read' | 'control'; + +/** Server-derived immutable authority context. Client identity fields are intentionally absent. */ +export interface RuntimeScope { + readonly actorId: string; + readonly tenantId: string; + readonly channelId: string; + readonly correlationId: string; +} +export interface RuntimeSession { + id: string; + providerId: string; + runtimeId: string; + parentSessionId?: string; + state: RuntimeSessionState; + createdAt: string; + updatedAt: string; +} +export interface RuntimeSessionTree { + session: RuntimeSession; + children: RuntimeSessionTree[]; +} +export interface RuntimeCapabilitySet { + supported: RuntimeCapability[]; +} +export interface RuntimeHealth { + status: 'healthy' | 'degraded' | 'down'; + checkedAt: string; + detail?: string; +} +export interface RuntimeMessage { + content: string; + idempotencyKey: string; +} +export interface RuntimeAttachHandle { + attachmentId: string; + sessionId: string; + mode: RuntimeAttachMode; + expiresAt: string; +} +export interface RuntimeError { + code: + | 'capability_unsupported' + | 'not_found' + | 'forbidden' + | 'conflict' + | 'unavailable' + | 'invalid_request'; + message: string; + retryable: boolean; +} +export type RuntimeStreamEvent = + | { + type: 'session.state'; + sessionId: string; + cursor: string; + occurredAt: string; + state: RuntimeSessionState; + } + | { + type: 'message.delta'; + sessionId: string; + cursor: string; + occurredAt: string; + content: string; + } + | { + type: 'message.complete'; + sessionId: string; + cursor: string; + occurredAt: string; + messageId: string; + } + | { + type: 'runtime.error'; + sessionId: string; + cursor: string; + occurredAt: string; + error: RuntimeError; + }; + +/** Runtime-neutral boundary; implementations fail closed for unsupported operations. */ +export interface AgentRuntimeProvider { + readonly id: string; + capabilities(scope: RuntimeScope): Promise; + health(scope: RuntimeScope): Promise; + listSessions(scope: RuntimeScope): Promise; + getSessionTree(scope: RuntimeScope): Promise; + streamSession( + sessionId: string, + cursor: string | undefined, + scope: RuntimeScope, + ): AsyncIterable; + sendMessage(sessionId: string, message: RuntimeMessage, scope: RuntimeScope): Promise; + attach( + sessionId: string, + mode: RuntimeAttachMode, + scope: RuntimeScope, + ): Promise; + detach(attachmentId: string, scope: RuntimeScope): Promise; + terminate(sessionId: string, approvalRef: string, scope: RuntimeScope): Promise; +} diff --git a/packages/types/src/agent/index.ts b/packages/types/src/agent/index.ts index 8d303d2e..1803cd30 100644 --- a/packages/types/src/agent/index.ts +++ b/packages/types/src/agent/index.ts @@ -2,3 +2,5 @@ export interface AgentSessionHandle { readonly id: string; } + +export * from './agent-runtime-provider.js'; From a959b1d6b47589586440f1b21ff638017c9620df Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Sun, 12 Jul 2026 22:48:58 +0000 Subject: [PATCH 010/152] ci: restrict push-event CI to protected branches (halve feature-branch load) (#721) --- .woodpecker/ci.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.woodpecker/ci.yml b/.woodpecker/ci.yml index ce4d4da6..1789aa83 100644 --- a/.woodpecker/ci.yml +++ b/.woodpecker/ci.yml @@ -7,7 +7,14 @@ variables: - &enable_pnpm 'corepack enable' when: - - event: [push, pull_request, manual] + # PR + manual CI run on any branch — the pull_request pipeline is the merge gate. + # push CI is restricted to protected branches (main) so a feature-branch push no + # longer fires a redundant SECOND pipeline alongside its PR pipeline. This ~halves + # CI load on the storage-constrained runner with zero loss of gating (branch + # protection requires no push/ci status context; main still gets full push CI). + - event: [pull_request, manual] + - event: push + branch: main # Turbo remote cache (turbo.mosaicstack.dev) is configured via Woodpecker # repository-level environment variables (TURBO_API, TURBO_TEAM, TURBO_TOKEN). From 227b73fcdf0489ee9ffe2de4d17bd5b17d4c59b3 Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Sun, 12 Jul 2026 22:49:00 +0000 Subject: [PATCH 011/152] fix(security): enforce Tess MCP server identity (#717) --- apps/gateway/src/mcp/mcp.controller.ts | 27 +- apps/gateway/src/mcp/mcp.service.spec.ts | 461 ++++++++++++++++++++++ apps/gateway/src/mcp/mcp.service.ts | 477 +++++++++++++++++++---- 3 files changed, 882 insertions(+), 83 deletions(-) create mode 100644 apps/gateway/src/mcp/mcp.service.spec.ts diff --git a/apps/gateway/src/mcp/mcp.controller.ts b/apps/gateway/src/mcp/mcp.controller.ts index 55ad75ea..cfdbb3ed 100644 --- a/apps/gateway/src/mcp/mcp.controller.ts +++ b/apps/gateway/src/mcp/mcp.controller.ts @@ -3,7 +3,11 @@ import { Logger } from '@nestjs/common'; import { fromNodeHeaders } from 'better-auth/node'; import type { Auth } from '@mosaicstack/auth'; import type { NestFastifyApplication } from '@nestjs/platform-fastify'; -import type { McpService } from './mcp.service.js'; +import { + createMcpActorContext, + deriveMcpToolScopesForUser, + type McpService, +} from './mcp.service.js'; import { AUTH } from '../auth/auth.tokens.js'; /** @@ -67,14 +71,25 @@ async function handleMcpRequest( return; } - const userId = result.user.id; + const authUser = result.user as { + id: string; + role?: string | null; + tenantId?: string | null; + organizationId?: string | null; + }; + const actor = createMcpActorContext({ + userId: authUser.id, + role: authUser.role, + tenantId: authUser.tenantId ?? authUser.organizationId ?? undefined, + scopes: deriveMcpToolScopesForUser({ role: authUser.role }), + }); // ─── Session routing ───────────────────────────────────────────────────── const sessionId = req.raw.headers['mcp-session-id']; if (typeof sessionId === 'string' && sessionId.length > 0) { // Existing session request - const transport = mcpService.getSession(sessionId); + const transport = mcpService.getSession(sessionId, actor); if (!transport) { logger.warn(`MCP session not found: ${sessionId}`); reply.raw.writeHead(404, { 'Content-Type': 'application/json' }); @@ -112,8 +127,10 @@ async function handleMcpRequest( } // Create new session and handle this initializing request - const { transport } = mcpService.createSession(userId); - logger.log(`New MCP session created for user ${userId}`); + const { transport } = mcpService.createSession(actor); + logger.log( + `New MCP session created for actor=${actor.userId} tenant=${actor.tenantId} correlation=${actor.correlationId}`, + ); await transport.handleRequest(req.raw, reply.raw, body); } diff --git a/apps/gateway/src/mcp/mcp.service.spec.ts b/apps/gateway/src/mcp/mcp.service.spec.ts new file mode 100644 index 00000000..9842feea --- /dev/null +++ b/apps/gateway/src/mcp/mcp.service.spec.ts @@ -0,0 +1,461 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { z } from 'zod'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { Brain } from '@mosaicstack/brain'; +import type { Memory } from '@mosaicstack/memory'; +import type { EmbeddingService } from '../memory/embedding.service.js'; +import type { CoordService } from '../coord/coord.service.js'; +import { + assertMcpToolAuthorized, + createMcpActorContext, + deriveMcpToolScopesForUser, + MCP_TOOL_SCOPES, + McpService, + type McpToolName, +} from './mcp.service.js'; + +type ToolResult = { content: Array<{ type: 'text'; text: string }> }; +type ToolHandler = (params: Record) => Promise; + +interface CapturedTool { + inputSchema: z.ZodType; + handler: ToolHandler; +} + +function makeCapturingServer(): { server: McpServer; tools: Map } { + const tools = new Map(); + const server = { + registerTool(name: string, config: { inputSchema: z.ZodType }, handler: ToolHandler): void { + tools.set(name, { inputSchema: config.inputSchema, handler }); + }, + }; + return { server: server as unknown as McpServer, tools }; +} + +function makeService(opts?: { + projects?: Array & { id: string; ownerId?: string | null }>; + missions?: Array< + Record & { id: string; projectId?: string | null; userId?: string | null } + >; + tasks?: Array< + Record & { + id: string; + projectId?: string | null; + missionId?: string | null; + status?: string; + } + >; +}) { + const projects = opts?.projects ?? []; + const missions = opts?.missions ?? []; + const tasks = opts?.tasks ?? []; + const brain = { + projects: { + findAll: vi.fn(async () => projects), + findById: vi.fn(async (id: string) => projects.find((project) => project.id === id) ?? null), + }, + tasks: { + findAll: vi.fn(async () => tasks), + findById: vi.fn(async (id: string) => tasks.find((task) => task.id === id) ?? null), + findByProject: vi.fn(async (projectId: string) => + tasks.filter((task) => task.projectId === projectId), + ), + findByMission: vi.fn(async (missionId: string) => + tasks.filter((task) => task.missionId === missionId), + ), + findByStatus: vi.fn(async (status: string) => tasks.filter((task) => task.status === status)), + create: vi.fn(async (task: Record) => ({ id: 'task-1', ...task })), + update: vi.fn(async (id: string, updates: Record) => ({ id, ...updates })), + }, + missions: { + findAll: vi.fn(async () => missions), + findById: vi.fn(async (id: string) => missions.find((mission) => mission.id === id) ?? null), + findByProject: vi.fn(async (projectId: string) => + missions.filter((mission) => mission.projectId === projectId), + ), + }, + conversations: { + findAll: vi.fn(async (userId: string) => [{ id: 'conversation-1', userId }]), + }, + } as unknown as Brain; + + const memory = { + insights: { + searchByEmbedding: vi.fn(async (userId: string) => [{ id: 'insight-1', userId }]), + create: vi.fn(async (insight: Record) => ({ id: 'insight-2', ...insight })), + }, + preferences: { + findByUser: vi.fn(async (userId: string) => [{ key: 'theme', userId }]), + findByUserAndCategory: vi.fn(async (userId: string, category: string) => [ + { key: 'theme', userId, category }, + ]), + upsert: vi.fn(async (preference: Record) => ({ + id: 'pref-1', + ...preference, + })), + }, + } as unknown as Memory; + + const embeddings = { + available: true, + embed: vi.fn(async () => [0.1, 0.2, 0.3]), + } as unknown as EmbeddingService; + + const coord = { + getMissionStatus: vi.fn(async () => null), + listTasks: vi.fn(async () => []), + getTaskStatus: vi.fn(async () => null), + } as unknown as CoordService; + + return { + service: new McpService(brain, memory, embeddings, coord), + brain: brain as unknown as { + conversations: { findAll: ReturnType }; + tasks: { + create: ReturnType; + update: ReturnType; + }; + }, + memory: memory as unknown as { + insights: { searchByEmbedding: ReturnType }; + }, + coord: coord as unknown as { + listTasks: ReturnType; + }, + }; +} + +function getTool(tools: Map, name: McpToolName): CapturedTool { + const tool = tools.get(name); + if (!tool) throw new Error(`Missing captured tool ${name}`); + return tool; +} + +function makeMemberActor(userId = 'authenticated-user') { + return createMcpActorContext({ + userId, + role: 'member', + scopes: deriveMcpToolScopesForUser({ role: 'member' }), + }); +} + +function makeAdminActor(userId = 'admin-user', tenantId?: string) { + return createMcpActorContext({ + userId, + tenantId, + role: 'admin', + scopes: deriveMcpToolScopesForUser({ role: 'admin' }), + }); +} + +function makePlatformAdminActor(userId = 'platform-admin-user') { + return createMcpActorContext({ + userId, + role: 'platform-admin', + scopes: deriveMcpToolScopesForUser({ role: 'platform-admin' }), + }); +} + +describe('MCP actor identity and tool scope enforcement', () => { + it('derives immutable actor, tenant, channel, correlation, and explicit tool scopes server-side', () => { + const actor = createMcpActorContext({ + userId: ' user-authenticated ', + role: 'member', + scopes: deriveMcpToolScopesForUser({ role: 'member' }), + }); + + expect(actor.userId).toBe('user-authenticated'); + expect(actor.tenantId).toBe('user:user-authenticated'); + expect(actor.role).toBe('member'); + expect(actor.channel).toBe('mcp'); + expect(actor.correlationId).toMatch(/[0-9a-f-]{36}/i); + expect(actor.scopes.has(MCP_TOOL_SCOPES.memory_search)).toBe(true); + expect(actor.scopes.has(MCP_TOOL_SCOPES.coord_list_tasks)).toBe(false); + expect( + deriveMcpToolScopesForUser({ role: 'admin' }).has(MCP_TOOL_SCOPES.coord_list_tasks), + ).toBe(false); + expect( + deriveMcpToolScopesForUser({ role: 'platform-admin' }).has(MCP_TOOL_SCOPES.coord_list_tasks), + ).toBe(true); + }); + + it('fails closed when scopes are not supplied by the authenticated context policy', () => { + const actor = createMcpActorContext({ userId: 'user-authenticated' }); + + expect(actor.scopes.size).toBe(0); + expect(() => assertMcpToolAuthorized(actor, 'memory_search', { query: 'notes' })).toThrow( + 'MCP tool scope denied: memory:insight:read', + ); + }); + + it('fails closed when a tool caller supplies actor or tenant identity fields', () => { + const actor = createMcpActorContext({ userId: 'user-authenticated' }); + + expect(() => + assertMcpToolAuthorized(actor, 'memory_search', { + userId: 'victim-user', + query: 'private data', + }), + ).toThrow('MCP caller-controlled identity field is forbidden: userId'); + + expect(() => + assertMcpToolAuthorized(actor, 'coord_list_tasks', { + tenantId: 'victim-tenant', + projectPath: '/tmp/project', + }), + ).toThrow('MCP caller-controlled identity field is forbidden: tenantId'); + + expect(() => + assertMcpToolAuthorized(actor, 'brain_create_task', { + title: 'forged org', + organizationId: 'victim-org', + }), + ).toThrow('MCP caller-controlled identity field is forbidden: organizationId'); + + expect(() => + assertMcpToolAuthorized(actor, 'brain_update_task', { + title: 'forged team', + teamId: 'victim-team', + }), + ).toThrow('MCP caller-controlled identity field is forbidden: teamId'); + }); + + it('fails closed when the server-derived actor lacks the required per-tool scope', () => { + const actor = createMcpActorContext({ userId: 'user-authenticated', scopes: [] }); + + expect(() => assertMcpToolAuthorized(actor, 'memory_search', { query: 'notes' })).toThrow( + 'MCP tool scope denied: memory:insight:read', + ); + }); + + it('removes caller-controlled userId from memory schemas and never queries victim memory', async () => { + const { service, memory } = makeService(); + const { server, tools } = makeCapturingServer(); + const actor = makeMemberActor('authenticated-user'); + + service.registerTools(server, actor); + const tool = getTool(tools, 'memory_search'); + + expect(tool.inputSchema.safeParse({ userId: 'victim-user', query: 'anything' }).success).toBe( + false, + ); + await expect(tool.handler({ userId: 'victim-user', query: 'anything' })).rejects.toThrow( + 'MCP caller-controlled identity field is forbidden: userId', + ); + expect(memory.insights.searchByEmbedding).not.toHaveBeenCalled(); + + await tool.handler({ query: 'only my notes' }); + expect(memory.insights.searchByEmbedding).toHaveBeenCalledWith( + 'authenticated-user', + [0.1, 0.2, 0.3], + 5, + ); + }); + + it('binds conversation listing to the authenticated actor instead of a caller-supplied userId', async () => { + const { service, brain } = makeService(); + const { server, tools } = makeCapturingServer(); + const actor = makeMemberActor('authenticated-user'); + + service.registerTools(server, actor); + const tool = getTool(tools, 'brain_list_conversations'); + + expect(tool.inputSchema.safeParse({ userId: 'victim-user' }).success).toBe(false); + await expect(tool.handler({ userId: 'victim-user' })).rejects.toThrow( + 'MCP caller-controlled identity field is forbidden: userId', + ); + expect(brain.conversations.findAll).not.toHaveBeenCalled(); + + await tool.handler({}); + expect(brain.conversations.findAll).toHaveBeenCalledWith('authenticated-user'); + }); + + it('scopes brain project, mission, and task reads to the authenticated actor', async () => { + const { service } = makeService({ + projects: [ + { id: 'project-owned', ownerId: 'authenticated-user', name: 'owned' }, + { id: 'project-victim', ownerId: 'victim-user', name: 'victim' }, + ], + missions: [ + { id: 'mission-owned', projectId: 'project-owned' }, + { id: 'mission-victim', userId: 'victim-user', projectId: 'project-victim' }, + ], + tasks: [ + { id: 'task-owned-project', projectId: 'project-owned', status: 'not-started' }, + { id: 'task-owned-mission', missionId: 'mission-owned', status: 'not-started' }, + { id: 'task-victim-project', projectId: 'project-victim', status: 'not-started' }, + { id: 'task-unowned', status: 'not-started' }, + ], + }); + const { server, tools } = makeCapturingServer(); + const actor = makeMemberActor('authenticated-user'); + + service.registerTools(server, actor); + + const projects = JSON.parse( + (await getTool(tools, 'brain_list_projects').handler({})).content[0]!.text, + ); + expect(projects.map((project: { id: string }) => project.id)).toEqual(['project-owned']); + + const missions = JSON.parse( + (await getTool(tools, 'brain_list_missions').handler({})).content[0]!.text, + ); + expect(missions.map((mission: { id: string }) => mission.id)).toEqual(['mission-owned']); + + const tasks = JSON.parse( + (await getTool(tools, 'brain_list_tasks').handler({})).content[0]!.text, + ); + expect(tasks.map((task: { id: string }) => task.id)).toEqual([ + 'task-owned-project', + 'task-owned-mission', + ]); + }); + + it('enforces tenant boundaries for tenant-admin brain project, mission, and task reads', async () => { + const { service } = makeService({ + projects: [ + { + id: 'project-tenant-a', + ownerId: 'other-user-a', + teamId: 'tenant-a', + name: 'same tenant', + }, + { + id: 'project-tenant-b', + ownerId: 'other-user-b', + teamId: 'tenant-b', + name: 'other tenant', + }, + ], + missions: [ + { id: 'mission-tenant-a', tenantId: 'tenant-a', projectId: 'project-tenant-a' }, + { id: 'mission-tenant-b', tenantId: 'tenant-b', projectId: 'project-tenant-b' }, + ], + tasks: [ + { id: 'task-tenant-a', projectId: 'project-tenant-a', status: 'not-started' }, + { id: 'task-tenant-b', projectId: 'project-tenant-b', status: 'not-started' }, + ], + }); + const { server, tools } = makeCapturingServer(); + const actor = makeAdminActor('tenant-admin-user', 'tenant-a'); + + service.registerTools(server, actor); + + const projects = JSON.parse( + (await getTool(tools, 'brain_list_projects').handler({})).content[0]!.text, + ); + expect(projects.map((project: { id: string }) => project.id)).toEqual(['project-tenant-a']); + + const missions = JSON.parse( + (await getTool(tools, 'brain_list_missions').handler({})).content[0]!.text, + ); + expect(missions.map((mission: { id: string }) => mission.id)).toEqual(['mission-tenant-a']); + + const tasks = JSON.parse( + (await getTool(tools, 'brain_list_tasks').handler({})).content[0]!.text, + ); + expect(tasks.map((task: { id: string }) => task.id)).toEqual(['task-tenant-a']); + }); + + it('denies tenant-admin task writes outside the authenticated tenant', async () => { + const { service, brain } = makeService({ + projects: [ + { id: 'project-tenant-a', ownerId: 'other-user-a', teamId: 'tenant-a' }, + { id: 'project-tenant-b', ownerId: 'other-user-b', teamId: 'tenant-b' }, + ], + missions: [ + { id: 'mission-tenant-a', tenantId: 'tenant-a', projectId: 'project-tenant-a' }, + { id: 'mission-tenant-b', tenantId: 'tenant-b', projectId: 'project-tenant-b' }, + ], + tasks: [ + { id: 'task-tenant-a', projectId: 'project-tenant-a', status: 'not-started' }, + { id: 'task-tenant-b', projectId: 'project-tenant-b', status: 'not-started' }, + ], + }); + const { server, tools } = makeCapturingServer(); + const actor = makeAdminActor('tenant-admin-user', 'tenant-a'); + + service.registerTools(server, actor); + + await expect( + getTool(tools, 'brain_create_task').handler({ + title: 'unscoped tenant write', + }), + ).rejects.toThrow('MCP task scope denied'); + expect(brain.tasks.create).not.toHaveBeenCalled(); + + await expect( + getTool(tools, 'brain_create_task').handler({ + title: 'cross-tenant write', + projectId: 'project-tenant-b', + }), + ).rejects.toThrow('MCP task project scope denied'); + expect(brain.tasks.create).not.toHaveBeenCalled(); + + await expect( + getTool(tools, 'brain_update_task').handler({ + id: 'task-tenant-a', + projectId: 'project-tenant-b', + }), + ).rejects.toThrow('MCP task project scope denied'); + expect(brain.tasks.update).not.toHaveBeenCalled(); + + const updateResult = await getTool(tools, 'brain_update_task').handler({ + id: 'task-tenant-b', + title: 'cross-tenant update', + }); + expect(updateResult.content[0]!.text).toBe('Task not found: task-tenant-b'); + expect(brain.tasks.update).not.toHaveBeenCalled(); + + await getTool(tools, 'brain_create_task').handler({ + title: 'same-tenant write', + projectId: 'project-tenant-a', + }); + expect(brain.tasks.create).toHaveBeenCalledWith( + expect.objectContaining({ projectId: 'project-tenant-a', title: 'same-tenant write' }), + ); + }); + + it('keeps admin-only coordination tools on server-derived paths', async () => { + const { service, coord } = makeService(); + const { server, tools } = makeCapturingServer(); + const member = makeMemberActor('authenticated-user'); + const tenantAdmin = makeAdminActor('admin-user'); + const platformAdmin = makePlatformAdminActor('platform-admin-user'); + + service.registerTools(server, member); + const memberTool = getTool(tools, 'coord_list_tasks'); + expect(memberTool.inputSchema.safeParse({ projectPath: '/tmp/victim' }).success).toBe(false); + await expect(memberTool.handler({})).rejects.toThrow('MCP tool scope denied: coord:read'); + + tools.clear(); + service.registerTools(server, tenantAdmin); + const tenantAdminTool = getTool(tools, 'coord_list_tasks'); + await expect(tenantAdminTool.handler({})).rejects.toThrow('MCP tool scope denied: coord:read'); + + tools.clear(); + service.registerTools(server, platformAdmin); + const platformAdminTool = getTool(tools, 'coord_list_tasks'); + await platformAdminTool.handler({ projectPath: '/tmp/victim' }); + expect(coord.listTasks).toHaveBeenCalledWith(process.cwd()); + }); + + it('does not attach a guessed or stale-scope MCP session to another authenticated context', async () => { + const { service } = makeService(); + const owner = makeMemberActor('owner-user'); + const attacker = makeMemberActor('attacker-user'); + const admin = makeAdminActor('admin-user'); + const downgradedAdmin = makeMemberActor('admin-user'); + + const { sessionId, transport } = service.createSession(owner); + const staleSession = service.createSession(admin); + + expect(service.getSession(sessionId, owner)).toBe(transport); + expect(service.getSession(sessionId, attacker)).toBeNull(); + expect(service.getSession(sessionId, owner)).toBe(transport); + expect(service.getSession(staleSession.sessionId, downgradedAdmin)).toBeNull(); + expect(service.getSession(staleSession.sessionId, admin)).toBe(staleSession.transport); + + await service.onModuleDestroy(); + }); +}); diff --git a/apps/gateway/src/mcp/mcp.service.ts b/apps/gateway/src/mcp/mcp.service.ts index c32dfc03..e5fb6b92 100644 --- a/apps/gateway/src/mcp/mcp.service.ts +++ b/apps/gateway/src/mcp/mcp.service.ts @@ -10,11 +10,216 @@ import { MEMORY } from '../memory/memory.tokens.js'; import { EmbeddingService } from '../memory/embedding.service.js'; import { CoordService } from '../coord/coord.service.js'; +export const MCP_CALLER_IDENTITY_FIELDS = [ + 'actorId', + 'actor', + 'authenticatedUserId', + 'channel', + 'organizationId', + 'ownerId', + 'sessionUserId', + 'teamId', + 'tenant', + 'tenantId', + 'user', + 'userId', +] as const; + +type McpCallerIdentityField = (typeof MCP_CALLER_IDENTITY_FIELDS)[number]; + +export const MCP_TOOL_SCOPES = { + brain_list_projects: 'brain:project:read', + brain_get_project: 'brain:project:read', + brain_list_tasks: 'brain:task:read', + brain_create_task: 'brain:task:write', + brain_update_task: 'brain:task:write', + brain_list_missions: 'brain:mission:read', + brain_list_conversations: 'brain:conversation:read', + memory_search: 'memory:insight:read', + memory_get_preferences: 'memory:preference:read', + memory_save_preference: 'memory:preference:write', + memory_save_insight: 'memory:insight:write', + coord_mission_status: 'coord:read', + coord_list_tasks: 'coord:read', + coord_task_detail: 'coord:read', +} as const; + +export type McpToolName = keyof typeof MCP_TOOL_SCOPES; +export type McpToolScope = (typeof MCP_TOOL_SCOPES)[McpToolName]; + +export interface McpActorContext { + userId: string; + tenantId: string; + role: string; + channel: 'mcp'; + correlationId: string; + scopes: ReadonlySet; +} + interface SessionEntry { server: McpServer; transport: StreamableHTTPServerTransport; createdAt: Date; + actor: McpActorContext; +} + +const GLOBAL_ADMIN_MCP_SCOPES = new Set(Object.values(MCP_TOOL_SCOPES)); +const TENANT_ADMIN_MCP_SCOPES = new Set([ + MCP_TOOL_SCOPES.brain_list_projects, + MCP_TOOL_SCOPES.brain_get_project, + MCP_TOOL_SCOPES.brain_list_tasks, + MCP_TOOL_SCOPES.brain_create_task, + MCP_TOOL_SCOPES.brain_update_task, + MCP_TOOL_SCOPES.brain_list_missions, + MCP_TOOL_SCOPES.brain_list_conversations, + MCP_TOOL_SCOPES.memory_search, + MCP_TOOL_SCOPES.memory_get_preferences, + MCP_TOOL_SCOPES.memory_save_preference, + MCP_TOOL_SCOPES.memory_save_insight, +]); +const MEMBER_MCP_SCOPES = new Set([ + MCP_TOOL_SCOPES.brain_list_projects, + MCP_TOOL_SCOPES.brain_get_project, + MCP_TOOL_SCOPES.brain_list_tasks, + MCP_TOOL_SCOPES.brain_list_missions, + MCP_TOOL_SCOPES.brain_list_conversations, + MCP_TOOL_SCOPES.memory_search, + MCP_TOOL_SCOPES.memory_get_preferences, + MCP_TOOL_SCOPES.memory_save_preference, + MCP_TOOL_SCOPES.memory_save_insight, +]); + +export function deriveMcpToolScopesForUser(input: { + role?: string | null; +}): ReadonlySet { + if (input.role === 'platform-admin' || input.role === 'super-admin') { + return new Set(GLOBAL_ADMIN_MCP_SCOPES); + } + if (input.role === 'admin') { + return new Set(TENANT_ADMIN_MCP_SCOPES); + } + return new Set(MEMBER_MCP_SCOPES); +} + +export function createMcpActorContext(input: { userId: string; + tenantId?: string; + role?: string | null; + scopes?: Iterable; + correlationId?: string; +}): McpActorContext { + const userId = input.userId.trim(); + if (userId.length === 0) { + throw new Error('MCP authenticated user is required'); + } + + return { + userId, + tenantId: input.tenantId?.trim() || `user:${userId}`, + role: input.role ?? 'member', + channel: 'mcp', + correlationId: input.correlationId ?? randomUUID(), + scopes: new Set(input.scopes ?? []), + }; +} + +export function assertNoCallerControlledIdentity(params: unknown): void { + if (params === null || typeof params !== 'object') return; + + const keys = new Set(Object.keys(params)); + const forbidden = MCP_CALLER_IDENTITY_FIELDS.find((field: McpCallerIdentityField) => + keys.has(field), + ); + if (forbidden) { + throw new Error(`MCP caller-controlled identity field is forbidden: ${forbidden}`); + } +} + +export function assertMcpToolAuthorized( + actor: McpActorContext, + toolName: McpToolName, + params: unknown, +): void { + assertNoCallerControlledIdentity(params); + const requiredScope = MCP_TOOL_SCOPES[toolName]; + if (!actor.scopes.has(requiredScope)) { + throw new Error(`MCP tool scope denied: ${requiredScope}`); + } +} + +function strictObject(shape: T): z.ZodObject { + return z.object(shape).strict(); +} + +type TenantScopedLike = { + tenantId?: string | null; + organizationId?: string | null; + teamId?: string | null; +}; +type ProjectLike = TenantScopedLike & { id: string; ownerId?: string | null }; +type MissionLike = TenantScopedLike & { + id: string; + projectId?: string | null; + userId?: string | null; +}; +type TaskLike = TenantScopedLike & { + projectId?: string | null; + missionId?: string | null; + userId?: string | null; +}; + +function isGlobalAdminActor(actor: McpActorContext): boolean { + return actor.role === 'platform-admin' || actor.role === 'super-admin'; +} + +function isTenantAdminActor(actor: McpActorContext): boolean { + return actor.role === 'admin'; +} + +function matchesTenant(actor: McpActorContext, record: TenantScopedLike): boolean { + return ( + record.tenantId === actor.tenantId || + record.organizationId === actor.tenantId || + record.teamId === actor.tenantId + ); +} + +function filterProjectsForActor(actor: McpActorContext, projects: T[]): T[] { + if (isGlobalAdminActor(actor)) return projects; + return projects.filter( + (project) => + project.ownerId === actor.userId || + (isTenantAdminActor(actor) && matchesTenant(actor, project)), + ); +} + +function filterMissionsByDirectActorScope( + actor: McpActorContext, + missions: T[], +): T[] { + if (isGlobalAdminActor(actor)) return missions; + return missions.filter( + (mission) => + mission.userId === actor.userId || + (isTenantAdminActor(actor) && matchesTenant(actor, mission)), + ); +} + +function scopesEqual(left: ReadonlySet, right: ReadonlySet): boolean { + if (left.size !== right.size) return false; + for (const scope of left) { + if (!right.has(scope)) return false; + } + return true; +} + +function sameActorAuthorization(stored: McpActorContext, current: McpActorContext): boolean { + return ( + stored.userId === current.userId && + stored.tenantId === current.tenantId && + stored.role === current.role && + scopesEqual(stored.scopes, current.scopes) + ); } @Injectable() @@ -33,13 +238,18 @@ export class McpService implements OnModuleDestroy { * Creates a new MCP session with its own server + transport pair. * Returns the transport for use by the controller. */ - createSession(userId: string): { sessionId: string; transport: StreamableHTTPServerTransport } { + createSession(actor: McpActorContext): { + sessionId: string; + transport: StreamableHTTPServerTransport; + } { const sessionId = randomUUID(); const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => sessionId, onsessioninitialized: (id) => { - this.logger.log(`MCP session initialized: ${id} for user ${userId}`); + this.logger.log( + `MCP session initialized: ${id} for actor=${actor.userId} tenant=${actor.tenantId} correlation=${actor.correlationId}`, + ); }, }); @@ -48,7 +258,7 @@ export class McpService implements OnModuleDestroy { { capabilities: { tools: {} } }, ); - this.registerTools(server, userId); + this.registerTools(server, actor); transport.onclose = () => { this.logger.log(`MCP session closed: ${sessionId}`); @@ -61,31 +271,126 @@ export class McpService implements OnModuleDestroy { ); }); - this.sessions.set(sessionId, { server, transport, createdAt: new Date(), userId }); + this.sessions.set(sessionId, { server, transport, createdAt: new Date(), actor }); return { sessionId, transport }; } /** - * Returns the transport for an existing session, or null if not found. + * Returns the transport for an existing session only when it belongs to the + * currently authenticated MCP actor. Guessed or cross-tenant session IDs grant + * no authority. */ - getSession(sessionId: string): StreamableHTTPServerTransport | null { - return this.sessions.get(sessionId)?.transport ?? null; + getSession(sessionId: string, actor: McpActorContext): StreamableHTTPServerTransport | null { + const entry = this.sessions.get(sessionId); + if (!entry) return null; + if (!sameActorAuthorization(entry.actor, actor)) { + this.logger.warn( + `MCP session actor or scope mismatch: session=${sessionId} actor=${actor.userId} tenant=${actor.tenantId} role=${actor.role}`, + ); + return null; + } + return entry.transport; + } + + private async isProjectAuthorized(actor: McpActorContext, projectId: string): Promise { + if (isGlobalAdminActor(actor)) return true; + const project = (await this.brain.projects.findById(projectId)) as ProjectLike | undefined; + return project ? filterProjectsForActor(actor, [project]).length === 1 : false; + } + + private async filterMissionsForActor( + actor: McpActorContext, + missions: T[], + ): Promise { + if (isGlobalAdminActor(actor)) return missions; + + const projects = (await this.brain.projects.findAll()) as ProjectLike[]; + const projectIds = new Set( + filterProjectsForActor(actor, projects).map((project) => project.id), + ); + + return missions.filter( + (mission) => + filterMissionsByDirectActorScope(actor, [mission]).length === 1 || + (typeof mission.projectId === 'string' && projectIds.has(mission.projectId)), + ); + } + + private async isMissionAuthorized(actor: McpActorContext, missionId: string): Promise { + if (isGlobalAdminActor(actor)) return true; + const mission = (await this.brain.missions.findById(missionId)) as MissionLike | undefined; + if (!mission) return false; + return (await this.filterMissionsForActor(actor, [mission])).length === 1; + } + + private async assertTaskReferencesAuthorized( + actor: McpActorContext, + refs: { projectId?: string | null; missionId?: string | null }, + ): Promise { + if (refs.projectId && !(await this.isProjectAuthorized(actor, refs.projectId))) { + throw new Error('MCP task project scope denied'); + } + if (refs.missionId && !(await this.isMissionAuthorized(actor, refs.missionId))) { + throw new Error('MCP task mission scope denied'); + } + } + + private async assertTaskCreateScopeAuthorized( + actor: McpActorContext, + refs: { projectId?: string | null; missionId?: string | null }, + ): Promise { + if (!isGlobalAdminActor(actor) && !refs.projectId && !refs.missionId) { + throw new Error('MCP task scope denied'); + } + await this.assertTaskReferencesAuthorized(actor, refs); + } + + private async filterTasksForActor( + actor: McpActorContext, + tasks: T[], + ): Promise { + if (isGlobalAdminActor(actor)) return tasks; + + const [projects, missions] = await Promise.all([ + this.brain.projects.findAll(), + this.brain.missions.findAll(), + ]); + const projectIds = new Set( + filterProjectsForActor(actor, projects as ProjectLike[]).map((project) => project.id), + ); + const missionIds = new Set( + (await this.filterMissionsForActor(actor, missions as MissionLike[])).map( + (mission) => mission.id, + ), + ); + + return tasks.filter( + (task) => + task.userId === actor.userId || + (isTenantAdminActor(actor) && matchesTenant(actor, task)) || + (typeof task.projectId === 'string' && projectIds.has(task.projectId)) || + (typeof task.missionId === 'string' && missionIds.has(task.missionId)), + ); } /** * Registers all platform tools on the given McpServer instance. */ - private registerTools(server: McpServer, _userId: string): void { + registerTools(server: McpServer, actor: McpActorContext): void { // ─── Brain: Project tools ──────────────────────────────────────────── server.registerTool( 'brain_list_projects', { description: 'List all projects in the brain.', - inputSchema: z.object({}), + inputSchema: strictObject({}), }, - async () => { - const projects = await this.brain.projects.findAll(); + async (params) => { + assertMcpToolAuthorized(actor, 'brain_list_projects', params); + const projects = filterProjectsForActor( + actor, + (await this.brain.projects.findAll()) as ProjectLike[], + ); return { content: [{ type: 'text' as const, text: JSON.stringify(projects, null, 2) }], }; @@ -96,17 +401,21 @@ export class McpService implements OnModuleDestroy { 'brain_get_project', { description: 'Get a project by ID.', - inputSchema: z.object({ + inputSchema: strictObject({ id: z.string().describe('Project ID (UUID)'), }), }, - async ({ id }) => { - const project = await this.brain.projects.findById(id); + async ({ id, ...params }) => { + assertMcpToolAuthorized(actor, 'brain_get_project', params); + const project = (await this.brain.projects.findById(id)) as ProjectLike | undefined; + const authorizedProject = project ? filterProjectsForActor(actor, [project])[0] : undefined; return { content: [ { type: 'text' as const, - text: project ? JSON.stringify(project, null, 2) : `Project not found: ${id}`, + text: authorizedProject + ? JSON.stringify(authorizedProject, null, 2) + : `Project not found: ${id}`, }, ], }; @@ -119,20 +428,23 @@ export class McpService implements OnModuleDestroy { 'brain_list_tasks', { description: 'List tasks, optionally filtered by project, mission, or status.', - inputSchema: z.object({ + inputSchema: strictObject({ projectId: z.string().optional().describe('Filter by project ID'), missionId: z.string().optional().describe('Filter by mission ID'), status: z.string().optional().describe('Filter by status'), }), }, - async ({ projectId, missionId, status }) => { + async (params) => { + assertMcpToolAuthorized(actor, 'brain_list_tasks', params); + const { projectId, missionId, status } = params; type TaskStatus = 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled'; let tasks; if (projectId) tasks = await this.brain.tasks.findByProject(projectId); else if (missionId) tasks = await this.brain.tasks.findByMission(missionId); else if (status) tasks = await this.brain.tasks.findByStatus(status as TaskStatus); else tasks = await this.brain.tasks.findAll(); - return { content: [{ type: 'text' as const, text: JSON.stringify(tasks, null, 2) }] }; + const scopedTasks = await this.filterTasksForActor(actor, tasks as TaskLike[]); + return { content: [{ type: 'text' as const, text: JSON.stringify(scopedTasks, null, 2) }] }; }, ); @@ -140,7 +452,7 @@ export class McpService implements OnModuleDestroy { 'brain_create_task', { description: 'Create a new task in the brain.', - inputSchema: z.object({ + inputSchema: strictObject({ title: z.string().describe('Task title'), description: z.string().optional().describe('Task description'), projectId: z.string().optional().describe('Project ID'), @@ -149,6 +461,8 @@ export class McpService implements OnModuleDestroy { }), }, async (params) => { + assertMcpToolAuthorized(actor, 'brain_create_task', params); + await this.assertTaskCreateScopeAuthorized(actor, params); type Priority = 'low' | 'medium' | 'high' | 'critical'; const task = await this.brain.tasks.create({ ...params, @@ -162,7 +476,7 @@ export class McpService implements OnModuleDestroy { 'brain_update_task', { description: 'Update an existing task.', - inputSchema: z.object({ + inputSchema: strictObject({ id: z.string().describe('Task ID'), title: z.string().optional(), description: z.string().optional(), @@ -171,9 +485,17 @@ export class McpService implements OnModuleDestroy { .optional() .describe('not-started, in-progress, blocked, done, cancelled'), priority: z.string().optional(), + projectId: z.string().optional().describe('Project ID'), + missionId: z.string().optional().describe('Mission ID'), }), }, async ({ id, ...updates }) => { + assertMcpToolAuthorized(actor, 'brain_update_task', updates); + const existing = (await this.brain.tasks.findById(id)) as TaskLike | undefined; + if (!existing || (await this.filterTasksForActor(actor, [existing])).length === 0) { + return { content: [{ type: 'text' as const, text: `Task not found: ${id}` }] }; + } + await this.assertTaskReferencesAuthorized(actor, updates); type TaskStatus = 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled'; type Priority = 'low' | 'medium' | 'high' | 'critical'; const task = await this.brain.tasks.update(id, { @@ -198,14 +520,19 @@ export class McpService implements OnModuleDestroy { 'brain_list_missions', { description: 'List all missions, optionally filtered by project.', - inputSchema: z.object({ + inputSchema: strictObject({ projectId: z.string().optional().describe('Filter by project ID'), }), }, - async ({ projectId }) => { - const missions = projectId - ? await this.brain.missions.findByProject(projectId) - : await this.brain.missions.findAll(); + async (params) => { + assertMcpToolAuthorized(actor, 'brain_list_missions', params); + const { projectId } = params; + const missions = await this.filterMissionsForActor( + actor, + (projectId + ? await this.brain.missions.findByProject(projectId) + : await this.brain.missions.findAll()) as MissionLike[], + ); return { content: [{ type: 'text' as const, text: JSON.stringify(missions, null, 2) }] }; }, ); @@ -213,13 +540,12 @@ export class McpService implements OnModuleDestroy { server.registerTool( 'brain_list_conversations', { - description: 'List conversations for a user.', - inputSchema: z.object({ - userId: z.string().describe('User ID'), - }), + description: 'List conversations for the authenticated MCP actor.', + inputSchema: strictObject({}), }, - async ({ userId }) => { - const conversations = await this.brain.conversations.findAll(userId); + async (params) => { + assertMcpToolAuthorized(actor, 'brain_list_conversations', params); + const conversations = await this.brain.conversations.findAll(actor.userId); return { content: [{ type: 'text' as const, text: JSON.stringify(conversations, null, 2) }], }; @@ -232,14 +558,15 @@ export class McpService implements OnModuleDestroy { 'memory_search', { description: - 'Search across stored insights and knowledge using natural language. Returns semantically similar results.', - inputSchema: z.object({ - userId: z.string().describe('User ID to search memory for'), + 'Search stored insights and knowledge for the authenticated MCP actor using natural language.', + inputSchema: strictObject({ query: z.string().describe('Natural language search query'), limit: z.number().optional().describe('Max results (default 5)'), }), }, - async ({ userId, query, limit }) => { + async (params) => { + assertMcpToolAuthorized(actor, 'memory_search', params); + const { query, limit } = params; if (!this.embeddings.available) { return { content: [ @@ -251,7 +578,11 @@ export class McpService implements OnModuleDestroy { }; } const embedding = await this.embeddings.embed(query); - const results = await this.memory.insights.searchByEmbedding(userId, embedding, limit ?? 5); + const results = await this.memory.insights.searchByEmbedding( + actor.userId, + embedding, + limit ?? 5, + ); return { content: [{ type: 'text' as const, text: JSON.stringify(results, null, 2) }] }; }, ); @@ -259,20 +590,21 @@ export class McpService implements OnModuleDestroy { server.registerTool( 'memory_get_preferences', { - description: 'Retrieve stored preferences for a user.', - inputSchema: z.object({ - userId: z.string().describe('User ID'), + description: 'Retrieve stored preferences for the authenticated MCP actor.', + inputSchema: strictObject({ category: z .string() .optional() .describe('Filter by category: communication, coding, workflow, appearance, general'), }), }, - async ({ userId, category }) => { + async (params) => { + assertMcpToolAuthorized(actor, 'memory_get_preferences', params); + const { category } = params; type Cat = 'communication' | 'coding' | 'workflow' | 'appearance' | 'general'; const prefs = category - ? await this.memory.preferences.findByUserAndCategory(userId, category as Cat) - : await this.memory.preferences.findByUser(userId); + ? await this.memory.preferences.findByUserAndCategory(actor.userId, category as Cat) + : await this.memory.preferences.findByUser(actor.userId); return { content: [{ type: 'text' as const, text: JSON.stringify(prefs, null, 2) }] }; }, ); @@ -281,9 +613,8 @@ export class McpService implements OnModuleDestroy { 'memory_save_preference', { description: - 'Store a learned user preference (e.g., "prefers tables over paragraphs", "timezone: America/Chicago").', - inputSchema: z.object({ - userId: z.string().describe('User ID'), + 'Store a learned preference for the authenticated MCP actor (e.g., "prefers tables over paragraphs").', + inputSchema: strictObject({ key: z.string().describe('Preference key'), value: z.string().describe('Preference value (JSON string)'), category: z @@ -292,7 +623,9 @@ export class McpService implements OnModuleDestroy { .describe('Category: communication, coding, workflow, appearance, general'), }), }, - async ({ userId, key, value, category }) => { + async (params) => { + assertMcpToolAuthorized(actor, 'memory_save_preference', params); + const { key, value, category } = params; type Cat = 'communication' | 'coding' | 'workflow' | 'appearance' | 'general'; let parsedValue: unknown; try { @@ -301,7 +634,7 @@ export class McpService implements OnModuleDestroy { parsedValue = value; } const pref = await this.memory.preferences.upsert({ - userId, + userId: actor.userId, key, value: parsedValue, category: (category as Cat) ?? 'general', @@ -315,9 +648,8 @@ export class McpService implements OnModuleDestroy { 'memory_save_insight', { description: - 'Store a learned insight, decision, or knowledge extracted from the current interaction.', - inputSchema: z.object({ - userId: z.string().describe('User ID'), + 'Store a learned insight, decision, or knowledge for the authenticated MCP actor.', + inputSchema: strictObject({ content: z.string().describe('The insight or knowledge to store'), category: z .string() @@ -325,11 +657,13 @@ export class McpService implements OnModuleDestroy { .describe('Category: decision, learning, preference, fact, pattern, general'), }), }, - async ({ userId, content, category }) => { + async (params) => { + assertMcpToolAuthorized(actor, 'memory_save_insight', params); + const { content, category } = params; type Cat = 'decision' | 'learning' | 'preference' | 'fact' | 'pattern' | 'general'; const embedding = this.embeddings.available ? await this.embeddings.embed(content) : null; const insight = await this.memory.insights.create({ - userId, + userId: actor.userId, content, embedding, source: 'agent', @@ -346,16 +680,11 @@ export class McpService implements OnModuleDestroy { { description: 'Get the current orchestration mission status including milestones, tasks, and active session.', - inputSchema: z.object({ - projectPath: z - .string() - .optional() - .describe('Project path. Defaults to gateway working directory.'), - }), + inputSchema: strictObject({}), }, - async ({ projectPath }) => { - const resolvedPath = projectPath ?? process.cwd(); - const status = await this.coordService.getMissionStatus(resolvedPath); + async (params) => { + assertMcpToolAuthorized(actor, 'coord_mission_status', params); + const status = await this.coordService.getMissionStatus(process.cwd()); return { content: [ { @@ -371,16 +700,11 @@ export class McpService implements OnModuleDestroy { 'coord_list_tasks', { description: 'List all tasks from the orchestration TASKS.md file.', - inputSchema: z.object({ - projectPath: z - .string() - .optional() - .describe('Project path. Defaults to gateway working directory.'), - }), + inputSchema: strictObject({}), }, - async ({ projectPath }) => { - const resolvedPath = projectPath ?? process.cwd(); - const tasks = await this.coordService.listTasks(resolvedPath); + async (params) => { + assertMcpToolAuthorized(actor, 'coord_list_tasks', params); + const tasks = await this.coordService.listTasks(process.cwd()); return { content: [{ type: 'text' as const, text: JSON.stringify(tasks, null, 2) }] }; }, ); @@ -389,17 +713,14 @@ export class McpService implements OnModuleDestroy { 'coord_task_detail', { description: 'Get detailed status for a specific orchestration task.', - inputSchema: z.object({ + inputSchema: strictObject({ taskId: z.string().describe('Task ID (e.g. P2-005)'), - projectPath: z - .string() - .optional() - .describe('Project path. Defaults to gateway working directory.'), }), }, - async ({ taskId, projectPath }) => { - const resolvedPath = projectPath ?? process.cwd(); - const detail = await this.coordService.getTaskStatus(resolvedPath, taskId); + async (params) => { + assertMcpToolAuthorized(actor, 'coord_task_detail', params); + const { taskId } = params; + const detail = await this.coordService.getTaskStatus(process.cwd(), taskId); return { content: [ { From 46ca3ce742f4c0daa569e900b9f06bc9e95adf4c Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Sun, 12 Jul 2026 23:18:01 +0000 Subject: [PATCH 012/152] fix(tess): enforce command authorization approvals (#718) --- .../chat.gateway-command-approval.spec.ts | 74 ++++++++++ apps/gateway/src/chat/chat.gateway.ts | 25 ++++ .../command-authorization.service.spec.ts | 61 ++++++++ .../commands/command-authorization.service.ts | 138 ++++++++++++++++++ .../command-executor-tess-security.spec.ts | 109 ++++++++++++++ .../src/commands/command-executor.service.ts | 22 +++ apps/gateway/src/commands/commands.module.ts | 2 + docs/scratchpads/tess-m1-sec-001.md | 27 ++++ packages/types/src/chat/events.ts | 3 + packages/types/src/commands/index.ts | 12 ++ 10 files changed, 473 insertions(+) create mode 100644 apps/gateway/src/chat/chat.gateway-command-approval.spec.ts create mode 100644 apps/gateway/src/commands/command-authorization.service.spec.ts create mode 100644 apps/gateway/src/commands/command-authorization.service.ts create mode 100644 apps/gateway/src/commands/command-executor-tess-security.spec.ts create mode 100644 docs/scratchpads/tess-m1-sec-001.md diff --git a/apps/gateway/src/chat/chat.gateway-command-approval.spec.ts b/apps/gateway/src/chat/chat.gateway-command-approval.spec.ts new file mode 100644 index 00000000..ac297590 --- /dev/null +++ b/apps/gateway/src/chat/chat.gateway-command-approval.spec.ts @@ -0,0 +1,74 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { SlashCommandPayload } from '@mosaicstack/types'; +import { ChatGateway } from './chat.gateway.js'; + +const payload: SlashCommandPayload = { + command: 'gc', + conversationId: 'conversation-1', + approvalId: 'approval-1', +}; + +function buildGateway(commandExecutor: { + execute: ReturnType; + createApproval: ReturnType; +}): ChatGateway { + return new ChatGateway( + {} as never, + {} as never, + {} as never, + {} as never, + commandExecutor as never, + {} as never, + ); +} + +describe('ChatGateway command approval ingress', () => { + it('passes the client approval ID through to command execution while deriving the actor server-side', async (): Promise => { + const commandExecutor = { + execute: vi.fn().mockResolvedValue({ ...payload, success: true }), + createApproval: vi.fn(), + }; + const gateway = buildGateway(commandExecutor); + const client = { data: { user: { id: 'admin-1' } }, emit: vi.fn() }; + + await gateway.handleCommandExecute(client as never, payload); + + expect(commandExecutor.execute).toHaveBeenCalledWith(payload, { + userId: 'admin-1', + tenantId: 'admin-1', + }); + expect(client.emit).toHaveBeenCalledWith( + 'command:result', + expect.objectContaining({ success: true }), + ); + }); + + it('issues a durable approval only for the authenticated actor', async (): Promise => { + const commandExecutor = { + execute: vi.fn(), + createApproval: vi.fn().mockResolvedValue({ + approvalId: 'approval-1', + expiresAt: '2026-07-12T00:05:00.000Z', + }), + }; + const gateway = buildGateway(commandExecutor); + const client = { data: { user: { id: 'admin-1' } }, emit: vi.fn() }; + + await gateway.handleCommandApproval(client as never, { + command: 'gc', + conversationId: 'conversation-1', + }); + + expect(commandExecutor.createApproval).toHaveBeenCalledWith( + { command: 'gc', conversationId: 'conversation-1' }, + { userId: 'admin-1', tenantId: 'admin-1' }, + ); + expect(client.emit).toHaveBeenCalledWith('command:approval', { + command: 'gc', + conversationId: 'conversation-1', + success: true, + approvalId: 'approval-1', + expiresAt: '2026-07-12T00:05:00.000Z', + }); + }); +}); diff --git a/apps/gateway/src/chat/chat.gateway.ts b/apps/gateway/src/chat/chat.gateway.ts index 5528e326..c552ffe0 100644 --- a/apps/gateway/src/chat/chat.gateway.ts +++ b/apps/gateway/src/chat/chat.gateway.ts @@ -15,6 +15,7 @@ import type { Auth } from '@mosaicstack/auth'; import type { Brain } from '@mosaicstack/brain'; import type { SetThinkingPayload, + SlashCommandApprovalResultPayload, SlashCommandPayload, SystemReloadPayload, RoutingDecisionInfo, @@ -429,6 +430,30 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa client.emit('command:result', result); } + @SubscribeMessage('command:approve') + async handleCommandApproval( + @ConnectedSocket() client: Socket, + @MessageBody() payload: SlashCommandPayload, + ): Promise { + const scope = this.getClientScope(client); + const approval = scope ? await this.commandExecutor.createApproval(payload, scope) : null; + const result: SlashCommandApprovalResultPayload = approval + ? { + command: payload.command, + conversationId: payload.conversationId, + success: true, + approvalId: approval.approvalId, + expiresAt: approval.expiresAt, + } + : { + command: payload.command, + conversationId: payload.conversationId, + success: false, + message: 'Not authorized to approve this command.', + }; + client.emit('command:approval', result); + } + broadcastReload(payload: SystemReloadPayload): void { this.server.emit('system:reload', payload); this.logger.log('Broadcasted system:reload to all connected clients'); diff --git a/apps/gateway/src/commands/command-authorization.service.spec.ts b/apps/gateway/src/commands/command-authorization.service.spec.ts new file mode 100644 index 00000000..7eb70345 --- /dev/null +++ b/apps/gateway/src/commands/command-authorization.service.spec.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; +import type { CommandDef, SlashCommandPayload } from '@mosaicstack/types'; +import { CommandAuthorizationService } from './command-authorization.service.js'; + +const adminCommand: CommandDef = { + name: 'gc', + description: 'GC', + aliases: [], + scope: 'admin', + execution: 'socket', + available: true, +}; +const payload: SlashCommandPayload = { command: 'gc', conversationId: 'conversation-1' }; + +function createService(role: string): CommandAuthorizationService { + const entries = new Map(); + const db = { + select: () => ({ from: () => ({ where: () => ({ limit: async () => [{ role }] }) }) }), + }; + const redis = { + get: async (key: string) => entries.get(key) ?? null, + set: async (key: string, value: string) => { + entries.set(key, value); + }, + del: async (key: string) => Number(entries.delete(key)), + }; + return new CommandAuthorizationService(db as never, redis); +} + +describe('CommandAuthorizationService', () => { + it('consumes one exact actor-bound approval once', async (): Promise => { + const service = createService('admin'); + const approval = await service.createApproval(adminCommand, payload, 'admin-1'); + expect(approval).not.toBeNull(); + expect( + (await service.authorize(adminCommand, payload, 'admin-1', approval!.approvalId)).allowed, + ).toBe(true); + expect( + (await service.authorize(adminCommand, payload, 'admin-1', approval!.approvalId)).allowed, + ).toBe(false); + }); + + it('rejects an approval when the structured action is mutated', async (): Promise => { + const service = createService('admin'); + const approval = await service.createApproval(adminCommand, payload, 'admin-1'); + const mutated = { ...payload, conversationId: 'other-conversation' }; + expect(approval).not.toBeNull(); + expect( + (await service.authorize(adminCommand, mutated, 'admin-1', approval!.approvalId)).allowed, + ).toBe(false); + }); + + it('denies an admin command to a member before approval is considered', async (): Promise => { + const service = createService('member'); + const approval = await service.createApproval(adminCommand, payload, 'member-1'); + expect(approval).toBeNull(); + expect( + (await service.authorize(adminCommand, payload, 'member-1', 'forged-approval-id')).allowed, + ).toBe(false); + }); +}); diff --git a/apps/gateway/src/commands/command-authorization.service.ts b/apps/gateway/src/commands/command-authorization.service.ts new file mode 100644 index 00000000..ce7f4991 --- /dev/null +++ b/apps/gateway/src/commands/command-authorization.service.ts @@ -0,0 +1,138 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { Inject, Injectable } from '@nestjs/common'; +import { eq, users as usersTable, type Db } from '@mosaicstack/db'; +import type { CommandDef, SlashCommandPayload } from '@mosaicstack/types'; +import { DB } from '../database/database.module.js'; +import { COMMANDS_REDIS } from './commands.tokens.js'; + +export type CommandRole = 'admin' | 'member' | 'viewer'; + +export interface CommandApproval { + approvalId: string; + actionDigest: string; + actorId: string; + command: string; + expiresAt: string; +} + +export interface CommandAuthorizationResult { + allowed: boolean; + reason?: string; +} + +@Injectable() +export class CommandAuthorizationService { + constructor( + @Inject(DB) private readonly db: Db, + @Inject(COMMANDS_REDIS) + private readonly redis: { + get(key: string): Promise; + set(key: string, value: string, ...args: string[]): Promise; + del(key: string): Promise; + }, + ) {} + + async authorize( + command: CommandDef, + payload: SlashCommandPayload, + actorId: string, + approvalId?: string, + ): Promise { + const role = await this.resolveRole(actorId); + if (!role || !this.hasScope(role, command.scope)) { + return { allowed: false, reason: 'not authorized for this command scope' }; + } + if (command.scope !== 'admin') return { allowed: true }; + if (!approvalId) return { allowed: false, reason: 'durable approval is required' }; + const actionDigest = this.actionDigest(command.name, payload); + const approved = await this.consumeApproval(approvalId, actorId, actionDigest); + return approved + ? { allowed: true } + : { + allowed: false, + reason: 'approval is invalid, expired, replayed, or does not match this action', + }; + } + + async createApproval( + command: CommandDef, + payload: SlashCommandPayload, + actorId: string, + ): Promise { + const role = await this.resolveRole(actorId); + if (!role || command.scope !== 'admin' || !this.hasScope(role, command.scope)) return null; + + const approvalId = randomUUID(); + const expiresAt = new Date(Date.now() + 5 * 60_000).toISOString(); + const approval: CommandApproval = { + approvalId, + actionDigest: this.actionDigest(command.name, payload), + actorId, + command: command.name, + expiresAt, + }; + await this.redis.set(this.key(approvalId), JSON.stringify(approval), 'EX', '300'); + return approval; + } + + private async resolveRole(actorId: string): Promise { + const [user] = await this.db + .select({ role: usersTable.role }) + .from(usersTable) + .where(eq(usersTable.id, actorId)) + .limit(1); + const role = user?.role; + return role === 'admin' || role === 'member' || role === 'viewer' ? role : null; + } + + private hasScope(role: CommandRole, scope: CommandDef['scope']): boolean { + if (role === 'admin') return true; + return role === 'member' && (scope === 'core' || scope === 'agent'); + } + + private async consumeApproval( + approvalId: string, + actorId: string, + actionDigest: string, + ): Promise { + const key = this.key(approvalId); + const encoded = await this.redis.get(key); + if (!encoded) return false; + const parsed: unknown = JSON.parse(encoded); + if ( + !this.isApproval(parsed) || + parsed.actorId !== actorId || + parsed.actionDigest !== actionDigest || + Date.parse(parsed.expiresAt) <= Date.now() + ) + return false; + return (await this.redis.del(key)) === 1; + } + + private actionDigest(command: string, payload: SlashCommandPayload): string { + return createHash('sha256') + .update( + JSON.stringify({ + command, + args: payload.args?.trim() ?? '', + conversationId: payload.conversationId, + }), + ) + .digest('hex'); + } + + private isApproval(value: unknown): value is CommandApproval { + return ( + typeof value === 'object' && + value !== null && + 'approvalId' in value && + 'actionDigest' in value && + 'actorId' in value && + 'expiresAt' in value + ); + } + + private key(approvalId: string): string { + return `tess:command-approval:${approvalId}`; + } +} diff --git a/apps/gateway/src/commands/command-executor-tess-security.spec.ts b/apps/gateway/src/commands/command-executor-tess-security.spec.ts new file mode 100644 index 00000000..2fe55501 --- /dev/null +++ b/apps/gateway/src/commands/command-executor-tess-security.spec.ts @@ -0,0 +1,109 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { SlashCommandPayload } from '@mosaicstack/types'; +import { CommandAuthorizationService } from './command-authorization.service.js'; +import { CommandExecutorService } from './command-executor.service.js'; + +const registry = { + getManifest: vi.fn(() => ({ + version: 1, + commands: [ + { + name: 'gc', + description: 'System-wide garbage collection', + aliases: [], + scope: 'admin' as const, + execution: 'socket' as const, + available: true, + }, + ], + skills: [], + })), +}; + +const sessionGc = { + sweepOrphans: vi.fn().mockResolvedValue({ orphanedSessions: 1, totalCleaned: [], duration: 1 }), +}; + +const scope = (userId: string) => ({ userId, tenantId: 'tenant-1' }); + +const authorization = { + authorize: vi.fn((_command: unknown, _payload: unknown, actorId: string) => + Promise.resolve( + actorId === 'member-1' + ? { allowed: false, reason: 'durable approval is required' } + : { allowed: false, reason: 'not authorized for this command scope' }, + ), + ), +}; + +function buildExecutor(authorizationService: unknown = authorization): CommandExecutorService { + return new CommandExecutorService( + registry as never, + { getSession: vi.fn() } as never, + { clear: vi.fn(), set: vi.fn() } as never, + sessionGc as never, + { set: vi.fn() } as never, + { agents: {} } as never, + null, + null, + null, + authorizationService as never, + ); +} + +function createDurableAuthorization(): CommandAuthorizationService { + const entries = new Map(); + const db = { + select: () => ({ from: () => ({ where: () => ({ limit: async () => [{ role: 'admin' }] }) }) }), + }; + const redis = { + get: async (key: string) => entries.get(key) ?? null, + set: async (key: string, value: string) => { + entries.set(key, value); + }, + del: async (key: string) => Number(entries.delete(key)), + }; + return new CommandAuthorizationService(db as never, redis); +} + +describe('TESS-M1-SEC-001 command authorization abuse cases', () => { + const payload: SlashCommandPayload = { command: 'gc', conversationId: 'conversation-1' }; + + beforeEach((): void => { + vi.clearAllMocks(); + }); + + it('denies a forged admin identity and does not execute a system-wide command', async (): Promise => { + const result = await buildExecutor().execute(payload, scope('admin-forged-by-client')); + + expect(result.success).toBe(false); + expect(result.message).toContain('not authorized'); + expect(sessionGc.sweepOrphans).not.toHaveBeenCalled(); + }); + + it('denies a privileged command without a server-bound durable approval', async (): Promise => { + const result = await buildExecutor().execute(payload, scope('member-1')); + + expect(result.success).toBe(false); + expect(result.message).toContain('approval'); + expect(sessionGc.sweepOrphans).not.toHaveBeenCalled(); + }); + + it('executes an admin command only after a valid durable approval is issued and supplied', async (): Promise => { + const executor = buildExecutor(createDurableAuthorization()); + + const adminScope = scope('admin-1'); + const denied = await executor.execute(payload, adminScope); + const approval = await executor.createApproval(payload, adminScope); + const approved = await executor.execute( + { ...payload, approvalId: approval?.approvalId }, + adminScope, + ); + + expect(denied.success).toBe(false); + expect(denied.message).toContain('approval'); + expect(approval).not.toBeNull(); + expect(approved.success).toBe(true); + expect(sessionGc.sweepOrphans).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/gateway/src/commands/command-executor.service.ts b/apps/gateway/src/commands/command-executor.service.ts index f7fc0cca..a7a455a2 100644 --- a/apps/gateway/src/commands/command-executor.service.ts +++ b/apps/gateway/src/commands/command-executor.service.ts @@ -11,6 +11,7 @@ import { ReloadService } from '../reload/reload.service.js'; import { McpClientService } from '../mcp-client/mcp-client.service.js'; import { BRAIN } from '../brain/brain.tokens.js'; import { COMMANDS_REDIS } from './commands.tokens.js'; +import { CommandAuthorizationService } from './command-authorization.service.js'; import { CommandRegistryService } from './command-registry.service.js'; @Injectable() @@ -33,6 +34,9 @@ export class CommandExecutorService { @Optional() @Inject(McpClientService) private readonly mcpClient: McpClientService | null, + @Optional() + @Inject(CommandAuthorizationService) + private readonly authorization: CommandAuthorizationService | null = null, ) {} async execute( @@ -52,6 +56,16 @@ export class CommandExecutorService { }; } + const authorization = await this.authorization?.authorize( + def, + payload, + userId, + payload.approvalId, + ); + if (authorization && !authorization.allowed) { + return { command, conversationId, success: false, message: authorization.reason }; + } + try { switch (command) { case 'model': @@ -148,6 +162,14 @@ export class CommandExecutorService { } } + async createApproval(payload: SlashCommandPayload, scope: ActorTenantScope) { + const def = this.registry + .getManifest() + .commands.find((command) => command.name === payload.command); + if (!def || !this.authorization) return null; + return this.authorization.createApproval(def, payload, scope.userId); + } + private async handleModel( args: string | null, conversationId: string, diff --git a/apps/gateway/src/commands/commands.module.ts b/apps/gateway/src/commands/commands.module.ts index 1c3a82ce..0d6c30d2 100644 --- a/apps/gateway/src/commands/commands.module.ts +++ b/apps/gateway/src/commands/commands.module.ts @@ -3,6 +3,7 @@ import { createQueue, type QueueHandle } from '@mosaicstack/queue'; import { ChatModule } from '../chat/chat.module.js'; import { GCModule } from '../gc/gc.module.js'; import { ReloadModule } from '../reload/reload.module.js'; +import { CommandAuthorizationService } from './command-authorization.service.js'; import { CommandExecutorService } from './command-executor.service.js'; import { CommandRegistryService } from './command-registry.service.js'; import { COMMANDS_REDIS } from './commands.tokens.js'; @@ -24,6 +25,7 @@ const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE'; inject: [COMMANDS_QUEUE_HANDLE], }, CommandRegistryService, + CommandAuthorizationService, CommandExecutorService, ], exports: [CommandRegistryService, CommandExecutorService], diff --git a/docs/scratchpads/tess-m1-sec-001.md b/docs/scratchpads/tess-m1-sec-001.md new file mode 100644 index 00000000..b2971702 --- /dev/null +++ b/docs/scratchpads/tess-m1-sec-001.md @@ -0,0 +1,27 @@ +# TESS-M1-SEC-001 — Command authorization and exact-action approval + +- Issue/milestone: #707 / M1 +- Branch: `fix/tess-command-authz` +- Requirement: `TESS-SEC-002`, with approval binding controls from `TESS-SEC-007` +- Scope: `apps/gateway` only, plus required in-repo security/developer documentation. + +## Plan + +1. Locate the gateway command executor, command metadata, authorization context, and existing test conventions. +2. Write abuse/authz tests before production changes. Expected red cases: non-admin blocked from admin/system command; forged caller scope cannot authorize; privileged/destructive action requires durable exact-action approval; expired/replayed/mutated approvals deny. +3. Implement server-derived role/scope enforcement and durable approval validation/consumption with audit results. +4. Run focused security tests, then repository baseline gates: typecheck, lint, format-check, test. +5. Run independent security/code review, commit, queue-guard, push, and open the PR to `main` through the stated Gitea API fallback. Stop after PR creation. + +## Assumptions + +- The existing gateway persistence interface is the available durable approval boundary. If no persistence abstraction exists, a minimal injectable repository interface will be introduced rather than an in-memory approval implementation, because TESS-SEC-002/007 require durable enforcement. +- “Exact action” is a canonical digest over structured command identity and normalized arguments; role/scope checks always use authenticated server context, not client-declared claims. + +## TDD evidence + +- Pending: abuse/authz test written and observed red before implementation. + +## Verification evidence + +- Pending. diff --git a/packages/types/src/chat/events.ts b/packages/types/src/chat/events.ts index 6b12ecd5..432a53db 100644 --- a/packages/types/src/chat/events.ts +++ b/packages/types/src/chat/events.ts @@ -1,5 +1,6 @@ import type { CommandManifestPayload, + SlashCommandApprovalResultPayload, SlashCommandPayload, SlashCommandResultPayload, SystemReloadPayload, @@ -116,6 +117,7 @@ export interface ServerToClientEvents { 'session:info': (payload: SessionInfoPayload) => void; 'commands:manifest': (payload: CommandManifestPayload) => void; 'command:result': (payload: SlashCommandResultPayload) => void; + 'command:approval': (payload: SlashCommandApprovalResultPayload) => void; 'system:reload': (payload: SystemReloadPayload) => void; error: (payload: ErrorPayload) => void; } @@ -125,5 +127,6 @@ export interface ClientToServerEvents { message: (data: ChatMessagePayload) => void; 'set:thinking': (data: SetThinkingPayload) => void; 'command:execute': (data: SlashCommandPayload) => void; + 'command:approve': (data: SlashCommandPayload) => void; abort: (data: AbortPayload) => void; } diff --git a/packages/types/src/commands/index.ts b/packages/types/src/commands/index.ts index 5c984dfb..8b46aebf 100644 --- a/packages/types/src/commands/index.ts +++ b/packages/types/src/commands/index.ts @@ -63,6 +63,18 @@ export interface SlashCommandPayload { conversationId: string; command: string; args?: string; + /** One-time server-issued approval for a privileged command execution. */ + approvalId?: string; +} + +/** Server response to a request to approve a privileged slash command. */ +export interface SlashCommandApprovalResultPayload { + conversationId: string; + command: string; + success: boolean; + approvalId?: string; + expiresAt?: string; + message?: string; } /** Server response to a slash command */ From 119f64e69d109dc27338f51bc53452486825f199 Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Sun, 12 Jul 2026 23:18:29 +0000 Subject: [PATCH 013/152] feat(#707): secure Discord service ingress (#716) --- apps/gateway/src/chat/chat.gateway-auth.ts | 14 ++ apps/gateway/src/chat/chat.gateway.ts | 139 ++++++++++++- .../plugin/discord-ingress.security.spec.ts | 89 +++++++++ .../src/plugin/discord-replay-protector.ts | 40 ++++ apps/gateway/src/plugin/plugin.module.ts | 22 +++ docs/architecture/channel-protocol.md | 4 + docs/guides/admin-guide.md | 25 ++- .../tess-m1-sec-004-discord-ingress.md | 35 ++++ plugins/discord/src/index.ts | 184 +++++++++++++----- 9 files changed, 484 insertions(+), 68 deletions(-) create mode 100644 apps/gateway/src/plugin/discord-ingress.security.spec.ts create mode 100644 apps/gateway/src/plugin/discord-replay-protector.ts create mode 100644 docs/scratchpads/tess-m1-sec-004-discord-ingress.md diff --git a/apps/gateway/src/chat/chat.gateway-auth.ts b/apps/gateway/src/chat/chat.gateway-auth.ts index 16d034c4..b1bf5649 100644 --- a/apps/gateway/src/chat/chat.gateway-auth.ts +++ b/apps/gateway/src/chat/chat.gateway-auth.ts @@ -1,3 +1,4 @@ +import { timingSafeEqual } from 'node:crypto'; import type { IncomingHttpHeaders } from 'node:http'; import { fromNodeHeaders } from 'better-auth/node'; @@ -12,6 +13,19 @@ export interface SessionAuth { }; } +export function validateDiscordServiceToken( + candidate: unknown, + expected: string | undefined, +): boolean { + if (typeof candidate !== 'string' || !expected) return false; + const candidateBuffer = Buffer.from(candidate); + const expectedBuffer = Buffer.from(expected); + return ( + candidateBuffer.length === expectedBuffer.length && + timingSafeEqual(candidateBuffer, expectedBuffer) + ); +} + export async function validateSocketSession( headers: IncomingHttpHeaders, auth: SessionAuth, diff --git a/apps/gateway/src/chat/chat.gateway.ts b/apps/gateway/src/chat/chat.gateway.ts index c552ffe0..41fa81c6 100644 --- a/apps/gateway/src/chat/chat.gateway.ts +++ b/apps/gateway/src/chat/chat.gateway.ts @@ -11,6 +11,11 @@ import { } from '@nestjs/websockets'; import { Server, Socket } from 'socket.io'; import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent'; +import { + verifyDiscordIngressEnvelope, + type DiscordIngressEnvelope, + type DiscordIngressPayload, +} from '@mosaicstack/discord-plugin'; import type { Auth } from '@mosaicstack/auth'; import type { Brain } from '@mosaicstack/brain'; import type { @@ -34,7 +39,8 @@ import { CommandExecutorService } from '../commands/command-executor.service.js' import { RoutingEngineService } from '../agent/routing/routing-engine.service.js'; import { v4 as uuid } from 'uuid'; import { ChatSocketMessageDto } from './chat.dto.js'; -import { validateSocketSession } from './chat.gateway-auth.js'; +import { validateDiscordServiceToken, validateSocketSession } from './chat.gateway-auth.js'; +import { DiscordReplayProtector } from '../plugin/discord-replay-protector.js'; /** Per-client state tracking streaming accumulation for persistence. */ interface ClientSession { @@ -58,6 +64,37 @@ interface ClientSession { */ const modelOverrides = new Map(); +function isDiscordIngressEnvelope(value: unknown): value is DiscordIngressEnvelope { + if (typeof value !== 'object' || value === null) return false; + const envelope = value as { payload?: unknown; signature?: unknown }; + if ( + typeof envelope.signature !== 'string' || + typeof envelope.payload !== 'object' || + envelope.payload === null + ) { + return false; + } + const payload = envelope.payload as Record; + return [ + payload['correlationId'], + payload['messageId'], + payload['guildId'], + payload['channelId'], + payload['userId'], + payload['conversationId'], + payload['content'], + ].every((field: unknown): boolean => typeof field === 'string'); +} + +function isChatSocketMessage(value: unknown): value is ChatSocketMessageDto { + if (typeof value !== 'object' || value === null) return false; + const payload = value as { content?: unknown; conversationId?: unknown }; + return ( + typeof payload.content === 'string' && + (payload.conversationId === undefined || typeof payload.conversationId === 'string') + ); +} + @WebSocketGateway({ cors: { origin: process.env['GATEWAY_CORS_ORIGIN'] ?? 'http://localhost:3000', @@ -70,6 +107,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa private readonly logger = new Logger(ChatGateway.name); private readonly clientSessions = new Map(); + private readonly discordReplayProtector = new DiscordReplayProtector(); constructor( @Inject(AgentService) private readonly agentService: AgentService, @@ -85,6 +123,13 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa } async handleConnection(client: Socket): Promise { + const serviceToken = client.handshake.auth['discordServiceToken']; + if (validateDiscordServiceToken(serviceToken, process.env['DISCORD_SERVICE_TOKEN'])) { + client.data.discordService = true; + this.logger.log(`Authenticated Discord service connected: ${client.id}`); + return; + } + const session = await validateSocketSession(client.handshake.headers, this.auth); if (!session) { this.logger.warn(`Rejected unauthenticated WebSocket client: ${client.id}`); @@ -95,8 +140,6 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa client.data.user = session.user; client.data.session = session.session; this.logger.log(`Client connected: ${client.id}`); - - // Broadcast command manifest to the newly connected client client.emit('commands:manifest', { manifest: this.commandRegistry.getManifest() }); } @@ -131,17 +174,49 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa @SubscribeMessage('message') async handleMessage( @ConnectedSocket() client: Socket, - @MessageBody() data: ChatSocketMessageDto, + @MessageBody() rawData: unknown, ): Promise { + let discordIngress: DiscordIngressPayload | null = null; + let data: ChatSocketMessageDto; + if (client.data.discordService) { + if (!isDiscordIngressEnvelope(rawData)) { + this.logger.warn(`Rejected malformed Discord ingress from ${client.id}`); + return; + } + discordIngress = this.resolveDiscordIngress(client, rawData); + if (!discordIngress) return; + data = { conversationId: discordIngress.conversationId, content: discordIngress.content }; + } else { + if (!isChatSocketMessage(rawData)) { + this.logger.warn(`Rejected malformed chat message from ${client.id}`); + return; + } + data = rawData; + } const conversationId = data.conversationId ?? uuid(); - const scope = this.getClientScope(client); + const discordServiceUserId = process.env['DISCORD_SERVICE_USER_ID']; + if (discordIngress && !discordServiceUserId) { + this.logger.warn( + `Rejected Discord ingress without configured service owner from ${client.id}`, + ); + return; + } + const scope = discordIngress + ? { + userId: discordServiceUserId!, + tenantId: process.env['DISCORD_SERVICE_TENANT_ID'] ?? discordServiceUserId!, + } + : this.getClientScope(client); if (!scope) { client.emit('error', { conversationId, error: 'Authenticated user scope is required.' }); return; } const userId = scope.userId; + const correlationId = discordIngress?.correlationId; - this.logger.log(`Message from ${client.id} in conversation ${conversationId}`); + this.logger.log( + `Message from ${client.id} in conversation ${conversationId}${correlationId ? ` correlation=${correlationId}` : ''}`, + ); // Ensure agent session exists for this conversation let sessionRoutingDecision: RoutingDecisionInfo | undefined; @@ -245,6 +320,13 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa content: data.content, metadata: { timestamp: new Date().toISOString(), + ...(correlationId + ? { + correlationId, + discordMessageId: discordIngress?.messageId, + discordUserId: discordIngress?.userId, + } + : {}), }, }, userId, @@ -308,7 +390,17 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa } // Send acknowledgment - client.emit('message:ack', { conversationId, messageId: uuid() }); + client.emit('message:ack', { + conversationId, + messageId: uuid(), + ...(correlationId + ? { + correlationId, + discordMessageId: discordIngress?.messageId, + discordUserId: discordIngress?.userId, + } + : {}), + }); // Dispatch to agent try { @@ -534,6 +626,39 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa * Creates it if absent — safe to call concurrently since a duplicate insert * would fail on the PK constraint and be caught here. */ + private resolveDiscordIngress( + client: Socket, + envelope: DiscordIngressEnvelope, + ): DiscordIngressPayload | null { + const payload = verifyDiscordIngressEnvelope( + envelope, + process.env['DISCORD_SERVICE_TOKEN'] ?? '', + { + guildIds: this.readDiscordAllowlist('DISCORD_ALLOWED_GUILD_IDS'), + channelIds: this.readDiscordAllowlist('DISCORD_ALLOWED_CHANNEL_IDS'), + userIds: this.readDiscordAllowlist('DISCORD_ALLOWED_USER_IDS'), + }, + ); + if (!payload) { + this.logger.warn(`Rejected invalid Discord ingress envelope from ${client.id}`); + return null; + } + if (!this.discordReplayProtector.claim(payload.messageId)) { + this.logger.warn( + `Rejected replayed Discord message=${payload.messageId} correlation=${payload.correlationId}`, + ); + return null; + } + return payload; + } + + private readDiscordAllowlist(name: string): string[] { + return (process.env[name] ?? '') + .split(',') + .map((id: string): string => id.trim()) + .filter((id: string): boolean => id.length > 0); + } + private async ensureConversation(conversationId: string, userId: string): Promise { try { const existing = await this.brain.conversations.findById(conversationId, userId); diff --git a/apps/gateway/src/plugin/discord-ingress.security.spec.ts b/apps/gateway/src/plugin/discord-ingress.security.spec.ts new file mode 100644 index 00000000..05301703 --- /dev/null +++ b/apps/gateway/src/plugin/discord-ingress.security.spec.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest'; +import { + createDiscordIngressEnvelope, + verifyDiscordIngressEnvelope, + type DiscordIngressPayload, +} from '@mosaicstack/discord-plugin'; +import { validateDiscordServiceToken } from '../chat/chat.gateway-auth.js'; +import { DiscordReplayProtector } from './discord-replay-protector.js'; + +const SERVICE_TOKEN = 'test-service-token'; + +function createPayload(overrides: Partial = {}): DiscordIngressPayload { + return { + correlationId: 'correlation-001', + messageId: 'discord-message-001', + guildId: 'guild-001', + channelId: 'channel-001', + userId: 'user-001', + conversationId: 'discord-channel-001', + content: 'hello Tess', + ...overrides, + }; +} + +describe('Discord ingress security', () => { + it('accepts only the configured Discord service identity', () => { + expect(validateDiscordServiceToken(SERVICE_TOKEN, SERVICE_TOKEN)).toBe(true); + expect(validateDiscordServiceToken('wrong-service-token', SERVICE_TOKEN)).toBe(false); + expect(validateDiscordServiceToken(undefined, SERVICE_TOKEN)).toBe(false); + }); + + it('rejects unauthenticated or tampered service envelopes', () => { + const envelope = createDiscordIngressEnvelope(createPayload(), SERVICE_TOKEN); + + expect(verifyDiscordIngressEnvelope(envelope, SERVICE_TOKEN)).toEqual(createPayload()); + expect(verifyDiscordIngressEnvelope(envelope, 'wrong-service-token')).toBeNull(); + expect( + verifyDiscordIngressEnvelope( + { ...envelope, payload: { ...envelope.payload, content: 'forged command' } }, + SERVICE_TOKEN, + ), + ).toBeNull(); + }); + + it.each([ + ['guild', { guildId: 'unlisted-guild' }], + ['channel', { channelId: 'unlisted-channel' }], + ['user', { userId: 'unlisted-user' }], + ])( + 'rejects an unallowlisted Discord %s', + (_kind: string, overrides: Partial) => { + const envelope = createDiscordIngressEnvelope(createPayload(overrides), SERVICE_TOKEN); + + expect( + verifyDiscordIngressEnvelope(envelope, SERVICE_TOKEN, { + guildIds: ['guild-001'], + channelIds: ['channel-001'], + userIds: ['user-001'], + }), + ).toBeNull(); + }, + ); + + it('retains Discord message and correlation IDs after authenticated allowlisted validation', () => { + const payload = createPayload({ + correlationId: 'correlation-trace-123', + messageId: 'discord-snowflake-987', + }); + const envelope = createDiscordIngressEnvelope(payload, SERVICE_TOKEN); + + expect( + verifyDiscordIngressEnvelope(envelope, SERVICE_TOKEN, { + guildIds: ['guild-001'], + channelIds: ['channel-001'], + userIds: ['user-001'], + }), + ).toEqual(payload); + }); + + it('rejects a replayed Discord message ID while retaining bounded replay state', () => { + const replayProtector = new DiscordReplayProtector(60_000, 2); + + expect(replayProtector.claim('discord-message-001')).toBe(true); + expect(replayProtector.claim('discord-message-001')).toBe(false); + expect(replayProtector.claim('discord-message-002')).toBe(true); + expect(replayProtector.claim('discord-message-003')).toBe(true); + expect(replayProtector.size).toBe(2); + }); +}); diff --git a/apps/gateway/src/plugin/discord-replay-protector.ts b/apps/gateway/src/plugin/discord-replay-protector.ts new file mode 100644 index 00000000..626a193a --- /dev/null +++ b/apps/gateway/src/plugin/discord-replay-protector.ts @@ -0,0 +1,40 @@ +/** + * Bounded replay cache for Discord's globally unique native message IDs. + * Durable ingress idempotency is added with Tess's canonical inbox/outbox work. + */ +export class DiscordReplayProtector { + private readonly claimedAt = new Map(); + + constructor( + private readonly ttlMs = 15 * 60 * 1000, + private readonly maxEntries = 10_000, + ) {} + + get size(): number { + return this.claimedAt.size; + } + + /** Claims an ID exactly once within its bounded retention window. */ + claim(messageId: string, now = Date.now()): boolean { + this.prune(now); + if (this.claimedAt.has(messageId)) return false; + + this.claimedAt.set(messageId, now); + this.evictOverflow(); + return true; + } + + private prune(now: number): void { + for (const [messageId, claimedAt] of this.claimedAt) { + if (now - claimedAt >= this.ttlMs) this.claimedAt.delete(messageId); + } + } + + private evictOverflow(): void { + while (this.claimedAt.size > this.maxEntries) { + const oldestMessageId = this.claimedAt.keys().next().value; + if (oldestMessageId === undefined) return; + this.claimedAt.delete(oldestMessageId); + } + } +} diff --git a/apps/gateway/src/plugin/plugin.module.ts b/apps/gateway/src/plugin/plugin.module.ts index 3991c9bc..dc059ef5 100644 --- a/apps/gateway/src/plugin/plugin.module.ts +++ b/apps/gateway/src/plugin/plugin.module.ts @@ -50,19 +50,41 @@ class TelegramChannelPluginAdapter implements IChannelPlugin { const DEFAULT_GATEWAY_URL = 'http://localhost:14242'; +function requiredDiscordAllowlist(name: string): string[] { + const value = process.env[name] + ?.split(',') + .map((id: string): string => id.trim()) + .filter((id: string): boolean => id.length > 0); + if (!value || value.length === 0) { + throw new Error(`${name} is required when DISCORD_BOT_TOKEN is configured`); + } + return value; +} + function createPluginRegistry(): IChannelPlugin[] { const plugins: IChannelPlugin[] = []; const discordToken = process.env['DISCORD_BOT_TOKEN']; const discordGuildId = process.env['DISCORD_GUILD_ID']; const discordGatewayUrl = process.env['DISCORD_GATEWAY_URL'] ?? DEFAULT_GATEWAY_URL; + const discordServiceToken = process.env['DISCORD_SERVICE_TOKEN']; + const discordServiceUserId = process.env['DISCORD_SERVICE_USER_ID']; if (discordToken) { + if (!discordServiceToken || !discordServiceUserId) { + throw new Error( + 'DISCORD_SERVICE_TOKEN and DISCORD_SERVICE_USER_ID are required when DISCORD_BOT_TOKEN is configured', + ); + } plugins.push( new DiscordChannelPluginAdapter( new DiscordPlugin({ token: discordToken, guildId: discordGuildId, gatewayUrl: discordGatewayUrl, + serviceToken: discordServiceToken, + allowedGuildIds: requiredDiscordAllowlist('DISCORD_ALLOWED_GUILD_IDS'), + allowedChannelIds: requiredDiscordAllowlist('DISCORD_ALLOWED_CHANNEL_IDS'), + allowedUserIds: requiredDiscordAllowlist('DISCORD_ALLOWED_USER_IDS'), }), ), ); diff --git a/docs/architecture/channel-protocol.md b/docs/architecture/channel-protocol.md index ad96cbd1..315252a3 100644 --- a/docs/architecture/channel-protocol.md +++ b/docs/architecture/channel-protocol.md @@ -232,6 +232,10 @@ The following sections document how each supported channel maps its native messa **Outbound:** Adapter calls Discord REST `POST /channels/{id}/messages`. Markdown content is sent as-is (Discord renders it). For `contentType = "code"` the adapter wraps in triple-backtick fences with the `metadata.language` tag. +### Discord service ingress security + +The Discord adapter is an authenticated gateway service, not an anonymous Socket.IO client. It presents `DISCORD_SERVICE_TOKEN` during its `/chat` connection and signs each inbound envelope using HMAC-SHA-256. The envelope contains the Discord native message ID and a generated correlation ID. Gateway verifies the service credential, signature, and configured guild/channel/user allowlists before agent dispatch, then rejects duplicate native message IDs inside its bounded replay window. All three allowlists are default-deny and required when the Discord plugin is enabled. The service credential is injected at runtime and is never logged or included in protocol payloads. + --- ### Telegram diff --git a/docs/guides/admin-guide.md b/docs/guides/admin-guide.md index 4f7c6a1e..2430dbd4 100644 --- a/docs/guides/admin-guide.md +++ b/docs/guides/admin-guide.md @@ -293,13 +293,24 @@ Each OIDC provider requires its client ID, client secret, and issuer URL togethe ### Plugins -| Variable | Description | -| ---------------------- | -------------------------------------------------------------------------- | -| `DISCORD_BOT_TOKEN` | Discord bot token (enables Discord plugin) | -| `DISCORD_GUILD_ID` | Discord guild/server ID | -| `DISCORD_GATEWAY_URL` | Gateway URL for Discord plugin to call (default: `http://localhost:14242`) | -| `TELEGRAM_BOT_TOKEN` | Telegram bot token (enables Telegram plugin) | -| `TELEGRAM_GATEWAY_URL` | Gateway URL for Telegram plugin to call | +| Variable | Description | +| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| `DISCORD_BOT_TOKEN` | Discord bot token (enables Discord plugin) | +| `DISCORD_SERVICE_TOKEN` | Required high-entropy service credential used to authenticate and sign Discord ingress; inject through the approved secret mechanism only | +| `DISCORD_SERVICE_USER_ID` | Required Mosaic service-principal user ID that owns persisted Discord conversations; the original Discord user ID remains audit metadata | +| `DISCORD_GUILD_ID` | Discord guild/server ID | +| `DISCORD_GATEWAY_URL` | Gateway URL for Discord plugin to call (default: `http://localhost:14242`) | +| `DISCORD_ALLOWED_GUILD_IDS` | Required comma-separated Discord guild snowflake allowlist; default-deny | +| `DISCORD_ALLOWED_CHANNEL_IDS` | Required comma-separated Discord channel snowflake allowlist; default-deny | +| `DISCORD_ALLOWED_USER_IDS` | Required comma-separated Discord user snowflake allowlist; default-deny | +| `TELEGRAM_BOT_TOKEN` | Telegram bot token (enables Telegram plugin) | +| `TELEGRAM_GATEWAY_URL` | Gateway URL for Telegram plugin to call | + +### Discord ingress security + +When `DISCORD_BOT_TOKEN` is configured, `DISCORD_SERVICE_TOKEN`, `DISCORD_SERVICE_USER_ID`, and all three Discord allowlists are required. Gateway startup fails rather than enabling a broad or unauthenticated remote-control surface. The service user ID identifies a provisioned Mosaic service principal for persistence; the original Discord user ID is retained in ingress audit metadata. The service token is a secret supplied by the approved runtime secret mechanism and is never committed or logged. + +Inbound Discord messages must originate from an allowed guild, channel, and user, mention the bot, and carry a signed envelope containing the native Discord message ID and a generated correlation ID. The gateway validates the service identity, envelope signature, and allowlists again before dispatching. Replayed Discord message IDs are rejected during the bounded ingress replay window. Durable inbox/idempotency retention is introduced with Tess durable state. ### Observability diff --git a/docs/scratchpads/tess-m1-sec-004-discord-ingress.md b/docs/scratchpads/tess-m1-sec-004-discord-ingress.md new file mode 100644 index 00000000..92e28d3d --- /dev/null +++ b/docs/scratchpads/tess-m1-sec-004-discord-ingress.md @@ -0,0 +1,35 @@ +# Scratchpad — TESS-M1-SEC-004 Discord ingress + +- **Task / issue:** TESS-M1-SEC-004 / #707 +- **Branch:** `fix/tess-discord-ingress` from `origin/main` at `59e49cfd` +- **Objective:** Authenticate the Discord plugin service at gateway ingress; enforce explicit guild/channel/user allowlists; attach Discord message and generated correlation IDs; reject replayed native message IDs. +- **Scope:** `plugins/discord`, `apps/gateway`, and existing Discord admin/developer protocol docs. +- **Budget:** Task estimate 28K; no explicit hard cap supplied. +- **Assumptions:** The Discord plugin and gateway share an injected high-entropy `DISCORD_SERVICE_TOKEN`; a configured Discord plugin fails closed without it. Allowlist configuration is comma-separated Discord snowflakes. Discord native message ID is the replay key, with bounded in-memory retention pending the M2 durable inbox/idempotency work. + +## Plan + +1. Add failing tests covering ingress service authentication/signing, unlisted guild/channel/user rejection, correlation propagation, and replay rejection. +2. Implement the signed Discord ingress envelope and allowlist validation in the plugin. +3. Authenticate and validate the envelope at the gateway boundary, then enforce bounded replay protection before agent dispatch. +4. Document the service-token and allowlist operations; run focused and baseline gates; obtain independent review. + +## Progress + +- 2026-07-12: Intake complete; PRD TESS-SEC-005, architecture, and threat model reviewed. +- Added service-token Socket.IO authentication, HMAC-signed Discord envelopes, default-deny guild/channel/user allowlists, correlated message metadata, bounded replay rejection, and fail-fast configuration checks. +- Code and security reviews completed. Code review findings on service persistence ownership, package-boundary tests, disconnected ingress observability, and chat payload validation were remediated; final independent code review approved. + +## Risks / blockers + +- Existing `main` has known unrelated Prettier debt; only changed files will be held format-clean. Durable replay persistence is intentionally out of scope for this M1 prerequisite and belongs to TESS-M2 durable inbox/idempotency work. + +## Verification evidence + +- Focused ingress suite: `pnpm --filter @mosaicstack/gateway test -- discord-ingress.security.spec.ts` — 7 passed. +- Gateway suite: `pnpm --filter @mosaicstack/gateway test` — 513 passed, 11 skipped. +- Plugin suite: `pnpm --filter @mosaicstack/discord-plugin test` — no tests, passed by configured `--passWithNoTests`. +- `pnpm typecheck` — passed. +- `pnpm lint` — passed. +- `pnpm format:check` — fails only on the known pre-existing Tess documentation debt listed in the task dispatch; changed files pass targeted Prettier verification. +- Codex security review — no findings; final Codex code review — approved. diff --git a/plugins/discord/src/index.ts b/plugins/discord/src/index.ts index 72fb27fa..d25e5559 100644 --- a/plugins/discord/src/index.ts +++ b/plugins/discord/src/index.ts @@ -1,24 +1,109 @@ +import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto'; import { ChannelType, Client, GatewayIntentBits, type Message as DiscordMessage } from 'discord.js'; import { io, type Socket } from 'socket.io-client'; export interface DiscordPluginConfig { token: string; gatewayUrl: string; - /** Which guild to bind to (single-guild only for v0.1.0) */ + /** Shared service credential injected by the approved secret mechanism. */ + serviceToken: string; + /** Which guild to bind to (single-guild only for v0.1.0). */ guildId?: string; + allowedGuildIds: readonly string[]; + allowedChannelIds: readonly string[]; + allowedUserIds: readonly string[]; +} + +export interface DiscordIngressPayload { + correlationId: string; + messageId: string; + guildId: string; + channelId: string; + userId: string; + conversationId: string; + content: string; +} + +export interface DiscordIngressEnvelope { + payload: DiscordIngressPayload; + signature: string; +} + +export interface DiscordIngressAllowlists { + guildIds: readonly string[]; + channelIds: readonly string[]; + userIds: readonly string[]; +} + +function signedPayload(payload: DiscordIngressPayload): string { + return [ + payload.correlationId, + payload.messageId, + payload.guildId, + payload.channelId, + payload.userId, + payload.conversationId, + payload.content, + ].join('\n'); +} + +function signPayload(payload: DiscordIngressPayload, serviceToken: string): string { + return createHmac('sha256', serviceToken).update(signedPayload(payload)).digest('hex'); +} + +function isSignatureValid(actual: string, expected: string): boolean { + const actualBuffer = Buffer.from(actual, 'hex'); + const expectedBuffer = Buffer.from(expected, 'hex'); + return ( + actualBuffer.length === expectedBuffer.length && timingSafeEqual(actualBuffer, expectedBuffer) + ); +} + +function includesId(allowedIds: readonly string[], id: string): boolean { + return allowedIds.includes(id); +} + +/** Creates the signed, auditable envelope accepted by the gateway Discord service boundary. */ +export function createDiscordIngressEnvelope( + payload: DiscordIngressPayload, + serviceToken: string, +): DiscordIngressEnvelope { + return { payload, signature: signPayload(payload, serviceToken) }; +} + +/** + * Verifies service-origin integrity and applies default-deny Discord identity allowlists. + * Returns null instead of a partially trusted payload on every failure path. + */ +export function verifyDiscordIngressEnvelope( + envelope: DiscordIngressEnvelope, + serviceToken: string, + allowlists?: DiscordIngressAllowlists, +): DiscordIngressPayload | null { + const expectedSignature = signPayload(envelope.payload, serviceToken); + if (!isSignatureValid(envelope.signature, expectedSignature)) return null; + + if ( + allowlists && + (!includesId(allowlists.guildIds, envelope.payload.guildId) || + !includesId(allowlists.channelIds, envelope.payload.channelId) || + !includesId(allowlists.userIds, envelope.payload.userId)) + ) { + return null; + } + + return envelope.payload; } export class DiscordPlugin { private client: Client; private socket: Socket | null = null; - private config: DiscordPluginConfig; - /** Map Discord channel ID → Mosaic conversation ID */ + /** Map Discord channel ID → Mosaic conversation ID. */ private channelConversations = new Map(); - /** Track in-flight responses to avoid duplicate streaming */ + /** Track in-flight responses to avoid duplicate streaming. */ private pendingResponses = new Map(); - constructor(config: DiscordPluginConfig) { - this.config = config; + constructor(private readonly config: DiscordPluginConfig) { this.client = new Client({ intents: [ GatewayIntentBits.Guilds, @@ -30,8 +115,8 @@ export class DiscordPlugin { } async start(): Promise { - // Connect to gateway WebSocket this.socket = io(`${this.config.gatewayUrl}/chat`, { + auth: { discordServiceToken: this.config.serviceToken }, transports: ['websocket'], }); @@ -48,7 +133,6 @@ export class DiscordPlugin { console.error(`[discord] Gateway connection error: ${err.message}`); }); - // Handle streaming text from gateway this.socket.on('agent:text', (data: { conversationId: string; text: string }) => { const pending = this.pendingResponses.get(data.conversationId); if (pending !== undefined) { @@ -56,12 +140,11 @@ export class DiscordPlugin { } }); - // When agent finishes, send the accumulated response this.socket.on('agent:end', (data: { conversationId: string }) => { const text = this.pendingResponses.get(data.conversationId); if (text) { this.pendingResponses.delete(data.conversationId); - this.sendToDiscord(data.conversationId, text).catch((err) => { + this.sendToDiscord(data.conversationId, text).catch((err: unknown) => { console.error(`[discord] Error sending response for ${data.conversationId}:`, err); }); } @@ -71,8 +154,9 @@ export class DiscordPlugin { this.pendingResponses.set(data.conversationId, ''); }); - // Set up Discord message handler - this.client.on('messageCreate', (message) => this.handleDiscordMessage(message)); + this.client.on('messageCreate', (message: DiscordMessage) => + this.handleDiscordMessage(message), + ); this.client.on('ready', () => { console.log(`[discord] Bot logged in as ${this.client.user?.tag}`); @@ -96,7 +180,6 @@ export class DiscordPlugin { const guild = this.client.guilds.cache.get(this.config.guildId); if (!guild) return null; - // Slugify project name for channel: lowercase, replace spaces/special chars with hyphens const channelName = `mosaic-${project.name .toLowerCase() .replace(/[^a-z0-9]+/g, '-') @@ -108,58 +191,58 @@ export class DiscordPlugin { topic: project.description ?? `Mosaic project: ${project.name}`, }); - // Register the channel mapping so messages route correctly this.channelConversations.set(channel.id, `discord-${channel.id}`); - return { channelId: channel.id }; } private handleDiscordMessage(message: DiscordMessage): void { - // Ignore bot messages - if (message.author.bot) return; + if (message.author.bot || !this.client.user) return; + if (!message.guildId || !this.isAllowedMessage(message)) return; - // Not ready yet - if (!this.client.user) return; - - // Check guild binding - if (this.config.guildId && message.guildId !== this.config.guildId) return; - - // Respond to DMs always, or mentions in channels - const isDM = !message.guildId; const isMention = message.mentions.has(this.client.user); + if (!isMention) return; - if (!isDM && !isMention) return; - - // Strip bot mention from message content const content = message.content .replace(new RegExp(`<@!?${this.client.user.id}>`, 'g'), '') .trim(); - if (!content) return; - - // Get or create conversation for this Discord channel - const channelId = message.channelId; - let conversationId = this.channelConversations.get(channelId); - if (!conversationId) { - conversationId = `discord-${channelId}`; - this.channelConversations.set(channelId, conversationId); - } - - // Send to gateway if (!this.socket?.connected) { console.error( - `[discord] Cannot forward message: not connected to gateway. channel=${channelId}`, + `[discord] Cannot forward message: not connected to gateway. channel=${message.channelId} message=${message.id}`, ); return; } - this.socket.emit('message', { - conversationId, - content, - }); + + const channelId = message.channelId; + const conversationId = this.channelConversations.get(channelId) ?? `discord-${channelId}`; + this.channelConversations.set(channelId, conversationId); + + const envelope = createDiscordIngressEnvelope( + { + correlationId: randomUUID(), + messageId: message.id, + guildId: message.guildId, + channelId, + userId: message.author.id, + conversationId, + content, + }, + this.config.serviceToken, + ); + this.socket.emit('message', envelope); + } + + private isAllowedMessage(message: DiscordMessage): boolean { + const guildId = message.guildId; + return ( + guildId !== null && + includesId(this.config.allowedGuildIds, guildId) && + includesId(this.config.allowedChannelIds, message.channelId) && + includesId(this.config.allowedUserIds, message.author.id) + ); } private async sendToDiscord(conversationId: string, text: string): Promise { - // Find the Discord channel for this conversation const channelId = Array.from(this.channelConversations.entries()).find( ([, convId]) => convId === conversationId, )?.[0]; @@ -177,12 +260,10 @@ export class DiscordPlugin { return; } - // Chunk responses for Discord's 2000-char limit - const chunks = this.chunkText(text, 1900); - for (const chunk of chunks) { + for (const chunk of this.chunkText(text, 1900)) { try { await (channel as { send: (content: string) => Promise }).send(chunk); - } catch (err) { + } catch (err: unknown) { console.error(`[discord] Failed to send message to channel ${channelId}:`, err); } } @@ -193,21 +274,16 @@ export class DiscordPlugin { const chunks: string[] = []; let remaining = text; - while (remaining.length > 0) { if (remaining.length <= maxLength) { chunks.push(remaining); break; } - - // Try to break at a newline let breakPoint = remaining.lastIndexOf('\n', maxLength); if (breakPoint <= 0) breakPoint = maxLength; - chunks.push(remaining.slice(0, breakPoint)); remaining = remaining.slice(breakPoint).trimStart(); } - return chunks; } } From e92186d768f236a3972d6abf0e349b71419bb1f4 Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Sun, 12 Jul 2026 23:53:54 +0000 Subject: [PATCH 014/152] feat: add Tess runtime provider registry (#722) --- apps/gateway/package.json | 1 + .../runtime-provider-registry.service.test.ts | 285 ++++++++++++ apps/gateway/src/agent/agent.module.ts | 26 ++ .../runtime-provider-registry.service.ts | 404 ++++++++++++++++++ .../tess-m1-002-provider-registry.md | 38 ++ docs/tess/ARCHITECTURE.md | 6 + packages/agent/src/index.ts | 2 + .../src/runtime-provider-registry.test.ts | 95 ++++ .../agent/src/runtime-provider-registry.ts | 40 ++ pnpm-lock.yaml | 3 + 10 files changed, 900 insertions(+) create mode 100644 apps/gateway/src/agent/__tests__/runtime-provider-registry.service.test.ts create mode 100644 apps/gateway/src/agent/runtime-provider-registry.service.ts create mode 100644 docs/scratchpads/tess-m1-002-provider-registry.md create mode 100644 packages/agent/src/runtime-provider-registry.test.ts create mode 100644 packages/agent/src/runtime-provider-registry.ts diff --git a/apps/gateway/package.json b/apps/gateway/package.json index 1a2cb973..8e5819f9 100644 --- a/apps/gateway/package.json +++ b/apps/gateway/package.json @@ -31,6 +31,7 @@ "@mariozechner/pi-ai": "^0.65.0", "@mariozechner/pi-coding-agent": "^0.65.0", "@modelcontextprotocol/sdk": "^1.27.1", + "@mosaicstack/agent": "workspace:^", "@mosaicstack/auth": "workspace:^", "@mosaicstack/brain": "workspace:^", "@mosaicstack/config": "workspace:^", diff --git a/apps/gateway/src/agent/__tests__/runtime-provider-registry.service.test.ts b/apps/gateway/src/agent/__tests__/runtime-provider-registry.service.test.ts new file mode 100644 index 00000000..85415545 --- /dev/null +++ b/apps/gateway/src/agent/__tests__/runtime-provider-registry.service.test.ts @@ -0,0 +1,285 @@ +import { describe, expect, it } from 'vitest'; +import type { + AgentRuntimeProvider, + RuntimeAttachHandle, + RuntimeAttachMode, + RuntimeCapability, + RuntimeCapabilitySet, + RuntimeHealth, + RuntimeMessage, + RuntimeScope, + RuntimeSession, + RuntimeSessionTree, + RuntimeStreamEvent, +} from '@mosaicstack/types'; +import { AgentRuntimeProviderRegistry } from '@mosaicstack/agent'; +import type { ActorTenantScope } from '../../auth/session-scope.js'; +import { + RuntimeProviderService, + type RuntimeAuditEvent, + type RuntimeAuditSink, + type RuntimeApprovalVerifier, +} from '../runtime-provider-registry.service.js'; + +const OWNER_SCOPE: ActorTenantScope = { userId: 'owner-1', tenantId: 'tenant-1' }; +const CONTEXT = { + actorScope: OWNER_SCOPE, + channelId: 'cli', + correlationId: 'correlation-1', +}; + +class RecordingRuntimeProvider implements AgentRuntimeProvider { + readonly id = 'fleet'; + readonly receivedScopes: RuntimeScope[] = []; + readonly sentMessages: RuntimeMessage[] = []; + terminateCalls = 0; + throwAfterSend = false; + + constructor(private readonly supported: RuntimeCapability[]) {} + + async capabilities(scope: RuntimeScope): Promise { + this.receivedScopes.push(scope); + return { supported: this.supported }; + } + + async health(scope: RuntimeScope): Promise { + this.receivedScopes.push(scope); + return { status: 'healthy', checkedAt: '2026-07-12T00:00:00.000Z' }; + } + + async listSessions(scope: RuntimeScope): Promise { + this.receivedScopes.push(scope); + return []; + } + + async getSessionTree(scope: RuntimeScope): Promise { + this.receivedScopes.push(scope); + return []; + } + + async *streamSession( + _sessionId: string, + _cursor: string | undefined, + scope: RuntimeScope, + ): AsyncIterable { + this.receivedScopes.push(scope); + return; + } + + async sendMessage( + _sessionId: string, + message: RuntimeMessage, + scope: RuntimeScope, + ): Promise { + this.receivedScopes.push(scope); + this.sentMessages.push(message); + if (this.throwAfterSend) { + throw new Error('provider acknowledgement failed'); + } + } + + async attach( + sessionId: string, + mode: RuntimeAttachMode, + scope: RuntimeScope, + ): Promise { + this.receivedScopes.push(scope); + return { + attachmentId: 'attachment-1', + sessionId, + mode, + expiresAt: '2026-07-12T00:00:00.000Z', + }; + } + + async detach(_attachmentId: string, scope: RuntimeScope): Promise { + this.receivedScopes.push(scope); + } + + async terminate(_sessionId: string, _approvalRef: string, scope: RuntimeScope): Promise { + this.receivedScopes.push(scope); + this.terminateCalls += 1; + } +} + +class RecordingAuditSink implements RuntimeAuditSink { + readonly events: RuntimeAuditEvent[] = []; + + async record(event: RuntimeAuditEvent): Promise { + this.events.push(event); + } +} + +class DenyingApprovalVerifier implements RuntimeApprovalVerifier { + async consume(): Promise { + return false; + } +} + +class AcceptingApprovalVerifier implements RuntimeApprovalVerifier { + consumedAction: Parameters[1] | undefined; + + async consume( + _approvalRef: string, + action: Parameters[1], + ): Promise { + this.consumedAction = action; + return true; + } +} + +function makeService( + provider: RecordingRuntimeProvider, + audit: RuntimeAuditSink = new RecordingAuditSink(), + approval: RuntimeApprovalVerifier = new DenyingApprovalVerifier(), +): RuntimeProviderService { + const registry = new AgentRuntimeProviderRegistry(); + registry.register(provider); + return new RuntimeProviderService(registry, audit, approval); +} + +describe('RuntimeProviderService security boundary', (): void => { + it('derives and freezes only the authenticated actor scope while preserving correlation metadata', async (): Promise => { + const provider = new RecordingRuntimeProvider(['session.send']); + const audit = new RecordingAuditSink(); + const service = makeService(provider, audit); + + await service.sendMessage( + 'fleet', + 'session-1', + { content: 'hello', idempotencyKey: 'key-1' }, + CONTEXT, + ); + + const providerScope = provider.receivedScopes[0]; + expect(providerScope).toEqual({ + actorId: OWNER_SCOPE.userId, + tenantId: OWNER_SCOPE.tenantId, + channelId: CONTEXT.channelId, + correlationId: CONTEXT.correlationId, + }); + expect(Object.isFrozen(providerScope)).toBe(true); + expect(audit.events).toContainEqual({ + providerId: 'fleet', + operation: 'session.send', + outcome: 'succeeded', + actorId: OWNER_SCOPE.userId, + tenantId: OWNER_SCOPE.tenantId, + channelId: CONTEXT.channelId, + correlationId: CONTEXT.correlationId, + resourceId: 'session-1', + }); + expect(JSON.stringify(audit.events)).not.toContain('hello'); + expect(JSON.stringify(audit.events)).not.toContain('key-1'); + }); + + it('fails closed before a provider side effect when a capability is missing', async (): Promise => { + const provider = new RecordingRuntimeProvider([]); + const service = makeService(provider); + + await expect( + service.sendMessage( + 'fleet', + 'session-1', + { content: 'hello', idempotencyKey: 'key-1' }, + CONTEXT, + ), + ).rejects.toThrow(/capability denied/); + expect(provider.sentMessages).toEqual([]); + }); + + it('requires a consumed exact-action approval before termination', async (): Promise => { + const provider = new RecordingRuntimeProvider(['session.terminate']); + const approval = new DenyingApprovalVerifier(); + const service = makeService(provider, new RecordingAuditSink(), approval); + + await expect( + service.terminate('fleet', 'session-1', 'forged-approval', CONTEXT), + ).rejects.toThrow(/approval denied/); + expect(provider.terminateCalls).toBe(0); + }); + + it('binds an accepted termination approval to provider, session, immutable scope, and correlation', async (): Promise => { + const provider = new RecordingRuntimeProvider(['session.terminate']); + const approval = new AcceptingApprovalVerifier(); + const service = makeService(provider, new RecordingAuditSink(), approval); + + await service.terminate('fleet', 'session-1', 'approval-1', CONTEXT); + + expect(approval.consumedAction).toEqual({ + providerId: 'fleet', + sessionId: 'session-1', + actorId: OWNER_SCOPE.userId, + tenantId: OWNER_SCOPE.tenantId, + channelId: CONTEXT.channelId, + correlationId: CONTEXT.correlationId, + }); + expect(provider.terminateCalls).toBe(1); + }); + + it('fails closed before invoking a provider when audit persistence rejects the request', async (): Promise => { + const provider = new RecordingRuntimeProvider(['session.send']); + const unavailableAudit: RuntimeAuditSink = { + async record(): Promise { + throw new Error('audit unavailable'); + }, + }; + const service = makeService(provider, unavailableAudit); + + await expect( + service.sendMessage( + 'fleet', + 'session-1', + { content: 'hello', idempotencyKey: 'key-1' }, + CONTEXT, + ), + ).rejects.toThrow(/audit unavailable/); + expect(provider.sentMessages).toEqual([]); + }); + + it('records a provider error after invocation as failed rather than denied', async (): Promise => { + const provider = new RecordingRuntimeProvider(['session.send']); + provider.throwAfterSend = true; + const audit = new RecordingAuditSink(); + const service = makeService(provider, audit); + + await expect( + service.sendMessage( + 'fleet', + 'session-1', + { content: 'hello', idempotencyKey: 'key-1' }, + CONTEXT, + ), + ).rejects.toThrow(/provider acknowledgement failed/); + expect(provider.sentMessages).toHaveLength(1); + expect(audit.events.map((event: RuntimeAuditEvent): string => event.outcome)).toEqual([ + 'requested', + 'failed', + ]); + }); + + it('does not misreport a completed provider side effect when completion auditing fails', async (): Promise => { + const provider = new RecordingRuntimeProvider(['session.send']); + let auditCalls = 0; + const audit: RuntimeAuditSink = { + async record(): Promise { + auditCalls += 1; + if (auditCalls === 2) { + throw new Error('completion audit unavailable'); + } + }, + }; + const service = makeService(provider, audit); + + await expect( + service.sendMessage( + 'fleet', + 'session-1', + { content: 'hello', idempotencyKey: 'key-1' }, + CONTEXT, + ), + ).resolves.toBeUndefined(); + expect(provider.sentMessages).toHaveLength(1); + expect(auditCalls).toBe(2); + }); +}); diff --git a/apps/gateway/src/agent/agent.module.ts b/apps/gateway/src/agent/agent.module.ts index 94b9fa3c..3d789ae8 100644 --- a/apps/gateway/src/agent/agent.module.ts +++ b/apps/gateway/src/agent/agent.module.ts @@ -1,4 +1,5 @@ import { Global, Module } from '@nestjs/common'; +import { AgentRuntimeProviderRegistry } from '@mosaicstack/agent'; import { AgentService } from './agent.service.js'; import { ProviderService } from './provider.service.js'; import { ProviderCredentialsService } from './provider-credentials.service.js'; @@ -13,6 +14,14 @@ import { CoordModule } from '../coord/coord.module.js'; import { McpClientModule } from '../mcp-client/mcp-client.module.js'; import { SkillsModule } from '../skills/skills.module.js'; import { GCModule } from '../gc/gc.module.js'; +import { + AGENT_RUNTIME_PROVIDER_REGISTRY, + DenyRuntimeApprovalVerifier, + RUNTIME_APPROVAL_VERIFIER, + RUNTIME_PROVIDER_AUDIT_SINK, + RuntimeProviderAuditService, + RuntimeProviderService, +} from './runtime-provider-registry.service.js'; @Global() @Module({ @@ -23,6 +32,21 @@ import { GCModule } from '../gc/gc.module.js'; RoutingService, RoutingEngineService, SkillLoaderService, + { + provide: AGENT_RUNTIME_PROVIDER_REGISTRY, + useFactory: (): AgentRuntimeProviderRegistry => new AgentRuntimeProviderRegistry(), + }, + RuntimeProviderAuditService, + { + provide: RUNTIME_PROVIDER_AUDIT_SINK, + useExisting: RuntimeProviderAuditService, + }, + DenyRuntimeApprovalVerifier, + { + provide: RUNTIME_APPROVAL_VERIFIER, + useExisting: DenyRuntimeApprovalVerifier, + }, + RuntimeProviderService, AgentService, ], controllers: [ProvidersController, SessionsController, AgentConfigsController, RoutingController], @@ -33,6 +57,8 @@ import { GCModule } from '../gc/gc.module.js'; RoutingService, RoutingEngineService, SkillLoaderService, + RuntimeProviderService, + AGENT_RUNTIME_PROVIDER_REGISTRY, ], }) export class AgentModule {} diff --git a/apps/gateway/src/agent/runtime-provider-registry.service.ts b/apps/gateway/src/agent/runtime-provider-registry.service.ts new file mode 100644 index 00000000..89617e59 --- /dev/null +++ b/apps/gateway/src/agent/runtime-provider-registry.service.ts @@ -0,0 +1,404 @@ +import { ForbiddenException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { AgentRuntimeProviderRegistry } from '@mosaicstack/agent'; +import type { + AgentRuntimeProvider, + RuntimeAttachHandle, + RuntimeAttachMode, + RuntimeCapability, + RuntimeCapabilitySet, + RuntimeHealth, + RuntimeMessage, + RuntimeScope, + RuntimeSession, + RuntimeSessionTree, + RuntimeStreamEvent, +} from '@mosaicstack/types'; +import type { ActorTenantScope } from '../auth/session-scope.js'; + +export const AGENT_RUNTIME_PROVIDER_REGISTRY = Symbol('AGENT_RUNTIME_PROVIDER_REGISTRY'); +export const RUNTIME_PROVIDER_AUDIT_SINK = Symbol('RUNTIME_PROVIDER_AUDIT_SINK'); +export const RUNTIME_APPROVAL_VERIFIER = Symbol('RUNTIME_APPROVAL_VERIFIER'); + +export type RuntimeProviderOperation = + | RuntimeCapability + | 'runtime.capabilities' + | 'runtime.health'; +export type RuntimeProviderAuditOutcome = 'requested' | 'succeeded' | 'denied' | 'failed'; + +/** Trusted server-side context only; it intentionally excludes client-provided identity fields. */ +export interface RuntimeProviderRequestContext { + actorScope: ActorTenantScope; + channelId: string; + correlationId: string; +} + +/** Metadata-only audit record. Message bodies, idempotency keys, and approval refs are excluded. */ +export interface RuntimeAuditEvent { + providerId: string; + operation: RuntimeProviderOperation; + outcome: RuntimeProviderAuditOutcome; + actorId: string; + tenantId: string; + channelId: string; + correlationId: string; + resourceId?: string; +} + +export interface RuntimeAuditSink { + record(event: RuntimeAuditEvent): Promise; +} + +/** Exact action shape that a durable approval implementation must consume once. */ +export interface RuntimeTerminationAction { + providerId: string; + sessionId: string; + actorId: string; + tenantId: string; + channelId: string; + correlationId: string; +} + +export interface RuntimeApprovalVerifier { + consume(approvalRef: string, action: RuntimeTerminationAction): Promise; +} + +class RuntimeApprovalDeniedError extends Error { + constructor() { + super('Runtime termination approval denied'); + } +} + +/** + * The default denies all runtime termination until a durable, exact-action + * approval implementation is configured. This is safer than a permissive stub. + */ +@Injectable() +export class DenyRuntimeApprovalVerifier implements RuntimeApprovalVerifier { + async consume(_approvalRef: string, _action: RuntimeTerminationAction): Promise { + return false; + } +} + +/** + * Temporary metadata-only audit sink. M1 observability can replace this token + * with a durable audit writer without changing provider call sites. + */ +@Injectable() +export class RuntimeProviderAuditService implements RuntimeAuditSink { + private readonly logger = new Logger(RuntimeProviderAuditService.name); + + async record(event: RuntimeAuditEvent): Promise { + this.logger.log(JSON.stringify(event)); + } +} + +@Injectable() +export class RuntimeProviderService { + private readonly logger = new Logger(RuntimeProviderService.name); + + constructor( + @Inject(AGENT_RUNTIME_PROVIDER_REGISTRY) + private readonly registry: AgentRuntimeProviderRegistry, + @Inject(RUNTIME_PROVIDER_AUDIT_SINK) + private readonly audit: RuntimeAuditSink, + @Inject(RUNTIME_APPROVAL_VERIFIER) + private readonly approvals: RuntimeApprovalVerifier, + ) {} + + async capabilities( + providerId: string, + context: RuntimeProviderRequestContext, + ): Promise { + return this.execute( + providerId, + 'runtime.capabilities', + undefined, + undefined, + context, + (provider: AgentRuntimeProvider, scope: RuntimeScope): Promise => + provider.capabilities(scope), + ); + } + + async health(providerId: string, context: RuntimeProviderRequestContext): Promise { + return this.execute( + providerId, + 'runtime.health', + undefined, + undefined, + context, + (provider: AgentRuntimeProvider, scope: RuntimeScope): Promise => + provider.health(scope), + ); + } + + async listSessions( + providerId: string, + context: RuntimeProviderRequestContext, + ): Promise { + return this.execute( + providerId, + 'session.list', + 'session.list', + undefined, + context, + (provider: AgentRuntimeProvider, scope: RuntimeScope): Promise => + provider.listSessions(scope), + ); + } + + async getSessionTree( + providerId: string, + context: RuntimeProviderRequestContext, + ): Promise { + return this.execute( + providerId, + 'session.tree', + 'session.tree', + undefined, + context, + (provider: AgentRuntimeProvider, scope: RuntimeScope): Promise => + provider.getSessionTree(scope), + ); + } + + streamSession( + providerId: string, + sessionId: string, + cursor: string | undefined, + context: RuntimeProviderRequestContext, + ): AsyncIterable { + return this.stream( + providerId, + 'session.stream', + 'session.stream', + sessionId, + context, + (provider: AgentRuntimeProvider, scope: RuntimeScope): AsyncIterable => + provider.streamSession(sessionId, cursor, scope), + ); + } + + async sendMessage( + providerId: string, + sessionId: string, + message: RuntimeMessage, + context: RuntimeProviderRequestContext, + ): Promise { + await this.execute( + providerId, + 'session.send', + 'session.send', + sessionId, + context, + (provider: AgentRuntimeProvider, scope: RuntimeScope): Promise => + provider.sendMessage(sessionId, message, scope), + ); + } + + async attach( + providerId: string, + sessionId: string, + mode: RuntimeAttachMode, + context: RuntimeProviderRequestContext, + ): Promise { + return this.execute( + providerId, + 'session.attach', + 'session.attach', + sessionId, + context, + (provider: AgentRuntimeProvider, scope: RuntimeScope): Promise => + provider.attach(sessionId, mode, scope), + ); + } + + async detach( + providerId: string, + attachmentId: string, + context: RuntimeProviderRequestContext, + ): Promise { + await this.execute( + providerId, + 'session.attach', + 'session.attach', + attachmentId, + context, + (provider: AgentRuntimeProvider, scope: RuntimeScope): Promise => + provider.detach(attachmentId, scope), + ); + } + + async terminate( + providerId: string, + sessionId: string, + approvalRef: string, + context: RuntimeProviderRequestContext, + ): Promise { + await this.execute( + providerId, + 'session.terminate', + 'session.terminate', + sessionId, + context, + async (provider: AgentRuntimeProvider, scope: RuntimeScope): Promise => { + const approved = await this.approvals.consume(approvalRef, { + providerId, + sessionId, + actorId: scope.actorId, + tenantId: scope.tenantId, + channelId: scope.channelId, + correlationId: scope.correlationId, + }); + if (!approved) { + throw new RuntimeApprovalDeniedError(); + } + await provider.terminate(sessionId, approvalRef, scope); + }, + ); + } + + private async execute( + providerId: string, + operation: RuntimeProviderOperation, + requiredCapability: RuntimeCapability | undefined, + resourceId: string | undefined, + context: RuntimeProviderRequestContext, + invoke: (provider: AgentRuntimeProvider, scope: RuntimeScope) => Promise, + ): Promise { + const scope = this.deriveScope(context); + await this.record(providerId, operation, 'requested', scope, resourceId); + let invocationStarted = false; + try { + const provider = this.provider(providerId); + if (requiredCapability) { + await this.assertCapability(provider, requiredCapability, scope); + } + invocationStarted = true; + const result = await invoke(provider, scope); + await this.recordCompletion(providerId, operation, scope, resourceId); + return result; + } catch (error: unknown) { + if (invocationStarted && !(error instanceof RuntimeApprovalDeniedError)) { + await this.recordFailure(providerId, operation, scope, resourceId); + } else { + await this.record(providerId, operation, 'denied', scope, resourceId); + } + throw error; + } + } + + private async *stream( + providerId: string, + operation: RuntimeProviderOperation, + requiredCapability: RuntimeCapability, + resourceId: string, + context: RuntimeProviderRequestContext, + invoke: ( + provider: AgentRuntimeProvider, + scope: RuntimeScope, + ) => AsyncIterable, + ): AsyncIterable { + const scope = this.deriveScope(context); + await this.record(providerId, operation, 'requested', scope, resourceId); + let invocationStarted = false; + try { + const provider = this.provider(providerId); + await this.assertCapability(provider, requiredCapability, scope); + invocationStarted = true; + for await (const event of invoke(provider, scope)) { + yield event; + } + await this.recordCompletion(providerId, operation, scope, resourceId); + } catch (error: unknown) { + if (invocationStarted) { + await this.recordFailure(providerId, operation, scope, resourceId); + } else { + await this.record(providerId, operation, 'denied', scope, resourceId); + } + throw error; + } + } + + private provider(providerId: string): AgentRuntimeProvider { + try { + return this.registry.require(providerId); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Runtime provider is not registered'; + throw new NotFoundException(message); + } + } + + private async assertCapability( + provider: AgentRuntimeProvider, + requiredCapability: RuntimeCapability, + scope: RuntimeScope, + ): Promise { + const capabilities = await provider.capabilities(scope); + if (!capabilities.supported.includes(requiredCapability)) { + throw new ForbiddenException(`Runtime provider capability denied: ${requiredCapability}`); + } + } + + private deriveScope(context: RuntimeProviderRequestContext): RuntimeScope { + const actorId = context.actorScope.userId.trim(); + const tenantId = context.actorScope.tenantId.trim(); + const channelId = context.channelId.trim(); + const correlationId = context.correlationId.trim(); + if (!actorId || !tenantId || !channelId || !correlationId) { + throw new ForbiddenException( + 'Authenticated runtime actor scope and correlation are required', + ); + } + return Object.freeze({ actorId, tenantId, channelId, correlationId }); + } + + private async recordFailure( + providerId: string, + operation: RuntimeProviderOperation, + scope: RuntimeScope, + resourceId: string | undefined, + ): Promise { + try { + await this.record(providerId, operation, 'failed', scope, resourceId); + } catch { + this.logger.error( + `Runtime provider failure audit failed provider=${providerId} operation=${operation} correlation=${scope.correlationId}`, + ); + } + } + + private async recordCompletion( + providerId: string, + operation: RuntimeProviderOperation, + scope: RuntimeScope, + resourceId: string | undefined, + ): Promise { + try { + await this.record(providerId, operation, 'succeeded', scope, resourceId); + } catch { + this.logger.error( + `Runtime provider completion audit failed provider=${providerId} operation=${operation} correlation=${scope.correlationId}`, + ); + } + } + + private async record( + providerId: string, + operation: RuntimeProviderOperation, + outcome: RuntimeProviderAuditOutcome, + scope: RuntimeScope, + resourceId: string | undefined, + ): Promise { + await this.audit.record({ + providerId, + operation, + outcome, + actorId: scope.actorId, + tenantId: scope.tenantId, + channelId: scope.channelId, + correlationId: scope.correlationId, + ...(resourceId ? { resourceId } : {}), + }); + } +} diff --git a/docs/scratchpads/tess-m1-002-provider-registry.md b/docs/scratchpads/tess-m1-002-provider-registry.md new file mode 100644 index 00000000..f9d6c67a --- /dev/null +++ b/docs/scratchpads/tess-m1-002-provider-registry.md @@ -0,0 +1,38 @@ +# TESS-M1-002 — Provider Registry + +- **Issue:** #707 +- **Branch:** `feat/tess-provider-registry` +- **Objective:** Build the runtime provider registry/service boundary that derives immutable actor/tenant/channel/correlation scope server-side, fail-closes unsupported and destructive runtime operations, binds termination approval to the exact structured action, and emits correlation-safe audit events. + +## Plan + +1. Add a provider-agnostic registry in `@mosaicstack/agent` over the merged `AgentRuntimeProvider` contract. +2. Write abuse-case tests before implementation for duplicate/unknown providers, immutable server-derived scope, capability denial, approval mismatch/absence, and audit failure. +3. Implement the Gateway service that converts only authenticated `ActorTenantScope` plus trusted ingress metadata into a frozen `RuntimeScope`, gates capabilities and terminate approval, and records metadata-only audits. +4. Register the service in `AgentModule`, then run focused, baseline, cold-cache, and independent-review gates. + +## Security Invariants + +- Caller-supplied actor/tenant identity never reaches runtime providers. +- Provider capability absence and approval/audit failure deny before side effects. +- Termination approval is verified against provider, session, actor, tenant, channel, and correlation context. +- Audit events retain correlation and authority metadata but never message content or approval material. + +## Progress + +- 2026-07-12: Created fresh worktree from `origin/main` at `119f64e6`; source and Tess planning/security documentation reviewed. +- 2026-07-12: Security TDD added registry and gateway abuse tests before implementation. +- 2026-07-12: Implemented `AgentRuntimeProviderRegistry` and gateway `RuntimeProviderService`; registered both in `AgentModule` and documented the internal boundary. +- 2026-07-12: Independent review found two audit correctness issues. Remediated completion-audit failure handling and provider execution failures: pre-invocation denials are audited as `denied`; post-invocation errors as `failed`; completion audit failure does not misreport a completed effect as retryable. +- 2026-07-12: Final independent security review: no findings. Final code review had one false positive: `@mosaicstack/types` is already declared in `packages/agent/package.json`. + +## Tests + +- TDD red: `pnpm --filter @mosaicstack/gateway test -- runtime-provider-registry.service.test.ts` failed before both audit remediations, as expected. +- Focused: package registry 2 tests and gateway security boundary 7 tests pass. +- Cold-cache: removed this worktree's `node_modules`, then `pnpm install --offline --frozen-lockfile --store-dir /home/jarvis/.local/share/pnpm/store` passed. +- Cold-cache baseline: `TURBO_FORCE=true pnpm typecheck` — 42/42 tasks passed; `TURBO_FORCE=true pnpm lint` — 23/23 tasks passed; `TURBO_FORCE=true pnpm format:check` passed; `TURBO_FORCE=true pnpm test` — 42/42 tasks passed (gateway 548 tests passed, 11 intentionally skipped). + +## Risks / Blockers + +- The canonical durable approval implementation is currently command-specific. This card introduces a fail-closed runtime approval verifier boundary so a runtime provider cannot terminate until its exact-action verifier is wired; later provider implementations cannot bypass it. diff --git a/docs/tess/ARCHITECTURE.md b/docs/tess/ARCHITECTURE.md index 180cf0a8..36472c44 100644 --- a/docs/tess/ARCHITECTURE.md +++ b/docs/tess/ARCHITECTURE.md @@ -37,6 +37,12 @@ Required operations: Every call receives an immutable, server-derived actor/tenant/channel scope and correlation ID. Caller-supplied actor IDs are forbidden. Unsupported capabilities fail closed with typed errors. +### M1 Registry Boundary + +`@mosaicstack/agent` owns the explicit `AgentRuntimeProviderRegistry`; duplicate provider IDs are rejected rather than replaced. Gateway owns `RuntimeProviderService`, which creates a frozen `RuntimeScope` from authenticated `ActorTenantScope` and trusted ingress channel/correlation metadata before every provider call. The service checks the declared provider capability before invoking a side effect and records metadata-only audit events (`providerId`, operation, outcome, actor/tenant/channel, correlation, and resource ID). It never records message bodies, idempotency keys, or approval references. + +Termination is fail-closed: a runtime approval verifier must consume a one-time, exact action binding for the provider, session, actor, tenant, channel, and correlation ID before `terminate` reaches a provider. Until the durable verifier is wired, the default verifier denies termination. This internal service introduces no HTTP endpoint; later Discord, CLI, MCP, and provider adapters consume the same gateway boundary. + ## Authority Model | Intent | Owner | Tess behavior | diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 0c18d5d9..409ae61b 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -1 +1,3 @@ export const VERSION = '0.0.0'; + +export * from './runtime-provider-registry.js'; diff --git a/packages/agent/src/runtime-provider-registry.test.ts b/packages/agent/src/runtime-provider-registry.test.ts new file mode 100644 index 00000000..4e88f080 --- /dev/null +++ b/packages/agent/src/runtime-provider-registry.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest'; +import type { + AgentRuntimeProvider, + RuntimeAttachHandle, + RuntimeAttachMode, + RuntimeCapabilitySet, + RuntimeHealth, + RuntimeMessage, + RuntimeScope, + RuntimeSession, + RuntimeSessionTree, + RuntimeStreamEvent, +} from '@mosaicstack/types'; +import { AgentRuntimeProviderRegistry } from './runtime-provider-registry.js'; + +class TestRuntimeProvider implements AgentRuntimeProvider { + readonly id = 'test-runtime'; + + async capabilities(_scope: RuntimeScope): Promise { + return { supported: ['session.list'] }; + } + + async health(_scope: RuntimeScope): Promise { + return { status: 'healthy', checkedAt: '2026-07-12T00:00:00.000Z' }; + } + + async listSessions(_scope: RuntimeScope): Promise { + return []; + } + + async getSessionTree(_scope: RuntimeScope): Promise { + return []; + } + + async *streamSession( + _sessionId: string, + _cursor: string | undefined, + _scope: RuntimeScope, + ): AsyncIterable { + return; + } + + async sendMessage( + _sessionId: string, + _message: RuntimeMessage, + _scope: RuntimeScope, + ): Promise {} + + async attach( + _sessionId: string, + _mode: RuntimeAttachMode, + _scope: RuntimeScope, + ): Promise { + return { + attachmentId: 'attachment-1', + sessionId: 'session-1', + mode: 'read', + expiresAt: '2026-07-12T00:00:00.000Z', + }; + } + + async detach(_attachmentId: string, _scope: RuntimeScope): Promise {} + + async terminate(_sessionId: string, _approvalRef: string, _scope: RuntimeScope): Promise {} +} + +describe('AgentRuntimeProviderRegistry', (): void => { + it('resolves only registered runtime providers', (): void => { + const registry = new AgentRuntimeProviderRegistry(); + const provider = new TestRuntimeProvider(); + + registry.register(provider); + + expect(registry.get(provider.id)).toBe(provider); + expect(registry.get('unknown-runtime')).toBeUndefined(); + expect(registry.list()).toEqual([provider]); + }); + + it('rejects duplicate and blank provider identities rather than silently replacing a runtime', (): void => { + const registry = new AgentRuntimeProviderRegistry(); + const provider = new TestRuntimeProvider(); + + registry.register(provider); + + expect((): void => { + registry.register(provider); + }).toThrow(/already registered/); + expect((): void => { + registry.require(''); + }).toThrow(/provider ID is required/); + expect((): void => { + registry.require('unknown-runtime'); + }).toThrow(/not registered/); + }); +}); diff --git a/packages/agent/src/runtime-provider-registry.ts b/packages/agent/src/runtime-provider-registry.ts new file mode 100644 index 00000000..16cb7d90 --- /dev/null +++ b/packages/agent/src/runtime-provider-registry.ts @@ -0,0 +1,40 @@ +import type { AgentRuntimeProvider } from '@mosaicstack/types'; + +/** + * Registry for runtime providers. Registration is explicit and replacement is + * forbidden so a provider identity cannot be silently hijacked at runtime. + */ +export class AgentRuntimeProviderRegistry { + private readonly providers = new Map(); + + register(provider: AgentRuntimeProvider): void { + const providerId = provider.id.trim(); + if (providerId.length === 0) { + throw new Error('Runtime provider ID is required'); + } + if (this.providers.has(providerId)) { + throw new Error(`Runtime provider is already registered: ${providerId}`); + } + this.providers.set(providerId, provider); + } + + get(providerId: string): AgentRuntimeProvider | undefined { + return this.providers.get(providerId); + } + + require(providerId: string): AgentRuntimeProvider { + const normalizedProviderId = providerId.trim(); + if (normalizedProviderId.length === 0) { + throw new Error('Runtime provider ID is required'); + } + const provider = this.providers.get(normalizedProviderId); + if (!provider) { + throw new Error(`Runtime provider is not registered: ${normalizedProviderId}`); + } + return provider; + } + + list(): AgentRuntimeProvider[] { + return Array.from(this.providers.values()); + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bf89e429..5124acd5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -75,6 +75,9 @@ importers: '@modelcontextprotocol/sdk': specifier: ^1.27.1 version: 1.27.1(zod@4.3.6) + '@mosaicstack/agent': + specifier: workspace:^ + version: link:../../packages/agent '@mosaicstack/auth': specifier: workspace:^ version: link:../../packages/auth From 753a360517a1268634a8f5078403d21d35340e23 Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Mon, 13 Jul 2026 00:44:19 +0000 Subject: [PATCH 015/152] fix(#707): scope session GC retention (#720) --- .../command-executor-tess-security.spec.ts | 7 +- .../src/commands/command-executor.service.ts | 11 +- .../src/commands/commands.integration.spec.ts | 10 +- .../gateway/src/gc/session-gc.service.spec.ts | 115 +++++++++++------- apps/gateway/src/gc/session-gc.service.ts | 115 ++---------------- apps/gateway/src/log/cron.service.ts | 16 +-- apps/gateway/src/queue/queue.service.ts | 17 +++ docs/guides/admin-guide.md | 4 + .../tess-m1-sec-006-session-gc-scope.md | 22 ++++ packages/log/src/agent-logs.ts | 21 +++- 10 files changed, 163 insertions(+), 175 deletions(-) create mode 100644 docs/scratchpads/tess-m1-sec-006-session-gc-scope.md diff --git a/apps/gateway/src/commands/command-executor-tess-security.spec.ts b/apps/gateway/src/commands/command-executor-tess-security.spec.ts index 2fe55501..8356ead0 100644 --- a/apps/gateway/src/commands/command-executor-tess-security.spec.ts +++ b/apps/gateway/src/commands/command-executor-tess-security.spec.ts @@ -103,7 +103,10 @@ describe('TESS-M1-SEC-001 command authorization abuse cases', () => { expect(denied.success).toBe(false); expect(denied.message).toContain('approval'); expect(approval).not.toBeNull(); - expect(approved.success).toBe(true); - expect(sessionGc.sweepOrphans).toHaveBeenCalledOnce(); + // A valid durable approval is consumed, but cannot authorize an unimplemented + // global retention operation. Session-scoped cleanup remains lifecycle-only. + expect(approved.success).toBe(false); + expect(approved.message).toContain('Global GC is disabled'); + expect(sessionGc.sweepOrphans).not.toHaveBeenCalled(); }); }); diff --git a/apps/gateway/src/commands/command-executor.service.ts b/apps/gateway/src/commands/command-executor.service.ts index a7a455a2..0003af87 100644 --- a/apps/gateway/src/commands/command-executor.service.ts +++ b/apps/gateway/src/commands/command-executor.service.ts @@ -102,16 +102,15 @@ export class CommandExecutorService { success: true, message: 'Retry last message requested.', }; - case 'gc': { - // Admin-only: system-wide GC sweep across all sessions - const result = await this.sessionGC.sweepOrphans(); + case 'gc': + // Global retention requires a separate, authorized and audited job. + // Session cleanup is performed only through the session lifecycle. return { command: 'gc', - success: true, - message: `GC sweep complete: ${result.orphanedSessions} orphaned sessions cleaned in ${result.duration}ms.`, + success: false, + message: 'Global GC is disabled pending an authorized retention job.', conversationId, }; - } case 'agent': return await this.handleAgent(args ?? null, conversationId, scope); case 'provider': diff --git a/apps/gateway/src/commands/commands.integration.spec.ts b/apps/gateway/src/commands/commands.integration.spec.ts index afa3cfa5..2001bba5 100644 --- a/apps/gateway/src/commands/commands.integration.spec.ts +++ b/apps/gateway/src/commands/commands.integration.spec.ts @@ -177,14 +177,12 @@ describe('CommandExecutorService — integration', () => { expect(result.command).toBe('nonexistent'); }); - // /gc handler calls SessionGCService.sweepOrphans (admin-only, no userId arg) - it('/gc calls SessionGCService.sweepOrphans without arguments', async () => { + it('/gc refuses an unaudited global sweep', async () => { const payload: SlashCommandPayload = { command: 'gc', conversationId }; const result = await executor.execute(payload, userScope); - expect(mockSessionGC.sweepOrphans).toHaveBeenCalledWith(); - expect(result.success).toBe(true); - expect(result.message).toContain('GC sweep complete'); - expect(result.message).toContain('3 orphaned sessions'); + expect(mockSessionGC.sweepOrphans).not.toHaveBeenCalled(); + expect(result.success).toBe(false); + expect(result.message).toContain('disabled pending an authorized retention job'); }); // /system with args calls SystemOverrideService.set diff --git a/apps/gateway/src/gc/session-gc.service.spec.ts b/apps/gateway/src/gc/session-gc.service.spec.ts index d92ac6aa..c6f3948e 100644 --- a/apps/gateway/src/gc/session-gc.service.spec.ts +++ b/apps/gateway/src/gc/session-gc.service.spec.ts @@ -3,6 +3,7 @@ import { Logger } from '@nestjs/common'; import type { QueueHandle } from '@mosaicstack/queue'; import type { LogService } from '@mosaicstack/log'; import { SessionGCService } from './session-gc.service.js'; +import { CommandAuthorizationService } from '../commands/command-authorization.service.js'; type MockRedis = { scan: ReturnType; @@ -12,7 +13,12 @@ type MockRedis = { describe('SessionGCService', () => { let service: SessionGCService; let mockRedis: MockRedis; - let mockLogService: { logs: { promoteToWarm: ReturnType } }; + let mockLogService: { + logs: { + promoteSessionToWarm: ReturnType; + promoteToWarm: ReturnType; + }; + }; /** * Helper: build a scan mock that returns all provided keys in a single @@ -30,6 +36,7 @@ describe('SessionGCService', () => { mockLogService = { logs: { + promoteSessionToWarm: vi.fn().mockResolvedValue(0), promoteToWarm: vi.fn().mockResolvedValue(0), }, }; @@ -59,54 +66,76 @@ describe('SessionGCService', () => { expect(result.cleaned.valkeyKeys).toBeUndefined(); }); + it('escapes glob metacharacters in a session identifier', async () => { + await service.collect('abc*?[tenant]\\escape'); + + expect(mockRedis.scan).toHaveBeenCalledWith( + '0', + 'MATCH', + 'mosaic:session:abc\\*\\?\\[tenant\\]\\\\escape:*', + 'COUNT', + 100, + ); + }); + + it('preserves a valid durable approval after session GC', async () => { + const entries = new Map(); + const redis = { + scan: vi.fn().mockResolvedValue(['0', ['mosaic:session:owned:state']]), + get: vi.fn(async (key: string) => entries.get(key) ?? null), + set: vi.fn(async (key: string, value: string) => entries.set(key, value)), + del: vi.fn(async (...keys: string[]) => { + let deleted = 0; + for (const key of keys) deleted += Number(entries.delete(key)); + return deleted; + }), + }; + const authorization = new CommandAuthorizationService( + { + select: () => ({ + from: () => ({ where: () => ({ limit: async () => [{ role: 'admin' }] }) }), + }), + } as never, + redis, + ); + const command = { + name: 'gc', + description: 'System-wide garbage collection', + aliases: [], + scope: 'admin', + execution: 'socket', + available: true, + } as never; + const payload = { command: 'gc', conversationId: 'owned' }; + const approval = await authorization.createApproval(command, payload, 'admin-1'); + const approvalKey = `tess:command-approval:${approval!.approvalId}`; + const gc = new SessionGCService(redis as never, mockLogService as unknown as LogService); + + await gc.collect('owned'); + + expect(entries.has(approvalKey)).toBe(true); + await expect( + authorization.authorize(command, payload, 'admin-1', approval!.approvalId), + ).resolves.toEqual({ allowed: true }); + }); + it('collect() returns sessionId in result', async () => { const result = await service.collect('test-session-id'); expect(result.sessionId).toBe('test-session-id'); }); - it('fullCollect() deletes all session keys', async () => { - mockRedis.scan = makeScanMock(['mosaic:session:abc:system', 'mosaic:session:xyz:foo']); - const result = await service.fullCollect(); - expect(mockRedis.del).toHaveBeenCalled(); - expect(result.valkeyKeys).toBe(2); + it('collect() demotes logs only for the requested session', async () => { + await service.collect('owned-session'); + + expect(mockLogService.logs.promoteSessionToWarm).toHaveBeenCalledWith( + 'owned-session', + expect.any(Date), + ); + expect(mockLogService.logs.promoteToWarm).not.toHaveBeenCalled(); }); - it('fullCollect() with no keys returns 0 valkeyKeys', async () => { - mockRedis.scan = makeScanMock([]); - const result = await service.fullCollect(); - expect(result.valkeyKeys).toBe(0); - expect(mockRedis.del).not.toHaveBeenCalled(); - }); - - it('fullCollect() returns duration', async () => { - const result = await service.fullCollect(); - expect(result.duration).toBeGreaterThanOrEqual(0); - }); - - it('sweepOrphans() extracts unique session IDs and collects them', async () => { - // First scan call returns the global session list; subsequent calls return - // per-session keys during collect(). - mockRedis.scan = vi - .fn() - .mockResolvedValueOnce([ - '0', - ['mosaic:session:abc:system', 'mosaic:session:abc:messages', 'mosaic:session:xyz:system'], - ]) - // collect('abc') scan - .mockResolvedValueOnce(['0', ['mosaic:session:abc:system', 'mosaic:session:abc:messages']]) - // collect('xyz') scan - .mockResolvedValueOnce(['0', ['mosaic:session:xyz:system']]); - mockRedis.del.mockResolvedValue(1); - - const result = await service.sweepOrphans(); - expect(result.orphanedSessions).toBeGreaterThanOrEqual(0); - expect(result.duration).toBeGreaterThanOrEqual(0); - }); - - it('sweepOrphans() returns empty when no session keys', async () => { - mockRedis.scan = makeScanMock([]); - const result = await service.sweepOrphans(); - expect(result.orphanedSessions).toBe(0); - expect(result.totalCleaned).toHaveLength(0); + it('does not expose automatic global GC entry points', () => { + expect('fullCollect' in service).toBe(false); + expect('sweepOrphans' in service).toBe(false); }); }); diff --git a/apps/gateway/src/gc/session-gc.service.ts b/apps/gateway/src/gc/session-gc.service.ts index 18d1e39d..3146b846 100644 --- a/apps/gateway/src/gc/session-gc.service.ts +++ b/apps/gateway/src/gc/session-gc.service.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable, Logger, type OnModuleInit } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import type { QueueHandle } from '@mosaicstack/queue'; import type { LogService } from '@mosaicstack/log'; import { LOG_SERVICE } from '../log/log.tokens.js'; @@ -13,49 +13,18 @@ export interface GCResult { }; } -export interface GCSweepResult { - orphanedSessions: number; - totalCleaned: GCResult[]; - duration: number; -} - -export interface FullGCResult { - valkeyKeys: number; - logsDemoted: number; - jobsPurged: number; - tempFilesRemoved: number; - duration: number; +/** Escape Redis glob metacharacters so a session identifier is always literal. */ +function escapeRedisGlobLiteral(value: string): string { + return value.replace(/[\\*?\[\]]/g, '\\$&'); } @Injectable() -export class SessionGCService implements OnModuleInit { - private readonly logger = new Logger(SessionGCService.name); - +export class SessionGCService { constructor( @Inject(REDIS) private readonly redis: QueueHandle['redis'], @Inject(LOG_SERVICE) private readonly logService: LogService, ) {} - onModuleInit(): void { - // Fire-and-forget: run full GC asynchronously so it does not block the - // NestJS bootstrap chain. Cold-start GC typically takes 100–500 ms - // depending on Valkey key count; deferring it removes that latency from - // the TTFB of the first HTTP request. - this.fullCollect() - .then((result) => { - this.logger.log( - `Full GC complete: ${result.valkeyKeys} Valkey keys, ` + - `${result.logsDemoted} logs demoted, ` + - `${result.jobsPurged} jobs purged, ` + - `${result.tempFilesRemoved} temp dirs removed ` + - `(${result.duration}ms)`, - ); - }) - .catch((err: unknown) => { - this.logger.error('Cold-start GC failed', err instanceof Error ? err.stack : String(err)); - }); - } - /** * Scan Valkey for all keys matching a pattern using SCAN (non-blocking). * KEYS is avoided because it blocks the Valkey event loop for the full scan @@ -79,86 +48,20 @@ export class SessionGCService implements OnModuleInit { const result: GCResult = { sessionId, cleaned: {} }; // 1. Valkey: delete all session-scoped keys - const pattern = `mosaic:session:${sessionId}:*`; + const pattern = `mosaic:session:${escapeRedisGlobLiteral(sessionId)}:*`; const valkeyKeys = await this.scanKeys(pattern); if (valkeyKeys.length > 0) { await this.redis.del(...valkeyKeys); result.cleaned.valkeyKeys = valkeyKeys.length; } - // 2. PG: demote hot-tier agent_logs for this session to warm - const cutoff = new Date(); // demote all hot logs for this session - const logsDemoted = await this.logService.logs.promoteToWarm(cutoff); + // 2. PG: demote hot-tier agent logs for this session only. + const cutoff = new Date(); + const logsDemoted = await this.logService.logs.promoteSessionToWarm(sessionId, cutoff); if (logsDemoted > 0) { result.cleaned.logsDemoted = logsDemoted; } return result; } - - /** - * Sweep GC — find orphaned artifacts from dead sessions. - * System-wide operation: only call from admin-authorized paths or internal - * scheduled jobs. Individual session cleanup is handled by collect(). - */ - async sweepOrphans(): Promise { - const start = Date.now(); - const cleaned: GCResult[] = []; - - // 1. Find all session-scoped Valkey keys (non-blocking SCAN) - const allSessionKeys = await this.scanKeys('mosaic:session:*'); - - // Extract unique session IDs from keys - const sessionIds = new Set(); - for (const key of allSessionKeys) { - const match = key.match(/^mosaic:session:([^:]+):/); - if (match) sessionIds.add(match[1]!); - } - - // 2. For each session ID, collect stale keys - for (const sessionId of sessionIds) { - const gcResult = await this.collect(sessionId); - if (Object.keys(gcResult.cleaned).length > 0) { - cleaned.push(gcResult); - } - } - - return { - orphanedSessions: cleaned.length, - totalCleaned: cleaned, - duration: Date.now() - start, - }; - } - - /** - * Full GC — aggressive collection for cold start. - * Assumes no sessions survived the restart. - */ - async fullCollect(): Promise { - const start = Date.now(); - - // 1. Valkey: delete ALL session-scoped keys (non-blocking SCAN) - const sessionKeys = await this.scanKeys('mosaic:session:*'); - if (sessionKeys.length > 0) { - await this.redis.del(...sessionKeys); - } - - // 2. NOTE: channel keys are NOT collected on cold start - // (discord/telegram plugins may reconnect and resume) - - // 3. PG: demote stale hot-tier logs older than 24h to warm - const hotCutoff = new Date(Date.now() - 24 * 60 * 60 * 1000); - const logsDemoted = await this.logService.logs.promoteToWarm(hotCutoff); - - // 4. No summarization job purge API available yet - const jobsPurged = 0; - - return { - valkeyKeys: sessionKeys.length, - logsDemoted, - jobsPurged, - tempFilesRemoved: 0, - duration: Date.now() - start, - }; - } } diff --git a/apps/gateway/src/log/cron.service.ts b/apps/gateway/src/log/cron.service.ts index aa9b82fd..f66d39d3 100644 --- a/apps/gateway/src/log/cron.service.ts +++ b/apps/gateway/src/log/cron.service.ts @@ -6,11 +6,10 @@ import { type OnModuleDestroy, } from '@nestjs/common'; import { SummarizationService } from './summarization.service.js'; -import { SessionGCService } from '../gc/session-gc.service.js'; import { QueueService, - QUEUE_SUMMARIZATION, QUEUE_GC, + QUEUE_SUMMARIZATION, QUEUE_TIER_MANAGEMENT, } from '../queue/queue.service.js'; import type { Worker } from 'bullmq'; @@ -23,14 +22,12 @@ export class CronService implements OnModuleInit, OnModuleDestroy { constructor( @Inject(SummarizationService) private readonly summarization: SummarizationService, - @Inject(SessionGCService) private readonly sessionGC: SessionGCService, @Inject(QueueService) private readonly queueService: QueueService, ) {} async onModuleInit(): Promise { const summarizationSchedule = process.env['SUMMARIZATION_CRON'] ?? '0 */6 * * *'; // every 6 hours const tierManagementSchedule = process.env['TIER_MANAGEMENT_CRON'] ?? '0 3 * * *'; // daily at 3am - const gcSchedule = process.env['SESSION_GC_CRON'] ?? '0 4 * * *'; // daily at 4am // M6-003: Summarization repeatable job await this.queueService.addRepeatableJob( @@ -56,15 +53,12 @@ export class CronService implements OnModuleInit, OnModuleDestroy { }); this.registeredWorkers.push(tierWorker); - // M6-004: GC repeatable job - await this.queueService.addRepeatableJob(QUEUE_GC, 'session-gc', {}, gcSchedule); - const gcWorker = this.queueService.registerWorker(QUEUE_GC, async () => { - await this.sessionGC.sweepOrphans(); - }); - this.registeredWorkers.push(gcWorker); + // Retire any repeatable global GC schedule created by older deployments. + // Session cleanup is now triggered only by an authorized session lifecycle operation. + await this.queueService.removeRepeatableJobs(QUEUE_GC, 'session-gc'); this.logger.log( - `BullMQ jobs scheduled: summarization="${summarizationSchedule}", tier="${tierManagementSchedule}", gc="${gcSchedule}"`, + `BullMQ jobs scheduled: summarization="${summarizationSchedule}", tier="${tierManagementSchedule}"`, ); } diff --git a/apps/gateway/src/queue/queue.service.ts b/apps/gateway/src/queue/queue.service.ts index a50a773f..a8424ce8 100644 --- a/apps/gateway/src/queue/queue.service.ts +++ b/apps/gateway/src/queue/queue.service.ts @@ -162,6 +162,23 @@ export class QueueService implements OnModuleInit, OnModuleDestroy { ); } + /** + * Remove every existing repeatable schedule for a job name. This supports + * safe retirement of previously registered system-wide jobs. + */ + async removeRepeatableJobs(queueName: string, jobName: string): Promise { + const queue = this.getQueue(queueName); + const jobs = await queue.getRepeatableJobs(); + const matchingJobs = jobs.filter((job) => job.name === jobName); + await Promise.all(matchingJobs.map((job) => queue.removeRepeatableByKey(job.key))); + if (matchingJobs.length > 0) { + this.logger.log( + `Removed ${matchingJobs.length} repeatable "${jobName}" job(s) from "${queueName}"`, + ); + } + return matchingJobs.length; + } + /** * Register a Worker for the given queue name with error handling and * exponential backoff. diff --git a/docs/guides/admin-guide.md b/docs/guides/admin-guide.md index 2430dbd4..e6a098b4 100644 --- a/docs/guides/admin-guide.md +++ b/docs/guides/admin-guide.md @@ -312,6 +312,10 @@ When `DISCORD_BOT_TOKEN` is configured, `DISCORD_SERVICE_TOKEN`, `DISCORD_SERVIC Inbound Discord messages must originate from an allowed guild, channel, and user, mention the bot, and carry a signed envelope containing the native Discord message ID and a generated correlation ID. The gateway validates the service identity, envelope signature, and allowlists again before dispatching. Replayed Discord message IDs are rejected during the bounded ingress replay window. Durable inbox/idempotency retention is introduced with Tess durable state. +### Session retention and garbage collection + +Session cleanup is scoped to one session identifier and only removes that session's Valkey keys and demotes that session's hot logs. Gateway startup and scheduled jobs do not perform global session cleanup; startup removes legacy repeatable `session-gc` schedules created by older deployments. The `/gc` command is intentionally disabled until a distinct global-retention job supplies explicit authorization and audit evidence. This prevents one tenant or session's cleanup from changing another's retained data. + ### Observability | Variable | Default | Description | diff --git a/docs/scratchpads/tess-m1-sec-006-session-gc-scope.md b/docs/scratchpads/tess-m1-sec-006-session-gc-scope.md new file mode 100644 index 00000000..b87b37d1 --- /dev/null +++ b/docs/scratchpads/tess-m1-sec-006-session-gc-scope.md @@ -0,0 +1,22 @@ +# Scratchpad — TESS-M1-SEC-006 Session GC scope + +- **Task / issue:** TESS-M1-SEC-006 / #707 +- **Branch:** `fix/tess-session-gc-scope` from `origin/main` at `59e49cfd` +- **Objective:** Make session cleanup session-scoped and prevent automatic global retention/GC without an authorized, auditable operation. +- **Scope:** `apps/gateway`, `packages/log`, admin/developer operations documentation. +- **Budget:** Task estimate 18K; no explicit hard cap supplied. +- **Assumption:** No authorized global retention service exists today. Existing full/sweep GC must therefore be disabled from startup and cron paths, while single-session cleanup remains available. + +## Plan + +1. Add failing isolation tests proving single-session cleanup only demotes its own logs and automatic startup/scheduled GC cannot globally delete session data. +2. Add session-scoped log repository retention and make `collect(sessionId)` use it. +3. Remove automatic full/sweep GC invocation; preserve any future global operation behind an explicit authorization/audit seam. +4. Document the operational boundary, run gates, review, and commit without push. + +## Verification evidence + +- Isolation TDD: `pnpm --filter @mosaicstack/gateway test -- session-gc.service.spec.ts commands.integration.spec.ts command-executor-p8012.spec.ts` — 61 passed. +- `pnpm typecheck` — passed. +- `pnpm lint` — passed. +- `pnpm format:check` remains red only on the known pre-existing Tess documentation debt; changed files are Prettier-clean. diff --git a/packages/log/src/agent-logs.ts b/packages/log/src/agent-logs.ts index e303ee48..337f409a 100644 --- a/packages/log/src/agent-logs.ts +++ b/packages/log/src/agent-logs.ts @@ -58,9 +58,28 @@ export function createAgentLogsRepo(db: Db) { return rows[0]; }, + /** + * Transition hot logs for one session to warm tier. Session retention is + * default-deny: no other session's logs can be changed by this operation. + */ + async promoteSessionToWarm(sessionId: string, olderThan: Date): Promise { + const result = await db + .update(agentLogs) + .set({ tier: 'warm', summarizedAt: new Date() }) + .where( + and( + eq(agentLogs.sessionId, sessionId), + eq(agentLogs.tier, 'hot'), + lt(agentLogs.createdAt, olderThan), + ), + ) + .returning(); + return result.length; + }, + /** * Transition hot logs older than the cutoff to warm tier. - * Returns the number of logs transitioned. + * Reserved for a separately authorized global retention job. */ async promoteToWarm(olderThan: Date): Promise { const result = await db From 9a8a572fcf6e6488f3bdc50d44e2ad6e9287035f Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Mon, 13 Jul 2026 00:59:24 +0000 Subject: [PATCH 016/152] feat(tess): add roster-bound tmux fleet provider (#724) --- .../scratchpads/tess-m1-003-fleet-provider.md | 75 ++++ docs/tess/ARCHITECTURE.md | 6 + packages/agent/src/index.ts | 1 + .../src/tmux-fleet-runtime-provider.test.ts | 261 +++++++++++++ .../agent/src/tmux-fleet-runtime-provider.ts | 368 ++++++++++++++++++ .../src/fleet/tmux-runtime-transport.test.ts | 160 ++++++++ .../src/fleet/tmux-runtime-transport.ts | 200 ++++++++++ packages/mosaic/src/index.ts | 2 + 8 files changed, 1073 insertions(+) create mode 100644 docs/scratchpads/tess-m1-003-fleet-provider.md create mode 100644 packages/agent/src/tmux-fleet-runtime-provider.test.ts create mode 100644 packages/agent/src/tmux-fleet-runtime-provider.ts create mode 100644 packages/mosaic/src/fleet/tmux-runtime-transport.test.ts create mode 100644 packages/mosaic/src/fleet/tmux-runtime-transport.ts diff --git a/docs/scratchpads/tess-m1-003-fleet-provider.md b/docs/scratchpads/tess-m1-003-fleet-provider.md new file mode 100644 index 00000000..25dae597 --- /dev/null +++ b/docs/scratchpads/tess-m1-003-fleet-provider.md @@ -0,0 +1,75 @@ +# TESS-M1-003 — Fleet/tmux Runtime Provider + +- **Task:** `TESS-M1-003` +- **Issue:** `#707` +- **Branch:** `feat/tess-fleet-provider` +- **PR target:** `main` +- **Budget:** 30K estimate from `docs/tess/TASKS.md`; work remains scoped to `packages/mosaic`, `packages/agent`, and Tess architecture/scratchpad documentation. + +## Objective + +Implement `TESS-FLT-001` as a tmux/fleet `AgentRuntimeProvider` on the M1 registry contract. Operations must use roster-bound, exact tmux targets; fail closed on missing or mismatched peer identity; allow read-only attach only; use exact-target message delivery and termination; and expose no arbitrary shell, socket, or fuzzy session targeting. + +## Requirements and Security Invariants + +- `TESS-FLT-001`: fleet roster/status/heartbeat inspection, message delivery, session hierarchy, safe attach, controlled termination/recovery. +- `TESS-ARP-001` / `TESS-TRN-001`: conform to the runtime provider contract and advertise only implemented capabilities. +- TM-10: exact target/socket binding and peer identity verification; wrong socket, target, or identity must refuse delivery/attach. +- Gateway supplies immutable actor/tenant/channel/correlation scope and consumes durable termination approvals before provider invocation. +- `control` attach is denied. A provider attach is a scoped read-only logical handle; it never opens a server-side interactive terminal or exposes a raw tmux target. +- M2 will make durable attachment/session state available. This M1 provider does not claim durable attachment handles or durable message idempotency. + +## Plan + +1. Add security TDD cases first for fuzzy/unrostered targets, incorrect socket/identity, control attach, attachment scope replay, and termination exact targeting. +2. Add Mosaic fleet primitives for exact target validation and identity probing from the roster/socket. +3. Implement and export the fleet/tmux provider in `@mosaicstack/agent`, using only those primitives and a command-runner seam. +4. Update Tess architecture docs and this evidence log. +5. Run focused tests, independent code/security reviews, cold-cache forced gates, then create a PR with `Refs #707`. + +## Branch/Base Note + +The orchestrator corrected the initial brief: `feat/tess-interaction-agent` is a stale planning branch. This branch was correctly created from `origin/main` at `e92186d7` (including M1-002) and will open a clean PR to `main` with `Refs #707`. + +## Progress + +- [x] Read PRD, Tess architecture, threat model, runtime contract, registry, fleet command primitives, task record, and issue #707. +- [x] Created clean worktree from `origin/main` at `e92186d7`. +- [x] Security TDD tests written before implementation; initially failed because the transport/provider modules did not exist. +- [x] Fleet transport and capability-limited provider implemented; read/list/attach and direct Tess write/control default to deny pending scope-aware authority adapters. +- [x] Focused typecheck, lint, formatting, and abuse tests passed (transport: 7; provider: 14). +- [x] Cold-cache forced workspace gates passed after reinstall; final workspace gates also passed. +- [x] Independent Codex code and security reviews passed with no findings. + +## Verification Evidence + +- `pnpm --filter @mosaicstack/mosaic typecheck` — pass. +- `pnpm --filter @mosaicstack/agent typecheck` — pass. +- `pnpm --filter @mosaicstack/mosaic lint` — pass. +- `pnpm --filter @mosaicstack/agent lint` — pass. +- `pnpm --filter @mosaicstack/mosaic test -- src/fleet/tmux-runtime-transport.test.ts` — 7 passed. +- `pnpm --filter @mosaicstack/agent test -- src/tmux-fleet-runtime-provider.test.ts` — 14 passed. +- The worktree dependency install must use `--store-dir /home/jarvis/.local/share/pnpm/store` because machine pnpm config points to an unreadable root-owned store. This is a local tool configuration issue, not an application workaround. + +## Documentation Checklist + +- [x] Canonical PRD and Tess architecture are current for this internal provider; no HTTP/API endpoint changed. +- [x] `docs/tess/ARCHITECTURE.md` documents the internal fleet target/identity, read-only attach, and Mos authority boundary. +- [x] No user/admin/API sitemap updates are applicable because no user-facing or HTTP API surface was introduced. + +## Acceptance Criteria to Evidence + +| Acceptance criterion | Evidence target | +| --- | --- | +| Only roster-bound exact targets are operated | Provider abuse tests prove unknown/prefix targets yield typed denial and runner is untouched. | +| Socket and peer runtime identity are exact | Provider abuse tests prove wrong socket/no pane/runtime drift deny before send/attach/terminate. | +| Message sends are capability-safe and exact | Tests assert the maintained sender receives only the configured socket and exact roster session. | +| Fleet reads cannot cross an authority boundary | Tests prove list and read attach default-deny without a scope-aware read authority; per-target authority filtering is enforced. | +| Attach cannot grant control or replay across scope | Tests deny `control`; attachment handles are random, scoped, short-lived, single-use for detach, and pruned after expiry. | +| Termination is exact and caller cannot select arbitrary target | Tests assert roster/identity validation precedes exact `tmux kill-session -t =`. Gateway tests from M1-002 cover approval consumption. | +| Documentation describes the boundary | `docs/tess/ARCHITECTURE.md` documents fleet capability, scope, and non-goals. | + +## Risks / Decisions + +- Runtime process identity can only be verified from the declared fleet roster and exact tmux pane command in M1. The tmux server itself is a trusted local transport boundary; stronger authenticated peer attestations are deferred to the Matrix/native provider. +- The current roster schema does not encode per-agent tenant/owner. Scope-aware read/write authority adapters remain the integration point for gateway/Mos ownership policy; provider scope is bound to logical attachment handles to prevent replay. diff --git a/docs/tess/ARCHITECTURE.md b/docs/tess/ARCHITECTURE.md index 36472c44..bd8e647e 100644 --- a/docs/tess/ARCHITECTURE.md +++ b/docs/tess/ARCHITECTURE.md @@ -64,6 +64,12 @@ Valkey may hold ephemeral coordination state; PostgreSQL is canonical for durabl - **Forward:** Matrix/native Mosaic provider using authenticated identity, idempotent transaction IDs, replay cursors, and the same contract suite. - Discord/CLI never call tmux or Matrix directly. +### Fleet/tmux Provider Boundary + +`TmuxFleetRuntimeProvider` supports only rostered fleet peers. Its transport resolves the configured roster socket itself and verifies the exact `=:0.0` pane and declared runtime command before every attach, message, or termination operation. Prefixes, unrostered session IDs, unavailable sockets, dead panes, and runtime identity mismatches fail closed; callers cannot supply a socket or raw tmux target. + +The provider advertises list, tree, read-only attach, send, and terminate. List/tree/health and read attach all default-deny until a scope-aware read authority permits the operation and exact peer. Attach produces a short-lived handle bound to the immutable actor, tenant, channel, and correlation scope; it never opens a server-side terminal and rejects `control` mode. Fleet stream support is intentionally absent. Tess has no direct write/control authority: send and terminate default-deny until a Mos authority adapter explicitly allows the exact session and immutable scope. The gateway registry remains the audit boundary for every requested, denied, and successful provider operation, and still consumes the exact-action termination approval before the provider is invoked. + ## Plugin Families 1. Channel: Discord now; other channels later. diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 409ae61b..c4f2fc80 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -1,3 +1,4 @@ export const VERSION = '0.0.0'; export * from './runtime-provider-registry.js'; +export * from './tmux-fleet-runtime-provider.js'; diff --git a/packages/agent/src/tmux-fleet-runtime-provider.test.ts b/packages/agent/src/tmux-fleet-runtime-provider.test.ts new file mode 100644 index 00000000..84f01466 --- /dev/null +++ b/packages/agent/src/tmux-fleet-runtime-provider.test.ts @@ -0,0 +1,261 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { RuntimeScope } from '@mosaicstack/types'; +import { + type FleetReadAuthority, + type FleetWriteAuthority, + TmuxFleetRuntimeProvider, +} from './tmux-fleet-runtime-provider.js'; +import type { + FleetRuntimeProviderError, + FleetRuntimeTarget, + FleetRuntimeTransport, +} from './tmux-fleet-runtime-provider.js'; + +const scope: RuntimeScope = { + actorId: 'operator-1', + tenantId: 'tenant-a', + channelId: 'discord-1', + correlationId: 'corr-1', +}; + +const target: FleetRuntimeTarget = { + id: 'coder0', + runtimeId: 'codex', + socketName: 'tess-fleet', +}; + +function transport(): FleetRuntimeTransport { + return { + verifySession: vi.fn(async (): Promise => target), + listSessions: vi.fn(async (): Promise => [target]), + sendMessage: vi.fn(async (): Promise => undefined), + terminate: vi.fn(async (): Promise => undefined), + }; +} + +function readAuthority(): FleetReadAuthority { + return { canRead: vi.fn(async (): Promise => true) }; +} + +describe('TmuxFleetRuntimeProvider security policy', (): void => { + it('advertises only fleet operations it can safely implement', async (): Promise => { + const provider = new TmuxFleetRuntimeProvider({ transport: transport() }); + + await expect(provider.capabilities(scope)).resolves.toEqual({ + supported: [ + 'session.list', + 'session.tree', + 'session.send', + 'session.attach', + 'session.terminate', + ], + }); + }); + + it('rejects control attach without consulting the tmux transport', async (): Promise => { + const fleet = transport(); + const provider = new TmuxFleetRuntimeProvider({ transport: fleet }); + + await expect(provider.attach('coder0', 'control', scope)).rejects.toMatchObject({ + code: 'forbidden', + } satisfies Partial); + expect(fleet.verifySession).not.toHaveBeenCalled(); + }); + + it('denies fleet listing without an exact-scope read authority decision', async (): Promise => { + const fleet = transport(); + const provider = new TmuxFleetRuntimeProvider({ transport: fleet }); + + await expect(provider.listSessions(scope)).rejects.toMatchObject({ + code: 'forbidden', + } satisfies Partial); + expect(fleet.listSessions).not.toHaveBeenCalled(); + }); + + it('denies read attachment without an exact-scope read authority decision', async (): Promise => { + const fleet = transport(); + const provider = new TmuxFleetRuntimeProvider({ transport: fleet }); + + await expect(provider.attach('coder0', 'read', scope)).rejects.toMatchObject({ + code: 'forbidden', + } satisfies Partial); + expect(fleet.verifySession).not.toHaveBeenCalled(); + }); + + it('creates a read-only attachment only after exact target verification', async (): Promise => { + const fleet = transport(); + const provider = new TmuxFleetRuntimeProvider({ + transport: fleet, + readAuthority: readAuthority(), + attachmentIdFactory: (): string => 'attachment-1', + now: (): Date => new Date('2026-07-12T00:00:00.000Z'), + }); + + await expect(provider.attach('coder0', 'read', scope)).resolves.toEqual({ + attachmentId: 'attachment-1', + sessionId: 'coder0', + mode: 'read', + expiresAt: '2026-07-12T00:05:00.000Z', + }); + expect(fleet.verifySession).toHaveBeenCalledWith('coder0'); + }); + + it('denies attachment-handle replay from another immutable actor scope', async (): Promise => { + const fleet = transport(); + const provider = new TmuxFleetRuntimeProvider({ + transport: fleet, + readAuthority: readAuthority(), + attachmentIdFactory: (): string => 'attachment-1', + now: (): Date => new Date('2026-07-12T00:00:00.000Z'), + }); + await provider.attach('coder0', 'read', scope); + + await expect( + provider.detach('attachment-1', { ...scope, actorId: 'operator-2' }), + ).rejects.toMatchObject({ + code: 'forbidden', + } satisfies Partial); + }); + + it('keeps attachment scope immutable after caller-side scope mutation', async (): Promise => { + const mutableScope = { ...scope }; + const provider = new TmuxFleetRuntimeProvider({ + transport: transport(), + readAuthority: readAuthority(), + attachmentIdFactory: (): string => 'attachment-1', + now: (): Date => new Date('2026-07-12T00:00:00.000Z'), + }); + await provider.attach('coder0', 'read', mutableScope); + mutableScope.actorId = 'operator-2'; + + await expect(provider.detach('attachment-1', scope)).resolves.toBeUndefined(); + }); + + it('denies expired attachment handles and removes them', async (): Promise => { + let now = new Date('2026-07-12T00:00:00.000Z'); + const provider = new TmuxFleetRuntimeProvider({ + transport: transport(), + readAuthority: readAuthority(), + attachmentIdFactory: (): string => 'attachment-1', + now: (): Date => now, + }); + await provider.attach('coder0', 'read', scope); + now = new Date('2026-07-12T00:05:00.001Z'); + + await expect(provider.detach('attachment-1', scope)).rejects.toMatchObject({ + code: 'forbidden', + } satisfies Partial); + await expect(provider.detach('attachment-1', scope)).rejects.toMatchObject({ + code: 'not_found', + } satisfies Partial); + }); + + it('prunes expired attachment handles before creating a new handle', async (): Promise => { + let now = new Date('2026-07-12T00:00:00.000Z'); + let attachmentSequence = 0; + const provider = new TmuxFleetRuntimeProvider({ + transport: transport(), + readAuthority: readAuthority(), + attachmentIdFactory: (): string => `attachment-${++attachmentSequence}`, + now: (): Date => now, + }); + await provider.attach('coder0', 'read', scope); + now = new Date('2026-07-12T00:05:00.001Z'); + await provider.attach('coder0', 'read', scope); + + await expect(provider.detach('attachment-1', scope)).rejects.toMatchObject({ + code: 'not_found', + } satisfies Partial); + }); + + it('rejects an empty message before consulting write authority or tmux', async (): Promise => { + const fleet = transport(); + const writeAuthority: FleetWriteAuthority = { + canWrite: vi.fn(async (): Promise => true), + assertAuthorized: vi.fn(async (): Promise => undefined), + }; + const provider = new TmuxFleetRuntimeProvider({ transport: fleet, writeAuthority }); + + await expect( + provider.sendMessage('coder0', { content: '', idempotencyKey: 'message-1' }, scope), + ).rejects.toMatchObject({ + code: 'invalid_request', + } satisfies Partial); + expect(writeAuthority.canWrite).not.toHaveBeenCalled(); + expect(writeAuthority.assertAuthorized).not.toHaveBeenCalled(); + expect(fleet.verifySession).not.toHaveBeenCalled(); + expect(fleet.sendMessage).not.toHaveBeenCalled(); + }); + + it('denies fleet writes by default before probing the tmux transport', async (): Promise => { + const fleet = transport(); + const provider = new TmuxFleetRuntimeProvider({ transport: fleet }); + + await expect( + provider.sendMessage('coder0', { content: 'hello', idempotencyKey: 'message-1' }, scope), + ).rejects.toMatchObject({ code: 'forbidden' } satisfies Partial); + await expect(provider.terminate('coder0', 'approval-1', scope)).rejects.toMatchObject({ + code: 'forbidden', + } satisfies Partial); + expect(fleet.verifySession).not.toHaveBeenCalled(); + expect(fleet.sendMessage).not.toHaveBeenCalled(); + expect(fleet.terminate).not.toHaveBeenCalled(); + }); + + it('rejects an unverified target before consulting Mos write authority', async (): Promise => { + const fleet = transport(); + fleet.verifySession = vi.fn(async (): Promise => { + throw new Error('target identity mismatch'); + }); + const writeAuthority: FleetWriteAuthority = { + canWrite: vi.fn(async (): Promise => true), + assertAuthorized: vi.fn(async (): Promise => undefined), + }; + const provider = new TmuxFleetRuntimeProvider({ transport: fleet, writeAuthority }); + + await expect( + provider.sendMessage('coder', { content: 'hello', idempotencyKey: 'message-1' }, scope), + ).rejects.toThrow('target identity mismatch'); + expect(writeAuthority.assertAuthorized).not.toHaveBeenCalled(); + expect(fleet.sendMessage).not.toHaveBeenCalled(); + }); + + it('passes an exact session ID to the fleet transport only through authorized Mos writes', async (): Promise => { + const fleet = transport(); + const writeAuthority: FleetWriteAuthority = { + canWrite: vi.fn(async (): Promise => true), + assertAuthorized: vi.fn(async (): Promise => undefined), + }; + const provider = new TmuxFleetRuntimeProvider({ + transport: fleet, + sourceLabel: 'tess', + writeAuthority, + }); + + await provider.sendMessage('coder0', { content: 'hello', idempotencyKey: 'message-1' }, scope); + await provider.terminate('coder0', 'approval-1', scope); + + expect(writeAuthority.assertAuthorized).toHaveBeenCalledWith({ + operation: 'session.send', + sessionId: 'coder0', + scope, + }); + expect(writeAuthority.assertAuthorized).toHaveBeenCalledWith({ + operation: 'session.terminate', + sessionId: 'coder0', + scope, + approvalRef: 'approval-1', + }); + expect(fleet.sendMessage).toHaveBeenCalledWith('coder0', 'hello', 'tess'); + expect(fleet.terminate).toHaveBeenCalledWith('coder0'); + }); + + it('fails closed when consumers ask for session streaming', async (): Promise => { + const provider = new TmuxFleetRuntimeProvider({ transport: transport() }); + const stream = provider.streamSession('coder0', undefined, scope)[Symbol.asyncIterator](); + + await expect(stream.next()).rejects.toMatchObject({ + code: 'capability_unsupported', + } satisfies Partial); + }); +}); diff --git a/packages/agent/src/tmux-fleet-runtime-provider.ts b/packages/agent/src/tmux-fleet-runtime-provider.ts new file mode 100644 index 00000000..ed36795b --- /dev/null +++ b/packages/agent/src/tmux-fleet-runtime-provider.ts @@ -0,0 +1,368 @@ +import { randomUUID } from 'node:crypto'; +import type { + AgentRuntimeProvider, + RuntimeAttachHandle, + RuntimeAttachMode, + RuntimeCapabilitySet, + RuntimeHealth, + RuntimeMessage, + RuntimeScope, + RuntimeSession, + RuntimeSessionTree, + RuntimeStreamEvent, +} from '@mosaicstack/types'; +const FLEET_PROVIDER_ID = 'fleet.tmux'; +const ATTACHMENT_TTL_MS = 5 * 60 * 1_000; + +export type FleetRuntimeProviderErrorCode = + | 'capability_unsupported' + | 'forbidden' + | 'invalid_request' + | 'not_found'; + +/** A roster-bound target verified by the concrete fleet transport. */ +export interface FleetRuntimeTarget { + id: string; + runtimeId: string; + socketName: string; +} + +/** + * Narrow transport boundary implemented by the Mosaic tmux adapter. Keeping it + * here prevents the runtime package from depending on the Mosaic CLI package. + */ +export interface FleetRuntimeTransport { + verifySession(sessionId: string): Promise; + listSessions(): Promise; + sendMessage(sessionId: string, message: string, sourceLabel: string): Promise; + terminate(sessionId: string): Promise; +} + +export type FleetReadOperation = + | 'runtime.health' + | 'session.list' + | 'session.tree' + | 'session.attach'; + +export interface FleetReadAuthorization { + operation: FleetReadOperation; + scope: RuntimeScope; + sessionId?: string; +} + +/** Authorization for fleet inspection and read-only attachments. */ +export interface FleetReadAuthority { + canRead(authorization: FleetReadAuthorization): Promise; +} + +export interface FleetWriteAuthorization { + operation: 'session.send' | 'session.terminate'; + sessionId: string; + scope: RuntimeScope; + /** Present only for terminate; authority adapters bind it to the exact action. */ + approvalRef?: string; +} + +/** + * Mos is the only authority that may permit Tess write/control requests to a + * fleet peer. Gateway records the request and denial/success around provider + * invocation; the default authority prevents direct Tess writes by design. + */ +export interface FleetWriteAuthority { + /** Non-consuming preflight used before probing the fleet transport. */ + canWrite(authorization: FleetWriteAuthorization): Promise; + /** Final exact-target authorization; may consume a Mos grant. */ + assertAuthorized(authorization: FleetWriteAuthorization): Promise; +} + +export interface TmuxFleetRuntimeProviderOptions { + transport: FleetRuntimeTransport; + readAuthority?: FleetReadAuthority; + writeAuthority?: FleetWriteAuthority; + sourceLabel?: string; + attachmentIdFactory?: () => string; + now?: () => Date; + attachmentTtlMs?: number; +} + +interface FleetAttachment { + sessionId: string; + scope: RuntimeScope; + expiresAtMs: number; +} + +/** A typed, fail-closed provider error that callers can normalize at the boundary. */ +export class FleetRuntimeProviderError extends Error { + constructor( + readonly code: FleetRuntimeProviderErrorCode, + message: string, + ) { + super(message); + this.name = FleetRuntimeProviderError.name; + } +} + +class DenyFleetReadAuthority implements FleetReadAuthority { + async canRead(_authorization: FleetReadAuthorization): Promise { + return false; + } +} + +class DenyFleetWriteAuthority implements FleetWriteAuthority { + async canWrite(_authorization: FleetWriteAuthorization): Promise { + return false; + } + + async assertAuthorized(_authorization: FleetWriteAuthorization): Promise { + throw new FleetRuntimeProviderError( + 'forbidden', + 'Fleet writes require an explicit Mos authority decision', + ); + } +} + +/** + * A capability-limited provider for rostered local fleet peers. It never + * permits raw tmux socket/target selection, interactive control attach, or + * direct Tess writes; all side effects pass through exact transport checks. + */ +export class TmuxFleetRuntimeProvider implements AgentRuntimeProvider { + readonly id = FLEET_PROVIDER_ID; + private readonly attachments = new Map(); + private readonly readAuthority: FleetReadAuthority; + private readonly writeAuthority: FleetWriteAuthority; + private readonly sourceLabel: string; + private readonly attachmentIdFactory: () => string; + private readonly now: () => Date; + private readonly attachmentTtlMs: number; + + constructor(private readonly options: TmuxFleetRuntimeProviderOptions) { + this.readAuthority = options.readAuthority ?? new DenyFleetReadAuthority(); + this.writeAuthority = options.writeAuthority ?? new DenyFleetWriteAuthority(); + this.sourceLabel = options.sourceLabel ?? 'tess'; + this.attachmentIdFactory = options.attachmentIdFactory ?? randomUUID; + this.now = options.now ?? (() => new Date()); + this.attachmentTtlMs = options.attachmentTtlMs ?? ATTACHMENT_TTL_MS; + } + + async capabilities(_scope: RuntimeScope): Promise { + return { + supported: [ + 'session.list', + 'session.tree', + 'session.send', + 'session.attach', + 'session.terminate', + ], + }; + } + + async health(scope: RuntimeScope): Promise { + const targets = await this.readTargets('runtime.health', scope); + return { + status: targets.length > 0 ? 'healthy' : 'down', + checkedAt: this.now().toISOString(), + detail: + targets.length > 0 + ? 'Authorized rostered fleet peers are reachable' + : 'No authorized rostered fleet peers are reachable', + }; + } + + async listSessions(scope: RuntimeScope): Promise { + const targets = await this.readTargets('session.list', scope); + return this.toRuntimeSessions(targets); + } + + async getSessionTree(scope: RuntimeScope): Promise { + const targets = await this.readTargets('session.tree', scope); + return this.toRuntimeSessions(targets).map( + (session): RuntimeSessionTree => ({ + session, + children: [], + }), + ); + } + + async *streamSession( + _sessionId: string, + _cursor: string | undefined, + _scope: RuntimeScope, + ): AsyncIterable { + throw new FleetRuntimeProviderError( + 'capability_unsupported', + 'Fleet session streaming is not supported by the tmux provider', + ); + } + + async sendMessage( + sessionId: string, + message: RuntimeMessage, + scope: RuntimeScope, + ): Promise { + if (message.content.length === 0) { + throw new FleetRuntimeProviderError('invalid_request', 'Fleet message content is required'); + } + await this.assertWritePermitted('session.send', sessionId, scope); + const target = await this.options.transport.verifySession(sessionId); + await this.assertWriteAuthorized('session.send', target.id, scope); + await this.options.transport.sendMessage(target.id, message.content, this.sourceLabel); + } + + async attach( + sessionId: string, + mode: RuntimeAttachMode, + scope: RuntimeScope, + ): Promise { + if (mode !== 'read') { + throw new FleetRuntimeProviderError('forbidden', 'Fleet control attach is not permitted'); + } + await this.assertReadAuthorized('session.attach', sessionId, scope); + const target = await this.options.transport.verifySession(sessionId); + await this.assertReadAuthorized('session.attach', target.id, scope); + const attachmentId = this.attachmentIdFactory(); + const nowMs = this.now().getTime(); + this.pruneExpiredAttachments(nowMs); + const expiresAtMs = nowMs + this.attachmentTtlMs; + this.attachments.set(attachmentId, { + sessionId: target.id, + scope: snapshotScope(scope), + expiresAtMs, + }); + return { + attachmentId, + sessionId: target.id, + mode, + expiresAt: new Date(expiresAtMs).toISOString(), + }; + } + + async detach(attachmentId: string, scope: RuntimeScope): Promise { + const attachment = this.attachments.get(attachmentId); + if (!attachment) { + throw new FleetRuntimeProviderError('not_found', 'Fleet attachment is not active'); + } + if (this.now().getTime() >= attachment.expiresAtMs) { + this.attachments.delete(attachmentId); + throw new FleetRuntimeProviderError('forbidden', 'Fleet attachment has expired'); + } + if (!sameScope(attachment.scope, scope)) { + throw new FleetRuntimeProviderError('forbidden', 'Fleet attachment scope does not match'); + } + this.attachments.delete(attachmentId); + } + + async terminate(sessionId: string, approvalRef: string, scope: RuntimeScope): Promise { + if (approvalRef.trim().length === 0) { + throw new FleetRuntimeProviderError( + 'invalid_request', + 'Fleet termination approval is required', + ); + } + await this.assertWritePermitted('session.terminate', sessionId, scope, approvalRef); + const target = await this.options.transport.verifySession(sessionId); + await this.assertWriteAuthorized('session.terminate', target.id, scope, approvalRef); + await this.options.transport.terminate(target.id); + } + + private async readTargets( + operation: FleetReadOperation, + scope: RuntimeScope, + ): Promise { + await this.assertReadAuthorized(operation, undefined, scope); + const targets = await this.options.transport.listSessions(); + const authorization = await Promise.all( + targets.map( + async (target): Promise => + this.readAuthority.canRead({ operation, sessionId: target.id, scope }), + ), + ); + return targets.filter((_target, index): boolean => authorization[index] === true); + } + + private toRuntimeSessions(targets: FleetRuntimeTarget[]): RuntimeSession[] { + const timestamp = this.now().toISOString(); + return targets.map( + (target): RuntimeSession => ({ + id: target.id, + providerId: this.id, + runtimeId: target.runtimeId, + state: 'active', + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + } + + private async assertReadAuthorized( + operation: FleetReadOperation, + sessionId: string | undefined, + scope: RuntimeScope, + ): Promise { + const allowed = await this.readAuthority.canRead({ + operation, + scope, + ...(sessionId ? { sessionId } : {}), + }); + if (!allowed) { + throw new FleetRuntimeProviderError('forbidden', 'Fleet read is not authorized'); + } + } + + private pruneExpiredAttachments(nowMs: number): void { + for (const [attachmentId, attachment] of this.attachments) { + if (attachment.expiresAtMs <= nowMs) { + this.attachments.delete(attachmentId); + } + } + } + + private async assertWritePermitted( + operation: FleetWriteAuthorization['operation'], + sessionId: string, + scope: RuntimeScope, + approvalRef?: string, + ): Promise { + const permitted = await this.writeAuthority.canWrite({ + operation, + sessionId, + scope, + ...(approvalRef ? { approvalRef } : {}), + }); + if (!permitted) { + throw new FleetRuntimeProviderError('forbidden', 'Fleet write is not authorized'); + } + } + + private async assertWriteAuthorized( + operation: FleetWriteAuthorization['operation'], + sessionId: string, + scope: RuntimeScope, + approvalRef?: string, + ): Promise { + await this.writeAuthority.assertAuthorized({ + operation, + sessionId, + scope, + ...(approvalRef ? { approvalRef } : {}), + }); + } +} + +function snapshotScope(scope: RuntimeScope): RuntimeScope { + return Object.freeze({ + actorId: scope.actorId, + tenantId: scope.tenantId, + channelId: scope.channelId, + correlationId: scope.correlationId, + }); +} + +function sameScope(left: RuntimeScope, right: RuntimeScope): boolean { + return ( + left.actorId === right.actorId && + left.tenantId === right.tenantId && + left.channelId === right.channelId && + left.correlationId === right.correlationId + ); +} diff --git a/packages/mosaic/src/fleet/tmux-runtime-transport.test.ts b/packages/mosaic/src/fleet/tmux-runtime-transport.test.ts new file mode 100644 index 00000000..f699e49c --- /dev/null +++ b/packages/mosaic/src/fleet/tmux-runtime-transport.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { CommandResult, CommandRunner, FleetRoster } from '../commands/fleet.js'; +import { FleetTmuxRuntimeTransport } from './tmux-runtime-transport.js'; +import type { FleetRuntimeTransportError } from './tmux-runtime-transport.js'; + +const roster: FleetRoster = { + version: 1, + transport: 'tmux', + tmux: { socketName: 'tess-fleet', holderSession: '_holder' }, + defaults: { workingDirectory: '~/src' }, + runtimes: { codex: { resetCommand: '/clear' } }, + agents: [{ name: 'coder0', runtime: 'codex', className: 'code' }], +}; + +function commandResult(stdout = '', exitCode = 0, stderr = ''): CommandResult { + return { stdout, stderr, exitCode }; +} + +describe('FleetTmuxRuntimeTransport security boundary', (): void => { + it('rejects a prefix target before it invokes tmux', async (): Promise => { + const runner = vi.fn(async (): Promise => commandResult()); + const transport = new FleetTmuxRuntimeTransport({ + rosterLoader: async (): Promise => roster, + runner, + mosaicHome: '/mosaic', + }); + + await expect(transport.verifySession('coder')).rejects.toMatchObject({ + code: 'not_found', + } satisfies Partial); + expect(runner).not.toHaveBeenCalled(); + }); + + it('uses the roster socket and exact pane target while verifying peer identity', async (): Promise => { + const runner = vi.fn( + async (): Promise => commandResult('111 codex 0 0 0 0\n'), + ); + const transport = new FleetTmuxRuntimeTransport({ + rosterLoader: async (): Promise => roster, + runner, + mosaicHome: '/mosaic', + }); + + await expect(transport.verifySession('coder0')).resolves.toEqual({ + id: 'coder0', + runtimeId: 'codex', + socketName: 'tess-fleet', + }); + expect(runner).toHaveBeenCalledWith('tmux', [ + '-L', + 'tess-fleet', + 'list-panes', + '-t', + '=coder0:0.0', + '-F', + '#{pane_pid} #{pane_current_command} #{pane_dead} #{pane_activity} #{window_activity} #{session_activity}', + ]); + }); + + it('denies a live pane whose runtime does not match the roster identity', async (): Promise => { + const runner = vi.fn( + async (): Promise => commandResult('111 python3 0 0 0 0\n'), + ); + const transport = new FleetTmuxRuntimeTransport({ + rosterLoader: async (): Promise => roster, + runner, + mosaicHome: '/mosaic', + }); + + await expect(transport.verifySession('coder0')).rejects.toMatchObject({ + code: 'forbidden', + } satisfies Partial); + expect(runner).toHaveBeenCalledTimes(1); + }); + + it('surfaces a runtime identity mismatch instead of hiding it from fleet status', async (): Promise => { + const rosterWithMismatchedPeer: FleetRoster = { + ...roster, + agents: [...roster.agents, { name: 'coder1', runtime: 'codex', className: 'code' }], + }; + const runner = vi.fn( + async (_command: string, args: string[]): Promise => + commandResult( + args.includes('=coder1:0.0') ? '222 python3 0 0 0 0\n' : '111 codex 0 0 0 0\n', + ), + ); + const transport = new FleetTmuxRuntimeTransport({ + rosterLoader: async (): Promise => rosterWithMismatchedPeer, + runner, + mosaicHome: '/mosaic', + }); + + await expect(transport.listSessions()).rejects.toMatchObject({ + code: 'forbidden', + } satisfies Partial); + }); + + it('denies sending when the roster socket does not contain the exact target', async (): Promise => { + const runner = vi.fn( + async (): Promise => commandResult('', 1, "can't find session: coder0"), + ); + const transport = new FleetTmuxRuntimeTransport({ + rosterLoader: async (): Promise => roster, + runner, + mosaicHome: '/mosaic', + }); + + await expect(transport.sendMessage('coder0', 'hello', 'tess')).rejects.toMatchObject({ + code: 'unavailable', + } satisfies Partial); + expect(runner).toHaveBeenCalledTimes(1); + }); + + it('sends only through the maintained sender after exact target and identity verification', async (): Promise => { + const runner = vi + .fn() + .mockResolvedValueOnce(commandResult('111 codex 0 0 0 0\n')) + .mockResolvedValueOnce(commandResult()); + const transport = new FleetTmuxRuntimeTransport({ + rosterLoader: async (): Promise => roster, + runner, + mosaicHome: '/mosaic', + }); + + await transport.sendMessage('coder0', 'hello', 'tess'); + + expect(runner).toHaveBeenNthCalledWith(2, '/mosaic/tools/tmux/agent-send.sh', [ + '-L', + 'tess-fleet', + '-S', + 'tess', + '-s', + 'coder0', + '-m', + 'hello', + ]); + }); + + it('terminates only the exact roster target after identity verification', async (): Promise => { + const runner = vi + .fn() + .mockResolvedValueOnce(commandResult('111 codex 0 0 0 0\n')) + .mockResolvedValueOnce(commandResult()); + const transport = new FleetTmuxRuntimeTransport({ + rosterLoader: async (): Promise => roster, + runner, + mosaicHome: '/mosaic', + }); + + await transport.terminate('coder0'); + + expect(runner).toHaveBeenNthCalledWith(2, 'tmux', [ + '-L', + 'tess-fleet', + 'kill-session', + '-t', + '=coder0', + ]); + }); +}); diff --git a/packages/mosaic/src/fleet/tmux-runtime-transport.ts b/packages/mosaic/src/fleet/tmux-runtime-transport.ts new file mode 100644 index 00000000..726ad8bb --- /dev/null +++ b/packages/mosaic/src/fleet/tmux-runtime-transport.ts @@ -0,0 +1,200 @@ +import { + buildAgentSendCommand, + buildTmuxListPanesCommand, + getRosterAgent, + loadFleetRoster, + parseTmuxListPanes, + resolveFleetPaths, + RUNTIME_ACCEPTABLE_COMMANDS, + socketArgs, + type CommandResult, + type CommandRunner, + type FleetRoster, +} from '../commands/fleet.js'; + +export type FleetRuntimeTransportErrorCode = + | 'forbidden' + | 'invalid_request' + | 'not_found' + | 'unavailable'; + +/** A roster-bound, verified tmux target. It never accepts a caller-selected socket. */ +export interface FleetRuntimeTarget { + id: string; + runtimeId: string; + socketName: string; +} + +/** Narrow transport boundary consumed by the runtime provider. */ +export interface FleetRuntimeTransport { + verifySession(sessionId: string): Promise; + listSessions(): Promise; + sendMessage(sessionId: string, message: string, sourceLabel: string): Promise; + terminate(sessionId: string): Promise; +} + +export interface FleetTmuxRuntimeTransportOptions { + mosaicHome: string; + rosterPath?: string; + rosterLoader?: () => Promise; + runner: CommandRunner; +} + +/** A typed, fail-closed error for roster, target, identity, and tmux failures. */ +export class FleetRuntimeTransportError extends Error { + constructor( + readonly code: FleetRuntimeTransportErrorCode, + message: string, + ) { + super(message); + this.name = FleetRuntimeTransportError.name; + } +} + +/** + * Roster-bound transport for the local fleet tmux server. Every side-effecting + * operation verifies an exact roster name, the roster socket, and the runtime + * command in the exact pane before it invokes the maintained sender or tmux. + */ +export class FleetTmuxRuntimeTransport implements FleetRuntimeTransport { + private readonly rosterLoader: () => Promise; + + constructor(private readonly options: FleetTmuxRuntimeTransportOptions) { + this.rosterLoader = + options.rosterLoader ?? + (() => + loadFleetRoster(options.rosterPath ?? resolveFleetPaths(options.mosaicHome).rosterPath)); + } + + async verifySession(sessionId: string): Promise { + const roster = await this.loadRoster(); + return this.verifyRosterSession(roster, sessionId); + } + + async listSessions(): Promise { + const roster = await this.loadRoster(); + const targets = await Promise.all( + roster.agents.map(async (agent): Promise => { + try { + return await this.verifyRosterSession(roster, agent.name); + } catch (error: unknown) { + if (error instanceof FleetRuntimeTransportError && error.code === 'unavailable') { + return undefined; + } + throw error; + } + }), + ); + return targets.filter((target): target is FleetRuntimeTarget => target !== undefined); + } + + async sendMessage(sessionId: string, message: string, sourceLabel: string): Promise { + if (message.length === 0) { + throw new FleetRuntimeTransportError('invalid_request', 'Fleet message content is required'); + } + if (sourceLabel.length === 0) { + throw new FleetRuntimeTransportError( + 'invalid_request', + 'Fleet message source label is required', + ); + } + const target = await this.verifySession(sessionId); + const command = buildAgentSendCommand( + resolveFleetPaths(this.options.mosaicHome), + target.id, + message, + target.socketName, + sourceLabel, + ); + await this.runChecked(command, 'Fleet message delivery failed'); + } + + async terminate(sessionId: string): Promise { + const target = await this.verifySession(sessionId); + await this.runChecked( + ['tmux', ...socketArgs(target.socketName), 'kill-session', '-t', `=${target.id}`], + 'Fleet session termination failed', + ); + } + + private async loadRoster(): Promise { + try { + return await this.rosterLoader(); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Fleet roster is unavailable'; + throw new FleetRuntimeTransportError( + 'unavailable', + `Fleet roster is unavailable: ${message}`, + ); + } + } + + private async verifyRosterSession( + roster: FleetRoster, + sessionId: string, + ): Promise { + const agent = this.exactRosterAgent(roster, sessionId); + const socketName = roster.tmux.socketName; + const result = await this.run(buildTmuxListPanesCommand(agent.name, socketName)); + if (result.exitCode !== 0) { + throw new FleetRuntimeTransportError( + 'unavailable', + `Fleet session is unavailable: ${agent.name}`, + ); + } + const pane = parseTmuxListPanes(result.stdout); + if (pane.dead || pane.command === null) { + throw new FleetRuntimeTransportError( + 'unavailable', + `Fleet session is unavailable: ${agent.name}`, + ); + } + if (!hasExactRuntimeIdentity(agent.runtime, pane.command)) { + throw new FleetRuntimeTransportError( + 'forbidden', + `Fleet runtime identity mismatch: ${agent.name}`, + ); + } + return { id: agent.name, runtimeId: agent.runtime, socketName }; + } + + private exactRosterAgent(roster: FleetRoster, sessionId: string): FleetRoster['agents'][number] { + try { + const agent = getRosterAgent(roster, sessionId); + if (agent.name !== sessionId) { + throw new FleetRuntimeTransportError('not_found', 'Fleet session target must be exact'); + } + return agent; + } catch (error: unknown) { + if (error instanceof FleetRuntimeTransportError) { + throw error; + } + throw new FleetRuntimeTransportError('not_found', 'Fleet session target is not roster-bound'); + } + } + + private async run(command: string[]): Promise { + const [executable, ...args] = command; + if (executable === undefined) { + throw new FleetRuntimeTransportError('invalid_request', 'Fleet command is required'); + } + try { + return await this.options.runner(executable, args); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Fleet command failed'; + throw new FleetRuntimeTransportError('unavailable', message); + } + } + + private async runChecked(command: string[], failureMessage: string): Promise { + const result = await this.run(command); + if (result.exitCode !== 0) { + throw new FleetRuntimeTransportError('unavailable', failureMessage); + } + } +} + +function hasExactRuntimeIdentity(runtimeId: string, paneCommand: string): boolean { + const expectedCommands = RUNTIME_ACCEPTABLE_COMMANDS[runtimeId]; + return expectedCommands !== undefined && expectedCommands.includes(paneCommand); +} diff --git a/packages/mosaic/src/index.ts b/packages/mosaic/src/index.ts index c0752d17..cc881e50 100644 --- a/packages/mosaic/src/index.ts +++ b/packages/mosaic/src/index.ts @@ -1,5 +1,7 @@ export const VERSION = '0.0.0'; +export * from './fleet/tmux-runtime-transport.js'; + export { backgroundUpdateCheck, checkForUpdate, From 7b9f40d3b7daa602ffb433b0fa2ea13348c02c24 Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Mon, 13 Jul 2026 01:14:16 +0000 Subject: [PATCH 017/152] fix(tess): redact chat persistence and egress (#725) --- .../src/chat/chat.gateway-redaction.spec.ts | 170 +++++++++++++++++ apps/gateway/src/chat/chat.gateway.ts | 179 +++++++++++++++++- .../commands/command-executor-p8012.spec.ts | 15 +- .../src/commands/command-executor.service.ts | 18 +- packages/log/src/index.ts | 5 + packages/log/src/redaction.spec.ts | 25 +++ packages/log/src/redaction.ts | 40 ++++ 7 files changed, 426 insertions(+), 26 deletions(-) create mode 100644 apps/gateway/src/chat/chat.gateway-redaction.spec.ts create mode 100644 packages/log/src/redaction.spec.ts create mode 100644 packages/log/src/redaction.ts diff --git a/apps/gateway/src/chat/chat.gateway-redaction.spec.ts b/apps/gateway/src/chat/chat.gateway-redaction.spec.ts new file mode 100644 index 00000000..23c70607 --- /dev/null +++ b/apps/gateway/src/chat/chat.gateway-redaction.spec.ts @@ -0,0 +1,170 @@ +import { describe, expect, it, vi } from 'vitest'; +import { ChatGateway } from './chat.gateway.js'; + +const CONVERSATION_ID = 'conversation-1'; +const CANARY = 'sk_canary12345678'; + +type GatewayInternals = { + clientSessions: Map; + relayEvent(client: unknown, conversationId: string, event: unknown): void; +}; + +function buildGateway() { + const brain = { + conversations: { + addMessage: vi.fn().mockResolvedValue(undefined), + }, + }; + const agentService = { + getSession: vi.fn().mockReturnValue(undefined), + }; + const gateway = new ChatGateway( + agentService as never, + {} as never, + brain as never, + {} as never, + {} as never, + {} as never, + ); + + return { gateway: gateway as unknown as GatewayInternals, brain }; +} + +describe('ChatGateway redaction boundary', (): void => { + it('redacts a secret split across assistant deltas before egress and persistence', (): void => { + const { gateway } = buildGateway(); + const client = { + connected: true, + id: 'client-1', + data: { user: { id: 'user-1' } }, + emit: vi.fn(), + }; + const session = { + conversationId: CONVERSATION_ID, + cleanup: vi.fn(), + assistantText: '', + toolCalls: [], + pendingToolCalls: new Map(), + scope: { userId: 'user-1', tenantId: 'tenant-1' }, + }; + gateway.clientSessions.set(client.id, session); + + gateway.relayEvent(client, CONVERSATION_ID, { + type: 'message_update', + assistantMessageEvent: { type: 'text_delta', delta: 'sk_canary' }, + }); + + expect(JSON.stringify(client.emit.mock.calls)).not.toContain('sk_canary'); + + gateway.relayEvent(client, CONVERSATION_ID, { + type: 'message_update', + assistantMessageEvent: { type: 'text_delta', delta: '12345678 ' }, + }); + + expect(client.emit).toHaveBeenCalledWith('agent:text', { + conversationId: CONVERSATION_ID, + text: '[REDACTED_SECRET] ', + }); + expect(session.assistantText).toBe(`${CANARY} `); + expect(JSON.stringify(client.emit.mock.calls)).not.toContain(CANARY); + }); + + it('retains a split secret label until its value can be redacted', (): void => { + const { gateway } = buildGateway(); + const client = { + connected: true, + id: 'client-1', + data: { user: { id: 'user-1' } }, + emit: vi.fn(), + }; + + gateway.relayEvent(client, CONVERSATION_ID, { + type: 'message_update', + assistantMessageEvent: { type: 'text_delta', delta: 'token ' }, + }); + gateway.relayEvent(client, CONVERSATION_ID, { + type: 'message_update', + assistantMessageEvent: { type: 'text_delta', delta: '=canaryvalue123 ' }, + }); + + expect(client.emit).toHaveBeenCalledWith('agent:text', { + conversationId: CONVERSATION_ID, + text: '[REDACTED_SECRET] ', + }); + expect(JSON.stringify(client.emit.mock.calls)).not.toContain('canaryvalue123'); + }); + + it('holds a streamed private key until it can be redacted', (): void => { + const { gateway } = buildGateway(); + const client = { + connected: true, + id: 'client-1', + data: { user: { id: 'user-1' } }, + emit: vi.fn(), + }; + + gateway.relayEvent(client, CONVERSATION_ID, { + type: 'message_update', + assistantMessageEvent: { type: 'text_delta', delta: '-----BEGIN PRIVATE KEY-----\ncanary' }, + }); + gateway.relayEvent(client, CONVERSATION_ID, { + type: 'message_update', + assistantMessageEvent: { type: 'text_delta', delta: '\n-----END PRIVATE KEY-----' }, + }); + + expect(client.emit).toHaveBeenCalledWith('agent:text', { + conversationId: CONVERSATION_ID, + text: '[REDACTED_SECRET]', + }); + expect(JSON.stringify(client.emit.mock.calls)).not.toContain('canary'); + }); + + it('drops an oversized unterminated stream fragment rather than retaining it', (): void => { + const { gateway } = buildGateway(); + const client = { + connected: true, + id: 'client-1', + data: { user: { id: 'user-1' } }, + emit: vi.fn(), + }; + + gateway.relayEvent(client, CONVERSATION_ID, { + type: 'message_update', + assistantMessageEvent: { type: 'text_delta', delta: 'x'.repeat(8_193) }, + }); + + expect(client.emit).toHaveBeenCalledWith('agent:text', { + conversationId: CONVERSATION_ID, + text: '[REDACTED_STREAM_OVERFLOW]', + }); + }); + + it('persists only redacted assistant content with classifications', (): void => { + const { gateway, brain } = buildGateway(); + const client = { + connected: true, + id: 'client-1', + data: { user: { id: 'user-1' } }, + emit: vi.fn(), + }; + gateway.clientSessions.set(client.id, { + conversationId: CONVERSATION_ID, + cleanup: vi.fn(), + assistantText: CANARY, + toolCalls: [], + pendingToolCalls: new Map(), + scope: { userId: 'user-1', tenantId: 'tenant-1' }, + }); + + gateway.relayEvent(client, CONVERSATION_ID, { type: 'agent_end' }); + + expect(brain.conversations.addMessage).toHaveBeenCalledWith( + expect.objectContaining({ + content: '[REDACTED_SECRET]', + metadata: expect.objectContaining({ classifications: ['secret'] }), + }), + 'user-1', + ); + expect(JSON.stringify(brain.conversations.addMessage.mock.calls)).not.toContain(CANARY); + }); +}); diff --git a/apps/gateway/src/chat/chat.gateway.ts b/apps/gateway/src/chat/chat.gateway.ts index 41fa81c6..7b4f0b3b 100644 --- a/apps/gateway/src/chat/chat.gateway.ts +++ b/apps/gateway/src/chat/chat.gateway.ts @@ -18,6 +18,7 @@ import { } from '@mosaicstack/discord-plugin'; import type { Auth } from '@mosaicstack/auth'; import type { Brain } from '@mosaicstack/brain'; +import { redactSensitiveContent } from '@mosaicstack/log'; import type { SetThinkingPayload, SlashCommandApprovalResultPayload, @@ -63,6 +64,7 @@ interface ClientSession { * Keyed by conversationId, value is the model name to use. */ const modelOverrides = new Map(); +const MAX_REDACTION_BUFFER_LENGTH = 8_192; function isDiscordIngressEnvelope(value: unknown): value is DiscordIngressEnvelope { if (typeof value !== 'object' || value === null) return false; @@ -107,6 +109,10 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa private readonly logger = new Logger(ChatGateway.name); private readonly clientSessions = new Map(); + /** Raw stream fragments are kept in memory only until they are safe to redact and emit. */ + private readonly textEgressBuffers = new Map(); + private readonly thinkingEgressBuffers = new Map(); + private readonly overflowedEgress = new Set(); private readonly discordReplayProtector = new DiscordReplayProtector(); constructor( @@ -155,6 +161,10 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa ); this.clientSessions.delete(client.id); } + this.textEgressBuffers.delete(client.id); + this.thinkingEgressBuffers.delete(client.id); + this.overflowedEgress.delete(this.egressKey(client, 'agent:text')); + this.overflowedEgress.delete(this.egressKey(client, 'agent:thinking')); } private getClientScope(client: Socket): ActorTenantScope | null { @@ -317,7 +327,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa { conversationId, role: 'user', - content: data.content, + content: redactSensitiveContent(data.content).content, metadata: { timestamp: new Date().toISOString(), ...(correlationId @@ -327,6 +337,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa discordUserId: discordIngress?.userId, } : {}), + classifications: redactSensitiveContent(data.content).classifications, }, }, userId, @@ -744,6 +755,127 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa } } + private appendAndFlushRedactedEgress( + client: Socket, + conversationId: string, + eventName: 'agent:text' | 'agent:thinking', + buffers: Map, + delta: string, + ): void { + const key = this.egressKey(client, eventName); + if (this.overflowedEgress.has(key)) return; + + const buffered = `${buffers.get(client.id) ?? ''}${delta}`; + if (buffered.length > MAX_REDACTION_BUFFER_LENGTH) { + buffers.delete(client.id); + this.overflowedEgress.add(key); + client.emit(eventName, { conversationId, text: '[REDACTED_STREAM_OVERFLOW]' }); + return; + } + + buffers.set(client.id, buffered); + this.flushRedactedEgress(client, conversationId, eventName, buffers, false); + } + + /** + * Holds any suffix that could become a secret, email, or phone number after a + * later stream chunk. This avoids relying on downstream redaction after data + * has already reached the socket. + */ + private flushRedactedEgress( + client: Socket, + conversationId: string, + eventName: 'agent:text' | 'agent:thinking', + buffers: Map, + final: boolean, + ): void { + const key = this.egressKey(client, eventName); + if (this.overflowedEgress.has(key)) { + if (final) this.overflowedEgress.delete(key); + return; + } + + const buffered = buffers.get(client.id) ?? ''; + const releaseLength = final ? buffered.length : this.safeRedactionPrefixLength(buffered); + const released = buffered.slice(0, releaseLength); + const pending = buffered.slice(releaseLength); + + if (pending) { + buffers.set(client.id, pending); + } else { + buffers.delete(client.id); + } + + if (released) { + client.emit(eventName, { + conversationId, + text: redactSensitiveContent(released).content, + }); + } + } + + private safeRedactionPrefixLength(content: string): number { + let retainedFrom = content.length; + + // Retain the current token because it may become a split secret or email. + const token = /(?:^|\s)(\S*)$/.exec(content); + if (token) { + const matched = token[0] ?? ''; + const trailingToken = token[1] ?? ''; + retainedFrom = token.index + matched.length - trailingToken.length; + } + + // The secret classifier accepts whitespace around ':' and '=', so preserve + // a pending label until its value and delimiter are both complete. + const pendingSecretLabel = + /(?:^|[^A-Za-z0-9_])((?:api[_-]?key|token|password|secret|bearer|authorization)\s*)$/i.exec( + content, + ); + if (pendingSecretLabel) { + const label = pendingSecretLabel[1] ?? ''; + retainedFrom = Math.min( + retainedFrom, + pendingSecretLabel.index + pendingSecretLabel[0].length - label.length, + ); + } + + const secretLabel = /(?:api[_-]?key|token|password|secret|authorization)\s*[:=]\s*$/i.exec( + content, + ); + if (secretLabel) { + retainedFrom = Math.min(retainedFrom, secretLabel.index); + } + + // Phone numbers can contain whitespace and punctuation; preserve the full + // trailing numeric candidate until a non-phone character establishes a boundary. + const phone = /(?:^|[^A-Za-z0-9_])(\+?\d[\d(). -]*)$/.exec(content); + if (phone) { + const matched = phone[0] ?? ''; + const trailingPhoneCandidate = phone[1] ?? ''; + retainedFrom = Math.min( + retainedFrom, + phone.index + matched.length - trailingPhoneCandidate.length, + ); + } + + const privateKeyStart = content.lastIndexOf('-----BEGIN'); + if (privateKeyStart >= 0) { + const privateKey = content.slice(privateKeyStart); + if (/-----END(?: [A-Z]+)* KEY-----/.test(privateKey)) { + // Release the complete block in one pass so the full-block classifier can redact it. + retainedFrom = content.length; + } else { + retainedFrom = Math.min(retainedFrom, privateKeyStart); + } + } + + return retainedFrom; + } + + private egressKey(client: Socket, eventName: 'agent:text' | 'agent:thinking'): string { + return `${client.id}:${eventName}`; + } + private relayEvent(client: Socket, conversationId: string, event: AgentSessionEvent): void { if (!client.connected) { this.logger.warn( @@ -761,6 +893,10 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa cs.toolCalls = []; cs.pendingToolCalls.clear(); } + this.textEgressBuffers.set(client.id, ''); + this.thinkingEgressBuffers.set(client.id, ''); + this.overflowedEgress.delete(this.egressKey(client, 'agent:text')); + this.overflowedEgress.delete(this.egressKey(client, 'agent:thinking')); client.emit('agent:start', { conversationId }); break; } @@ -789,6 +925,20 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa } : undefined; + this.flushRedactedEgress( + client, + conversationId, + 'agent:text', + this.textEgressBuffers, + true, + ); + this.flushRedactedEgress( + client, + conversationId, + 'agent:thinking', + this.thinkingEgressBuffers, + true, + ); client.emit('agent:end', { conversationId, usage: usagePayload, @@ -831,8 +981,11 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa { conversationId, role: 'assistant', - content: cs.assistantText, - metadata, + content: redactSensitiveContent(cs.assistantText).content, + metadata: { + ...metadata, + classifications: redactSensitiveContent(cs.assistantText).classifications, + }, }, userId, ) @@ -854,20 +1007,26 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa case 'message_update': { const assistantEvent = event.assistantMessageEvent; if (assistantEvent.type === 'text_delta') { - // Accumulate assistant text for persistence + // Keep raw stream material in memory only; persist and emit only redacted text. const cs = this.clientSessions.get(client.id); if (cs) { cs.assistantText += assistantEvent.delta; } - client.emit('agent:text', { + this.appendAndFlushRedactedEgress( + client, conversationId, - text: assistantEvent.delta, - }); + 'agent:text', + this.textEgressBuffers, + assistantEvent.delta, + ); } else if (assistantEvent.type === 'thinking_delta') { - client.emit('agent:thinking', { + this.appendAndFlushRedactedEgress( + client, conversationId, - text: assistantEvent.delta, - }); + 'agent:thinking', + this.thinkingEgressBuffers, + assistantEvent.delta, + ); } break; } diff --git a/apps/gateway/src/commands/command-executor-p8012.spec.ts b/apps/gateway/src/commands/command-executor-p8012.spec.ts index 242d2ac0..fc5d9660 100644 --- a/apps/gateway/src/commands/command-executor-p8012.spec.ts +++ b/apps/gateway/src/commands/command-executor-p8012.spec.ts @@ -106,8 +106,8 @@ describe('CommandExecutorService — P8-012 commands', () => { expect(result.command).toBe('provider'); }); - // /provider login anthropic — success with URL containing poll token - it('/provider login returns success with URL and poll token', async () => { + // /provider login anthropic — no bearer token or auth URL reaches chat output + it('/provider login keeps its one-time token out of chat output', async () => { const payload: SlashCommandPayload = { command: 'provider', args: 'login anthropic', @@ -117,14 +117,9 @@ describe('CommandExecutorService — P8-012 commands', () => { expect(result.success).toBe(true); expect(result.command).toBe('provider'); expect(result.message).toContain('anthropic'); - expect(result.message).toContain('http'); - // data should contain loginUrl and pollToken - expect(result.data).toBeDefined(); - const data = result.data as Record; - expect(typeof data['loginUrl']).toBe('string'); - expect(typeof data['pollToken']).toBe('string'); - expect(data['loginUrl'] as string).toContain('anthropic'); - expect(data['loginUrl'] as string).toContain(data['pollToken'] as string); + expect(result.message).not.toContain('http'); + expect(result.message).not.toContain('token='); + expect(result.data).toEqual({ provider: 'anthropic' }); // Verify Valkey was called expect(mockRedis.set).toHaveBeenCalledOnce(); const [key, value, , ttl] = mockRedis.set.mock.calls[0] as [string, string, string, number]; diff --git a/apps/gateway/src/commands/command-executor.service.ts b/apps/gateway/src/commands/command-executor.service.ts index 0003af87..a493b993 100644 --- a/apps/gateway/src/commands/command-executor.service.ts +++ b/apps/gateway/src/commands/command-executor.service.ts @@ -435,22 +435,28 @@ export class CommandExecutorService { }; } const pollToken = crypto.randomUUID(); - const key = `mosaic:auth:poll:${pollToken}`; - // Store pending state in Valkey (TTL 5 minutes) + const tokenDigest = await crypto.subtle.digest( + 'SHA-256', + new TextEncoder().encode(pollToken), + ); + const tokenHash = Array.from(new Uint8Array(tokenDigest), (byte: number): string => + byte.toString(16).padStart(2, '0'), + ).join(''); + const key = `mosaic:auth:poll:${tokenHash}`; + // Persist only a short-lived token digest. The raw token is delivered only by + // the authenticated dashboard flow, never in chat output or command metadata. await this.redis.set( key, JSON.stringify({ status: 'pending', provider: providerName, userId }), 'EX', 300, ); - // In production this would construct an OAuth URL - const loginUrl = `${process.env['MOSAIC_BASE_URL'] ?? 'http://localhost:3000'}/auth/provider/${providerName}?token=${pollToken}`; return { command: 'provider', success: true, - message: `Open this URL to authenticate with ${providerName}:\n${loginUrl}`, + message: `Provider login for ${providerName} is ready. Continue in the authenticated dashboard.`, conversationId, - data: { loginUrl, pollToken, provider: providerName }, + data: { provider: providerName }, }; } diff --git a/packages/log/src/index.ts b/packages/log/src/index.ts index 86bdffe2..9b49ccfb 100644 --- a/packages/log/src/index.ts +++ b/packages/log/src/index.ts @@ -10,3 +10,8 @@ export { type LogQuery, } from './agent-logs.js'; export { registerLogCommand } from './cli.js'; +export { + redactSensitiveContent, + type RedactionResult, + type SensitiveClassification, +} from './redaction.js'; diff --git a/packages/log/src/redaction.spec.ts b/packages/log/src/redaction.spec.ts new file mode 100644 index 00000000..4a0fd183 --- /dev/null +++ b/packages/log/src/redaction.spec.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import { redactSensitiveContent } from './redaction.js'; + +describe('redactSensitiveContent', (): void => { + it('redacts seeded secret and PII canaries before persistence or egress', (): void => { + const result = redactSensitiveContent( + 'email canary@example.test token=sk_CANARY12345678 phone +1 555 555 1212', + ); + expect(result.content).not.toContain('canary@example.test'); + expect(result.content).not.toContain('sk_CANARY12345678'); + expect(result.content).not.toContain('+1 555 555 1212'); + expect(result.classifications).toEqual(['secret', 'pii']); + }); + + it('redacts common provider credential formats', (): void => { + const result = redactSensitiveContent( + 'Authorization: Bearer canary.bearer.token jwt eyJcanary.eyJpayload.eyJsignature aws AKIACANARY1234567890', + ); + + expect(result.content).not.toContain('canary.bearer.token'); + expect(result.content).not.toContain('eyJcanary.eyJpayload.eyJsignature'); + expect(result.content).not.toContain('AKIACANARY1234567890'); + expect(result.classifications).toEqual(['secret']); + }); +}); diff --git a/packages/log/src/redaction.ts b/packages/log/src/redaction.ts new file mode 100644 index 00000000..3a9320b0 --- /dev/null +++ b/packages/log/src/redaction.ts @@ -0,0 +1,40 @@ +export type SensitiveClassification = 'secret' | 'pii'; + +export interface RedactionResult { + content: string; + classifications: SensitiveClassification[]; +} + +const SECRET_PATTERNS: RegExp[] = [ + /\b(?:sk|ghp|gitea)_[A-Za-z0-9_-]{8,}\b/g, + /\b(?:api[_-]?key|token|password|secret)\s*[:=]\s*[^\s,;]+/gi, + /\b(?:authorization\s*:\s*)?bearer\s+[A-Za-z0-9._~+/-]+=*/gi, + /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, + /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g, + /-----BEGIN(?: [A-Z]+)* KEY-----[\s\S]*?-----END(?: [A-Z]+)* KEY-----/g, + /https?:\/\/[^\s?#]+[^\s]*[?&](?:token|key|secret|signature|sig)=[^\s&#]+/gi, +]; +const PII_PATTERNS: RegExp[] = [ + /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, + /\b\+?\d[\d(). -]{7,}\d\b/g, +]; + +export function redactSensitiveContent(content: string): RedactionResult { + let redacted = content; + const classifications: SensitiveClassification[] = []; + for (const pattern of SECRET_PATTERNS) { + if (pattern.test(redacted)) { + classifications.push('secret'); + redacted = redacted.replace(pattern, '[REDACTED_SECRET]'); + } + pattern.lastIndex = 0; + } + for (const pattern of PII_PATTERNS) { + if (pattern.test(redacted)) { + classifications.push('pii'); + redacted = redacted.replace(pattern, '[REDACTED_PII]'); + } + pattern.lastIndex = 0; + } + return { content: redacted, classifications: [...new Set(classifications)] }; +} From 86a50138a9c640f295f736596975da74341540a5 Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Mon, 13 Jul 2026 01:29:18 +0000 Subject: [PATCH 018/152] feat(tess): add safe runtime observability (#726) --- .../runtime-provider-registry.service.test.ts | 83 +++++++++++++++--- apps/gateway/src/agent/agent.module.ts | 3 +- apps/gateway/src/agent/provider.service.ts | 25 +++++- .../src/agent/providers.controller.test.ts | 46 ++++++++++ .../gateway/src/agent/providers.controller.ts | 22 ++++- .../runtime-provider-registry.service.ts | 64 ++++++++++++-- .../src/health/health.controller.test.ts | 12 +++ apps/gateway/src/health/health.controller.ts | 6 ++ docs/scratchpads/tess-m1-obs-001.md | 9 ++ packages/log/src/index.ts | 7 ++ packages/log/src/runtime-audit.test.ts | 52 +++++++++++ packages/log/src/runtime-audit.ts | 86 +++++++++++++++++++ 12 files changed, 391 insertions(+), 24 deletions(-) create mode 100644 apps/gateway/src/agent/providers.controller.test.ts create mode 100644 apps/gateway/src/health/health.controller.test.ts create mode 100644 docs/scratchpads/tess-m1-obs-001.md create mode 100644 packages/log/src/runtime-audit.test.ts create mode 100644 packages/log/src/runtime-audit.ts diff --git a/apps/gateway/src/agent/__tests__/runtime-provider-registry.service.test.ts b/apps/gateway/src/agent/__tests__/runtime-provider-registry.service.test.ts index 85415545..a521b5d7 100644 --- a/apps/gateway/src/agent/__tests__/runtime-provider-registry.service.test.ts +++ b/apps/gateway/src/agent/__tests__/runtime-provider-registry.service.test.ts @@ -15,6 +15,7 @@ import type { import { AgentRuntimeProviderRegistry } from '@mosaicstack/agent'; import type { ActorTenantScope } from '../../auth/session-scope.js'; import { + RuntimeProviderAuditService, RuntimeProviderService, type RuntimeAuditEvent, type RuntimeAuditSink, @@ -159,20 +160,47 @@ describe('RuntimeProviderService security boundary', (): void => { correlationId: CONTEXT.correlationId, }); expect(Object.isFrozen(providerScope)).toBe(true); - expect(audit.events).toContainEqual({ - providerId: 'fleet', - operation: 'session.send', - outcome: 'succeeded', - actorId: OWNER_SCOPE.userId, - tenantId: OWNER_SCOPE.tenantId, - channelId: CONTEXT.channelId, - correlationId: CONTEXT.correlationId, - resourceId: 'session-1', - }); + expect(audit.events).toContainEqual( + expect.objectContaining({ + providerId: 'fleet', + operation: 'session.send', + outcome: 'succeeded', + actorId: OWNER_SCOPE.userId, + tenantId: OWNER_SCOPE.tenantId, + channelId: CONTEXT.channelId, + correlationId: CONTEXT.correlationId, + resourceId: 'session-1', + durationMs: expect.any(Number), + }), + ); expect(JSON.stringify(audit.events)).not.toContain('hello'); expect(JSON.stringify(audit.events)).not.toContain('key-1'); }); + it('does not block a provider operation when an unsafe resource ID is redacted in durable audit', async (): Promise => { + const provider = new RecordingRuntimeProvider(['session.send']); + let persisted: unknown; + const durableAudit = new RuntimeProviderAuditService({ + logs: { + ingest: async (entry: unknown): Promise => { + persisted = entry; + return entry; + }, + }, + } as never); + const service = makeService(provider, durableAudit); + + await service.sendMessage( + 'fleet', + 'session/credential-canary=secret-value', + { content: 'safe message', idempotencyKey: 'key-1' }, + CONTEXT, + ); + + expect(provider.sentMessages).toHaveLength(1); + expect(JSON.stringify(persisted)).not.toContain('secret-value'); + }); + it('fails closed before a provider side effect when a capability is missing', async (): Promise => { const provider = new RecordingRuntimeProvider([]); const service = makeService(provider); @@ -191,12 +219,14 @@ describe('RuntimeProviderService security boundary', (): void => { it('requires a consumed exact-action approval before termination', async (): Promise => { const provider = new RecordingRuntimeProvider(['session.terminate']); const approval = new DenyingApprovalVerifier(); - const service = makeService(provider, new RecordingAuditSink(), approval); + const audit = new RecordingAuditSink(); + const service = makeService(provider, audit, approval); await expect( service.terminate('fleet', 'session-1', 'forged-approval', CONTEXT), ).rejects.toThrow(/approval denied/); expect(provider.terminateCalls).toBe(0); + expect(audit.events.at(-1)).toMatchObject({ outcome: 'denied', errorCode: 'policy_denied' }); }); it('binds an accepted termination approval to provider, session, immutable scope, and correlation', async (): Promise => { @@ -256,6 +286,37 @@ describe('RuntimeProviderService security boundary', (): void => { 'requested', 'failed', ]); + expect(audit.events.at(-1)).toMatchObject({ + errorCode: 'provider_error', + durationMs: expect.any(Number), + }); + }); + + it('persists only metadata-only runtime audit fields', async (): Promise => { + let persisted: unknown; + const ingest = async (entry: unknown): Promise => { + persisted = entry; + return entry; + }; + const service = new RuntimeProviderAuditService({ logs: { ingest } } as never); + + await service.record({ + providerId: 'fleet', + operation: 'session.send', + outcome: 'succeeded', + actorId: 'owner-1', + tenantId: 'tenant-1', + channelId: 'cli', + correlationId: 'correlation-1', + resourceId: 'session-1', + durationMs: 12, + }); + + expect(persisted).toMatchObject({ + content: 'runtime.provider.audit', + metadata: expect.objectContaining({ correlationId: 'correlation-1', durationMs: 12 }), + }); + expect(JSON.stringify(persisted)).not.toContain('approval'); }); it('does not misreport a completed provider side effect when completion auditing fails', async (): Promise => { diff --git a/apps/gateway/src/agent/agent.module.ts b/apps/gateway/src/agent/agent.module.ts index 3d789ae8..bb1c1a4a 100644 --- a/apps/gateway/src/agent/agent.module.ts +++ b/apps/gateway/src/agent/agent.module.ts @@ -14,6 +14,7 @@ import { CoordModule } from '../coord/coord.module.js'; import { McpClientModule } from '../mcp-client/mcp-client.module.js'; import { SkillsModule } from '../skills/skills.module.js'; import { GCModule } from '../gc/gc.module.js'; +import { LogModule } from '../log/log.module.js'; import { AGENT_RUNTIME_PROVIDER_REGISTRY, DenyRuntimeApprovalVerifier, @@ -25,7 +26,7 @@ import { @Global() @Module({ - imports: [CoordModule, McpClientModule, SkillsModule, GCModule], + imports: [CoordModule, McpClientModule, SkillsModule, GCModule, LogModule], providers: [ ProviderService, ProviderCredentialsService, diff --git a/apps/gateway/src/agent/provider.service.ts b/apps/gateway/src/agent/provider.service.ts index a1d6dfa4..1cc0b81a 100644 --- a/apps/gateway/src/agent/provider.service.ts +++ b/apps/gateway/src/agent/provider.service.ts @@ -107,8 +107,7 @@ export class ProviderService implements OnModuleInit, OnModuleDestroy { * Interval is configurable via PROVIDER_HEALTH_INTERVAL env (seconds, default 60). */ private startHealthCheckScheduler(): void { - const intervalSecs = - parseInt(process.env['PROVIDER_HEALTH_INTERVAL'] ?? '', 10) || DEFAULT_HEALTH_INTERVAL_SECS; + const intervalSecs = this.effectiveHealthCheckIntervalSecs(); const intervalMs = intervalSecs * 1000; // Run an initial check immediately (non-blocking) @@ -176,6 +175,28 @@ export class ProviderService implements OnModuleInit, OnModuleDestroy { }); } + /** + * Returns the effective provider operational policy without credentials, + * endpoints, request content, or provider error details. + */ + getEffectivePolicyStatus(): { + healthCheckIntervalSecs: number; + configuredProviders: string[]; + availableModelCount: number; + } { + return { + healthCheckIntervalSecs: this.effectiveHealthCheckIntervalSecs(), + configuredProviders: this.adapters.map((adapter) => adapter.name), + availableModelCount: this.registry?.getAvailable().length ?? 0, + }; + } + + private effectiveHealthCheckIntervalSecs(): number { + return ( + parseInt(process.env['PROVIDER_HEALTH_INTERVAL'] ?? '', 10) || DEFAULT_HEALTH_INTERVAL_SECS + ); + } + // --------------------------------------------------------------------------- // Adapter-pattern API // --------------------------------------------------------------------------- diff --git a/apps/gateway/src/agent/providers.controller.test.ts b/apps/gateway/src/agent/providers.controller.test.ts new file mode 100644 index 00000000..4ce6266b --- /dev/null +++ b/apps/gateway/src/agent/providers.controller.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it, vi } from 'vitest'; +import { ProvidersController } from './providers.controller.js'; + +describe('ProvidersController operational status', (): void => { + it('reports provider latency and effective policy without exposing provider error details', (): void => { + const providerService = { + getProvidersHealth: vi.fn(() => [ + { + name: 'fleet', + status: 'down', + latencyMs: 42, + lastChecked: '2026-07-12T00:00:00.000Z', + modelCount: 0, + error: 'credential-canary=secret-value', + }, + ]), + getEffectivePolicyStatus: vi.fn(() => ({ + healthCheckIntervalSecs: 60, + configuredProviders: ['fleet'], + availableModelCount: 0, + })), + }; + const controller = new ProvidersController(providerService as never, {} as never, {} as never); + + const status = controller.status(); + + expect(status).toEqual({ + providers: [ + { + name: 'fleet', + status: 'down', + latencyMs: 42, + lastChecked: '2026-07-12T00:00:00.000Z', + modelCount: 0, + errorCode: 'provider_unavailable', + }, + ], + effectivePolicy: { + healthCheckIntervalSecs: 60, + configuredProviders: ['fleet'], + availableModelCount: 0, + }, + }); + expect(JSON.stringify(status)).not.toContain('secret-value'); + }); +}); diff --git a/apps/gateway/src/agent/providers.controller.ts b/apps/gateway/src/agent/providers.controller.ts index 60d5bed6..1c7144fc 100644 --- a/apps/gateway/src/agent/providers.controller.ts +++ b/apps/gateway/src/agent/providers.controller.ts @@ -33,7 +33,20 @@ export class ProvidersController { @Get('health') health() { - return { providers: this.providerService.getProvidersHealth() }; + return { providers: this.safeProviderHealth() }; + } + + /** + * Safe operational status for troubleshooting and readiness checks. Provider + * errors are reduced to a stable code so credentials and remote responses + * cannot leak through this endpoint. + */ + @Get('status') + status() { + return { + providers: this.safeProviderHealth(), + effectivePolicy: this.providerService.getEffectivePolicyStatus(), + }; } @Post('test') @@ -51,6 +64,13 @@ export class ProvidersController { return this.routingService.rank(criteria); } + private safeProviderHealth() { + return this.providerService.getProvidersHealth().map(({ error, ...provider }) => ({ + ...provider, + ...(error ? { errorCode: 'provider_unavailable' } : {}), + })); + } + // ── Credential CRUD ────────────────────────────────────────────────────── /** diff --git a/apps/gateway/src/agent/runtime-provider-registry.service.ts b/apps/gateway/src/agent/runtime-provider-registry.service.ts index 89617e59..c56906ff 100644 --- a/apps/gateway/src/agent/runtime-provider-registry.service.ts +++ b/apps/gateway/src/agent/runtime-provider-registry.service.ts @@ -1,5 +1,10 @@ import { ForbiddenException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { AgentRuntimeProviderRegistry } from '@mosaicstack/agent'; +import { + createRuntimeAuditLogEntry, + type LogService, + type RuntimeAuditErrorCode, +} from '@mosaicstack/log'; import type { AgentRuntimeProvider, RuntimeAttachHandle, @@ -14,6 +19,7 @@ import type { RuntimeStreamEvent, } from '@mosaicstack/types'; import type { ActorTenantScope } from '../auth/session-scope.js'; +import { LOG_SERVICE } from '../log/log.tokens.js'; export const AGENT_RUNTIME_PROVIDER_REGISTRY = Symbol('AGENT_RUNTIME_PROVIDER_REGISTRY'); export const RUNTIME_PROVIDER_AUDIT_SINK = Symbol('RUNTIME_PROVIDER_AUDIT_SINK'); @@ -42,6 +48,8 @@ export interface RuntimeAuditEvent { channelId: string; correlationId: string; resourceId?: string; + durationMs?: number; + errorCode?: RuntimeAuditErrorCode; } export interface RuntimeAuditSink { @@ -87,8 +95,12 @@ export class DenyRuntimeApprovalVerifier implements RuntimeApprovalVerifier { export class RuntimeProviderAuditService implements RuntimeAuditSink { private readonly logger = new Logger(RuntimeProviderAuditService.name); + constructor(@Inject(LOG_SERVICE) private readonly logService: LogService) {} + async record(event: RuntimeAuditEvent): Promise { - this.logger.log(JSON.stringify(event)); + const entry = createRuntimeAuditLogEntry(event); + await this.logService.logs.ingest(entry); + this.logger.log(JSON.stringify({ event: entry.content, metadata: entry.metadata })); } } @@ -267,6 +279,7 @@ export class RuntimeProviderService { invoke: (provider: AgentRuntimeProvider, scope: RuntimeScope) => Promise, ): Promise { const scope = this.deriveScope(context); + const startedAt = Date.now(); await this.record(providerId, operation, 'requested', scope, resourceId); let invocationStarted = false; try { @@ -276,13 +289,22 @@ export class RuntimeProviderService { } invocationStarted = true; const result = await invoke(provider, scope); - await this.recordCompletion(providerId, operation, scope, resourceId); + await this.recordCompletion(providerId, operation, scope, resourceId, Date.now() - startedAt); return result; } catch (error: unknown) { + const durationMs = Date.now() - startedAt; if (invocationStarted && !(error instanceof RuntimeApprovalDeniedError)) { - await this.recordFailure(providerId, operation, scope, resourceId); + await this.recordFailure(providerId, operation, scope, resourceId, durationMs); } else { - await this.record(providerId, operation, 'denied', scope, resourceId); + await this.record( + providerId, + operation, + 'denied', + scope, + resourceId, + durationMs, + 'policy_denied', + ); } throw error; } @@ -300,6 +322,7 @@ export class RuntimeProviderService { ) => AsyncIterable, ): AsyncIterable { const scope = this.deriveScope(context); + const startedAt = Date.now(); await this.record(providerId, operation, 'requested', scope, resourceId); let invocationStarted = false; try { @@ -309,12 +332,21 @@ export class RuntimeProviderService { for await (const event of invoke(provider, scope)) { yield event; } - await this.recordCompletion(providerId, operation, scope, resourceId); + await this.recordCompletion(providerId, operation, scope, resourceId, Date.now() - startedAt); } catch (error: unknown) { + const durationMs = Date.now() - startedAt; if (invocationStarted) { - await this.recordFailure(providerId, operation, scope, resourceId); + await this.recordFailure(providerId, operation, scope, resourceId, durationMs); } else { - await this.record(providerId, operation, 'denied', scope, resourceId); + await this.record( + providerId, + operation, + 'denied', + scope, + resourceId, + durationMs, + 'policy_denied', + ); } throw error; } @@ -358,9 +390,18 @@ export class RuntimeProviderService { operation: RuntimeProviderOperation, scope: RuntimeScope, resourceId: string | undefined, + durationMs: number, ): Promise { try { - await this.record(providerId, operation, 'failed', scope, resourceId); + await this.record( + providerId, + operation, + 'failed', + scope, + resourceId, + durationMs, + 'provider_error', + ); } catch { this.logger.error( `Runtime provider failure audit failed provider=${providerId} operation=${operation} correlation=${scope.correlationId}`, @@ -373,9 +414,10 @@ export class RuntimeProviderService { operation: RuntimeProviderOperation, scope: RuntimeScope, resourceId: string | undefined, + durationMs: number, ): Promise { try { - await this.record(providerId, operation, 'succeeded', scope, resourceId); + await this.record(providerId, operation, 'succeeded', scope, resourceId, durationMs); } catch { this.logger.error( `Runtime provider completion audit failed provider=${providerId} operation=${operation} correlation=${scope.correlationId}`, @@ -389,6 +431,8 @@ export class RuntimeProviderService { outcome: RuntimeProviderAuditOutcome, scope: RuntimeScope, resourceId: string | undefined, + durationMs?: number, + errorCode?: RuntimeAuditErrorCode, ): Promise { await this.audit.record({ providerId, @@ -399,6 +443,8 @@ export class RuntimeProviderService { channelId: scope.channelId, correlationId: scope.correlationId, ...(resourceId ? { resourceId } : {}), + ...(durationMs !== undefined ? { durationMs } : {}), + ...(errorCode ? { errorCode } : {}), }); } } diff --git a/apps/gateway/src/health/health.controller.test.ts b/apps/gateway/src/health/health.controller.test.ts new file mode 100644 index 00000000..4647eeef --- /dev/null +++ b/apps/gateway/src/health/health.controller.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest'; +import { HealthController } from './health.controller.js'; + +describe('HealthController', (): void => { + it('exposes liveness and readiness without configuration details', (): void => { + const controller = new HealthController(); + + expect(controller.check()).toEqual({ status: 'ok' }); + expect(controller.ready()).toEqual({ status: 'ready' }); + expect(JSON.stringify(controller.ready())).not.toContain('credential'); + }); +}); diff --git a/apps/gateway/src/health/health.controller.ts b/apps/gateway/src/health/health.controller.ts index c3d14da4..0dc59821 100644 --- a/apps/gateway/src/health/health.controller.ts +++ b/apps/gateway/src/health/health.controller.ts @@ -6,4 +6,10 @@ export class HealthController { check(): { status: string } { return { status: 'ok' }; } + + /** Readiness intentionally exposes no configuration, provider, or credential details. */ + @Get('ready') + ready(): { status: string } { + return { status: 'ready' }; + } } diff --git a/docs/scratchpads/tess-m1-obs-001.md b/docs/scratchpads/tess-m1-obs-001.md new file mode 100644 index 00000000..7bbb1b6a --- /dev/null +++ b/docs/scratchpads/tess-m1-obs-001.md @@ -0,0 +1,9 @@ +# TESS-M1-OBS-001 Scratchpad + +- Branch: `feat/tess-observability-terra` (the requested name is checked out by an abandoned worktree; orchestrator approved this clean branch). +- Base: `origin/main` at `e92186d7`. +- Scope: correlation propagation; metadata-only structured runtime/provider/tool audit; health/readiness; safe effective-policy status. +- Security invariant: audit and status data use an allowlist; no message bodies, credentials, approval references, tool arguments, or tool output. +- TDD: `packages/log/src/runtime-audit.test.ts` and `apps/gateway/src/health/health.controller.test.ts` failed before implementation and now pass. +- Verification: full `pnpm typecheck`, `pnpm lint`, `pnpm format:check`, and `pnpm test` passed after implementation (2026-07-12). +- Review: corrected audit sanitizer findings by hashing every resource ID. Durable audit persistence remains fail-closed by design: the pre-existing M1 provider-boundary suite requires it to prevent an unaudited side effect. diff --git a/packages/log/src/index.ts b/packages/log/src/index.ts index 9b49ccfb..699509c3 100644 --- a/packages/log/src/index.ts +++ b/packages/log/src/index.ts @@ -15,3 +15,10 @@ export { type RedactionResult, type SensitiveClassification, } from './redaction.js'; +export { + createRuntimeAuditLogEntry, + type RuntimeAuditEvent, + type RuntimeAuditErrorCode, + type RuntimeAuditOperation, + type RuntimeAuditOutcome, +} from './runtime-audit.js'; diff --git a/packages/log/src/runtime-audit.test.ts b/packages/log/src/runtime-audit.test.ts new file mode 100644 index 00000000..0651b5f1 --- /dev/null +++ b/packages/log/src/runtime-audit.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; +import { createRuntimeAuditLogEntry } from './runtime-audit.js'; + +describe('createRuntimeAuditLogEntry', (): void => { + it('serializes only allowlisted runtime audit metadata', (): void => { + const entry = createRuntimeAuditLogEntry({ + providerId: 'fleet', + operation: 'session.send', + outcome: 'succeeded', + actorId: 'actor-1', + tenantId: 'tenant-1', + channelId: 'discord', + correlationId: 'correlation-1', + resourceId: 'session-1', + durationMs: 12, + }); + + expect(entry).toMatchObject({ + sessionId: 'runtime:fleet', + userId: 'actor-1', + level: 'info', + category: 'tool_use', + content: 'runtime.provider.audit', + metadata: { + providerId: 'fleet', + operation: 'session.send', + outcome: 'succeeded', + correlationId: 'correlation-1', + resourceId: expect.stringMatching(/^sha256:/), + durationMs: 12, + }, + }); + expect(JSON.stringify(entry)).not.toContain('approvalRef'); + }); + + it('hashes every resource ID without blocking a runtime audit or persisting its raw value', (): void => { + const entry = createRuntimeAuditLogEntry({ + providerId: 'fleet', + operation: 'session.send', + outcome: 'succeeded', + actorId: 'actor-1', + tenantId: 'tenant-1', + channelId: 'discord', + correlationId: 'correlation-1', + resourceId: 'credential-canary:secret-value', + durationMs: 12, + }); + + expect(entry.metadata).toMatchObject({ resourceId: expect.stringMatching(/^sha256:/) }); + expect(JSON.stringify(entry)).not.toContain('secret-value'); + }); +}); diff --git a/packages/log/src/runtime-audit.ts b/packages/log/src/runtime-audit.ts new file mode 100644 index 00000000..12923290 --- /dev/null +++ b/packages/log/src/runtime-audit.ts @@ -0,0 +1,86 @@ +import { createHash } from 'node:crypto'; +import type { NewAgentLog } from './agent-logs.js'; + +export type RuntimeAuditOperation = + | 'session.list' + | 'session.tree' + | 'session.stream' + | 'session.send' + | 'session.attach' + | 'session.terminate' + | 'runtime.capabilities' + | 'runtime.health'; + +export type RuntimeAuditOutcome = 'requested' | 'succeeded' | 'denied' | 'failed'; +export type RuntimeAuditErrorCode = 'policy_denied' | 'provider_error'; + +/** + * Deliberately metadata-only runtime audit record. It has no fields for message + * content, credentials, approval references, tool arguments, or tool output. + */ +export interface RuntimeAuditEvent { + providerId: string; + operation: RuntimeAuditOperation; + outcome: RuntimeAuditOutcome; + actorId: string; + tenantId: string; + channelId: string; + correlationId: string; + resourceId?: string; + durationMs?: number; + errorCode?: RuntimeAuditErrorCode; +} + +const SAFE_IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; + +function safeIdentifier(value: string): string { + if (SAFE_IDENTIFIER.test(value)) return value; + return hashIdentifier(value); +} + +function hashIdentifier(value: string): string { + return `sha256:${createHash('sha256').update(value).digest('hex')}`; +} + +/** + * Converts a typed audit event into a durable log entry using an explicit + * allowlist. Values that could carry credentials or untrusted content are + * rejected before persistence or structured log emission. + */ +export function createRuntimeAuditLogEntry(event: RuntimeAuditEvent): NewAgentLog { + const providerId = safeIdentifier(event.providerId); + const actorId = safeIdentifier(event.actorId); + const tenantId = safeIdentifier(event.tenantId); + const channelId = safeIdentifier(event.channelId); + const correlationId = safeIdentifier(event.correlationId); + // Provider resource identifiers may be opaque or user-derived, so never persist them raw. + const resourceId = event.resourceId ? hashIdentifier(event.resourceId) : undefined; + const persistedUserId = SAFE_IDENTIFIER.test(event.actorId) ? event.actorId : null; + + if ( + event.durationMs !== undefined && + (!Number.isInteger(event.durationMs) || event.durationMs < 0) + ) { + throw new Error('Runtime audit duration must be a non-negative integer'); + } + + return { + sessionId: `runtime:${providerId}`, + userId: persistedUserId, + level: event.outcome === 'failed' ? 'error' : event.outcome === 'denied' ? 'warn' : 'info', + category: event.operation.startsWith('session.') ? 'tool_use' : 'general', + content: 'runtime.provider.audit', + metadata: { + providerId, + operation: event.operation, + outcome: event.outcome, + actorId, + tenantId, + channelId, + correlationId, + ...(resourceId ? { resourceId } : {}), + ...(event.durationMs !== undefined ? { durationMs: event.durationMs } : {}), + ...(event.errorCode ? { errorCode: event.errorCode } : {}), + }, + }; +} From 24b07d0f832adb3c9cdc534ceeb44312935ed1f4 Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Mon, 13 Jul 2026 02:14:13 +0000 Subject: [PATCH 019/152] docs(tess): sync M1 ledger to merged state; M1-V Mos-owned (#727) --- docs/tess/TASKS.md | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/docs/tess/TASKS.md b/docs/tess/TASKS.md index b536e96a..24b47b24 100644 --- a/docs/tess/TASKS.md +++ b/docs/tess/TASKS.md @@ -3,22 +3,24 @@ > Mission: `tess-20260712` · Issue: #706 · PRD requirements: `TESS-*` > Orchestrator is sole writer. Workers must not modify this file. > `repo` contains one or more comma-separated repository-relative roots; every listed root must exist before dispatch. +> **BASE CONVENTION (Mos-locked 2026-07-12):** EVERY M1 dispatch targets `base=main` and branches from fresh `origin/main`. Do NOT base on `feat/tess-interaction-agent` — that is the STALE planning branch (merged via #712); basing on it yields `mergeable=False` + intervening-commit noise (cf. #723/SEC-005 re-base). Every dispatch brief must state base=main + branch-from-origin/main. | id | status | description | issue | agent | repo | branch | depends_on | estimate | notes | | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | | TESS-PLAN-001 | done | Finalize PRD, architecture, authority boundary, threat model, migration inventory, and verification matrix | #706 | sonnet | docs, packages/types, apps/gateway | feat/tess-interaction-agent | — | 22K | Independent gate PASS after two remediation rounds; completion effective when planning PR merges | -| TESS-M1-SEC-001 | not-started | Enforce command scopes/roles and durable exact-action approval for privileged/destructive commands | #707 | codex | apps/gateway | fix/tess-command-authz | TESS-PLAN-001 | 25K | TESS-SEC-002; security TDD | -| TESS-M1-SEC-002 | not-started | Enforce owner/tenant binding on session list/read/attach/send/terminate across REST and WS | #707 | codex | apps/gateway | fix/tess-session-ownership | TESS-PLAN-001 | 30K | TESS-SEC-003; security TDD | -| TESS-M1-SEC-003 | not-started | Bind MCP actor/tenant to authenticated context and add per-tool scopes | #707 | codex | apps/gateway | fix/tess-mcp-identity | TESS-PLAN-001 | 22K | TESS-SEC-004; security TDD | -| TESS-M1-SEC-004 | not-started | Add authenticated Discord service ingress, allowlists, correlation and replay protection | #707 | codex | plugins/discord, apps/gateway | fix/tess-discord-ingress | TESS-PLAN-001 | 28K | TESS-SEC-005; security TDD | -| TESS-M1-SEC-005 | not-started | Redact/classify secret and PII before persistence/egress; harden provider login flow | #707 | codex | apps/gateway, packages/log | fix/tess-redaction | TESS-PLAN-001 | 28K | TESS-SEC-006; seeded canary tests | -| TESS-M1-SEC-006 | not-started | Scope session GC/retention or separate authorized global retention job | #707 | codex | apps/gateway, packages/log | fix/tess-session-gc-scope | TESS-PLAN-001 | 18K | TESS-SEC-009; isolation TDD | -| TESS-M1-001 | not-started | Define AgentRuntimeProvider, capabilities, session tree, normalized stream events/errors, attach semantics | #707 | codex | packages/types, packages/agent | feat/tess-runtime-contract | TESS-PLAN-001 | 25K | TESS-ARP-001, TESS-TRN-001; contract TDD | -| TESS-M1-002 | not-started | Implement provider registry/service with immutable actor scope, approval, audit and correlation boundaries | #707 | codex | apps/gateway, packages/agent | feat/tess-provider-registry | TESS-M1-001,TESS-M1-SEC-001,TESS-M1-SEC-002,TESS-M1-SEC-003 | 30K | TESS-SEC-001..004,007; security TDD | -| TESS-M1-003 | not-started | Implement tmux/fleet runtime provider and safe attach/message/terminate capability policy | #707 | codex | packages/mosaic, packages/agent | feat/tess-fleet-provider | TESS-M1-002 | 30K | TESS-FLT-001; exact target/identity tests | -| TESS-M1-OBS-001 | not-started | Implement correlation propagation, structured runtime/provider/tool audit, health/readiness and safe effective-policy status | #707 | codex | apps/gateway, packages/agent, packages/log | feat/tess-observability | TESS-M1-002 | 24K | TESS-OBS-001; no credential material | -| TESS-M1-V | not-started | Independent architecture/security review and complete contract/abuse-suite verification | #707 | sonnet | apps/gateway, packages/agent, packages/log, plugins/discord | review/tess-m1 | TESS-M1-SEC-001,TESS-M1-SEC-002,TESS-M1-SEC-003,TESS-M1-SEC-004,TESS-M1-SEC-005,TESS-M1-SEC-006,TESS-M1-003,TESS-M1-OBS-001 | 20K | Gate M2 | -| TESS-M2-001 | not-started | Add Tess roster/profile/service pinned to GPT-5.6 Sol high with fail-fast config and observable effective policy | #708 | codex | packages/mosaic/framework | feat/tess-pi-service | TESS-M1-V | 22K | TESS-PI-001; explicit AC-TESS-03 test | +| TESS-M1-SEC-001 | done | Enforce command scopes/roles and durable exact-action approval for privileged/destructive commands | #707 | codex | apps/gateway | fix/tess-command-authz | TESS-PLAN-001 | 25K | TESS-SEC-002; diskhygiene-terra; PR #718 head e3d98d73 MERGED (ROR 16829) | +| TESS-M1-SEC-002 | done | Enforce owner/tenant binding on session list/read/attach/send/terminate across REST and WS | #707 | codex | apps/gateway | fix/tess-session-ownership | TESS-PLAN-001 | 30K | TESS-SEC-003; coder0; PR #715 head 43ffbe7b MERGED (ROR 16808) | +| TESS-M1-SEC-003 | done | Bind MCP actor/tenant to authenticated context and add per-tool scopes | #707 | codex | apps/gateway | fix/tess-mcp-identity | TESS-PLAN-001 | 22K | TESS-SEC-004; coder1; PR #717 head 9ab776f9 MERGED (ROR 16819) | +| TESS-M1-SEC-004 | done | Add authenticated Discord service ingress, allowlists, correlation and replay protection | #707 | codex | plugins/discord, apps/gateway | fix/tess-discord-ingress | TESS-PLAN-001 | 28K | TESS-SEC-005; coder4; PR #716 head 55ae77b6 MERGED (ROR 16830) | +| TESS-M1-SEC-005 | done | Redact/classify secret and PII before persistence/egress; harden provider login flow | #707 | codex | apps/gateway, packages/log | fix/tess-redaction | TESS-PLAN-001 | 28K | TESS-SEC-006; coder1 (Mos-routed from blocked wrapfix-terra); mis-based #723 CLOSED superseded. MERGED to main: PR #725 head 726f7ab7 (ROR 16880). Verified chat persistence + egress redaction hooks; canary tests split-secret/private-key/overflow/persistence classification; provider login no auth URL/raw token in chat output | +| TESS-M1-SEC-006 | done | Scope session GC/retention or separate authorized global retention job | #707 | codex | apps/gateway, packages/log | fix/tess-session-gc-scope | TESS-PLAN-001 | 18K | TESS-SEC-009; coder4; PR #720 base=main. MERGED to main: head 37be090e (ROR 16861). Both blockers fixed: glob metachar sessionIds escaped+regression; durable approval survives GC pass (create→GC→key remains→authz succeeds); /gc disabled, per-session log demotion, no fullCollect/sweepOrphans entry, legacy repeatable GC schedule removed | +| TESS-M1-001 | done | Define AgentRuntimeProvider, capabilities, session tree, normalized stream events/errors, attach semantics | #707 | codex | packages/types, packages/agent | feat/tess-runtime-contract | TESS-PLAN-001 | 25K | TESS-ARP-001, TESS-TRN-001; wrapfix-terra; PR #719 head 5c42e67f MERGED (ROR 16813) | +| TESS-M1-002 | done | Implement provider registry/service with immutable actor scope, approval, audit and correlation boundaries | #707 | codex | apps/gateway, packages/agent | feat/tess-provider-registry | TESS-M1-001,TESS-M1-SEC-001,TESS-M1-SEC-002,TESS-M1-SEC-003 | 30K | TESS-SEC-001..004,007; coder0; PR #722 head c529022d MERGED (ROR 16849) | +| TESS-M1-003 | done | Implement tmux/fleet runtime provider and safe attach/message/terminate capability policy | #707 | codex | packages/mosaic, packages/agent | feat/tess-fleet-provider | TESS-M1-002 | 30K | TESS-FLT-001; coder0; PR #724 base=main. MERGED to main: head 355d814f (ROR 16868). HARD BOUNDARY verified: writes/control default-deny before tmux probing unless write authority permits; final exact-target authz after roster/socket/runtime verification; exact target/prefix/runtime-drift tests; read-only attach with immutable scope handles; empty-msg/default-deny/unverified-target tests; no credential material | +| TESS-M1-OBS-001 | done | Implement correlation propagation, structured runtime/provider/tool audit, health/readiness and safe effective-policy status | #707 | codex | apps/gateway, packages/agent, packages/log | feat/tess-observability-terra | TESS-M1-002 | 24K | TESS-OBS-001; SOLE owner=diskhygiene-terra. MERGED to main at REBASED head: PR #726 head 53f5414 (re-ROR comment 16886, prior 16881@adfa5c06 discarded after head move), CI 1723 green. Both @mosaicstack/log barrel exports coexist (redaction + runtime-audit), metadata-only/SHA-256 audit intact, fail-closed audit-before-side-effects intact, safe status/effective-policy/readiness | +| TESS-M1-V | done | Independent architecture/security review and complete contract/abuse-suite verification | #707 | mos-reviewer | apps/gateway, packages/agent, packages/log, plugins/discord | review/tess-m1 | TESS-M1-SEC-001,TESS-M1-SEC-002,TESS-M1-SEC-003,TESS-M1-SEC-004,TESS-M1-SEC-005,TESS-M1-SEC-006,TESS-M1-003,TESS-M1-OBS-001 | 20K | Gate M2 = PASS (Mos-owned independent non-author reviewer, 2026-07-12): all 5 priority seams verified live, 60+ tests green. M2 (#708) OPEN. Orchestrator did NOT run a competing lane (prior reviewer-lane dispatch recalled). Non-gating follow-up from this review captured as TESS-M1-FUP-001 (execute() authz-error mis-classification), scheduled to land before/with TESS-M3-001 — not now | +| TESS-M1-FUP-001 | not-started | RuntimeProviderService.execute() records provider-thrown authorization errors as failed/provider_error instead of denied/policy_denied | #707 | — | apps/gateway, packages/agent | — | — | 6K | NON-GATING follow-up from M1-V review (2026-07-12). Provider-thrown authz errors (e.g. FleetRuntimeProviderError 'forbidden' from M1-003 write-authority) mis-classify as outcome:'failed'/'provider_error' rather than 'denied'/'policy_denied'. Low-priority. Land BEFORE/WITH TESS-M3-001 fleet-provider registry wiring (#709), NOT now | +| TESS-M2-001 | in-progress | Add Tess roster/profile/service pinned to GPT-5.6 Sol high with fail-fast config and observable effective policy | #708 | coder0 | packages/mosaic | feat/tess-pi-service | TESS-M1-V | 22K | TESS-PI-001; explicit AC-TESS-03 test. Mos-dispatched DIRECTLY to coder0 (2026-07-12) — orchestrator tracks, does NOT dispatch a competing lane. Branch feat/tess-pi-service from fresh origin/main 86a50138; first TDD packages/mosaic/src/fleet/tess-service-profile.test.ts. NAME-AS-CONFIG INVARIANT (MISSION-MANIFEST #6, b01fdf11): agent name = config parameter (default example only), NO hardcoded key/identifier/default; profile carries name as DATA; M2 exit gate must prove a DISTINCT name provisions cleanly with ZERO code change (coder0 enforcing via a 'Nova' provisioning case). base=main, PR-open-STOP, independent non-author ROR then Mos merges. | | TESS-M2-002 | not-started | Implement durable session identity, inbox/outbox, approval, checkpoint, handoff, compaction and restart recovery | #708 | codex | apps/gateway, packages/agent, packages/db | feat/tess-durable-state | TESS-M2-001 | 38K | TESS-STA-001, TESS-SEC-007..008; recovery TDD | | TESS-M2-V | not-started | Clean-host Pi launch plus model/policy status and restart/compaction/duplicate-side-effect verification | #708 | sonnet | apps/gateway/src/__tests__/integration, packages/mosaic/src | review/tess-m2 | TESS-M2-002 | 18K | Gate M3; AC-TESS-03/06 | | TESS-M3-001 | not-started | Bind dedicated Tess Discord channel with streaming, threads, attachments, pairing/RBAC and approvals | #709 | codex | plugins/discord, apps/gateway | feat/tess-discord | TESS-M2-V,TESS-M1-SEC-004 | 35K | TESS-DSC-001 | From e3b5113be21e51d015fa1ae54572929b2a4acd9f Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Mon, 13 Jul 2026 02:59:27 +0000 Subject: [PATCH 020/152] feat(tess): add configurable Pi interaction service (#728) --- docs/scratchpads/tess-m2-001-pi-service.md | 40 +++++ docs/tess/ARCHITECTURE.md | 2 + packages/mosaic/framework/fleet/README.md | 15 ++ .../fleet/examples/operator-interaction.yaml | 19 +++ .../fleet/roles/operator-interaction.md | 11 ++ .../mosaic/framework/fleet/roster.schema.json | 12 ++ .../fleet/services/operator-interaction.yaml | 5 + .../mosaic/framework/systemd/user/README.md | 16 +- .../user/mosaic-interaction-agent@.service | 17 ++ .../systemd/user/test-fleet-units.sh | 7 +- .../print-interaction-effective-policy.sh | 18 ++ .../tools/fleet/start-agent-session.sh | 16 +- .../tools/fleet/start-interaction-service.sh | 19 +++ .../tools/fleet/test-start-agent-session.sh | 3 + .../src/commands/compose-contract.spec.ts | 13 ++ packages/mosaic/src/commands/fleet.ts | 58 ++++++- packages/mosaic/src/commands/launch.ts | 14 ++ .../src/fleet/interaction-service-profile.ts | 110 ++++++++++++ .../src/fleet/tess-service-profile.test.ts | 161 ++++++++++++++++++ packages/mosaic/src/index.ts | 1 + 20 files changed, 550 insertions(+), 7 deletions(-) create mode 100644 docs/scratchpads/tess-m2-001-pi-service.md create mode 100644 packages/mosaic/framework/fleet/examples/operator-interaction.yaml create mode 100644 packages/mosaic/framework/fleet/roles/operator-interaction.md create mode 100644 packages/mosaic/framework/fleet/services/operator-interaction.yaml create mode 100644 packages/mosaic/framework/systemd/user/mosaic-interaction-agent@.service create mode 100755 packages/mosaic/framework/tools/fleet/print-interaction-effective-policy.sh create mode 100755 packages/mosaic/framework/tools/fleet/start-interaction-service.sh create mode 100644 packages/mosaic/src/fleet/interaction-service-profile.ts create mode 100644 packages/mosaic/src/fleet/tess-service-profile.test.ts diff --git a/docs/scratchpads/tess-m2-001-pi-service.md b/docs/scratchpads/tess-m2-001-pi-service.md new file mode 100644 index 00000000..16aaffde --- /dev/null +++ b/docs/scratchpads/tess-m2-001-pi-service.md @@ -0,0 +1,40 @@ +# TESS-M2-001 — Pi Interaction Service + +## Scope + +- Add a generic rostered/systemd Pi operator-interaction service in + `packages/mosaic/framework`. +- Pin the service to `openai/gpt-5.6-sol`, high reasoning, and the + `operator-interaction` tool policy. +- Keep identity as provisioning data; the product name appears only in the + committed example roster. + +## Security and Configuration Invariants + +1. The chosen display/roster name is supplied as data and must exactly match the + generic systemd instance. +2. The service fails before launch if runtime, model, reasoning, or tool policy + differs from the pinned policy. +3. Effective-policy output includes only name, runtime, model, reasoning, and + tool policy; it does not inspect or output credential variables. +4. The default example is replaceable without a source change; the TDD suite + provisions `Nova` from the same profile. + +## Evidence + +- `src/fleet/tess-service-profile.test.ts` proves a `Nova` provisioning path, + roster parser/env serialization, effective-policy output, fail-fast drift + rejection, and absence of the product name from generic source/profile. +- `test-fleet-units.sh` validates the generic interaction systemd unit requires + per-agent config and invokes fail-fast startup validation. +- `test-start-agent-session.sh` proves the tool-policy value is exported into + the Pi pane; `compose-contract.spec.ts` proves it becomes an explicit + runtime contract block. +- Fresh-worktree dependency install plus root `pnpm typecheck`, `pnpm lint`, + `pnpm format:check`, and `pnpm test` passed; package/full fleet suites passed. +- Independent Codex code and security reviews passed with no remaining findings. + +## Delivery Notes + +- Branch starts from fresh `origin/main` at `86a50138`. +- PR targets `main` and references issue `#708`. diff --git a/docs/tess/ARCHITECTURE.md b/docs/tess/ARCHITECTURE.md index bd8e647e..4c031620 100644 --- a/docs/tess/ARCHITECTURE.md +++ b/docs/tess/ARCHITECTURE.md @@ -81,3 +81,5 @@ The provider advertises list, tree, read-only attach, send, and terminate. List/ ## Deployment Tess runs as a rostered, systemd-supervised Pi agent using GPT-5.6 Sol and high reasoning. Secrets are supplied through approved runtime secret mechanisms. Startup fails when required model, gateway identity, Discord binding, or durable-state dependencies are missing. Health reports effective model/reasoning/tool policy without credential material. + +The interaction-service identity is provisioning data, not a source identifier: the roster and per-agent environment carry the chosen display/roster name into a generic systemd instance. The service rejects a name mismatch or any drift from its pinned Pi/GPT-5.6 Sol/high/operator-interaction effective policy before launch. Its policy printer exposes only those resolved safe fields. diff --git a/packages/mosaic/framework/fleet/README.md b/packages/mosaic/framework/fleet/README.md index b1480fd1..36cfb1e1 100644 --- a/packages/mosaic/framework/fleet/README.md +++ b/packages/mosaic/framework/fleet/README.md @@ -15,6 +15,21 @@ default tmux server. - `examples/minimal.yaml` starts one local canary slot. - `examples/local-canary.yaml` starts a small generic dogfood fleet. +- `examples/operator-interaction.yaml` is an example Pi operator-interaction + service; replace its example agent name before provisioning. + +## Operator interaction service + +`services/operator-interaction.yaml` pins the Pi runtime, GPT-5.6 Sol model, +high reasoning, and the `operator-interaction` tool policy. The agent identity +is provisioning data: choose a roster name, generate its per-agent environment +file, then start the matching generic systemd instance. The service fails before +launch if the configured identity does not match the instance or any pinned +policy field drifts. + +The installed `tools/fleet/print-interaction-effective-policy.sh` prints only +the resolved name, runtime, model, reasoning, and tool policy. It never reads +or prints credential variables. Initialize a roster: diff --git a/packages/mosaic/framework/fleet/examples/operator-interaction.yaml b/packages/mosaic/framework/fleet/examples/operator-interaction.yaml new file mode 100644 index 00000000..1f9a4f9f --- /dev/null +++ b/packages/mosaic/framework/fleet/examples/operator-interaction.yaml @@ -0,0 +1,19 @@ +# Example instance only. Replace `Tess` with the chosen provisioned identity. +version: 1 +transport: tmux +tmux: + socket_name: mosaic-fleet + holder_session: _holder +defaults: + working_directory: ~/src +runtimes: + pi: + reset_command: /new +agents: + - name: Tess + runtime: pi + class: operator-interaction + model_hint: openai/gpt-5.6-sol + reasoning_level: high + tool_policy: operator-interaction + persistent_persona: true diff --git a/packages/mosaic/framework/fleet/roles/operator-interaction.md b/packages/mosaic/framework/fleet/roles/operator-interaction.md new file mode 100644 index 00000000..48f2490e --- /dev/null +++ b/packages/mosaic/framework/fleet/roles/operator-interaction.md @@ -0,0 +1,11 @@ +# Operator Interaction — fleet role definition + +The **operator-interaction** role is the authorized human interaction plane for +Mosaic. It presents runtime and fleet state, mediates approved actions, and +hands coding or general orchestration work to Mos. + +## Boundaries + +- It does not claim Mos-owned coding or general orchestration work. +- It exposes only the configured, observable tool policy. +- It does not receive or surface credentials in its effective policy. diff --git a/packages/mosaic/framework/fleet/roster.schema.json b/packages/mosaic/framework/fleet/roster.schema.json index 43270758..85b862c5 100644 --- a/packages/mosaic/framework/fleet/roster.schema.json +++ b/packages/mosaic/framework/fleet/roster.schema.json @@ -105,6 +105,18 @@ "modelHint": { "type": "string" }, + "reasoning_level": { + "type": "string" + }, + "reasoningLevel": { + "type": "string" + }, + "tool_policy": { + "type": "string" + }, + "toolPolicy": { + "type": "string" + }, "persistent_persona": { "oneOf": [{ "type": "boolean" }, { "type": "string" }] }, diff --git a/packages/mosaic/framework/fleet/services/operator-interaction.yaml b/packages/mosaic/framework/fleet/services/operator-interaction.yaml new file mode 100644 index 00000000..1d016fdc --- /dev/null +++ b/packages/mosaic/framework/fleet/services/operator-interaction.yaml @@ -0,0 +1,5 @@ +# Generic service policy. Provisioning supplies the agent name as data. +runtime: pi +model: openai/gpt-5.6-sol +reasoning: high +tool_policy: operator-interaction diff --git a/packages/mosaic/framework/systemd/user/README.md b/packages/mosaic/framework/systemd/user/README.md index f1e0b8aa..1811ead4 100644 --- a/packages/mosaic/framework/systemd/user/README.md +++ b/packages/mosaic/framework/systemd/user/README.md @@ -12,6 +12,8 @@ exact-match session. - `mosaic-tmux-holder.service` — user-mode holder that owns the named tmux server. - `mosaic-agent@.service` — user-mode template for one reusable agent session. +- `mosaic-interaction-agent@.service` — generic Pi operator-interaction template + that fails fast when its pinned runtime policy is incomplete or changed. - `test-fleet-units.sh` — validates unit syntax and required relationships. The agent template calls: @@ -45,12 +47,22 @@ MOSAIC_AGENT_WORKDIR=$HOME/src/your-project ```bash mkdir -p ~/.config/systemd/user ~/.config/mosaic/tools/fleet ~/.config/mosaic/fleet/agents cp packages/mosaic/framework/systemd/user/mosaic-*.service ~/.config/systemd/user/ -cp packages/mosaic/framework/tools/fleet/start-agent-session.sh ~/.config/mosaic/tools/fleet/ -chmod +x ~/.config/mosaic/tools/fleet/start-agent-session.sh +cp packages/mosaic/framework/tools/fleet/*.sh ~/.config/mosaic/tools/fleet/ +chmod +x ~/.config/mosaic/tools/fleet/*.sh systemctl --user daemon-reload systemctl --user start mosaic-tmux-holder.service systemctl --user start mosaic-agent@canary.service tmux -L mosaic-fleet ls + +# For an operator-interaction service, the roster/env identity selects the +# generic unit instance; no service source is renamed for an instance. +systemctl --user start mosaic-interaction-agent@.service +MOSAIC_AGENT_NAME= \ +MOSAIC_AGENT_RUNTIME=pi \ +MOSAIC_AGENT_MODEL=openai/gpt-5.6-sol \ +MOSAIC_AGENT_REASONING=high \ +MOSAIC_AGENT_TOOL_POLICY=operator-interaction \ + ~/.config/mosaic/tools/fleet/print-interaction-effective-policy.sh ``` Do not use `tmux kill-server` without `-L mosaic-fleet`; this pattern is meant diff --git a/packages/mosaic/framework/systemd/user/mosaic-interaction-agent@.service b/packages/mosaic/framework/systemd/user/mosaic-interaction-agent@.service new file mode 100644 index 00000000..346dbd80 --- /dev/null +++ b/packages/mosaic/framework/systemd/user/mosaic-interaction-agent@.service @@ -0,0 +1,17 @@ +[Unit] +Description=Mosaic operator interaction agent %i +Documentation=https://git.mosaicstack.dev/mosaicstack/stack +Requires=mosaic-tmux-holder.service +After=mosaic-tmux-holder.service +PartOf=mosaic-tmux-holder.service + +[Service] +Type=oneshot +RemainAfterExit=yes +Environment=MOSAIC_AGENT_NAME=%i +EnvironmentFile=%h/.config/mosaic/fleet/agents/%i.env +ExecStart=/bin/bash %h/.config/mosaic/tools/fleet/start-interaction-service.sh %i +ExecStop=-/bin/bash -lc 'if [ -n "${MOSAIC_TMUX_SOCKET:-}" ]; then tmux -L "$MOSAIC_TMUX_SOCKET" kill-session -t "=%i"; else tmux kill-session -t "=%i"; fi' + +[Install] +WantedBy=default.target diff --git a/packages/mosaic/framework/systemd/user/test-fleet-units.sh b/packages/mosaic/framework/systemd/user/test-fleet-units.sh index de5d7c2a..041a3251 100755 --- a/packages/mosaic/framework/systemd/user/test-fleet-units.sh +++ b/packages/mosaic/framework/systemd/user/test-fleet-units.sh @@ -4,6 +4,7 @@ set -euo pipefail SCRIPT_DIR=$(cd -- "$(dirname -- "$0")" && pwd) HOLDER="$SCRIPT_DIR/mosaic-tmux-holder.service" AGENT="$SCRIPT_DIR/mosaic-agent@.service" +INTERACTION="$SCRIPT_DIR/mosaic-interaction-agent@.service" fail() { echo "FAIL: $*" >&2 @@ -12,6 +13,7 @@ fail() { [ -f "$HOLDER" ] || fail "missing mosaic-tmux-holder.service" [ -f "$AGENT" ] || fail "missing mosaic-agent@.service" +[ -f "$INTERACTION" ] || fail "missing mosaic-interaction-agent@.service" grep -qF 'ExecStart=' "$HOLDER" || fail "holder has no ExecStart" grep -qF 'tmux -L' "$HOLDER" || fail "holder does not use named tmux socket" @@ -19,9 +21,12 @@ grep -qF '_holder' "$HOLDER" || fail "holder session is not explicit" grep -qF 'Requires=mosaic-tmux-holder.service' "$AGENT" || fail "agent does not require holder" grep -qF 'start-agent-session.sh' "$AGENT" || fail "agent unit does not call start-agent-session.sh" grep -qF 'kill-session -t "=%i"' "$AGENT" || fail "agent stop does not exact-match its session" +grep -qF 'Requires=mosaic-tmux-holder.service' "$INTERACTION" || fail "interaction service does not require holder" +grep -qF 'EnvironmentFile=%h/.config/mosaic/fleet/agents/%i.env' "$INTERACTION" || fail "interaction service does not require per-agent config" +grep -qF 'start-interaction-service.sh %i' "$INTERACTION" || fail "interaction service does not validate before startup" if command -v systemd-analyze >/dev/null 2>&1; then - systemd-analyze verify --user "$HOLDER" "$AGENT" >/tmp/mosaic-fleet-systemd-verify.log 2>&1 || { + systemd-analyze verify --user "$HOLDER" "$AGENT" "$INTERACTION" >/tmp/mosaic-fleet-systemd-verify.log 2>&1 || { cat /tmp/mosaic-fleet-systemd-verify.log >&2 fail "systemd-analyze verify failed" } diff --git a/packages/mosaic/framework/tools/fleet/print-interaction-effective-policy.sh b/packages/mosaic/framework/tools/fleet/print-interaction-effective-policy.sh new file mode 100755 index 00000000..9f31e0aa --- /dev/null +++ b/packages/mosaic/framework/tools/fleet/print-interaction-effective-policy.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail + +AGENT_NAME=${MOSAIC_AGENT_NAME:-} +RUNTIME=${MOSAIC_AGENT_RUNTIME:-} +MODEL=${MOSAIC_AGENT_MODEL:-} +REASONING=${MOSAIC_AGENT_REASONING:-} +TOOL_POLICY=${MOSAIC_AGENT_TOOL_POLICY:-} + +[ -n "$AGENT_NAME" ] || { echo 'ERROR: MOSAIC_AGENT_NAME is required' >&2; exit 64; } +[[ "$AGENT_NAME" =~ ^[A-Za-z0-9_.-]+$ ]] || { echo 'ERROR: invalid agent name' >&2; exit 64; } +[ "$RUNTIME" = 'pi' ] || { echo 'ERROR: invalid runtime policy' >&2; exit 64; } +[ "$MODEL" = 'openai/gpt-5.6-sol' ] || { echo 'ERROR: invalid model policy' >&2; exit 64; } +[ "$REASONING" = 'high' ] || { echo 'ERROR: invalid reasoning policy' >&2; exit 64; } +[ "$TOOL_POLICY" = 'operator-interaction' ] || { echo 'ERROR: invalid tool policy' >&2; exit 64; } + +printf '{"agentName":"%s","runtime":"%s","model":"%s","reasoning":"%s","toolPolicy":"%s"}\n' \ + "$AGENT_NAME" "$RUNTIME" "$MODEL" "$REASONING" "$TOOL_POLICY" diff --git a/packages/mosaic/framework/tools/fleet/start-agent-session.sh b/packages/mosaic/framework/tools/fleet/start-agent-session.sh index 7bb9731b..e5c9f2c5 100755 --- a/packages/mosaic/framework/tools/fleet/start-agent-session.sh +++ b/packages/mosaic/framework/tools/fleet/start-agent-session.sh @@ -8,6 +8,7 @@ AGENT_NAME=${1:-${MOSAIC_AGENT_NAME:-}} MOSAIC_TMUX_SOCKET=${MOSAIC_TMUX_SOCKET:-} MOSAIC_AGENT_RUNTIME=${MOSAIC_AGENT_RUNTIME:-pi} MOSAIC_AGENT_MODEL=${MOSAIC_AGENT_MODEL:-} +MOSAIC_AGENT_REASONING=${MOSAIC_AGENT_REASONING:-} MOSAIC_AGENT_WORKDIR=${MOSAIC_AGENT_WORKDIR:-$HOME} MOSAIC_AGENT_COMMAND=${MOSAIC_AGENT_COMMAND:-} MOSAIC_HEARTBEAT_RUN_DIR=${MOSAIC_HEARTBEAT_RUN_DIR:-${MOSAIC_HOME:-$HOME/.config/mosaic}/fleet/run} @@ -18,6 +19,14 @@ if [ -z "$AGENT_NAME" ]; then exit 64 fi +case "$MOSAIC_AGENT_REASONING" in + ''|low|medium|high) ;; + *) + echo "ERROR: MOSAIC_AGENT_REASONING must be low, medium, or high" >&2 + exit 64 + ;; +esac + if ! command -v tmux >/dev/null 2>&1; then echo "ERROR: tmux is required" >&2 exit 69 @@ -41,7 +50,7 @@ fi if [ -z "$MOSAIC_AGENT_COMMAND" ]; then # Map the roster's per-agent model_hint to `--model` so workers launch on the # configured model (e.g. pi on openai-codex/gpt-5.5:high). Omitted when unset. - MOSAIC_AGENT_COMMAND="mosaic yolo $MOSAIC_AGENT_RUNTIME${MOSAIC_AGENT_MODEL:+ --model $MOSAIC_AGENT_MODEL}" + MOSAIC_AGENT_COMMAND="mosaic yolo $MOSAIC_AGENT_RUNTIME${MOSAIC_AGENT_MODEL:+ --model $MOSAIC_AGENT_MODEL}${MOSAIC_AGENT_REASONING:+ --thinking $MOSAIC_AGENT_REASONING}" fi # ── Derive a runtime-bin PATH prefix ───────────────────────────────────────── @@ -124,11 +133,12 @@ AGENT_NAME_Q=$(printf '%q' "$AGENT_NAME") # safe single bash token; an empty/unset class %q-quotes to '' and is a harmless # no-op downstream (readPersonaContractBlock returns '' for an empty class). AGENT_CLASS_Q=$(printf '%q' "${MOSAIC_AGENT_CLASS:-}") +AGENT_TOOL_POLICY_Q=$(printf '%q' "${MOSAIC_AGENT_TOOL_POLICY:-}") if [ -n "$MOSAIC_RUNTIME_BIN_PREFIX" ]; then - PANE_SHELL_SNIPPET="export MOSAIC_AGENT_NAME=${AGENT_NAME_Q}; export MOSAIC_AGENT_CLASS=${AGENT_CLASS_Q}; export PATH=\"${MOSAIC_RUNTIME_BIN_PREFIX}:\${PATH}\"; exec ${MOSAIC_AGENT_COMMAND}" + PANE_SHELL_SNIPPET="export MOSAIC_AGENT_NAME=${AGENT_NAME_Q}; export MOSAIC_AGENT_CLASS=${AGENT_CLASS_Q}; export MOSAIC_AGENT_TOOL_POLICY=${AGENT_TOOL_POLICY_Q}; export PATH=\"${MOSAIC_RUNTIME_BIN_PREFIX}:\${PATH}\"; exec ${MOSAIC_AGENT_COMMAND}" else - PANE_SHELL_SNIPPET="export MOSAIC_AGENT_NAME=${AGENT_NAME_Q}; export MOSAIC_AGENT_CLASS=${AGENT_CLASS_Q}; exec ${MOSAIC_AGENT_COMMAND}" + PANE_SHELL_SNIPPET="export MOSAIC_AGENT_NAME=${AGENT_NAME_Q}; export MOSAIC_AGENT_CLASS=${AGENT_CLASS_Q}; export MOSAIC_AGENT_TOOL_POLICY=${AGENT_TOOL_POLICY_Q}; exec ${MOSAIC_AGENT_COMMAND}" fi mkdir -p "$MOSAIC_AGENT_WORKDIR" diff --git a/packages/mosaic/framework/tools/fleet/start-interaction-service.sh b/packages/mosaic/framework/tools/fleet/start-interaction-service.sh new file mode 100755 index 00000000..4efc5397 --- /dev/null +++ b/packages/mosaic/framework/tools/fleet/start-interaction-service.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -euo pipefail + +AGENT_NAME=${1:-} + +fail() { + echo "ERROR: $*" >&2 + exit 64 +} + +[ -n "$AGENT_NAME" ] || fail "agent name argument is required" +[[ "$AGENT_NAME" =~ ^[A-Za-z0-9_.-]+$ ]] || fail "agent name contains unsupported characters" +[ "${MOSAIC_AGENT_NAME:-}" = "$AGENT_NAME" ] || fail "configured agent name must exactly match the service instance" +[ "${MOSAIC_AGENT_RUNTIME:-}" = "pi" ] || fail "operator interaction service requires runtime pi" +[ "${MOSAIC_AGENT_MODEL:-}" = "openai/gpt-5.6-sol" ] || fail "operator interaction service requires the pinned model" +[ "${MOSAIC_AGENT_REASONING:-}" = "high" ] || fail "operator interaction service requires high reasoning" +[ "${MOSAIC_AGENT_TOOL_POLICY:-}" = "operator-interaction" ] || fail "operator interaction service requires the operator-interaction tool policy" + +exec "$(cd -- "$(dirname -- "$0")" && pwd)/start-agent-session.sh" "$AGENT_NAME" diff --git a/packages/mosaic/framework/tools/fleet/test-start-agent-session.sh b/packages/mosaic/framework/tools/fleet/test-start-agent-session.sh index 837ef930..0e83ecc1 100755 --- a/packages/mosaic/framework/tools/fleet/test-start-agent-session.sh +++ b/packages/mosaic/framework/tools/fleet/test-start-agent-session.sh @@ -105,6 +105,7 @@ MOSAIC_TMUX_SOCKET="$SOCKET3" \ MOSAIC_AGENT_WORKDIR="$WORKDIR3" \ MOSAIC_AGENT_RUNTIME="pi" \ MOSAIC_AGENT_CLASS="code" \ +MOSAIC_AGENT_TOOL_POLICY="operator-interaction" \ MOSAIC_RUNTIME_BIN="$FAKE_RUNTIME_BIN" \ MOSAIC_AGENT_COMMAND="mosaic yolo pi --model openai-codex/gpt-5.5:high" \ MOSAIC_HEARTBEAT_RUN_DIR="$HB_RUN_DIR3" \ @@ -139,6 +140,8 @@ echo "$all_args" | grep -qF "export MOSAIC_AGENT_NAME=" || \ fail "pane command does not export MOSAIC_AGENT_NAME into the pane" echo "$all_args" | grep -qF "export MOSAIC_AGENT_CLASS=code" || \ fail "pane command does not export MOSAIC_AGENT_CLASS into the pane (persona would silently drop)" +echo "$all_args" | grep -qF "export MOSAIC_AGENT_TOOL_POLICY=operator-interaction" || \ + fail "pane command does not export MOSAIC_AGENT_TOOL_POLICY into the pane" # ── Test 4: when no extra runtime-bin dirs exist, exec still appears ─────────── TMUX_ARGS_FILE2=$(mktemp) diff --git a/packages/mosaic/src/commands/compose-contract.spec.ts b/packages/mosaic/src/commands/compose-contract.spec.ts index bc021e4f..6fcef512 100644 --- a/packages/mosaic/src/commands/compose-contract.spec.ts +++ b/packages/mosaic/src/commands/compose-contract.spec.ts @@ -165,6 +165,19 @@ describe('composeContract — overlay composer', () => { expect(composeContract('codex', fixture.home)).not.toContain('# pi runtime contract'); }); + it('injects the configured operator-interaction tool policy into the runtime contract', () => { + const previous = process.env['MOSAIC_AGENT_TOOL_POLICY']; + try { + process.env['MOSAIC_AGENT_TOOL_POLICY'] = 'operator-interaction'; + const out = composeContract('pi', fixture.home); + expect(out).toContain('# Fleet Tool Policy (operator-interaction)'); + expect(out).toContain('Denied by default: coding/general orchestration claims'); + } finally { + if (previous === undefined) delete process.env['MOSAIC_AGENT_TOOL_POLICY']; + else process.env['MOSAIC_AGENT_TOOL_POLICY'] = previous; + } + }); + // ── Persona contract injection (A3b) ────────────────────────────────────── // composeContract reads MOSAIC_AGENT_CLASS and injects the resolved persona // (override-aware). Save/restore the env so these tests don't leak state. diff --git a/packages/mosaic/src/commands/fleet.ts b/packages/mosaic/src/commands/fleet.ts index d287db56..293fc9bc 100644 --- a/packages/mosaic/src/commands/fleet.ts +++ b/packages/mosaic/src/commands/fleet.ts @@ -87,6 +87,10 @@ interface RawFleetRoster { workingDirectory?: unknown; model_hint?: unknown; modelHint?: unknown; + reasoning_level?: unknown; + reasoningLevel?: unknown; + tool_policy?: unknown; + toolPolicy?: unknown; persistent_persona?: unknown; persistentPersona?: unknown; reset_between_tasks?: unknown; @@ -102,6 +106,8 @@ export interface FleetAgent { className: string; workingDirectory?: string; modelHint?: string; + reasoningLevel?: string; + toolPolicy?: string; persistentPersona?: boolean | string; resetBetweenTasks?: boolean; kickstartTemplate?: string; @@ -510,6 +516,12 @@ export function generateAgentEnv(roster: FleetRoster, agent: FleetAgent): string // the `mosaic yolo` launch so workers run on the roster's model (e.g. pi on // openai-codex/gpt-5.5:high). Empty when the agent declares no model_hint. `MOSAIC_AGENT_MODEL=${shellEnvValue(agent.modelHint ?? '')}`, + ...(agent.reasoningLevel !== undefined + ? [`MOSAIC_AGENT_REASONING=${shellEnvValue(agent.reasoningLevel)}`] + : []), + ...(agent.toolPolicy !== undefined + ? [`MOSAIC_AGENT_TOOL_POLICY=${shellEnvValue(agent.toolPolicy)}`] + : []), `MOSAIC_AGENT_WORKDIR=${shellEnvValue(expandHome(workingDirectory))}`, `MOSAIC_TMUX_SOCKET=${shellEnvValue(roster.tmux.socketName)}`, '', @@ -2316,13 +2328,35 @@ async function installFleet(cmd: Command, frameworkRoot: string): Promise await mkdir(activePaths.agentEnvDir, { recursive: true }); const startAgentSessionPath = join(activePaths.fleetToolsDir, 'start-agent-session.sh'); + const startInteractionServicePath = join( + activePaths.fleetToolsDir, + 'start-interaction-service.sh', + ); + const printInteractionPolicyPath = join( + activePaths.fleetToolsDir, + 'print-interaction-effective-policy.sh', + ); const sendMessagePath = join(activePaths.tmuxToolsDir, 'send-message.sh'); const agentSendPath = join(activePaths.tmuxToolsDir, 'agent-send.sh'); - const executableToolPaths = [startAgentSessionPath, sendMessagePath, agentSendPath]; + const executableToolPaths = [ + startAgentSessionPath, + startInteractionServicePath, + printInteractionPolicyPath, + sendMessagePath, + agentSendPath, + ]; await copyFile( join(frameworkRoot, 'tools', 'fleet', 'start-agent-session.sh'), startAgentSessionPath, ); + await copyFile( + join(frameworkRoot, 'tools', 'fleet', 'start-interaction-service.sh'), + startInteractionServicePath, + ); + await copyFile( + join(frameworkRoot, 'tools', 'fleet', 'print-interaction-effective-policy.sh'), + printInteractionPolicyPath, + ); await copyFile(join(frameworkRoot, 'tools', 'tmux', 'send-message.sh'), sendMessagePath); await copyFile(join(frameworkRoot, 'tools', 'tmux', 'agent-send.sh'), agentSendPath); for (const toolPath of executableToolPaths) { @@ -2336,6 +2370,10 @@ async function installFleet(cmd: Command, frameworkRoot: string): Promise join(frameworkRoot, 'systemd', 'user', 'mosaic-agent@.service'), join(activePaths.systemdUserDir, 'mosaic-agent@.service'), ); + await copyFile( + join(frameworkRoot, 'systemd', 'user', 'mosaic-interaction-agent@.service'), + join(activePaths.systemdUserDir, 'mosaic-interaction-agent@.service'), + ); for (const agent of roster.agents) { const envPath = join(activePaths.agentEnvDir, `${agent.name}.env`); @@ -2462,6 +2500,10 @@ function normalizeAgent(raw: NonNullable[number]): Fle 'workingDirectory', 'model_hint', 'modelHint', + 'reasoning_level', + 'reasoningLevel', + 'tool_policy', + 'toolPolicy', 'persistent_persona', 'persistentPersona', 'reset_between_tasks', @@ -2493,6 +2535,14 @@ function normalizeAgent(raw: NonNullable[number]): Fle raw.model_hint ?? raw.modelHint, `Fleet roster agent "${name}" model_hint`, ), + reasoningLevel: optionalString( + raw.reasoning_level ?? raw.reasoningLevel, + `Fleet roster agent "${name}" reasoning_level`, + ), + toolPolicy: optionalString( + raw.tool_policy ?? raw.toolPolicy, + `Fleet roster agent "${name}" tool_policy`, + ), persistentPersona: optionalBooleanOrString( raw.persistent_persona ?? raw.persistentPersona, `Fleet roster agent "${name}" persistent_persona`, @@ -2783,6 +2833,12 @@ export function serializeRosterToYaml(roster: FleetRoster): string { if (agent.modelHint !== undefined) { raw['model_hint'] = agent.modelHint; } + if (agent.reasoningLevel !== undefined) { + raw['reasoning_level'] = agent.reasoningLevel; + } + if (agent.toolPolicy !== undefined) { + raw['tool_policy'] = agent.toolPolicy; + } if (agent.persistentPersona !== undefined) { raw['persistent_persona'] = agent.persistentPersona; } diff --git a/packages/mosaic/src/commands/launch.ts b/packages/mosaic/src/commands/launch.ts index fabff2a9..4d96f9af 100644 --- a/packages/mosaic/src/commands/launch.ts +++ b/packages/mosaic/src/commands/launch.ts @@ -395,6 +395,9 @@ For required push/merge/issue-close/release actions, execute without routine con const persona = readPersonaContractBlock(mosaicHome, process.env['MOSAIC_AGENT_CLASS']); if (persona) parts.push('\n\n' + persona); + const toolPolicy = readFleetToolPolicyBlock(process.env['MOSAIC_AGENT_TOOL_POLICY']); + if (toolPolicy) parts.push('\n\n' + toolPolicy); + // Fleet onboarding: when this is a spawned fleet agent (MOSAIC_AGENT_NAME set // and present in the roster), inject a comms cheat-sheet + peer roster so it // knows how to reach the orchestrator and its peers from its first turn. @@ -404,6 +407,17 @@ For required push/merge/issue-close/release actions, execute without routine con return parts.join('\n'); } +function readFleetToolPolicyBlock(policy: string | undefined): string { + if (policy !== 'operator-interaction') return ''; + return [ + '# Fleet Tool Policy (operator-interaction)', + '', + 'Permitted: authorized conversation, status, retrieval, and safe diagnostics.', + 'Denied by default: coding/general orchestration claims, direct fleet control, destructive actions, and credential access.', + 'Delegate Mos-owned work through the authorized handoff boundary.', + ].join('\n'); +} + /** @deprecated internal alias — use composeContract. Retained for call-site clarity. */ function buildRuntimePrompt(runtime: RuntimeName): string { return composeContract(runtime); diff --git a/packages/mosaic/src/fleet/interaction-service-profile.ts b/packages/mosaic/src/fleet/interaction-service-profile.ts new file mode 100644 index 00000000..d22fccb1 --- /dev/null +++ b/packages/mosaic/src/fleet/interaction-service-profile.ts @@ -0,0 +1,110 @@ +import { readFile } from 'node:fs/promises'; +import YAML from 'yaml'; +import type { FleetAgent } from '../commands/fleet.js'; + +const REQUIRED_RUNTIME = 'pi'; +const REQUIRED_MODEL = 'openai/gpt-5.6-sol'; +const REQUIRED_REASONING = 'high'; +const REQUIRED_TOOL_POLICY = 'operator-interaction'; +const AGENT_NAME_PATTERN = /^[A-Za-z0-9_.-]+$/; + +export interface InteractionServiceProfile { + runtime: string; + model: string; + reasoning: string; + toolPolicy: string; +} + +export interface EffectiveInteractionPolicy { + agentName: string; + runtime: string; + model: string; + reasoning: string; + toolPolicy: string; +} + +export class InteractionServiceProfileError extends Error { + constructor( + readonly code: 'invalid_profile' | 'invalid_request', + message: string, + ) { + super(message); + this.name = InteractionServiceProfileError.name; + } +} + +export async function readInteractionServiceProfile( + path: string, + overrides: Partial = {}, +): Promise { + const parsed: unknown = YAML.parse(await readFile(path, 'utf8')); + if (!isRecord(parsed)) { + throw new InteractionServiceProfileError( + 'invalid_profile', + 'Service profile must be an object', + ); + } + const profile: InteractionServiceProfile = { + runtime: valueOrUndefined(overrides.runtime, parsed['runtime']), + model: valueOrUndefined(overrides.model, parsed['model']), + reasoning: valueOrUndefined(overrides.reasoning, parsed['reasoning']), + toolPolicy: valueOrUndefined(overrides.toolPolicy, parsed['tool_policy']), + }; + assertPinnedPolicy(profile); + return profile; +} + +export function provisionInteractionService( + profile: InteractionServiceProfile, + input: { agentName: string }, +): { rosterAgent: FleetAgent; effectivePolicy: EffectiveInteractionPolicy } { + assertPinnedPolicy(profile); + if (!AGENT_NAME_PATTERN.test(input.agentName)) { + throw new InteractionServiceProfileError( + 'invalid_request', + 'Agent name must contain only letters, numbers, dots, underscores, or hyphens', + ); + } + const effectivePolicy: EffectiveInteractionPolicy = { + agentName: input.agentName, + runtime: profile.runtime, + model: profile.model, + reasoning: profile.reasoning, + toolPolicy: profile.toolPolicy, + }; + return { + rosterAgent: { + name: input.agentName, + runtime: profile.runtime, + className: profile.toolPolicy, + modelHint: profile.model, + reasoningLevel: profile.reasoning, + toolPolicy: profile.toolPolicy, + persistentPersona: true, + }, + effectivePolicy, + }; +} + +function assertPinnedPolicy(profile: InteractionServiceProfile): void { + const invalid = + profile.runtime !== REQUIRED_RUNTIME || + profile.model !== REQUIRED_MODEL || + profile.reasoning !== REQUIRED_REASONING || + profile.toolPolicy !== REQUIRED_TOOL_POLICY; + if (invalid) { + throw new InteractionServiceProfileError( + 'invalid_profile', + `Service profile must pin ${REQUIRED_RUNTIME}, ${REQUIRED_MODEL}, ${REQUIRED_REASONING}, and ${REQUIRED_TOOL_POLICY}`, + ); + } +} + +function valueOrUndefined(override: string | undefined, value: unknown): string { + if (override !== undefined) return override; + return typeof value === 'string' ? value : ''; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/packages/mosaic/src/fleet/tess-service-profile.test.ts b/packages/mosaic/src/fleet/tess-service-profile.test.ts new file mode 100644 index 00000000..5c651a58 --- /dev/null +++ b/packages/mosaic/src/fleet/tess-service-profile.test.ts @@ -0,0 +1,161 @@ +import { execFile } from 'node:child_process'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; +import { describe, expect, it } from 'vitest'; +import { generateAgentEnv, loadFleetRoster } from '../commands/fleet.js'; +import { + provisionInteractionService, + readInteractionServiceProfile, +} from './interaction-service-profile.js'; +import type { InteractionServiceProfileError } from './interaction-service-profile.js'; + +const frameworkFleet = resolve( + dirname(fileURLToPath(import.meta.url)), + '..', + '..', + 'framework', + 'fleet', +); +const profilePath = join(frameworkFleet, 'services', 'operator-interaction.yaml'); +const examplePath = join(frameworkFleet, 'examples', 'operator-interaction.yaml'); +const policyScript = join( + frameworkFleet, + '..', + 'tools', + 'fleet', + 'print-interaction-effective-policy.sh', +); +const interactionStartScript = join( + frameworkFleet, + '..', + 'tools', + 'fleet', + 'start-interaction-service.sh', +); +const agentStartScript = join(frameworkFleet, '..', 'tools', 'fleet', 'start-agent-session.sh'); +const execFileAsync = promisify(execFile); + +describe('operator interaction service profile', (): void => { + it('provisions a user-supplied Nova identity as data with the required effective policy', async (): Promise => { + const profile = await readInteractionServiceProfile(profilePath); + const provisioned = provisionInteractionService(profile, { agentName: 'Nova' }); + + expect(provisioned.rosterAgent).toEqual({ + name: 'Nova', + runtime: 'pi', + className: 'operator-interaction', + modelHint: 'openai/gpt-5.6-sol', + reasoningLevel: 'high', + toolPolicy: 'operator-interaction', + persistentPersona: true, + }); + expect(provisioned.effectivePolicy).toEqual({ + agentName: 'Nova', + runtime: 'pi', + model: 'openai/gpt-5.6-sol', + reasoning: 'high', + toolPolicy: 'operator-interaction', + }); + expect(JSON.stringify(provisioned.effectivePolicy)).not.toMatch( + /token|secret|credential|api.?key/i, + ); + }); + + it('accepts a renamed example roster and surfaces only its effective policy', async (): Promise => { + const directory = await mkdtemp(join(tmpdir(), 'mosaic-interaction-profile-')); + const rosterPath = join(directory, 'roster.yaml'); + try { + const example = await readFile(examplePath, 'utf8'); + await writeFile(rosterPath, example.replace('name: Tess', 'name: Nova'), 'utf8'); + const roster = await loadFleetRoster(rosterPath); + const agent = roster.agents[0]!; + + expect(agent.name).toBe('Nova'); + expect(agent.reasoningLevel).toBe('high'); + expect(agent.toolPolicy).toBe('operator-interaction'); + expect(generateAgentEnv(roster, agent)).toContain('MOSAIC_AGENT_NAME=Nova'); + expect(generateAgentEnv(roster, agent)).toContain('MOSAIC_AGENT_REASONING=high'); + + const { stdout } = await execFileAsync(policyScript, [], { + env: { + ...process.env, + MOSAIC_AGENT_NAME: agent.name, + MOSAIC_AGENT_RUNTIME: agent.runtime, + MOSAIC_AGENT_MODEL: agent.modelHint, + MOSAIC_AGENT_REASONING: agent.reasoningLevel, + MOSAIC_AGENT_TOOL_POLICY: agent.toolPolicy, + }, + }); + expect(JSON.parse(stdout)).toEqual({ + agentName: 'Nova', + runtime: 'pi', + model: 'openai/gpt-5.6-sol', + reasoning: 'high', + toolPolicy: 'operator-interaction', + }); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + it('fails before launch when service environment drifts from the pinned policy', async (): Promise => { + await expect( + execFileAsync(interactionStartScript, ['Nova'], { + env: { + ...process.env, + MOSAIC_AGENT_NAME: 'Nova', + MOSAIC_AGENT_RUNTIME: 'pi', + MOSAIC_AGENT_MODEL: 'other/model', + MOSAIC_AGENT_REASONING: 'high', + MOSAIC_AGENT_TOOL_POLICY: 'operator-interaction', + }, + }), + ).rejects.toMatchObject({ code: 64 }); + }); + + it('rejects unsafe names from the policy printer and reasoning before tmux launch', async (): Promise => { + await expect( + execFileAsync(policyScript, [], { + env: { + ...process.env, + MOSAIC_AGENT_NAME: 'Nova"bad', + MOSAIC_AGENT_RUNTIME: 'pi', + MOSAIC_AGENT_MODEL: 'openai/gpt-5.6-sol', + MOSAIC_AGENT_REASONING: 'high', + MOSAIC_AGENT_TOOL_POLICY: 'operator-interaction', + }, + }), + ).rejects.toMatchObject({ code: 64 }); + await expect( + execFileAsync(agentStartScript, ['Nova'], { + env: { ...process.env, MOSAIC_AGENT_REASONING: 'high; id' }, + }), + ).rejects.toMatchObject({ code: 64 }); + }); + + it('keeps the product name confined to an example instance, not service source or defaults', async (): Promise => { + const [profile, source, example] = await Promise.all([ + readFile(profilePath, 'utf8'), + readFile(new URL('./interaction-service-profile.ts', import.meta.url), 'utf8'), + readFile(examplePath, 'utf8'), + ]); + + expect(profile).not.toMatch(/tess/i); + expect(source).not.toMatch(/tess/i); + expect(example).toMatch(/name: Tess/); + }); + + it('fails fast when a required policy field is absent or changed from the pinned policy', async (): Promise => { + await expect(readInteractionServiceProfile(profilePath, { model: '' })).rejects.toMatchObject({ + code: 'invalid_profile', + } satisfies Partial); + await expect( + readInteractionServiceProfile(profilePath, { reasoning: 'medium' }), + ).rejects.toMatchObject({ + code: 'invalid_profile', + } satisfies Partial); + }); +}); diff --git a/packages/mosaic/src/index.ts b/packages/mosaic/src/index.ts index cc881e50..4ff41622 100644 --- a/packages/mosaic/src/index.ts +++ b/packages/mosaic/src/index.ts @@ -1,5 +1,6 @@ export const VERSION = '0.0.0'; +export * from './fleet/interaction-service-profile.js'; export * from './fleet/tmux-runtime-transport.js'; export { From 99a2d0fc9d7103154c89f61f5f8fff995488cceb Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Mon, 13 Jul 2026 05:14:11 +0000 Subject: [PATCH 021/152] feat(tess): persist durable session state (#729) --- .../runtime-provider-registry.service.test.ts | 3 + apps/gateway/src/agent/agent.module.ts | 13 +- .../runtime-provider-registry.service.ts | 8 + .../src/agent/tess-durable-session.dto.ts | 10 + .../tess-durable-session.repository.test.ts | 418 ++ .../agent/tess-durable-session.repository.ts | 521 ++ .../src/agent/tess-durable-session.service.ts | 93 + .../command-authorization.service.spec.ts | 59 +- .../commands/command-authorization.service.ts | 140 +- apps/gateway/src/commands/commands.module.ts | 9 +- .../src/commands/runtime-approval-verifier.ts | 23 + .../gateway/src/gc/session-gc.service.spec.ts | 2 +- docs/scratchpads/tess-m2-002-durable-state.md | 63 + docs/tess/ARCHITECTURE.md | 27 +- packages/agent/src/index.ts | 1 + .../agent/src/tess-durable-session.test.ts | 288 ++ packages/agent/src/tess-durable-session.ts | 498 ++ .../0012_interaction_durable_state.sql | 71 + .../0013_interaction_checkpoint_history.sql | 5 + .../0014_interaction_outbox_channel_scope.sql | 5 + ..._interaction_checkpoint_payload_digest.sql | 3 + packages/db/drizzle/meta/0012_snapshot.json | 4172 ++++++++++++++++ packages/db/drizzle/meta/0013_snapshot.json | 4214 ++++++++++++++++ packages/db/drizzle/meta/0014_snapshot.json | 4220 ++++++++++++++++ packages/db/drizzle/meta/0015_snapshot.json | 4244 +++++++++++++++++ packages/db/drizzle/meta/_journal.json | 28 + packages/db/src/schema.ts | 114 + 27 files changed, 19237 insertions(+), 15 deletions(-) create mode 100644 apps/gateway/src/agent/tess-durable-session.dto.ts create mode 100644 apps/gateway/src/agent/tess-durable-session.repository.test.ts create mode 100644 apps/gateway/src/agent/tess-durable-session.repository.ts create mode 100644 apps/gateway/src/agent/tess-durable-session.service.ts create mode 100644 apps/gateway/src/commands/runtime-approval-verifier.ts create mode 100644 docs/scratchpads/tess-m2-002-durable-state.md create mode 100644 packages/agent/src/tess-durable-session.test.ts create mode 100644 packages/agent/src/tess-durable-session.ts create mode 100644 packages/db/drizzle/0012_interaction_durable_state.sql create mode 100644 packages/db/drizzle/0013_interaction_checkpoint_history.sql create mode 100644 packages/db/drizzle/0014_interaction_outbox_channel_scope.sql create mode 100644 packages/db/drizzle/0015_interaction_checkpoint_payload_digest.sql create mode 100644 packages/db/drizzle/meta/0012_snapshot.json create mode 100644 packages/db/drizzle/meta/0013_snapshot.json create mode 100644 packages/db/drizzle/meta/0014_snapshot.json create mode 100644 packages/db/drizzle/meta/0015_snapshot.json diff --git a/apps/gateway/src/agent/__tests__/runtime-provider-registry.service.test.ts b/apps/gateway/src/agent/__tests__/runtime-provider-registry.service.test.ts index a521b5d7..a70eafe5 100644 --- a/apps/gateway/src/agent/__tests__/runtime-provider-registry.service.test.ts +++ b/apps/gateway/src/agent/__tests__/runtime-provider-registry.service.test.ts @@ -22,6 +22,8 @@ import { type RuntimeApprovalVerifier, } from '../runtime-provider-registry.service.js'; +process.env['MOSAIC_AGENT_NAME'] ??= 'test-runtime-agent'; + const OWNER_SCOPE: ActorTenantScope = { userId: 'owner-1', tenantId: 'tenant-1' }; const CONTEXT = { actorScope: OWNER_SCOPE, @@ -243,6 +245,7 @@ describe('RuntimeProviderService security boundary', (): void => { tenantId: OWNER_SCOPE.tenantId, channelId: CONTEXT.channelId, correlationId: CONTEXT.correlationId, + agentName: process.env['MOSAIC_AGENT_NAME'], }); expect(provider.terminateCalls).toBe(1); }); diff --git a/apps/gateway/src/agent/agent.module.ts b/apps/gateway/src/agent/agent.module.ts index bb1c1a4a..8e90ef28 100644 --- a/apps/gateway/src/agent/agent.module.ts +++ b/apps/gateway/src/agent/agent.module.ts @@ -10,14 +10,17 @@ import { ProvidersController } from './providers.controller.js'; import { SessionsController } from './sessions.controller.js'; import { AgentConfigsController } from './agent-configs.controller.js'; import { RoutingController } from './routing/routing.controller.js'; +import { TessDurableSessionRepository } from './tess-durable-session.repository.js'; +import { TessDurableSessionService } from './tess-durable-session.service.js'; import { CoordModule } from '../coord/coord.module.js'; import { McpClientModule } from '../mcp-client/mcp-client.module.js'; import { SkillsModule } from '../skills/skills.module.js'; import { GCModule } from '../gc/gc.module.js'; import { LogModule } from '../log/log.module.js'; +import { CommandsModule } from '../commands/commands.module.js'; +import { CommandRuntimeApprovalVerifier } from '../commands/runtime-approval-verifier.js'; import { AGENT_RUNTIME_PROVIDER_REGISTRY, - DenyRuntimeApprovalVerifier, RUNTIME_APPROVAL_VERIFIER, RUNTIME_PROVIDER_AUDIT_SINK, RuntimeProviderAuditService, @@ -26,13 +29,15 @@ import { @Global() @Module({ - imports: [CoordModule, McpClientModule, SkillsModule, GCModule, LogModule], + imports: [CoordModule, McpClientModule, SkillsModule, GCModule, LogModule, CommandsModule], providers: [ ProviderService, ProviderCredentialsService, RoutingService, RoutingEngineService, SkillLoaderService, + TessDurableSessionRepository, + TessDurableSessionService, { provide: AGENT_RUNTIME_PROVIDER_REGISTRY, useFactory: (): AgentRuntimeProviderRegistry => new AgentRuntimeProviderRegistry(), @@ -42,10 +47,9 @@ import { provide: RUNTIME_PROVIDER_AUDIT_SINK, useExisting: RuntimeProviderAuditService, }, - DenyRuntimeApprovalVerifier, { provide: RUNTIME_APPROVAL_VERIFIER, - useExisting: DenyRuntimeApprovalVerifier, + useExisting: CommandRuntimeApprovalVerifier, }, RuntimeProviderService, AgentService, @@ -58,6 +62,7 @@ import { RoutingService, RoutingEngineService, SkillLoaderService, + TessDurableSessionService, RuntimeProviderService, AGENT_RUNTIME_PROVIDER_REGISTRY, ], diff --git a/apps/gateway/src/agent/runtime-provider-registry.service.ts b/apps/gateway/src/agent/runtime-provider-registry.service.ts index c56906ff..cb16a29e 100644 --- a/apps/gateway/src/agent/runtime-provider-registry.service.ts +++ b/apps/gateway/src/agent/runtime-provider-registry.service.ts @@ -64,12 +64,19 @@ export interface RuntimeTerminationAction { tenantId: string; channelId: string; correlationId: string; + agentName: string; } export interface RuntimeApprovalVerifier { consume(approvalRef: string, action: RuntimeTerminationAction): Promise; } +function configuredAgentName(): string { + const agentName = process.env['MOSAIC_AGENT_NAME']?.trim(); + if (!agentName) throw new RuntimeApprovalDeniedError(); + return agentName; +} + class RuntimeApprovalDeniedError extends Error { constructor() { super('Runtime termination approval denied'); @@ -261,6 +268,7 @@ export class RuntimeProviderService { tenantId: scope.tenantId, channelId: scope.channelId, correlationId: scope.correlationId, + agentName: configuredAgentName(), }); if (!approved) { throw new RuntimeApprovalDeniedError(); diff --git a/apps/gateway/src/agent/tess-durable-session.dto.ts b/apps/gateway/src/agent/tess-durable-session.dto.ts new file mode 100644 index 00000000..92487979 --- /dev/null +++ b/apps/gateway/src/agent/tess-durable-session.dto.ts @@ -0,0 +1,10 @@ +import type { RuntimeProviderRequestContext } from './runtime-provider-registry.service.js'; + +/** Server-side request for a replay-safe provider message. */ +export interface TessProviderOutboxDto { + sessionId: string; + idempotencyKey: string; + correlationId: string; + content: string; + context: RuntimeProviderRequestContext; +} diff --git a/apps/gateway/src/agent/tess-durable-session.repository.test.ts b/apps/gateway/src/agent/tess-durable-session.repository.test.ts new file mode 100644 index 00000000..c1944999 --- /dev/null +++ b/apps/gateway/src/agent/tess-durable-session.repository.test.ts @@ -0,0 +1,418 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createHash } from 'node:crypto'; +import { eq, sql, interactionCheckpoints, interactionInbox } from '@mosaicstack/db'; +import { DurableSessionCoordinator, type DurableSessionIdentity } from '@mosaicstack/agent'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPgliteDb, runPgliteMigrations, type DbHandle } from '@mosaicstack/db'; +import { TessDurableSessionRepository } from './tess-durable-session.repository.js'; +import { TessDurableSessionService } from './tess-durable-session.service.js'; + +const IDENTITY: DurableSessionIdentity = { + agentName: 'Nova', + sessionId: 'tess-pglite-session', + tenantId: 'tenant-pglite', + ownerId: 'tess-owner', + providerId: 'fleet', + runtimeSessionId: 'nova', +}; + +describe('TessDurableSessionRepository', () => { + let dataDir: string | undefined; + let handle: DbHandle; + let previousAuthSecret: string | undefined; + + beforeAll(async (): Promise => { + previousAuthSecret = process.env['BETTER_AUTH_SECRET']; + process.env['BETTER_AUTH_SECRET'] = 'tess-durable-state-test-sealing-key'; + dataDir = mkdtempSync(join(tmpdir(), 'tess-durable-state-')); + handle = createPgliteDb(dataDir); + await runPgliteMigrations(handle); + await seedOwner(handle); + }, 30_000); + + beforeEach(async (): Promise => { + await handle.db.execute(sql`DELETE FROM interaction_handoffs`); + await handle.db.execute(sql`DELETE FROM interaction_checkpoints`); + await handle.db.execute(sql`DELETE FROM interaction_inbox`); + await handle.db.execute(sql`DELETE FROM interaction_outbox`); + await handle.db.execute(sql`DELETE FROM interaction_sessions`); + }); + + afterAll(async (): Promise => { + await handle.close(); + if (dataDir) rmSync(dataDir, { recursive: true, force: true }); + if (previousAuthSecret === undefined) delete process.env['BETTER_AUTH_SECRET']; + else process.env['BETTER_AUTH_SECRET'] = previousAuthSecret; + }); + + it('survives a full PGlite close/reopen mid-session without duplicate inbox or outbox side effects', async () => { + const beforeRestart = new DurableSessionCoordinator( + new TessDurableSessionRepository(handle.db), + ); + await beforeRestart.create(IDENTITY); + await beforeRestart.receive({ + sessionId: IDENTITY.sessionId, + idempotencyKey: 'inbox-before-kill', + correlationId: 'correlation-before-kill', + content: 'resume after a kill', + }); + await beforeRestart.enqueueOutbox({ + sessionId: IDENTITY.sessionId, + idempotencyKey: 'outbox-before-kill', + correlationId: 'correlation-before-kill', + channelId: 'cli', + kind: 'provider.send', + content: 'one response only', + }); + await beforeRestart.checkpoint({ + sessionId: IDENTITY.sessionId, + checkpointId: 'checkpoint-before-kill', + cursor: 'cursor-before-kill', + summary: 'restart-safe state', + compactionEpoch: 1, + }); + await beforeRestart.handoff({ + sessionId: IDENTITY.sessionId, + handoffId: 'handoff-before-kill', + destination: 'mos', + correlationId: 'correlation-before-kill', + checkpointId: 'checkpoint-before-kill', + status: 'pending', + }); + await beforeRestart.checkpoint({ + sessionId: IDENTITY.sessionId, + checkpointId: 'checkpoint-after-handoff', + cursor: 'cursor-after-handoff', + summary: 'newer state cannot strand the portable handoff', + compactionEpoch: 2, + }); + + await handle.close(); + handle = createPgliteDb(dataDir!); + + const afterRestart = new DurableSessionCoordinator(new TessDurableSessionRepository(handle.db)); + const recovered = await afterRestart.recover(IDENTITY.sessionId); + const resumedHandoff = await afterRestart.resumeHandoff('handoff-before-kill'); + const handled: string[] = []; + const effects: string[] = []; + + await afterRestart.drainInbox(IDENTITY.sessionId, async (entry): Promise => { + handled.push(entry.idempotencyKey); + }); + await afterRestart.dispatchOutbox(IDENTITY.sessionId, async (entry): Promise => { + effects.push(entry.idempotencyKey); + }); + await afterRestart.drainInbox(IDENTITY.sessionId, async (entry): Promise => { + handled.push(entry.idempotencyKey); + }); + await afterRestart.dispatchOutbox(IDENTITY.sessionId, async (entry): Promise => { + effects.push(entry.idempotencyKey); + }); + + expect(recovered.identity).toEqual(IDENTITY); + expect(recovered.checkpoint).toMatchObject({ checkpointId: 'checkpoint-after-handoff' }); + expect(recovered.handoffs).toMatchObject([{ handoffId: 'handoff-before-kill' }]); + expect(resumedHandoff.checkpoint).toMatchObject({ checkpointId: 'checkpoint-before-kill' }); + expect(handled).toEqual(['inbox-before-kill']); + expect(effects).toEqual(['outbox-before-kill']); + }, 30_000); + + it('redacts sensitive durable payloads before persistence', async () => { + const coordinator = new DurableSessionCoordinator(new TessDurableSessionRepository(handle.db)); + await coordinator.create(IDENTITY); + await coordinator.receive({ + sessionId: IDENTITY.sessionId, + idempotencyKey: 'redacted-inbox', + correlationId: 'correlation-redaction', + content: 'api_key=super-secret-canary', + }); + await coordinator.enqueueOutbox({ + sessionId: IDENTITY.sessionId, + idempotencyKey: 'redacted-outbox', + correlationId: 'correlation-redaction', + channelId: 'cli', + kind: 'provider.send', + content: 'email operator@example.test api_key=super-secret-canary', + }); + await coordinator.checkpoint({ + sessionId: IDENTITY.sessionId, + checkpointId: 'redacted-checkpoint', + cursor: 'bearer super-secret-canary', + summary: 'email operator@example.test', + compactionEpoch: 0, + }); + + const snapshot = await coordinator.snapshot(IDENTITY.sessionId); + const [persisted] = await handle.db + .select({ content: interactionInbox.content }) + .from(interactionInbox) + .where(eq(interactionInbox.idempotencyKey, 'redacted-inbox')); + + expect(JSON.stringify(snapshot)).not.toContain('super-secret-canary'); + expect(JSON.stringify(snapshot)).not.toContain('operator@example.test'); + expect(persisted?.content).not.toContain('super-secret-canary'); + expect(persisted?.content).not.toContain('[REDACTED]'); + }, 30_000); + + it('fails closed when the configured idempotency secret is unavailable', async () => { + const coordinator = new DurableSessionCoordinator(new TessDurableSessionRepository(handle.db)); + await coordinator.create(IDENTITY); + const secret = process.env['BETTER_AUTH_SECRET']; + delete process.env['BETTER_AUTH_SECRET']; + try { + await expect( + coordinator.receive({ + sessionId: IDENTITY.sessionId, + idempotencyKey: 'requires-idempotency-secret', + correlationId: 'correlation-secret', + content: 'sensitive payload', + }), + ).rejects.toThrow(/required for durable idempotency digests/); + } finally { + if (secret === undefined) delete process.env['BETTER_AUTH_SECRET']; + else process.env['BETTER_AUTH_SECRET'] = secret; + } + }, 30_000); + + it('uses keyed pre-redaction digests to reject distinct sensitive checkpoint payloads', async () => { + const coordinator = new DurableSessionCoordinator(new TessDurableSessionRepository(handle.db)); + await coordinator.create(IDENTITY); + const input = { + sessionId: IDENTITY.sessionId, + checkpointId: 'checkpoint-secret-conflict', + cursor: 'api_key=secret-one', + summary: 'bearer secret-one', + compactionEpoch: 1, + }; + await coordinator.checkpoint(input); + + await expect( + coordinator.checkpoint({ + ...input, + cursor: 'api_key=secret-two', + summary: 'bearer secret-two', + }), + ).rejects.toThrow(/checkpoint identity conflict/); + + const [persisted] = await handle.db + .select({ + digest: interactionCheckpoints.contentDigest, + cursor: interactionCheckpoints.cursor, + }) + .from(interactionCheckpoints) + .where(eq(interactionCheckpoints.checkpointId, input.checkpointId)); + expect(persisted?.cursor).not.toContain('secret-one'); + expect(persisted?.digest).not.toBe( + createHash('sha256') + .update(JSON.stringify([input.cursor, input.summary])) + .digest('hex'), + ); + + await coordinator.checkpoint({ + ...input, + checkpointId: 'checkpoint-delimiter-conflict', + cursor: 'a\u0000b', + summary: 'c', + }); + await expect( + coordinator.checkpoint({ + ...input, + checkpointId: 'checkpoint-delimiter-conflict', + cursor: 'a', + summary: 'b\u0000c', + }), + ).rejects.toThrow(/checkpoint identity conflict/); + }, 30_000); + + it('rejects distinct sensitive inbox and outbox payloads under reused idempotency keys', async () => { + const coordinator = new DurableSessionCoordinator(new TessDurableSessionRepository(handle.db)); + await coordinator.create(IDENTITY); + const inbox = { + sessionId: IDENTITY.sessionId, + idempotencyKey: 'inbox-secret-conflict', + correlationId: 'correlation-inbox-secret', + content: 'api_key=secret-one', + }; + const outbox = { + sessionId: IDENTITY.sessionId, + idempotencyKey: 'outbox-secret-conflict', + correlationId: 'correlation-outbox-secret', + channelId: 'cli', + kind: 'provider.send', + content: 'api_key=secret-one', + }; + await coordinator.receive(inbox); + await coordinator.enqueueOutbox(outbox); + + await expect(coordinator.receive({ ...inbox, content: 'api_key=secret-two' })).rejects.toThrow( + /idempotency conflict/, + ); + await expect( + coordinator.enqueueOutbox({ ...outbox, content: 'api_key=secret-two' }), + ).rejects.toThrow(/idempotency conflict/); + }, 30_000); + + it('rejects database inbox and outbox idempotency-key conflicts', async () => { + const coordinator = new DurableSessionCoordinator(new TessDurableSessionRepository(handle.db)); + await coordinator.create(IDENTITY); + await coordinator.receive({ + sessionId: IDENTITY.sessionId, + idempotencyKey: 'inbox-conflict', + correlationId: 'correlation-inbox', + content: 'original inbox', + }); + await coordinator.enqueueOutbox({ + sessionId: IDENTITY.sessionId, + idempotencyKey: 'outbox-conflict', + correlationId: 'correlation-outbox', + channelId: 'cli', + kind: 'provider.send', + content: 'original outbox', + }); + + await expect( + coordinator.receive({ + sessionId: IDENTITY.sessionId, + idempotencyKey: 'inbox-conflict', + correlationId: 'forged-correlation', + content: 'original inbox', + }), + ).rejects.toThrow(/idempotency conflict/); + await expect( + coordinator.enqueueOutbox({ + sessionId: IDENTITY.sessionId, + idempotencyKey: 'outbox-conflict', + correlationId: 'correlation-outbox', + channelId: 'forged-channel', + kind: 'provider.send', + content: 'original outbox', + }), + ).rejects.toThrow(/idempotency conflict/); + }, 30_000); + + it('does not requeue a live outbox claim during a normal scoped dispatch', async () => { + const repository = new TessDurableSessionRepository(handle.db); + const coordinator = new DurableSessionCoordinator(repository); + const runtimeProviders = { sendMessage: vi.fn().mockResolvedValue(undefined) }; + const service = new TessDurableSessionService(repository, runtimeProviders as never); + const input = { + sessionId: IDENTITY.sessionId, + idempotencyKey: 'live-effect', + correlationId: 'correlation-live', + content: 'must not duplicate', + context: { + actorScope: { userId: IDENTITY.ownerId, tenantId: IDENTITY.tenantId }, + channelId: 'cli', + correlationId: 'correlation-live', + }, + }; + + await coordinator.create(IDENTITY); + await service.queueProviderSend(input); + expect(await repository.claimOutbox(IDENTITY.sessionId)).toMatchObject({ + status: 'processing', + }); + + await service.dispatchProviderOutbox(IDENTITY.sessionId, input); + await expect( + service.recoverProviderSession(IDENTITY.sessionId, { + ...input, + context: { + ...input.context, + actorScope: { userId: 'intruder', tenantId: 'tenant-pglite' }, + }, + }), + ).rejects.toThrow(/scope or correlation mismatch/); + + expect(runtimeProviders.sendMessage).not.toHaveBeenCalled(); + expect(await coordinator.snapshot(IDENTITY.sessionId)).toMatchObject({ + outbox: [{ idempotencyKey: 'live-effect', status: 'processing' }], + }); + }, 30_000); + + it('rejects an outbox correlation mismatch before claiming the pending effect', async () => { + const repository = new TessDurableSessionRepository(handle.db); + const coordinator = new DurableSessionCoordinator(repository); + const runtimeProviders = { sendMessage: vi.fn().mockResolvedValue(undefined) }; + const service = new TessDurableSessionService(repository, runtimeProviders as never); + const input = { + sessionId: IDENTITY.sessionId, + idempotencyKey: 'mismatch-effect', + correlationId: 'correlation-expected', + content: 'must remain pending', + context: { + actorScope: { userId: IDENTITY.ownerId, tenantId: IDENTITY.tenantId }, + channelId: 'cli', + correlationId: 'correlation-expected', + }, + }; + + await coordinator.create(IDENTITY); + await service.queueProviderSend(input); + await expect( + service.dispatchProviderOutbox(IDENTITY.sessionId, { + ...input, + correlationId: 'correlation-forged', + context: { ...input.context, correlationId: 'correlation-forged' }, + }), + ).rejects.toThrow(/scope or correlation mismatch/); + + expect(runtimeProviders.sendMessage).not.toHaveBeenCalled(); + expect(await coordinator.snapshot(IDENTITY.sessionId)).toMatchObject({ + outbox: [{ idempotencyKey: 'mismatch-effect', status: 'pending' }], + }); + }, 30_000); + + it('dispatches only the outbox record bound to the supplied correlation and channel', async () => { + const repository = new TessDurableSessionRepository(handle.db); + const coordinator = new DurableSessionCoordinator(repository); + const runtimeProviders = { sendMessage: vi.fn().mockResolvedValue(undefined) }; + const service = new TessDurableSessionService(repository, runtimeProviders as never); + const first = { + sessionId: IDENTITY.sessionId, + idempotencyKey: 'scoped-effect-one', + correlationId: 'correlation-one', + content: 'first result', + context: { + actorScope: { userId: IDENTITY.ownerId, tenantId: IDENTITY.tenantId }, + channelId: 'cli', + correlationId: 'correlation-one', + }, + }; + const second = { + ...first, + idempotencyKey: 'scoped-effect-two', + correlationId: 'correlation-two', + content: 'second result', + context: { ...first.context, correlationId: 'correlation-two' }, + }; + + await coordinator.create(IDENTITY); + await service.queueProviderSend(first); + await service.queueProviderSend(second); + await service.dispatchProviderOutbox(IDENTITY.sessionId, first); + + expect(runtimeProviders.sendMessage).toHaveBeenCalledTimes(1); + expect(runtimeProviders.sendMessage).toHaveBeenCalledWith( + IDENTITY.providerId, + IDENTITY.runtimeSessionId, + { content: 'first result', idempotencyKey: 'scoped-effect-one' }, + first.context, + ); + expect(await coordinator.snapshot(IDENTITY.sessionId)).toMatchObject({ + outbox: [ + { idempotencyKey: 'scoped-effect-one', status: 'delivered' }, + { idempotencyKey: 'scoped-effect-two', status: 'pending' }, + ], + }); + }, 30_000); +}); + +async function seedOwner(handle: DbHandle): Promise { + await handle.db.execute(sql` + INSERT INTO users (id, name, email, email_verified, created_at, updated_at) + VALUES ('tess-owner', 'Tess Owner', 'tess-owner@example.test', false, now(), now()) + `); +} diff --git a/apps/gateway/src/agent/tess-durable-session.repository.ts b/apps/gateway/src/agent/tess-durable-session.repository.ts new file mode 100644 index 00000000..8d2f52ce --- /dev/null +++ b/apps/gateway/src/agent/tess-durable-session.repository.ts @@ -0,0 +1,521 @@ +import { createHash, createHmac } from 'node:crypto'; +import { Inject, Injectable } from '@nestjs/common'; +import { + and, + asc, + desc, + eq, + interactionCheckpoints, + interactionHandoffs, + interactionInbox, + interactionOutbox, + interactionSessions, + type Db, +} from '@mosaicstack/db'; +import { seal, unseal } from '@mosaicstack/auth'; +import { redactSensitiveContent } from '@mosaicstack/log'; +import type { + DurableCheckpoint, + DurableCheckpointInput, + DurableEnqueueResult, + DurableHandoff, + DurableHandoffInput, + DurableInboxEntry, + DurableInboxInput, + DurableInboxStatus, + DurableOutboxEntry, + DurableOutboxInput, + DurableOutboxStatus, + DurableSessionIdentity, + DurableSessionSnapshot, + DurableSessionStore, +} from '@mosaicstack/agent'; +import { DB } from '../database/database.module.js'; + +@Injectable() +export class TessDurableSessionRepository implements DurableSessionStore { + constructor(@Inject(DB) private readonly db: Db) {} + + async create(identity: DurableSessionIdentity): Promise { + await this.db + .insert(interactionSessions) + .values({ + id: identity.sessionId, + agentName: identity.agentName, + tenantId: identity.tenantId, + ownerId: identity.ownerId, + providerId: identity.providerId, + runtimeSessionId: identity.runtimeSessionId, + }) + .onConflictDoNothing(); + + const existing = await this.session(identity.sessionId); + if (!existing || !sameIdentity(existing, identity)) { + throw new Error(`Durable Tess session identity conflict: ${identity.sessionId}`); + } + } + + async snapshot(sessionId: string): Promise { + const identity = await this.session(sessionId); + if (!identity) return null; + + const [inbox, outbox, checkpoints, handoffs] = await Promise.all([ + this.db + .select() + .from(interactionInbox) + .where(eq(interactionInbox.sessionId, sessionId)) + .orderBy(asc(interactionInbox.createdAt)), + this.db + .select() + .from(interactionOutbox) + .where(eq(interactionOutbox.sessionId, sessionId)) + .orderBy(asc(interactionOutbox.createdAt)), + this.db + .select() + .from(interactionCheckpoints) + .where(eq(interactionCheckpoints.sessionId, sessionId)) + .orderBy( + desc(interactionCheckpoints.compactionEpoch), + desc(interactionCheckpoints.createdAt), + ) + .limit(1), + this.db + .select() + .from(interactionHandoffs) + .where(eq(interactionHandoffs.sessionId, sessionId)) + .orderBy(asc(interactionHandoffs.createdAt)), + ]); + + const checkpoint = checkpoints[0]; + return { + identity, + inbox: inbox.map(toInbox), + outbox: outbox.map(toOutbox), + ...(checkpoint ? { checkpoint: toCheckpoint(checkpoint) } : {}), + handoffs: handoffs.map(toHandoff), + }; + } + + async enqueueInbox(input: DurableInboxInput): Promise> { + const digest = contentDigest(input.content); + const record: DurableInboxInput = { + ...input, + content: redactSensitiveContent(input.content).content, + }; + const inserted = await this.db + .insert(interactionInbox) + .values({ + ...record, + content: seal(record.content), + contentDigest: digest, + status: 'pending', + }) + .onConflictDoNothing() + .returning({ status: interactionInbox.status }); + if (inserted[0]) return { accepted: true, status: inserted[0].status }; + + const existing = await this.db + .select() + .from(interactionInbox) + .where( + and( + eq(interactionInbox.sessionId, input.sessionId), + eq(interactionInbox.idempotencyKey, input.idempotencyKey), + ), + ) + .limit(1); + if (!existing[0]) throw new Error(`Durable Tess inbox enqueue failed: ${input.idempotencyKey}`); + const entry = toInbox(existing[0]); + if ( + !sameInbox(entry, record) || + !matchesContentDigest(existing[0].contentDigest, input.content) + ) { + throw new Error(`Durable Tess inbox idempotency conflict: ${input.idempotencyKey}`); + } + return { accepted: false, status: entry.status }; + } + + async claimInbox(sessionId: string): Promise { + for (let attempt = 0; attempt < 3; attempt += 1) { + const candidate = await this.db + .select() + .from(interactionInbox) + .where( + and(eq(interactionInbox.sessionId, sessionId), eq(interactionInbox.status, 'pending')), + ) + .orderBy(asc(interactionInbox.createdAt)) + .limit(1); + const entry = candidate[0]; + if (!entry) return null; + const claimed = await this.db + .update(interactionInbox) + .set({ status: 'processing', updatedAt: new Date() }) + .where(and(eq(interactionInbox.id, entry.id), eq(interactionInbox.status, 'pending'))) + .returning(); + if (claimed[0]) return toInbox(claimed[0]); + } + return null; + } + + async completeInbox(sessionId: string, idempotencyKey: string): Promise { + await this.db + .update(interactionInbox) + .set({ status: 'processed', updatedAt: new Date() }) + .where( + and( + eq(interactionInbox.sessionId, sessionId), + eq(interactionInbox.idempotencyKey, idempotencyKey), + eq(interactionInbox.status, 'processing'), + ), + ); + } + + async releaseInbox(sessionId: string, idempotencyKey: string): Promise { + await this.db + .update(interactionInbox) + .set({ status: 'pending', updatedAt: new Date() }) + .where( + and( + eq(interactionInbox.sessionId, sessionId), + eq(interactionInbox.idempotencyKey, idempotencyKey), + eq(interactionInbox.status, 'processing'), + ), + ); + } + + async enqueueOutbox( + input: DurableOutboxInput, + ): Promise> { + const digest = contentDigest(input.content); + const record: DurableOutboxInput = { + ...input, + content: redactSensitiveContent(input.content).content, + }; + const inserted = await this.db + .insert(interactionOutbox) + .values({ + ...record, + content: seal(record.content), + contentDigest: digest, + status: 'pending', + }) + .onConflictDoNothing() + .returning({ status: interactionOutbox.status }); + if (inserted[0]) return { accepted: true, status: inserted[0].status }; + + const existing = await this.db + .select() + .from(interactionOutbox) + .where( + and( + eq(interactionOutbox.sessionId, input.sessionId), + eq(interactionOutbox.idempotencyKey, input.idempotencyKey), + ), + ) + .limit(1); + if (!existing[0]) + throw new Error(`Durable Tess outbox enqueue failed: ${input.idempotencyKey}`); + const entry = toOutbox(existing[0]); + if ( + !sameOutbox(entry, record) || + !matchesContentDigest(existing[0].contentDigest, input.content) + ) { + throw new Error(`Durable Tess outbox idempotency conflict: ${input.idempotencyKey}`); + } + return { accepted: false, status: entry.status }; + } + + async claimOutbox(sessionId: string): Promise { + for (let attempt = 0; attempt < 3; attempt += 1) { + const candidate = await this.db + .select() + .from(interactionOutbox) + .where( + and(eq(interactionOutbox.sessionId, sessionId), eq(interactionOutbox.status, 'pending')), + ) + .orderBy(asc(interactionOutbox.createdAt)) + .limit(1); + const entry = candidate[0]; + if (!entry) return null; + const claimed = await this.db + .update(interactionOutbox) + .set({ status: 'processing', updatedAt: new Date() }) + .where(and(eq(interactionOutbox.id, entry.id), eq(interactionOutbox.status, 'pending'))) + .returning(); + if (claimed[0]) return toOutbox(claimed[0]); + } + return null; + } + + async claimOutboxByKey( + sessionId: string, + idempotencyKey: string, + ): Promise { + const claimed = await this.db + .update(interactionOutbox) + .set({ status: 'processing', updatedAt: new Date() }) + .where( + and( + eq(interactionOutbox.sessionId, sessionId), + eq(interactionOutbox.idempotencyKey, idempotencyKey), + eq(interactionOutbox.status, 'pending'), + ), + ) + .returning(); + return claimed[0] ? toOutbox(claimed[0]) : null; + } + + async completeOutbox(sessionId: string, idempotencyKey: string): Promise { + await this.db + .update(interactionOutbox) + .set({ status: 'delivered', updatedAt: new Date() }) + .where( + and( + eq(interactionOutbox.sessionId, sessionId), + eq(interactionOutbox.idempotencyKey, idempotencyKey), + eq(interactionOutbox.status, 'processing'), + ), + ); + } + + async releaseOutbox(sessionId: string, idempotencyKey: string): Promise { + await this.db + .update(interactionOutbox) + .set({ status: 'pending', updatedAt: new Date() }) + .where( + and( + eq(interactionOutbox.sessionId, sessionId), + eq(interactionOutbox.idempotencyKey, idempotencyKey), + eq(interactionOutbox.status, 'processing'), + ), + ); + } + + async checkpoint(input: DurableCheckpointInput): Promise { + // Compute identity before redaction. The persisted digest is keyed so a database + // reader cannot use it as an offline oracle for sensitive cursor/summary values. + const digest = contentDigest(JSON.stringify([input.cursor, input.summary])); + const checkpoint: DurableCheckpointInput = { + ...input, + cursor: redactSensitiveContent(input.cursor).content, + summary: redactSensitiveContent(input.summary).content, + }; + const inserted = await this.db + .insert(interactionCheckpoints) + .values({ + ...checkpoint, + contentDigest: digest, + cursor: seal(checkpoint.cursor), + summary: seal(checkpoint.summary), + }) + .onConflictDoNothing() + .returning({ checkpointId: interactionCheckpoints.checkpointId }); + if (inserted[0]) return; + + const existing = await this.db + .select() + .from(interactionCheckpoints) + .where( + and( + eq(interactionCheckpoints.sessionId, input.sessionId), + eq(interactionCheckpoints.checkpointId, input.checkpointId), + ), + ) + .limit(1); + if ( + !existing[0] || + !sameCheckpoint(toCheckpoint(existing[0]), checkpoint) || + !matchesCheckpointDigest(existing[0].contentDigest, digest) + ) { + throw new Error(`Durable Tess checkpoint identity conflict: ${input.checkpointId}`); + } + } + + async findCheckpoint(sessionId: string, checkpointId: string): Promise { + const checkpoints = await this.db + .select() + .from(interactionCheckpoints) + .where( + and( + eq(interactionCheckpoints.sessionId, sessionId), + eq(interactionCheckpoints.checkpointId, checkpointId), + ), + ) + .limit(1); + const checkpoint = checkpoints[0]; + return checkpoint ? toCheckpoint(checkpoint) : null; + } + + async handoff(input: DurableHandoffInput): Promise { + const checkpoint = await this.findCheckpoint(input.sessionId, input.checkpointId); + if (!checkpoint) { + throw new Error(`Durable Tess handoff checkpoint is unavailable: ${input.checkpointId}`); + } + const inserted = await this.db + .insert(interactionHandoffs) + .values({ ...input }) + .onConflictDoNothing() + .returning({ handoffId: interactionHandoffs.handoffId }); + if (inserted[0]) return; + + const existing = await this.findHandoff(input.handoffId); + if (!existing || !sameHandoff(existing, input)) { + throw new Error(`Durable Tess handoff identity conflict: ${input.handoffId}`); + } + } + + async findHandoff(handoffId: string): Promise { + const handoffs = await this.db + .select() + .from(interactionHandoffs) + .where(eq(interactionHandoffs.handoffId, handoffId)) + .limit(1); + const handoff = handoffs[0]; + return handoff ? toHandoff(handoff) : null; + } + + async requeueInFlight(sessionId: string): Promise { + // Inbox handlers are process-local work. A provider outbox claim may have + // reached an external target before a crash, so it is deliberately not + // replayed by generic recovery. + await this.db + .update(interactionInbox) + .set({ status: 'pending', updatedAt: new Date() }) + .where( + and(eq(interactionInbox.sessionId, sessionId), eq(interactionInbox.status, 'processing')), + ); + } + + private async session(sessionId: string): Promise { + const sessions = await this.db + .select() + .from(interactionSessions) + .where(eq(interactionSessions.id, sessionId)) + .limit(1); + const session = sessions[0]; + return session + ? { + agentName: session.agentName, + sessionId: session.id, + tenantId: session.tenantId, + ownerId: session.ownerId, + providerId: session.providerId, + runtimeSessionId: session.runtimeSessionId, + } + : null; + } +} + +function contentDigest(content: string): string { + const secret = process.env['BETTER_AUTH_SECRET']; + if (!secret) { + throw new Error('BETTER_AUTH_SECRET is required for durable idempotency digests'); + } + return `hmac:v1:${createHmac('sha256', secret).update(content).digest('hex')}`; +} + +function matchesContentDigest(stored: string, content: string): boolean { + return ( + stored === contentDigest(content) || + stored === createHash('sha256').update(content).digest('hex') + ); +} + +function matchesCheckpointDigest(stored: string, digest: string): boolean { + // Legacy rows predate any pre-redaction identity and cannot safely prove equality. + // Reject rather than let redaction collapse distinct sensitive checkpoint payloads. + return stored === digest; +} + +function sameIdentity(left: DurableSessionIdentity, right: DurableSessionIdentity): boolean { + return ( + left.agentName === right.agentName && + left.sessionId === right.sessionId && + left.tenantId === right.tenantId && + left.ownerId === right.ownerId && + left.providerId === right.providerId && + left.runtimeSessionId === right.runtimeSessionId + ); +} + +function sameInbox(left: DurableInboxEntry, right: DurableInboxInput): boolean { + return ( + left.sessionId === right.sessionId && + left.idempotencyKey === right.idempotencyKey && + left.correlationId === right.correlationId && + left.content === right.content + ); +} + +function sameOutbox(left: DurableOutboxEntry, right: DurableOutboxInput): boolean { + return ( + left.sessionId === right.sessionId && + left.idempotencyKey === right.idempotencyKey && + left.correlationId === right.correlationId && + left.channelId === right.channelId && + left.kind === right.kind && + left.content === right.content + ); +} + +function sameCheckpoint(left: DurableCheckpoint, right: DurableCheckpointInput): boolean { + return ( + left.sessionId === right.sessionId && + left.checkpointId === right.checkpointId && + left.compactionEpoch === right.compactionEpoch + ); +} + +function sameHandoff(left: DurableHandoff, right: DurableHandoffInput): boolean { + return ( + left.sessionId === right.sessionId && + left.handoffId === right.handoffId && + left.destination === right.destination && + left.correlationId === right.correlationId && + left.checkpointId === right.checkpointId && + left.status === right.status + ); +} + +function toInbox(row: typeof interactionInbox.$inferSelect): DurableInboxEntry { + return { + sessionId: row.sessionId, + idempotencyKey: row.idempotencyKey, + correlationId: row.correlationId, + content: unseal(row.content), + status: row.status, + }; +} + +function toOutbox(row: typeof interactionOutbox.$inferSelect): DurableOutboxEntry { + return { + sessionId: row.sessionId, + idempotencyKey: row.idempotencyKey, + correlationId: row.correlationId, + channelId: row.channelId, + kind: row.kind, + content: unseal(row.content), + status: row.status, + }; +} + +function toCheckpoint(row: typeof interactionCheckpoints.$inferSelect): DurableCheckpoint { + return { + sessionId: row.sessionId, + checkpointId: row.checkpointId, + cursor: unseal(row.cursor), + summary: unseal(row.summary), + compactionEpoch: row.compactionEpoch, + }; +} + +function toHandoff(row: typeof interactionHandoffs.$inferSelect): DurableHandoff { + return { + sessionId: row.sessionId, + handoffId: row.handoffId, + destination: row.destination, + correlationId: row.correlationId, + checkpointId: row.checkpointId, + status: row.status, + }; +} diff --git a/apps/gateway/src/agent/tess-durable-session.service.ts b/apps/gateway/src/agent/tess-durable-session.service.ts new file mode 100644 index 00000000..095448b3 --- /dev/null +++ b/apps/gateway/src/agent/tess-durable-session.service.ts @@ -0,0 +1,93 @@ +import { ForbiddenException, Inject, Injectable } from '@nestjs/common'; +import { DurableSessionCoordinator } from '@mosaicstack/agent'; +import type { TessProviderOutboxDto } from './tess-durable-session.dto.js'; +import { TessDurableSessionRepository } from './tess-durable-session.repository.js'; +import { RuntimeProviderService } from './runtime-provider-registry.service.js'; + +/** + * Scoped gateway boundary for the canonical Tess state machine. It deliberately + * uses composition: raw state methods cannot be injected into channel, CLI, or + * MCP adapters without a server-derived actor/tenant/correlation context. + */ +@Injectable() +export class TessDurableSessionService { + private readonly coordinator: DurableSessionCoordinator; + + constructor( + @Inject(TessDurableSessionRepository) repository: TessDurableSessionRepository, + @Inject(RuntimeProviderService) private readonly runtimeProviders: RuntimeProviderService, + ) { + this.coordinator = new DurableSessionCoordinator(repository); + } + + async queueProviderSend(input: TessProviderOutboxDto): Promise { + const snapshot = await this.coordinator.snapshot(input.sessionId); + this.assertScope(snapshot.identity.ownerId, snapshot.identity.tenantId, input); + await this.coordinator.enqueueOutbox({ + sessionId: input.sessionId, + idempotencyKey: input.idempotencyKey, + correlationId: input.correlationId, + channelId: input.context.channelId, + kind: 'provider.send', + content: input.content, + }); + } + + async dispatchProviderOutbox(sessionId: string, input: TessProviderOutboxDto): Promise { + if (sessionId !== input.sessionId) { + throw new ForbiddenException('Durable Tess outbox session mismatch'); + } + const snapshot = await this.coordinator.snapshot(sessionId); + this.assertScope(snapshot.identity.ownerId, snapshot.identity.tenantId, input); + const pendingEntry = snapshot.outbox.find( + (entry): boolean => entry.idempotencyKey === input.idempotencyKey, + ); + if (!pendingEntry) return; + // Validate immutable routing before claiming. A caller with a mismatched + // correlation/channel must not strand a pending external side effect. + this.assertOutboxScope(pendingEntry, input); + await this.coordinator.dispatchOutboxEntry( + sessionId, + input.idempotencyKey, + async (entry): Promise => { + this.assertOutboxScope(entry, input); + await this.runtimeProviders.sendMessage( + snapshot.identity.providerId, + snapshot.identity.runtimeSessionId, + { content: entry.content, idempotencyKey: entry.idempotencyKey }, + input.context, + ); + }, + ); + } + + /** Startup/recovery-only path; normal queue/dispatch methods never requeue live work. */ + async recoverProviderSession(sessionId: string, input: TessProviderOutboxDto): Promise { + const snapshot = await this.coordinator.snapshot(sessionId); + this.assertScope(snapshot.identity.ownerId, snapshot.identity.tenantId, input); + await this.coordinator.recover(sessionId); + } + + private assertOutboxScope( + entry: { kind: string; correlationId: string; channelId: string }, + input: TessProviderOutboxDto, + ): void { + if ( + entry.kind !== 'provider.send' || + entry.correlationId !== input.correlationId || + entry.channelId !== input.context.channelId + ) { + throw new ForbiddenException('Durable Tess outbox scope or correlation mismatch'); + } + } + + private assertScope(ownerId: string, tenantId: string, input: TessProviderOutboxDto): void { + if ( + input.context.actorScope.userId !== ownerId || + input.context.actorScope.tenantId !== tenantId || + input.context.correlationId !== input.correlationId + ) { + throw new ForbiddenException('Durable Tess session scope or correlation mismatch'); + } + } +} diff --git a/apps/gateway/src/commands/command-authorization.service.spec.ts b/apps/gateway/src/commands/command-authorization.service.spec.ts index 7eb70345..315d902b 100644 --- a/apps/gateway/src/commands/command-authorization.service.spec.ts +++ b/apps/gateway/src/commands/command-authorization.service.spec.ts @@ -12,8 +12,10 @@ const adminCommand: CommandDef = { }; const payload: SlashCommandPayload = { command: 'gc', conversationId: 'conversation-1' }; -function createService(role: string): CommandAuthorizationService { - const entries = new Map(); +function createService( + role: string, + entries: Map = new Map(), +): CommandAuthorizationService { const db = { select: () => ({ from: () => ({ where: () => ({ limit: async () => [{ role }] }) }) }), }; @@ -58,4 +60,57 @@ describe('CommandAuthorizationService', () => { (await service.authorize(adminCommand, payload, 'member-1', 'forged-approval-id')).allowed, ).toBe(false); }); + + it('denies a malformed durable approval expiry instead of treating it as unexpired', async (): Promise => { + const entries = new Map(); + const action = { + providerId: 'fleet', + sessionId: 'nova', + actorId: 'admin-1', + tenantId: 'tenant-1', + channelId: 'discord:operator', + correlationId: 'correlation-malformed-expiry', + agentName: 'Nova', + }; + const service = createService('admin', entries); + const approval = await service.createRuntimeTerminationApproval(action); + expect(approval).not.toBeNull(); + const key = `agent:Nova:command-approval:${approval!.approvalId}`; + const stored = entries.get(key); + expect(stored).toBeDefined(); + entries.set(key, JSON.stringify({ ...JSON.parse(stored!), expiresAt: 'not-a-date' })); + + expect(await service.consumeRuntimeTerminationApproval(approval!.approvalId, action)).toBe( + false, + ); + }); + + it('persists and consumes one exact runtime termination approval across a service restart', async (): Promise => { + const entries = new Map(); + const action = { + providerId: 'fleet', + sessionId: 'nova', + actorId: 'admin-1', + tenantId: 'tenant-1', + channelId: 'discord:operator', + correlationId: 'correlation-1', + agentName: 'Nova', + }; + const beforeRestart = createService('admin', entries); + const approval = await beforeRestart.createRuntimeTerminationApproval(action); + + const afterRestart = createService('admin', entries); + expect( + await afterRestart.consumeRuntimeTerminationApproval(approval!.approvalId, { + ...action, + sessionId: 'forged-session', + }), + ).toBe(false); + expect(await afterRestart.consumeRuntimeTerminationApproval(approval!.approvalId, action)).toBe( + true, + ); + expect(await afterRestart.consumeRuntimeTerminationApproval(approval!.approvalId, action)).toBe( + false, + ); + }); }); diff --git a/apps/gateway/src/commands/command-authorization.service.ts b/apps/gateway/src/commands/command-authorization.service.ts index ce7f4991..a9f829e7 100644 --- a/apps/gateway/src/commands/command-authorization.service.ts +++ b/apps/gateway/src/commands/command-authorization.service.ts @@ -15,6 +15,24 @@ export interface CommandApproval { expiresAt: string; } +/** Exact immutable binding for a privileged runtime termination. */ +export interface RuntimeTerminationApprovalAction { + providerId: string; + sessionId: string; + actorId: string; + tenantId: string; + channelId: string; + correlationId: string; + /** Provisioned roster identity; isolates approvals between interaction agents. */ + agentName: string; +} + +export interface RuntimeTerminationApproval extends RuntimeTerminationApprovalAction { + approvalId: string; + actionDigest: string; + expiresAt: string; +} + export interface CommandAuthorizationResult { allowed: boolean; reason?: string; @@ -75,6 +93,57 @@ export class CommandAuthorizationService { return approval; } + /** + * Uses the same `interaction:command-approval:*` store and one-time deletion rule as + * command approvals. This deliberately avoids a parallel approval database. + */ + async createRuntimeTerminationApproval( + action: RuntimeTerminationApprovalAction, + ): Promise { + if (!this.hasRuntimeTerminationAction(action)) return null; + const role = await this.resolveRole(action.actorId); + if (role !== 'admin') return null; + + const approval: RuntimeTerminationApproval = { + approvalId: randomUUID(), + actionDigest: this.runtimeActionDigest(action), + ...action, + expiresAt: new Date(Date.now() + 5 * 60_000).toISOString(), + }; + await this.redis.set( + this.runtimeKey(action.agentName, approval.approvalId), + JSON.stringify(approval), + 'EX', + '300', + ); + return approval; + } + + async consumeRuntimeTerminationApproval( + approvalId: string, + action: RuntimeTerminationApprovalAction, + ): Promise { + const encoded = await this.redis.get(this.runtimeKey(action.agentName, approvalId)); + if (!encoded) return false; + let approval: unknown; + try { + approval = JSON.parse(encoded); + } catch { + return false; + } + if ( + !this.isRuntimeTerminationApproval(approval) || + approval.actionDigest !== this.runtimeActionDigest(action) || + approval.actorId !== action.actorId || + approval.tenantId !== action.tenantId || + !this.isUnexpired(approval.expiresAt) + ) { + return false; + } + if ((await this.resolveRole(approval.actorId)) !== 'admin') return false; + return (await this.redis.del(this.runtimeKey(action.agentName, approvalId))) === 1; + } + private async resolveRole(actorId: string): Promise { const [user] = await this.db .select({ role: usersTable.role }) @@ -98,12 +167,17 @@ export class CommandAuthorizationService { const key = this.key(approvalId); const encoded = await this.redis.get(key); if (!encoded) return false; - const parsed: unknown = JSON.parse(encoded); + let parsed: unknown; + try { + parsed = JSON.parse(encoded); + } catch { + return false; + } if ( - !this.isApproval(parsed) || + !this.isCommandApproval(parsed) || parsed.actorId !== actorId || parsed.actionDigest !== actionDigest || - Date.parse(parsed.expiresAt) <= Date.now() + !this.isUnexpired(parsed.expiresAt) ) return false; return (await this.redis.del(key)) === 1; @@ -121,18 +195,74 @@ export class CommandAuthorizationService { .digest('hex'); } - private isApproval(value: unknown): value is CommandApproval { + private hasRuntimeTerminationAction(action: RuntimeTerminationApprovalAction): boolean { + return [ + action.providerId, + action.sessionId, + action.actorId, + action.tenantId, + action.channelId, + action.correlationId, + action.agentName, + ].every((value: string): boolean => value.trim().length > 0); + } + + private runtimeActionDigest(action: RuntimeTerminationApprovalAction): string { + return createHash('sha256') + .update( + JSON.stringify({ + providerId: action.providerId, + sessionId: action.sessionId, + actorId: action.actorId, + tenantId: action.tenantId, + channelId: action.channelId, + correlationId: action.correlationId, + agentName: action.agentName, + }), + ) + .digest('hex'); + } + + private isUnexpired(expiresAt: unknown): expiresAt is string { + if (typeof expiresAt !== 'string') return false; + const expiresAtMs = Date.parse(expiresAt); + return Number.isFinite(expiresAtMs) && expiresAtMs > Date.now(); + } + + private isCommandApproval(value: unknown): value is CommandApproval { return ( typeof value === 'object' && value !== null && 'approvalId' in value && 'actionDigest' in value && 'actorId' in value && + 'expiresAt' in value && + 'command' in value + ); + } + + private isRuntimeTerminationApproval(value: unknown): value is RuntimeTerminationApproval { + return ( + typeof value === 'object' && + value !== null && + 'approvalId' in value && + 'actionDigest' in value && + 'actorId' in value && + 'tenantId' in value && + 'providerId' in value && + 'sessionId' in value && + 'channelId' in value && + 'correlationId' in value && + 'agentName' in value && 'expiresAt' in value ); } private key(approvalId: string): string { - return `tess:command-approval:${approvalId}`; + return `interaction:command-approval:${approvalId}`; + } + + private runtimeKey(agentName: string, approvalId: string): string { + return `agent:${encodeURIComponent(agentName)}:command-approval:${approvalId}`; } } diff --git a/apps/gateway/src/commands/commands.module.ts b/apps/gateway/src/commands/commands.module.ts index 0d6c30d2..09fb440b 100644 --- a/apps/gateway/src/commands/commands.module.ts +++ b/apps/gateway/src/commands/commands.module.ts @@ -6,6 +6,7 @@ import { ReloadModule } from '../reload/reload.module.js'; import { CommandAuthorizationService } from './command-authorization.service.js'; import { CommandExecutorService } from './command-executor.service.js'; import { CommandRegistryService } from './command-registry.service.js'; +import { CommandRuntimeApprovalVerifier } from './runtime-approval-verifier.js'; import { COMMANDS_REDIS } from './commands.tokens.js'; const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE'; @@ -26,9 +27,15 @@ const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE'; }, CommandRegistryService, CommandAuthorizationService, + CommandRuntimeApprovalVerifier, + CommandExecutorService, + ], + exports: [ + CommandRegistryService, + CommandAuthorizationService, + CommandRuntimeApprovalVerifier, CommandExecutorService, ], - exports: [CommandRegistryService, CommandExecutorService], }) export class CommandsModule implements OnApplicationShutdown { constructor(@Inject(COMMANDS_QUEUE_HANDLE) private readonly handle: QueueHandle) {} diff --git a/apps/gateway/src/commands/runtime-approval-verifier.ts b/apps/gateway/src/commands/runtime-approval-verifier.ts new file mode 100644 index 00000000..47e0973b --- /dev/null +++ b/apps/gateway/src/commands/runtime-approval-verifier.ts @@ -0,0 +1,23 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type { + RuntimeApprovalVerifier, + RuntimeTerminationAction, +} from '../agent/runtime-provider-registry.service.js'; +import { CommandAuthorizationService } from './command-authorization.service.js'; + +/** + * Adapter from the provider registry's exact termination action to the shared, + * Redis-backed `interaction:command-approval:*` store. It has no separate approval + * persistence or replay semantics. + */ +@Injectable() +export class CommandRuntimeApprovalVerifier implements RuntimeApprovalVerifier { + constructor( + @Inject(CommandAuthorizationService) + private readonly authorization: CommandAuthorizationService, + ) {} + + async consume(approvalRef: string, action: RuntimeTerminationAction): Promise { + return this.authorization.consumeRuntimeTerminationApproval(approvalRef, action); + } +} diff --git a/apps/gateway/src/gc/session-gc.service.spec.ts b/apps/gateway/src/gc/session-gc.service.spec.ts index c6f3948e..8c5ab6ad 100644 --- a/apps/gateway/src/gc/session-gc.service.spec.ts +++ b/apps/gateway/src/gc/session-gc.service.spec.ts @@ -108,7 +108,7 @@ describe('SessionGCService', () => { } as never; const payload = { command: 'gc', conversationId: 'owned' }; const approval = await authorization.createApproval(command, payload, 'admin-1'); - const approvalKey = `tess:command-approval:${approval!.approvalId}`; + const approvalKey = `interaction:command-approval:${approval!.approvalId}`; const gc = new SessionGCService(redis as never, mockLogService as unknown as LogService); await gc.collect('owned'); diff --git a/docs/scratchpads/tess-m2-002-durable-state.md b/docs/scratchpads/tess-m2-002-durable-state.md new file mode 100644 index 00000000..c2e3b809 --- /dev/null +++ b/docs/scratchpads/tess-m2-002-durable-state.md @@ -0,0 +1,63 @@ +# TESS-M2-002 — Durable Tess State + +- **Issue:** #708 +- **Task:** `TESS-M2-002` / `TESS-STA-001`, `TESS-SEC-007..008` +- **Branch:** `feat/tess-durable-state` +- **Base:** fresh `origin/main` at `e3b5113be21e51d015fa1ae54572929b2a4acd9f` +- **Budget assumption:** 38K task estimate; no explicit cap. Use focused TDD plus workspace validation. + +## Objective + +Persist a Tess session's immutable identity, inbox/outbox idempotency state, checkpoints, +handoffs, and approval bindings so a new service instance can recover it after a process +restart or context compaction without replaying a completed message or applied side effect. + +## Plan + +1. Write recovery/idempotency tests first in `packages/agent/src/tess-durable-session.test.ts`. +2. Add transport-neutral durable-state contracts/state machine in `packages/agent`. +3. Add canonical PostgreSQL schema/migration and a gateway Drizzle repository adapter. +4. Wire gateway service/module and reuse `tess:command-approval:*` durable approval semantics + for exact, actor/tenant/action-bound approval consumption. +5. Test PGlite restart recovery with separate service instances sharing the same durable DB. +6. Document the recovery/compaction operation and update Tess architecture evidence. +7. Run focused, cold-cache, workspace, migration, review, commit, push, and open PR to `main`. + +## Required Evidence + +| Requirement | Primary evidence | +| --- | --- | +| Restart recovery | Test creates a second coordinator over unchanged durable store after simulated process death. | +| No duplicate side effects | Duplicate ingress and post-restart dispatch assert one handler/effect invocation. | +| Compaction survival | Checkpoint/handoff/reconstructed state preserve the same session identity and pending records. | +| Durable approvals | Existing `tess:command-approval` record is consumed only once and survives a new authorization service instance. | +| Handoff | Stored handoff is portable and reconstructed without live process state. | + +## Progress + +- Intake complete: PRD AC-TESS-06, threat TM-07/TM-08, and verification matrix reviewed. +- Affected surfaces: `packages/agent`, `apps/gateway`, `packages/db`; auth/authorization and DB migration tests required. +- TDD is required (security authorization and critical state mutation). + +## Risks + +- An external provider action cannot be atomically committed with the database. The outbox + gives the receiver a stable idempotency key; generic recovery never replays an ambiguous + `processing` effect, and completed effects are never redispatched. +- PostgreSQL is canonical; PGlite is the local/restart test implementation. + +## Verification + +- TDD red: `pnpm --filter @mosaicstack/agent test src/tess-durable-session.test.ts` + initially failed because the durable-state module did not exist. +- Focused green: 7 agent state-machine tests; 6 PGlite repository tests (including + close/reopen recovery and encrypted-at-rest redaction); 5 durable-approval tests; DB migration tests. +- Full cold-cache green: `pnpm turbo run typecheck lint test --force` completed + 88 tasks with zero cache hits; `pnpm format:check` and `git diff --check` passed. +- Fresh worktree dependency install passed with + `pnpm install --frozen-lockfile --store-dir /home/jarvis/.local/share/pnpm/store`. + The default pnpm store path was inaccessible to this harness, so the explicit + user-owned store path was required. +- Codex review identified plaintext durable payload risk; resolved by AES-256-GCM sealing + after redaction, with an at-rest ciphertext assertion in the PGlite suite. +- Pending final clean review, commit, and PR. diff --git a/docs/tess/ARCHITECTURE.md b/docs/tess/ARCHITECTURE.md index 4c031620..31ae36d4 100644 --- a/docs/tess/ARCHITECTURE.md +++ b/docs/tess/ARCHITECTURE.md @@ -41,7 +41,7 @@ Every call receives an immutable, server-derived actor/tenant/channel scope and `@mosaicstack/agent` owns the explicit `AgentRuntimeProviderRegistry`; duplicate provider IDs are rejected rather than replaced. Gateway owns `RuntimeProviderService`, which creates a frozen `RuntimeScope` from authenticated `ActorTenantScope` and trusted ingress channel/correlation metadata before every provider call. The service checks the declared provider capability before invoking a side effect and records metadata-only audit events (`providerId`, operation, outcome, actor/tenant/channel, correlation, and resource ID). It never records message bodies, idempotency keys, or approval references. -Termination is fail-closed: a runtime approval verifier must consume a one-time, exact action binding for the provider, session, actor, tenant, channel, and correlation ID before `terminate` reaches a provider. Until the durable verifier is wired, the default verifier denies termination. This internal service introduces no HTTP endpoint; later Discord, CLI, MCP, and provider adapters consume the same gateway boundary. +Termination is fail-closed: a runtime approval verifier consumes a one-time, exact action binding for the provider, session, actor, tenant, channel, and correlation ID before `terminate` reaches a provider. The verifier reuses the Redis-backed `interaction:command-approval:*` store and its expiry/delete-on-consume semantics; it has no parallel approval store. This internal service introduces no HTTP endpoint; later Discord, CLI, MCP, and provider adapters consume the same gateway boundary. ## Authority Model @@ -56,7 +56,30 @@ Termination is fail-closed: a runtime approval verifier must consume a one-time, A Tess session has stable `sessionId`, `tenantId`, `ownerId`, provider/runtime identity, ingress bindings, cursor, checkpoint, inbox/outbox, and idempotency records. Discord and CLI bind to the same authorized session. Ownership is verified server-side on every list/read/attach/send/terminate operation. -Valkey may hold ephemeral coordination state; PostgreSQL is canonical for durable session bindings, approvals, audit, checkpoints, inbox/outbox, and idempotency. Pi session files are replay sources, not cross-agent truth. +Valkey holds the existing short-lived, one-time command-approval records; PostgreSQL is canonical for durable session bindings, checkpoints, inbox/outbox, and idempotency. Pi session files are replay sources, not cross-agent truth. + +### M2 Durable Recovery + +`@mosaicstack/agent` owns a transport-neutral state machine and `apps/gateway` provides its +PostgreSQL adapter. `interaction_sessions` holds immutable identity; inbox/outbox records use a +per-session unique idempotency key and transition `pending → processing → processed|delivered`. +Checkpoints are immutable history scoped by session and checkpoint ID: the latest checkpoint +supports compaction recovery, while a handoff always resolves the exact checkpoint it references. +Recovery requeues only interrupted inbox work; an ambiguous `processing` outbox record is preserved +until separately authorized reconciliation can establish its external delivery state. + +Provider sends travel through the existing `RuntimeProviderService` with the persisted outbox +idempotency key. A normal dispatch claims exactly one outbox record and verifies its stored +correlation and channel against the server-derived request scope; it never requeues or drains +another live record. Inbox/outbox payloads and checkpoint cursor/summary pass through the existing +secret/PII redactor and AES-256-GCM sealing before persistence; decryption occurs only in the +scoped gateway repository path, and runtime audit remains metadata-only. + +An external effect cannot share a database transaction. If a process dies after an effect begins +but before its terminal outbox transition, automatic recovery does not replay that ambiguous claim. +It remains `processing` until separately authorized reconciliation can establish delivery state; +completed effects are never redispatched. Operators can therefore restart the gateway/Pi service, +reconstruct the session, and resume pending inbox work without relying on process-local state. ## Transport Strategy diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index c4f2fc80..68d9d14f 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -2,3 +2,4 @@ export const VERSION = '0.0.0'; export * from './runtime-provider-registry.js'; export * from './tmux-fleet-runtime-provider.js'; +export * from './tess-durable-session.js'; diff --git a/packages/agent/src/tess-durable-session.test.ts b/packages/agent/src/tess-durable-session.test.ts new file mode 100644 index 00000000..64e5ca85 --- /dev/null +++ b/packages/agent/src/tess-durable-session.test.ts @@ -0,0 +1,288 @@ +import { describe, expect, it } from 'vitest'; +import { + DurableSessionCoordinator, + InMemoryDurableSessionStore, + type DurableSessionIdentity, +} from './tess-durable-session.js'; + +const IDENTITY: DurableSessionIdentity = { + agentName: 'Nova', + sessionId: 'tess-session-1', + tenantId: 'tenant-1', + ownerId: 'owner-1', + providerId: 'fleet', + runtimeSessionId: 'nova', +}; + +describe('DurableSessionCoordinator', () => { + it('reconstructs an exact session identity, pending inbox/outbox, checkpoint, and handoff after a simulated process restart', async () => { + const store = new InMemoryDurableSessionStore(); + const beforeRestart = new DurableSessionCoordinator(store); + + await beforeRestart.create(IDENTITY); + await beforeRestart.receive({ + sessionId: IDENTITY.sessionId, + idempotencyKey: 'ingress-1', + correlationId: 'correlation-1', + content: 'continue the session', + }); + await beforeRestart.enqueueOutbox({ + sessionId: IDENTITY.sessionId, + idempotencyKey: 'outbox-1', + correlationId: 'correlation-1', + channelId: 'cli', + kind: 'provider.send', + content: 'resumable response', + }); + await beforeRestart.checkpoint({ + sessionId: IDENTITY.sessionId, + checkpointId: 'checkpoint-1', + cursor: 'cursor-42', + summary: 'operator asked for recovery proof', + compactionEpoch: 0, + }); + await beforeRestart.handoff({ + sessionId: IDENTITY.sessionId, + handoffId: 'handoff-1', + destination: 'mos', + correlationId: 'correlation-1', + checkpointId: 'checkpoint-1', + status: 'pending', + }); + + // Simulate an ungraceful process death: no in-memory coordinator state survives. + const afterRestart = new DurableSessionCoordinator(store); + const recovered = await afterRestart.recover(IDENTITY.sessionId); + + expect(recovered.identity).toEqual(IDENTITY); + expect(recovered.inbox).toMatchObject([{ idempotencyKey: 'ingress-1', status: 'pending' }]); + expect(recovered.outbox).toMatchObject([{ idempotencyKey: 'outbox-1', status: 'pending' }]); + expect(recovered.checkpoint).toMatchObject({ + checkpointId: 'checkpoint-1', + cursor: 'cursor-42', + }); + expect(recovered.handoffs).toMatchObject([{ handoffId: 'handoff-1', status: 'pending' }]); + }); + + it('deduplicates duplicate ingress and never reprocesses an inbox record after restart or compaction', async () => { + const store = new InMemoryDurableSessionStore(); + const firstProcess = new DurableSessionCoordinator(store); + const handled: string[] = []; + + await firstProcess.create(IDENTITY); + await expect( + firstProcess.receive({ + sessionId: IDENTITY.sessionId, + idempotencyKey: 'ingress-duplicate', + correlationId: 'correlation-2', + content: 'only process me once', + }), + ).resolves.toMatchObject({ accepted: true }); + await expect( + firstProcess.receive({ + sessionId: IDENTITY.sessionId, + idempotencyKey: 'ingress-duplicate', + correlationId: 'correlation-2', + content: 'only process me once', + }), + ).resolves.toMatchObject({ accepted: false, status: 'pending' }); + await expect( + firstProcess.receive({ + sessionId: IDENTITY.sessionId, + idempotencyKey: 'ingress-duplicate', + correlationId: 'forged-correlation', + content: 'only process me once', + }), + ).rejects.toThrow(/idempotency conflict/); + + await firstProcess.drainInbox(IDENTITY.sessionId, async (entry) => { + handled.push(entry.idempotencyKey); + }); + await firstProcess.checkpoint({ + sessionId: IDENTITY.sessionId, + checkpointId: 'checkpoint-after-inbox', + cursor: 'cursor-43', + summary: 'safe to compact', + compactionEpoch: 1, + }); + + const afterRestartAndCompaction = new DurableSessionCoordinator(store); + await afterRestartAndCompaction.recover(IDENTITY.sessionId); + await afterRestartAndCompaction.drainInbox(IDENTITY.sessionId, async (entry) => { + handled.push(entry.idempotencyKey); + }); + + expect(handled).toEqual(['ingress-duplicate']); + }); + + it('does not redispatch an already applied outbox side effect after replay, restart, or compaction', async () => { + const store = new InMemoryDurableSessionStore(); + const beforeRestart = new DurableSessionCoordinator(store); + const appliedEffects: string[] = []; + + await beforeRestart.create(IDENTITY); + await beforeRestart.enqueueOutbox({ + sessionId: IDENTITY.sessionId, + idempotencyKey: 'effect-1', + correlationId: 'correlation-3', + channelId: 'cli', + kind: 'provider.send', + content: 'send exactly once', + }); + await expect( + beforeRestart.enqueueOutbox({ + sessionId: IDENTITY.sessionId, + idempotencyKey: 'effect-1', + correlationId: 'correlation-3', + channelId: 'cli', + kind: 'provider.send', + content: 'send exactly once', + }), + ).resolves.toMatchObject({ accepted: false, status: 'pending' }); + + await beforeRestart.dispatchOutbox(IDENTITY.sessionId, async (entry) => { + appliedEffects.push(entry.idempotencyKey); + }); + await beforeRestart.checkpoint({ + sessionId: IDENTITY.sessionId, + checkpointId: 'checkpoint-after-effect', + cursor: 'cursor-44', + summary: 'effect persisted before compaction', + compactionEpoch: 1, + }); + + const afterRestartAndCompaction = new DurableSessionCoordinator(store); + await afterRestartAndCompaction.recover(IDENTITY.sessionId); + await afterRestartAndCompaction.dispatchOutbox(IDENTITY.sessionId, async (entry) => { + appliedEffects.push(entry.idempotencyKey); + }); + + expect(appliedEffects).toEqual(['effect-1']); + }); + + it('rejects outbox idempotency-key reuse when immutable effect data differs', async () => { + const store = new InMemoryDurableSessionStore(); + const coordinator = new DurableSessionCoordinator(store); + + await coordinator.create(IDENTITY); + await coordinator.enqueueOutbox({ + sessionId: IDENTITY.sessionId, + idempotencyKey: 'outbox-conflict', + correlationId: 'correlation-outbox', + channelId: 'cli', + kind: 'provider.send', + content: 'original effect', + }); + await expect( + coordinator.enqueueOutbox({ + sessionId: IDENTITY.sessionId, + idempotencyKey: 'outbox-conflict', + correlationId: 'correlation-outbox', + channelId: 'forged-channel', + kind: 'provider.send', + content: 'original effect', + }), + ).rejects.toThrow(/idempotency conflict/); + }); + + it('retains an ambiguous failed provider effect as processing until explicit recovery', async () => { + const store = new InMemoryDurableSessionStore(); + const beforeRestart = new DurableSessionCoordinator(store); + + await beforeRestart.create(IDENTITY); + await beforeRestart.enqueueOutbox({ + sessionId: IDENTITY.sessionId, + idempotencyKey: 'ambiguous-effect', + correlationId: 'correlation-ambiguous', + channelId: 'cli', + kind: 'provider.send', + content: 'preserve this effect claim', + }); + await expect( + beforeRestart.dispatchOutbox(IDENTITY.sessionId, async (): Promise => { + throw new Error('provider connection dropped after submit'); + }), + ).rejects.toThrow(/connection dropped/); + + const afterRestart = new DurableSessionCoordinator(store); + await afterRestart.recover(IDENTITY.sessionId); + const calls: string[] = []; + await afterRestart.dispatchOutbox(IDENTITY.sessionId, async (entry): Promise => { + calls.push(entry.idempotencyKey); + }); + + expect(calls).toEqual([]); + expect(await afterRestart.snapshot(IDENTITY.sessionId)).toMatchObject({ + outbox: [{ idempotencyKey: 'ambiguous-effect', status: 'processing' }], + }); + }); + + it('rejects a handoff-id replay with different immutable state', async () => { + const store = new InMemoryDurableSessionStore(); + const coordinator = new DurableSessionCoordinator(store); + + await coordinator.create(IDENTITY); + await coordinator.checkpoint({ + sessionId: IDENTITY.sessionId, + checkpointId: 'checkpoint-conflict', + cursor: 'cursor-conflict', + summary: 'handoff conflict proof', + compactionEpoch: 0, + }); + await coordinator.handoff({ + sessionId: IDENTITY.sessionId, + handoffId: 'handoff-conflict', + destination: 'mos', + correlationId: 'correlation-conflict', + checkpointId: 'checkpoint-conflict', + status: 'pending', + }); + + await expect( + coordinator.handoff({ + sessionId: IDENTITY.sessionId, + handoffId: 'handoff-conflict', + destination: 'forged-destination', + correlationId: 'correlation-conflict', + checkpointId: 'checkpoint-conflict', + status: 'pending', + }), + ).rejects.toThrow(/identity conflict/); + }); + + it('keeps a handoff portable and resumes it from its durable checkpoint without process-local references', async () => { + const store = new InMemoryDurableSessionStore(); + const source = new DurableSessionCoordinator(store); + + await source.create(IDENTITY); + await source.checkpoint({ + sessionId: IDENTITY.sessionId, + checkpointId: 'checkpoint-handoff', + cursor: 'cursor-45', + summary: 'portable state', + compactionEpoch: 2, + }); + await source.handoff({ + sessionId: IDENTITY.sessionId, + handoffId: 'handoff-portable', + destination: 'mos', + correlationId: 'correlation-4', + checkpointId: 'checkpoint-handoff', + status: 'pending', + }); + await source.checkpoint({ + sessionId: IDENTITY.sessionId, + checkpointId: 'checkpoint-later', + cursor: 'cursor-46', + summary: 'newer compacted state must not strand the handoff', + compactionEpoch: 3, + }); + + const receivingProcess = new DurableSessionCoordinator(store); + const handoff = await receivingProcess.resumeHandoff('handoff-portable'); + + expect(handoff.identity).toEqual(IDENTITY); + expect(handoff.checkpoint).toMatchObject({ checkpointId: 'checkpoint-handoff' }); + expect(handoff.handoff).toMatchObject({ handoffId: 'handoff-portable', destination: 'mos' }); + }); +}); diff --git a/packages/agent/src/tess-durable-session.ts b/packages/agent/src/tess-durable-session.ts new file mode 100644 index 00000000..039fe069 --- /dev/null +++ b/packages/agent/src/tess-durable-session.ts @@ -0,0 +1,498 @@ +export interface DurableSessionIdentity { + /** Provisioned roster identity; isolates durable state between named agents. */ + agentName: string; + sessionId: string; + tenantId: string; + ownerId: string; + providerId: string; + runtimeSessionId: string; +} + +export type DurableInboxStatus = 'pending' | 'processing' | 'processed'; +export type DurableOutboxStatus = 'pending' | 'processing' | 'delivered'; + +export interface DurableInboxInput { + sessionId: string; + idempotencyKey: string; + correlationId: string; + content: string; +} + +export interface DurableInboxEntry extends DurableInboxInput { + status: DurableInboxStatus; +} + +export interface DurableOutboxInput { + sessionId: string; + idempotencyKey: string; + correlationId: string; + channelId: string; + kind: string; + content: string; +} + +export interface DurableOutboxEntry extends DurableOutboxInput { + status: DurableOutboxStatus; +} + +export interface DurableCheckpointInput { + sessionId: string; + checkpointId: string; + cursor: string; + summary: string; + compactionEpoch: number; +} + +export interface DurableCheckpoint extends DurableCheckpointInput {} + +export interface DurableHandoffInput { + sessionId: string; + handoffId: string; + destination: string; + correlationId: string; + checkpointId: string; + status: 'pending' | 'accepted'; +} + +export interface DurableHandoff extends DurableHandoffInput {} + +export interface DurableSessionSnapshot { + identity: DurableSessionIdentity; + inbox: DurableInboxEntry[]; + outbox: DurableOutboxEntry[]; + checkpoint?: DurableCheckpoint; + handoffs: DurableHandoff[]; +} + +export interface DurableHandoffRecovery { + identity: DurableSessionIdentity; + checkpoint: DurableCheckpoint; + handoff: DurableHandoff; +} + +export interface DurableEnqueueResult { + accepted: boolean; + status: TStatus; +} + +/** + * A durable-state port. Implementations must atomically claim and complete work + * records. Recovery may requeue interrupted inbox work, but never an + * externally visible outbox effect: an ambiguous provider result remains + * claimed until a separately authorized reconciliation proves it safe. + */ +export interface DurableSessionStore { + create(identity: DurableSessionIdentity): Promise; + snapshot(sessionId: string): Promise; + enqueueInbox(input: DurableInboxInput): Promise>; + claimInbox(sessionId: string): Promise; + completeInbox(sessionId: string, idempotencyKey: string): Promise; + releaseInbox(sessionId: string, idempotencyKey: string): Promise; + enqueueOutbox(input: DurableOutboxInput): Promise>; + claimOutbox(sessionId: string): Promise; + claimOutboxByKey(sessionId: string, idempotencyKey: string): Promise; + completeOutbox(sessionId: string, idempotencyKey: string): Promise; + releaseOutbox(sessionId: string, idempotencyKey: string): Promise; + checkpoint(input: DurableCheckpointInput): Promise; + findCheckpoint(sessionId: string, checkpointId: string): Promise; + handoff(input: DurableHandoffInput): Promise; + findHandoff(handoffId: string): Promise; + requeueInFlight(sessionId: string): Promise; +} + +export class DurableSessionNotFoundError extends Error { + constructor(sessionId: string) { + super(`Durable Tess session not found: ${sessionId}`); + this.name = 'DurableSessionNotFoundError'; + } +} + +export class DurableSessionCoordinator { + constructor(private readonly store: DurableSessionStore) {} + + async create(identity: DurableSessionIdentity): Promise { + this.assertIdentity(identity); + await this.store.create(identity); + } + + async receive(input: DurableInboxInput): Promise> { + this.assertRecord(input.sessionId, input.idempotencyKey, input.correlationId, input.content); + return this.store.enqueueInbox(input); + } + + async enqueueOutbox( + input: DurableOutboxInput, + ): Promise> { + this.assertRecord( + input.sessionId, + input.idempotencyKey, + input.correlationId, + input.channelId, + input.content, + ); + if (input.kind.trim().length === 0) throw new Error('Durable outbox kind is required'); + return this.store.enqueueOutbox(input); + } + + async checkpoint(input: DurableCheckpointInput): Promise { + this.assertRecord(input.sessionId, input.checkpointId, input.cursor, input.summary); + if (!Number.isInteger(input.compactionEpoch) || input.compactionEpoch < 0) { + throw new Error('Durable checkpoint compaction epoch must be a non-negative integer'); + } + await this.store.checkpoint(input); + } + + async handoff(input: DurableHandoffInput): Promise { + this.assertRecord(input.sessionId, input.handoffId, input.destination, input.correlationId); + if (input.checkpointId.trim().length === 0) + throw new Error('Durable handoff checkpoint is required'); + await this.store.handoff(input); + } + + /** Read durable state without changing claim status; safe during normal operation. */ + async snapshot(sessionId: string): Promise { + const snapshot = await this.store.snapshot(sessionId); + if (!snapshot) throw new DurableSessionNotFoundError(sessionId); + return snapshot; + } + + /** Requeue interrupted inbox work during recovery; preserve ambiguous outbox claims. */ + async recover(sessionId: string): Promise { + await this.store.requeueInFlight(sessionId); + return this.snapshot(sessionId); + } + + async drainInbox( + sessionId: string, + handler: (entry: DurableInboxEntry) => Promise, + ): Promise { + for (;;) { + const entry = await this.store.claimInbox(sessionId); + if (!entry) return; + try { + await handler(entry); + } catch (error: unknown) { + await this.store.releaseInbox(sessionId, entry.idempotencyKey); + throw error; + } + // If this write fails after the handler succeeded, leave the record + // processing. A recovery path can retry it with its stable idempotency key. + await this.store.completeInbox(sessionId, entry.idempotencyKey); + } + } + + async dispatchOutbox( + sessionId: string, + dispatcher: (entry: DurableOutboxEntry) => Promise, + ): Promise { + for (;;) { + const entry = await this.store.claimOutbox(sessionId); + if (!entry) return; + // A provider failure can be ambiguous: it may occur after the receiver + // accepted the idempotency key. Preserve the claim for reconciliation. + await dispatcher(entry); + // Do not requeue an effect after it has been applied but before its + // terminal state could be persisted; recovery preserves the claim. + await this.store.completeOutbox(sessionId, entry.idempotencyKey); + } + } + + async dispatchOutboxEntry( + sessionId: string, + idempotencyKey: string, + dispatcher: (entry: DurableOutboxEntry) => Promise, + ): Promise { + const entry = await this.store.claimOutboxByKey(sessionId, idempotencyKey); + if (!entry) return; + // A provider failure can be ambiguous, so this stays processing until + // separately authorized reconciliation proves it safe to resolve. + await dispatcher(entry); + await this.store.completeOutbox(sessionId, entry.idempotencyKey); + } + + async resumeHandoff(handoffId: string): Promise { + const handoff = await this.store.findHandoff(handoffId); + if (!handoff) throw new Error(`Durable Tess handoff not found: ${handoffId}`); + const snapshot = await this.snapshot(handoff.sessionId); + const checkpoint = await this.store.findCheckpoint(handoff.sessionId, handoff.checkpointId); + if (!checkpoint) { + throw new Error(`Durable Tess handoff checkpoint is unavailable: ${handoff.checkpointId}`); + } + return { identity: snapshot.identity, checkpoint, handoff }; + } + + private assertIdentity(identity: DurableSessionIdentity): void { + this.assertRecord( + identity.agentName, + identity.sessionId, + identity.tenantId, + identity.ownerId, + identity.providerId, + ); + if (identity.runtimeSessionId.trim().length === 0) { + throw new Error('Durable runtime session identity is required'); + } + } + + private assertRecord(...values: string[]): void { + if (values.some((value: string): boolean => value.trim().length === 0)) { + throw new Error('Durable session records require non-empty fields'); + } + } +} + +interface InMemorySessionState { + identity: DurableSessionIdentity; + inbox: Map; + outbox: Map; + checkpoints: Map; + handoffs: Map; +} + +/** Reference store for deterministic domain tests; production uses the gateway DB adapter. */ +export class InMemoryDurableSessionStore implements DurableSessionStore { + private readonly sessions = new Map(); + + async create(identity: DurableSessionIdentity): Promise { + const existing = this.sessions.get(identity.sessionId); + if (existing) { + if (!identitiesEqual(existing.identity, identity)) { + throw new Error(`Durable Tess session identity conflict: ${identity.sessionId}`); + } + return; + } + this.sessions.set(identity.sessionId, { + identity: copyIdentity(identity), + inbox: new Map(), + outbox: new Map(), + checkpoints: new Map(), + handoffs: new Map(), + }); + } + + async snapshot(sessionId: string): Promise { + const state = this.sessions.get(sessionId); + if (!state) return null; + const checkpoint = latestCheckpoint(state.checkpoints); + return { + identity: copyIdentity(state.identity), + inbox: [...state.inbox.values()].map(copyInbox), + outbox: [...state.outbox.values()].map(copyOutbox), + ...(checkpoint ? { checkpoint: copyCheckpoint(checkpoint) } : {}), + handoffs: [...state.handoffs.values()].map(copyHandoff), + }; + } + + async enqueueInbox(input: DurableInboxInput): Promise> { + const state = this.require(input.sessionId); + const existing = state.inbox.get(input.idempotencyKey); + if (existing) { + if (!sameInbox(existing, input)) { + throw new Error(`Durable Tess inbox idempotency conflict: ${input.idempotencyKey}`); + } + return { accepted: false, status: existing.status }; + } + state.inbox.set(input.idempotencyKey, { ...input, status: 'pending' }); + return { accepted: true, status: 'pending' }; + } + + async claimInbox(sessionId: string): Promise { + const state = this.require(sessionId); + const entry = [...state.inbox.values()].find( + (candidate: DurableInboxEntry): boolean => candidate.status === 'pending', + ); + if (!entry) return null; + entry.status = 'processing'; + return copyInbox(entry); + } + + async completeInbox(sessionId: string, idempotencyKey: string): Promise { + this.requireEntry(this.require(sessionId).inbox, idempotencyKey, 'inbox').status = 'processed'; + } + + async releaseInbox(sessionId: string, idempotencyKey: string): Promise { + this.requireEntry(this.require(sessionId).inbox, idempotencyKey, 'inbox').status = 'pending'; + } + + async enqueueOutbox( + input: DurableOutboxInput, + ): Promise> { + const state = this.require(input.sessionId); + const existing = state.outbox.get(input.idempotencyKey); + if (existing) { + if (!sameOutbox(existing, input)) { + throw new Error(`Durable Tess outbox idempotency conflict: ${input.idempotencyKey}`); + } + return { accepted: false, status: existing.status }; + } + state.outbox.set(input.idempotencyKey, { ...input, status: 'pending' }); + return { accepted: true, status: 'pending' }; + } + + async claimOutbox(sessionId: string): Promise { + const state = this.require(sessionId); + const entry = [...state.outbox.values()].find( + (candidate: DurableOutboxEntry): boolean => candidate.status === 'pending', + ); + if (!entry) return null; + entry.status = 'processing'; + return copyOutbox(entry); + } + + async claimOutboxByKey( + sessionId: string, + idempotencyKey: string, + ): Promise { + const entry = this.require(sessionId).outbox.get(idempotencyKey); + if (!entry || entry.status !== 'pending') return null; + entry.status = 'processing'; + return copyOutbox(entry); + } + + async completeOutbox(sessionId: string, idempotencyKey: string): Promise { + this.requireEntry(this.require(sessionId).outbox, idempotencyKey, 'outbox').status = + 'delivered'; + } + + async releaseOutbox(sessionId: string, idempotencyKey: string): Promise { + this.requireEntry(this.require(sessionId).outbox, idempotencyKey, 'outbox').status = 'pending'; + } + + async checkpoint(input: DurableCheckpointInput): Promise { + const checkpoints = this.require(input.sessionId).checkpoints; + const existing = checkpoints.get(input.checkpointId); + if (existing && !sameCheckpoint(existing, input)) { + throw new Error(`Durable Tess checkpoint identity conflict: ${input.checkpointId}`); + } + if (!existing) checkpoints.set(input.checkpointId, { ...input }); + } + + async findCheckpoint(sessionId: string, checkpointId: string): Promise { + const checkpoint = this.require(sessionId).checkpoints.get(checkpointId); + return checkpoint ? copyCheckpoint(checkpoint) : null; + } + + async handoff(input: DurableHandoffInput): Promise { + const state = this.require(input.sessionId); + if (!state.checkpoints.has(input.checkpointId)) { + throw new Error(`Durable Tess handoff checkpoint is unavailable: ${input.checkpointId}`); + } + const existing = state.handoffs.get(input.handoffId); + if (existing && !sameHandoff(existing, input)) { + throw new Error(`Durable Tess handoff identity conflict: ${input.handoffId}`); + } + if (!existing) state.handoffs.set(input.handoffId, { ...input }); + } + + async findHandoff(handoffId: string): Promise { + for (const state of this.sessions.values()) { + const handoff = state.handoffs.get(handoffId); + if (handoff) return copyHandoff(handoff); + } + return null; + } + + async requeueInFlight(sessionId: string): Promise { + const state = this.require(sessionId); + for (const entry of state.inbox.values()) { + if (entry.status === 'processing') entry.status = 'pending'; + } + } + + private require(sessionId: string): InMemorySessionState { + const state = this.sessions.get(sessionId); + if (!state) throw new DurableSessionNotFoundError(sessionId); + return state; + } + + private requireEntry( + records: Map, + idempotencyKey: string, + kind: string, + ): T { + const entry = records.get(idempotencyKey); + if (!entry) throw new Error(`Durable Tess ${kind} entry not found: ${idempotencyKey}`); + return entry; + } +} + +function identitiesEqual(left: DurableSessionIdentity, right: DurableSessionIdentity): boolean { + return ( + left.agentName === right.agentName && + left.sessionId === right.sessionId && + left.tenantId === right.tenantId && + left.ownerId === right.ownerId && + left.providerId === right.providerId && + left.runtimeSessionId === right.runtimeSessionId + ); +} + +function sameInbox(left: DurableInboxEntry, right: DurableInboxInput): boolean { + return ( + left.sessionId === right.sessionId && + left.idempotencyKey === right.idempotencyKey && + left.correlationId === right.correlationId && + left.content === right.content + ); +} + +function sameOutbox(left: DurableOutboxEntry, right: DurableOutboxInput): boolean { + return ( + left.sessionId === right.sessionId && + left.idempotencyKey === right.idempotencyKey && + left.correlationId === right.correlationId && + left.channelId === right.channelId && + left.kind === right.kind && + left.content === right.content + ); +} + +function sameCheckpoint(left: DurableCheckpoint, right: DurableCheckpointInput): boolean { + return ( + left.sessionId === right.sessionId && + left.checkpointId === right.checkpointId && + left.cursor === right.cursor && + left.summary === right.summary && + left.compactionEpoch === right.compactionEpoch + ); +} + +function latestCheckpoint( + checkpoints: Map, +): DurableCheckpoint | undefined { + return [...checkpoints.values()].sort( + (left: DurableCheckpoint, right: DurableCheckpoint): number => + right.compactionEpoch - left.compactionEpoch, + )[0]; +} + +function sameHandoff(left: DurableHandoff, right: DurableHandoffInput): boolean { + return ( + left.sessionId === right.sessionId && + left.handoffId === right.handoffId && + left.destination === right.destination && + left.correlationId === right.correlationId && + left.checkpointId === right.checkpointId && + left.status === right.status + ); +} + +function copyIdentity(identity: DurableSessionIdentity): DurableSessionIdentity { + return { ...identity }; +} + +function copyInbox(entry: DurableInboxEntry): DurableInboxEntry { + return { ...entry }; +} + +function copyOutbox(entry: DurableOutboxEntry): DurableOutboxEntry { + return { ...entry }; +} + +function copyCheckpoint(checkpoint: DurableCheckpoint): DurableCheckpoint { + return { ...checkpoint }; +} + +function copyHandoff(handoff: DurableHandoff): DurableHandoff { + return { ...handoff }; +} diff --git a/packages/db/drizzle/0012_interaction_durable_state.sql b/packages/db/drizzle/0012_interaction_durable_state.sql new file mode 100644 index 00000000..36d8680a --- /dev/null +++ b/packages/db/drizzle/0012_interaction_durable_state.sql @@ -0,0 +1,71 @@ +CREATE TYPE "public"."interaction_handoff_status" AS ENUM('pending', 'accepted');--> statement-breakpoint +CREATE TYPE "public"."interaction_inbox_status" AS ENUM('pending', 'processing', 'processed');--> statement-breakpoint +CREATE TYPE "public"."interaction_outbox_status" AS ENUM('pending', 'processing', 'delivered');--> statement-breakpoint +CREATE TABLE "interaction_checkpoints" ( + "session_id" text PRIMARY KEY NOT NULL, + "checkpoint_id" text NOT NULL, + "cursor" text NOT NULL, + "summary" text NOT NULL, + "compaction_epoch" integer NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "interaction_checkpoints_checkpoint_id_unique" UNIQUE("checkpoint_id") +); +--> statement-breakpoint +CREATE TABLE "interaction_handoffs" ( + "handoff_id" text PRIMARY KEY NOT NULL, + "session_id" text NOT NULL, + "destination" text NOT NULL, + "correlation_id" text NOT NULL, + "checkpoint_id" text NOT NULL, + "status" "interaction_handoff_status" DEFAULT 'pending' NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "interaction_inbox" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "session_id" text NOT NULL, + "idempotency_key" text NOT NULL, + "correlation_id" text NOT NULL, + "content" text NOT NULL, + "content_digest" text NOT NULL, + "status" "interaction_inbox_status" DEFAULT 'pending' NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "interaction_outbox" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "session_id" text NOT NULL, + "idempotency_key" text NOT NULL, + "correlation_id" text NOT NULL, + "kind" text NOT NULL, + "content" text NOT NULL, + "content_digest" text NOT NULL, + "status" "interaction_outbox_status" DEFAULT 'pending' NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "interaction_sessions" ( + "id" text PRIMARY KEY NOT NULL, + "agent_name" text NOT NULL, + "tenant_id" text NOT NULL, + "owner_id" text NOT NULL, + "provider_id" text NOT NULL, + "runtime_session_id" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "interaction_checkpoints" ADD CONSTRAINT "interaction_checkpoints_session_id_interaction_sessions_id_fk" FOREIGN KEY ("session_id") REFERENCES "public"."interaction_sessions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "interaction_handoffs" ADD CONSTRAINT "interaction_handoffs_session_id_interaction_sessions_id_fk" FOREIGN KEY ("session_id") REFERENCES "public"."interaction_sessions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "interaction_inbox" ADD CONSTRAINT "interaction_inbox_session_id_interaction_sessions_id_fk" FOREIGN KEY ("session_id") REFERENCES "public"."interaction_sessions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "interaction_outbox" ADD CONSTRAINT "interaction_outbox_session_id_interaction_sessions_id_fk" FOREIGN KEY ("session_id") REFERENCES "public"."interaction_sessions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "interaction_sessions" ADD CONSTRAINT "interaction_sessions_owner_id_users_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "interaction_handoffs_session_status_idx" ON "interaction_handoffs" USING btree ("session_id","status");--> statement-breakpoint +CREATE UNIQUE INDEX "interaction_inbox_session_idempotency_idx" ON "interaction_inbox" USING btree ("session_id","idempotency_key");--> statement-breakpoint +CREATE INDEX "interaction_inbox_session_status_created_idx" ON "interaction_inbox" USING btree ("session_id","status","created_at");--> statement-breakpoint +CREATE UNIQUE INDEX "interaction_outbox_session_idempotency_idx" ON "interaction_outbox" USING btree ("session_id","idempotency_key");--> statement-breakpoint +CREATE INDEX "interaction_outbox_session_status_created_idx" ON "interaction_outbox" USING btree ("session_id","status","created_at"); \ No newline at end of file diff --git a/packages/db/drizzle/0013_interaction_checkpoint_history.sql b/packages/db/drizzle/0013_interaction_checkpoint_history.sql new file mode 100644 index 00000000..7fc8573c --- /dev/null +++ b/packages/db/drizzle/0013_interaction_checkpoint_history.sql @@ -0,0 +1,5 @@ +ALTER TABLE "interaction_checkpoints" DROP CONSTRAINT "interaction_checkpoints_checkpoint_id_unique";--> statement-breakpoint +ALTER TABLE "interaction_checkpoints" DROP CONSTRAINT "interaction_checkpoints_pkey";--> statement-breakpoint +ALTER TABLE "interaction_checkpoints" ADD COLUMN "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL;--> statement-breakpoint +CREATE UNIQUE INDEX "interaction_checkpoints_session_idempotency_idx" ON "interaction_checkpoints" USING btree ("session_id","checkpoint_id");--> statement-breakpoint +CREATE INDEX "interaction_checkpoints_session_epoch_idx" ON "interaction_checkpoints" USING btree ("session_id","compaction_epoch"); \ No newline at end of file diff --git a/packages/db/drizzle/0014_interaction_outbox_channel_scope.sql b/packages/db/drizzle/0014_interaction_outbox_channel_scope.sql new file mode 100644 index 00000000..af44cf57 --- /dev/null +++ b/packages/db/drizzle/0014_interaction_outbox_channel_scope.sql @@ -0,0 +1,5 @@ +ALTER TABLE "interaction_outbox" ADD COLUMN "channel_id" text; +--> statement-breakpoint +UPDATE "interaction_outbox" SET "channel_id" = 'legacy:unknown' WHERE "channel_id" IS NULL; +--> statement-breakpoint +ALTER TABLE "interaction_outbox" ALTER COLUMN "channel_id" SET NOT NULL; diff --git a/packages/db/drizzle/0015_interaction_checkpoint_payload_digest.sql b/packages/db/drizzle/0015_interaction_checkpoint_payload_digest.sql new file mode 100644 index 00000000..f291de16 --- /dev/null +++ b/packages/db/drizzle/0015_interaction_checkpoint_payload_digest.sql @@ -0,0 +1,3 @@ +ALTER TABLE "interaction_checkpoints" ADD COLUMN "content_digest" text;--> statement-breakpoint +UPDATE "interaction_checkpoints" SET "content_digest" = 'legacy' WHERE "content_digest" IS NULL;--> statement-breakpoint +ALTER TABLE "interaction_checkpoints" ALTER COLUMN "content_digest" SET NOT NULL; diff --git a/packages/db/drizzle/meta/0012_snapshot.json b/packages/db/drizzle/meta/0012_snapshot.json new file mode 100644 index 00000000..721391c4 --- /dev/null +++ b/packages/db/drizzle/meta/0012_snapshot.json @@ -0,0 +1,4172 @@ +{ + "id": "5d2dbfe9-f5d2-4342-a613-8c73943a6122", + "prevId": "0aa37ae4-5a0b-464b-ba70-121c5d9bbd23", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "accounts_provider_account_idx": { + "name": "accounts_provider_account_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "accounts_user_id_idx": { + "name": "accounts_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.admin_tokens": { + "name": "admin_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'admin'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "admin_tokens_user_id_idx": { + "name": "admin_tokens_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "admin_tokens_hash_idx": { + "name": "admin_tokens_hash_idx", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "admin_tokens_user_id_users_id_fk": { + "name": "admin_tokens_user_id_users_id_fk", + "tableFrom": "admin_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_logs": { + "name": "agent_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'info'" + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'general'" + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "tier": { + "name": "tier", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'hot'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "summarized_at": { + "name": "summarized_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "agent_logs_session_tier_idx": { + "name": "agent_logs_session_tier_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_logs_user_id_idx": { + "name": "agent_logs_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_logs_tier_created_at_idx": { + "name": "agent_logs_tier_created_at_idx", + "columns": [ + { + "expression": "tier", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_logs_user_id_users_id_fk": { + "name": "agent_logs_user_id_users_id_fk", + "tableFrom": "agent_logs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agents": { + "name": "agents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "system_prompt": { + "name": "system_prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_tools": { + "name": "allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "skills": { + "name": "skills", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_system": { + "name": "is_system", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agents_project_id_idx": { + "name": "agents_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agents_owner_id_idx": { + "name": "agents_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agents_is_system_idx": { + "name": "agents_is_system_idx", + "columns": [ + { + "expression": "is_system", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agents_project_id_projects_id_fk": { + "name": "agents_project_id_projects_id_fk", + "tableFrom": "agents", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "agents_owner_id_users_id_fk": { + "name": "agents_owner_id_users_id_fk", + "tableFrom": "agents", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.appreciations": { + "name": "appreciations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "from_user": { + "name": "from_user", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "to_user": { + "name": "to_user", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.backlog": { + "name": "backlog", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "backlog_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'ready'" + }, + "depends_on": { + "name": "depends_on", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "claim_owner": { + "name": "claim_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "claim_ttl_seconds": { + "name": "claim_ttl_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "acceptance": { + "name": "acceptance", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "backlog_status_priority_idx": { + "name": "backlog_status_priority_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "backlog_status_claimed_at_idx": { + "name": "backlog_status_claimed_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "claimed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "backlog_idempotency_key_idx": { + "name": "backlog_idempotency_key_idx", + "columns": [ + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.conversations": { + "name": "conversations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "conversations_user_archived_idx": { + "name": "conversations_user_archived_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversations_project_id_idx": { + "name": "conversations_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversations_agent_id_idx": { + "name": "conversations_agent_id_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "conversations_user_id_users_id_fk": { + "name": "conversations_user_id_users_id_fk", + "tableFrom": "conversations", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "conversations_project_id_projects_id_fk": { + "name": "conversations_project_id_projects_id_fk", + "tableFrom": "conversations", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "conversations_agent_id_agents_id_fk": { + "name": "conversations_agent_id_agents_id_fk", + "tableFrom": "conversations", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.events": { + "name": "events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "events_type_idx": { + "name": "events_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "events_date_idx": { + "name": "events_date_idx", + "columns": [ + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.federation_audit_log": { + "name": "federation_audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "peer_id": { + "name": "peer_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "subject_user_id": { + "name": "subject_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_id": { + "name": "grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "verb": { + "name": "verb", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "result_count": { + "name": "result_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "denied_reason": { + "name": "denied_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "query_hash": { + "name": "query_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bytes_out": { + "name": "bytes_out", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "federation_audit_log_peer_created_at_idx": { + "name": "federation_audit_log_peer_created_at_idx", + "columns": [ + { + "expression": "peer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "federation_audit_log_subject_created_at_idx": { + "name": "federation_audit_log_subject_created_at_idx", + "columns": [ + { + "expression": "subject_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "federation_audit_log_created_at_idx": { + "name": "federation_audit_log_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "federation_audit_log_peer_id_federation_peers_id_fk": { + "name": "federation_audit_log_peer_id_federation_peers_id_fk", + "tableFrom": "federation_audit_log", + "tableTo": "federation_peers", + "columnsFrom": [ + "peer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "federation_audit_log_subject_user_id_users_id_fk": { + "name": "federation_audit_log_subject_user_id_users_id_fk", + "tableFrom": "federation_audit_log", + "tableTo": "users", + "columnsFrom": [ + "subject_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "federation_audit_log_grant_id_federation_grants_id_fk": { + "name": "federation_audit_log_grant_id_federation_grants_id_fk", + "tableFrom": "federation_audit_log", + "tableTo": "federation_grants", + "columnsFrom": [ + "grant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.federation_enrollment_tokens": { + "name": "federation_enrollment_tokens", + "schema": "", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "grant_id": { + "name": "grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "peer_id": { + "name": "peer_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "used_at": { + "name": "used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "federation_enrollment_tokens_grant_id_federation_grants_id_fk": { + "name": "federation_enrollment_tokens_grant_id_federation_grants_id_fk", + "tableFrom": "federation_enrollment_tokens", + "tableTo": "federation_grants", + "columnsFrom": [ + "grant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "federation_enrollment_tokens_peer_id_federation_peers_id_fk": { + "name": "federation_enrollment_tokens_peer_id_federation_peers_id_fk", + "tableFrom": "federation_enrollment_tokens", + "tableTo": "federation_peers", + "columnsFrom": [ + "peer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.federation_grants": { + "name": "federation_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "subject_user_id": { + "name": "subject_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "peer_id": { + "name": "peer_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "grant_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_reason": { + "name": "revoked_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "federation_grants_subject_status_idx": { + "name": "federation_grants_subject_status_idx", + "columns": [ + { + "expression": "subject_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "federation_grants_peer_status_idx": { + "name": "federation_grants_peer_status_idx", + "columns": [ + { + "expression": "peer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "federation_grants_subject_user_id_users_id_fk": { + "name": "federation_grants_subject_user_id_users_id_fk", + "tableFrom": "federation_grants", + "tableTo": "users", + "columnsFrom": [ + "subject_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "federation_grants_peer_id_federation_peers_id_fk": { + "name": "federation_grants_peer_id_federation_peers_id_fk", + "tableFrom": "federation_grants", + "tableTo": "federation_peers", + "columnsFrom": [ + "peer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.federation_peers": { + "name": "federation_peers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "common_name": { + "name": "common_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cert_pem": { + "name": "cert_pem", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cert_serial": { + "name": "cert_serial", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cert_not_after": { + "name": "cert_not_after", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "client_key_pem": { + "name": "client_key_pem", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "peer_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "endpoint_url": { + "name": "endpoint_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "federation_peers_cert_serial_idx": { + "name": "federation_peers_cert_serial_idx", + "columns": [ + { + "expression": "cert_serial", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "federation_peers_state_idx": { + "name": "federation_peers_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "federation_peers_common_name_unique": { + "name": "federation_peers_common_name_unique", + "nullsNotDistinct": false, + "columns": [ + "common_name" + ] + }, + "federation_peers_cert_serial_unique": { + "name": "federation_peers_cert_serial_unique", + "nullsNotDistinct": false, + "columns": [ + "cert_serial" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.insights": { + "name": "insights", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'agent'" + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'general'" + }, + "relevance_score": { + "name": "relevance_score", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "decayed_at": { + "name": "decayed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "insights_user_id_idx": { + "name": "insights_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "insights_category_idx": { + "name": "insights_category_idx", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "insights_relevance_idx": { + "name": "insights_relevance_idx", + "columns": [ + { + "expression": "relevance_score", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "insights_user_id_users_id_fk": { + "name": "insights_user_id_users_id_fk", + "tableFrom": "insights", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.messages": { + "name": "messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "conversation_id": { + "name": "conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "messages_conversation_id_idx": { + "name": "messages_conversation_id_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "messages_conversation_id_conversations_id_fk": { + "name": "messages_conversation_id_conversations_id_fk", + "tableFrom": "messages", + "tableTo": "conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mission_tasks": { + "name": "mission_tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "mission_id": { + "name": "mission_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'not-started'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pr": { + "name": "pr", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mission_tasks_mission_id_idx": { + "name": "mission_tasks_mission_id_idx", + "columns": [ + { + "expression": "mission_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mission_tasks_task_id_idx": { + "name": "mission_tasks_task_id_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mission_tasks_user_id_idx": { + "name": "mission_tasks_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mission_tasks_status_idx": { + "name": "mission_tasks_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mission_tasks_mission_id_missions_id_fk": { + "name": "mission_tasks_mission_id_missions_id_fk", + "tableFrom": "mission_tasks", + "tableTo": "missions", + "columnsFrom": [ + "mission_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mission_tasks_task_id_tasks_id_fk": { + "name": "mission_tasks_task_id_tasks_id_fk", + "tableFrom": "mission_tasks", + "tableTo": "tasks", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mission_tasks_user_id_users_id_fk": { + "name": "mission_tasks_user_id_users_id_fk", + "tableFrom": "mission_tasks", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.missions": { + "name": "missions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'planning'" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "milestones": { + "name": "milestones", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "missions_project_id_idx": { + "name": "missions_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "missions_user_id_idx": { + "name": "missions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "missions_project_id_projects_id_fk": { + "name": "missions_project_id_projects_id_fk", + "tableFrom": "missions", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "missions_user_id_users_id_fk": { + "name": "missions_user_id_users_id_fk", + "tableFrom": "missions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.preferences": { + "name": "preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'general'" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mutable": { + "name": "mutable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "preferences_user_id_idx": { + "name": "preferences_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "preferences_user_key_idx": { + "name": "preferences_user_key_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "preferences_user_id_users_id_fk": { + "name": "preferences_user_id_users_id_fk", + "tableFrom": "preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owner_type": { + "name": "owner_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "projects_owner_id_users_id_fk": { + "name": "projects_owner_id_users_id_fk", + "tableFrom": "projects", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "projects_team_id_teams_id_fk": { + "name": "projects_team_id_teams_id_fk", + "tableFrom": "projects", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_credentials": { + "name": "provider_credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_type": { + "name": "credential_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_value": { + "name": "encrypted_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "provider_credentials_user_provider_idx": { + "name": "provider_credentials_user_provider_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "provider_credentials_user_id_idx": { + "name": "provider_credentials_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "provider_credentials_user_id_users_id_fk": { + "name": "provider_credentials_user_id_users_id_fk", + "tableFrom": "provider_credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routing_rules": { + "name": "routing_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "conditions": { + "name": "conditions", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "routing_rules_scope_priority_idx": { + "name": "routing_rules_scope_priority_idx", + "columns": [ + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routing_rules_user_id_idx": { + "name": "routing_rules_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routing_rules_enabled_idx": { + "name": "routing_rules_enabled_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routing_rules_user_id_users_id_fk": { + "name": "routing_rules_user_id_users_id_fk", + "tableFrom": "routing_rules", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sessions_user_id_idx": { + "name": "sessions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_expires_at_idx": { + "name": "sessions_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skills": { + "name": "skills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'custom'" + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "installed_by": { + "name": "installed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skills_enabled_idx": { + "name": "skills_enabled_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skills_installed_by_users_id_fk": { + "name": "skills_installed_by_users_id_fk", + "tableFrom": "skills", + "tableTo": "users", + "columnsFrom": [ + "installed_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "skills_name_unique": { + "name": "skills_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.summarization_jobs": { + "name": "summarization_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "logs_processed": { + "name": "logs_processed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "insights_created": { + "name": "insights_created", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "summarization_jobs_status_idx": { + "name": "summarization_jobs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tasks": { + "name": "tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'not-started'" + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'medium'" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "mission_id": { + "name": "mission_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "assignee": { + "name": "assignee", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "due_date": { + "name": "due_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tasks_project_id_idx": { + "name": "tasks_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_mission_id_idx": { + "name": "tasks_mission_id_idx", + "columns": [ + { + "expression": "mission_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_status_idx": { + "name": "tasks_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tasks_project_id_projects_id_fk": { + "name": "tasks_project_id_projects_id_fk", + "tableFrom": "tasks", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tasks_mission_id_missions_id_fk": { + "name": "tasks_mission_id_missions_id_fk", + "tableFrom": "tasks", + "tableTo": "missions", + "columnsFrom": [ + "mission_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_members": { + "name": "team_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "team_members_team_user_idx": { + "name": "team_members_team_user_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "team_members_team_id_teams_id_fk": { + "name": "team_members_team_id_teams_id_fk", + "tableFrom": "team_members", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_members_user_id_users_id_fk": { + "name": "team_members_user_id_users_id_fk", + "tableFrom": "team_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_members_invited_by_users_id_fk": { + "name": "team_members_invited_by_users_id_fk", + "tableFrom": "team_members", + "tableTo": "users", + "columnsFrom": [ + "invited_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.teams": { + "name": "teams", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "manager_id": { + "name": "manager_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "teams_owner_id_users_id_fk": { + "name": "teams_owner_id_users_id_fk", + "tableFrom": "teams", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "teams_manager_id_users_id_fk": { + "name": "teams_manager_id_users_id_fk", + "tableFrom": "teams", + "tableTo": "users", + "columnsFrom": [ + "manager_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "teams_slug_unique": { + "name": "teams_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.interaction_checkpoints": { + "name": "interaction_checkpoints", + "schema": "", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "compaction_epoch": { + "name": "compaction_epoch", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "interaction_checkpoints_session_id_interaction_sessions_id_fk": { + "name": "interaction_checkpoints_session_id_interaction_sessions_id_fk", + "tableFrom": "interaction_checkpoints", + "tableTo": "interaction_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "interaction_checkpoints_checkpoint_id_unique": { + "name": "interaction_checkpoints_checkpoint_id_unique", + "nullsNotDistinct": false, + "columns": [ + "checkpoint_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.interaction_handoffs": { + "name": "interaction_handoffs", + "schema": "", + "columns": { + "handoff_id": { + "name": "handoff_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "destination": { + "name": "destination", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "correlation_id": { + "name": "correlation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "interaction_handoff_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "interaction_handoffs_session_status_idx": { + "name": "interaction_handoffs_session_status_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "interaction_handoffs_session_id_interaction_sessions_id_fk": { + "name": "interaction_handoffs_session_id_interaction_sessions_id_fk", + "tableFrom": "interaction_handoffs", + "tableTo": "interaction_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.interaction_inbox": { + "name": "interaction_inbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "correlation_id": { + "name": "correlation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "interaction_inbox_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "interaction_inbox_session_idempotency_idx": { + "name": "interaction_inbox_session_idempotency_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "interaction_inbox_session_status_created_idx": { + "name": "interaction_inbox_session_status_created_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "interaction_inbox_session_id_interaction_sessions_id_fk": { + "name": "interaction_inbox_session_id_interaction_sessions_id_fk", + "tableFrom": "interaction_inbox", + "tableTo": "interaction_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.interaction_outbox": { + "name": "interaction_outbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "correlation_id": { + "name": "correlation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "interaction_outbox_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "interaction_outbox_session_idempotency_idx": { + "name": "interaction_outbox_session_idempotency_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "interaction_outbox_session_status_created_idx": { + "name": "interaction_outbox_session_status_created_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "interaction_outbox_session_id_interaction_sessions_id_fk": { + "name": "interaction_outbox_session_id_interaction_sessions_id_fk", + "tableFrom": "interaction_outbox", + "tableTo": "interaction_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.interaction_sessions": { + "name": "interaction_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "runtime_session_id": { + "name": "runtime_session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "interaction_sessions_owner_id_users_id_fk": { + "name": "interaction_sessions_owner_id_users_id_fk", + "tableFrom": "interaction_sessions", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tickets": { + "name": "tickets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'medium'" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tickets_status_idx": { + "name": "tickets_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verifications": { + "name": "verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.backlog_status": { + "name": "backlog_status", + "schema": "public", + "values": [ + "ready", + "claimed", + "blocked", + "done" + ] + }, + "public.grant_status": { + "name": "grant_status", + "schema": "public", + "values": [ + "pending", + "active", + "revoked", + "expired" + ] + }, + "public.peer_state": { + "name": "peer_state", + "schema": "public", + "values": [ + "pending", + "active", + "suspended", + "revoked" + ] + }, + "public.interaction_handoff_status": { + "name": "interaction_handoff_status", + "schema": "public", + "values": [ + "pending", + "accepted" + ] + }, + "public.interaction_inbox_status": { + "name": "interaction_inbox_status", + "schema": "public", + "values": [ + "pending", + "processing", + "processed" + ] + }, + "public.interaction_outbox_status": { + "name": "interaction_outbox_status", + "schema": "public", + "values": [ + "pending", + "processing", + "delivered" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/0013_snapshot.json b/packages/db/drizzle/meta/0013_snapshot.json new file mode 100644 index 00000000..6c85c862 --- /dev/null +++ b/packages/db/drizzle/meta/0013_snapshot.json @@ -0,0 +1,4214 @@ +{ + "id": "0cd8e1a3-a7f6-4c39-ac19-7cc0f9c02164", + "prevId": "5d2dbfe9-f5d2-4342-a613-8c73943a6122", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "accounts_provider_account_idx": { + "name": "accounts_provider_account_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "accounts_user_id_idx": { + "name": "accounts_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.admin_tokens": { + "name": "admin_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'admin'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "admin_tokens_user_id_idx": { + "name": "admin_tokens_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "admin_tokens_hash_idx": { + "name": "admin_tokens_hash_idx", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "admin_tokens_user_id_users_id_fk": { + "name": "admin_tokens_user_id_users_id_fk", + "tableFrom": "admin_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_logs": { + "name": "agent_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'info'" + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'general'" + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "tier": { + "name": "tier", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'hot'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "summarized_at": { + "name": "summarized_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "agent_logs_session_tier_idx": { + "name": "agent_logs_session_tier_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_logs_user_id_idx": { + "name": "agent_logs_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_logs_tier_created_at_idx": { + "name": "agent_logs_tier_created_at_idx", + "columns": [ + { + "expression": "tier", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_logs_user_id_users_id_fk": { + "name": "agent_logs_user_id_users_id_fk", + "tableFrom": "agent_logs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agents": { + "name": "agents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "system_prompt": { + "name": "system_prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_tools": { + "name": "allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "skills": { + "name": "skills", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_system": { + "name": "is_system", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agents_project_id_idx": { + "name": "agents_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agents_owner_id_idx": { + "name": "agents_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agents_is_system_idx": { + "name": "agents_is_system_idx", + "columns": [ + { + "expression": "is_system", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agents_project_id_projects_id_fk": { + "name": "agents_project_id_projects_id_fk", + "tableFrom": "agents", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "agents_owner_id_users_id_fk": { + "name": "agents_owner_id_users_id_fk", + "tableFrom": "agents", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.appreciations": { + "name": "appreciations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "from_user": { + "name": "from_user", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "to_user": { + "name": "to_user", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.backlog": { + "name": "backlog", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "backlog_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'ready'" + }, + "depends_on": { + "name": "depends_on", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "claim_owner": { + "name": "claim_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "claim_ttl_seconds": { + "name": "claim_ttl_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "acceptance": { + "name": "acceptance", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "backlog_status_priority_idx": { + "name": "backlog_status_priority_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "backlog_status_claimed_at_idx": { + "name": "backlog_status_claimed_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "claimed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "backlog_idempotency_key_idx": { + "name": "backlog_idempotency_key_idx", + "columns": [ + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.conversations": { + "name": "conversations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "conversations_user_archived_idx": { + "name": "conversations_user_archived_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversations_project_id_idx": { + "name": "conversations_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversations_agent_id_idx": { + "name": "conversations_agent_id_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "conversations_user_id_users_id_fk": { + "name": "conversations_user_id_users_id_fk", + "tableFrom": "conversations", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "conversations_project_id_projects_id_fk": { + "name": "conversations_project_id_projects_id_fk", + "tableFrom": "conversations", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "conversations_agent_id_agents_id_fk": { + "name": "conversations_agent_id_agents_id_fk", + "tableFrom": "conversations", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.events": { + "name": "events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "events_type_idx": { + "name": "events_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "events_date_idx": { + "name": "events_date_idx", + "columns": [ + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.federation_audit_log": { + "name": "federation_audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "peer_id": { + "name": "peer_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "subject_user_id": { + "name": "subject_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_id": { + "name": "grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "verb": { + "name": "verb", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "result_count": { + "name": "result_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "denied_reason": { + "name": "denied_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "query_hash": { + "name": "query_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bytes_out": { + "name": "bytes_out", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "federation_audit_log_peer_created_at_idx": { + "name": "federation_audit_log_peer_created_at_idx", + "columns": [ + { + "expression": "peer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "federation_audit_log_subject_created_at_idx": { + "name": "federation_audit_log_subject_created_at_idx", + "columns": [ + { + "expression": "subject_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "federation_audit_log_created_at_idx": { + "name": "federation_audit_log_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "federation_audit_log_peer_id_federation_peers_id_fk": { + "name": "federation_audit_log_peer_id_federation_peers_id_fk", + "tableFrom": "federation_audit_log", + "tableTo": "federation_peers", + "columnsFrom": [ + "peer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "federation_audit_log_subject_user_id_users_id_fk": { + "name": "federation_audit_log_subject_user_id_users_id_fk", + "tableFrom": "federation_audit_log", + "tableTo": "users", + "columnsFrom": [ + "subject_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "federation_audit_log_grant_id_federation_grants_id_fk": { + "name": "federation_audit_log_grant_id_federation_grants_id_fk", + "tableFrom": "federation_audit_log", + "tableTo": "federation_grants", + "columnsFrom": [ + "grant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.federation_enrollment_tokens": { + "name": "federation_enrollment_tokens", + "schema": "", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "grant_id": { + "name": "grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "peer_id": { + "name": "peer_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "used_at": { + "name": "used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "federation_enrollment_tokens_grant_id_federation_grants_id_fk": { + "name": "federation_enrollment_tokens_grant_id_federation_grants_id_fk", + "tableFrom": "federation_enrollment_tokens", + "tableTo": "federation_grants", + "columnsFrom": [ + "grant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "federation_enrollment_tokens_peer_id_federation_peers_id_fk": { + "name": "federation_enrollment_tokens_peer_id_federation_peers_id_fk", + "tableFrom": "federation_enrollment_tokens", + "tableTo": "federation_peers", + "columnsFrom": [ + "peer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.federation_grants": { + "name": "federation_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "subject_user_id": { + "name": "subject_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "peer_id": { + "name": "peer_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "grant_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_reason": { + "name": "revoked_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "federation_grants_subject_status_idx": { + "name": "federation_grants_subject_status_idx", + "columns": [ + { + "expression": "subject_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "federation_grants_peer_status_idx": { + "name": "federation_grants_peer_status_idx", + "columns": [ + { + "expression": "peer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "federation_grants_subject_user_id_users_id_fk": { + "name": "federation_grants_subject_user_id_users_id_fk", + "tableFrom": "federation_grants", + "tableTo": "users", + "columnsFrom": [ + "subject_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "federation_grants_peer_id_federation_peers_id_fk": { + "name": "federation_grants_peer_id_federation_peers_id_fk", + "tableFrom": "federation_grants", + "tableTo": "federation_peers", + "columnsFrom": [ + "peer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.federation_peers": { + "name": "federation_peers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "common_name": { + "name": "common_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cert_pem": { + "name": "cert_pem", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cert_serial": { + "name": "cert_serial", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cert_not_after": { + "name": "cert_not_after", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "client_key_pem": { + "name": "client_key_pem", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "peer_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "endpoint_url": { + "name": "endpoint_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "federation_peers_cert_serial_idx": { + "name": "federation_peers_cert_serial_idx", + "columns": [ + { + "expression": "cert_serial", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "federation_peers_state_idx": { + "name": "federation_peers_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "federation_peers_common_name_unique": { + "name": "federation_peers_common_name_unique", + "nullsNotDistinct": false, + "columns": [ + "common_name" + ] + }, + "federation_peers_cert_serial_unique": { + "name": "federation_peers_cert_serial_unique", + "nullsNotDistinct": false, + "columns": [ + "cert_serial" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.insights": { + "name": "insights", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'agent'" + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'general'" + }, + "relevance_score": { + "name": "relevance_score", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "decayed_at": { + "name": "decayed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "insights_user_id_idx": { + "name": "insights_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "insights_category_idx": { + "name": "insights_category_idx", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "insights_relevance_idx": { + "name": "insights_relevance_idx", + "columns": [ + { + "expression": "relevance_score", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "insights_user_id_users_id_fk": { + "name": "insights_user_id_users_id_fk", + "tableFrom": "insights", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.messages": { + "name": "messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "conversation_id": { + "name": "conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "messages_conversation_id_idx": { + "name": "messages_conversation_id_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "messages_conversation_id_conversations_id_fk": { + "name": "messages_conversation_id_conversations_id_fk", + "tableFrom": "messages", + "tableTo": "conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mission_tasks": { + "name": "mission_tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "mission_id": { + "name": "mission_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'not-started'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pr": { + "name": "pr", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mission_tasks_mission_id_idx": { + "name": "mission_tasks_mission_id_idx", + "columns": [ + { + "expression": "mission_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mission_tasks_task_id_idx": { + "name": "mission_tasks_task_id_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mission_tasks_user_id_idx": { + "name": "mission_tasks_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mission_tasks_status_idx": { + "name": "mission_tasks_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mission_tasks_mission_id_missions_id_fk": { + "name": "mission_tasks_mission_id_missions_id_fk", + "tableFrom": "mission_tasks", + "tableTo": "missions", + "columnsFrom": [ + "mission_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mission_tasks_task_id_tasks_id_fk": { + "name": "mission_tasks_task_id_tasks_id_fk", + "tableFrom": "mission_tasks", + "tableTo": "tasks", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mission_tasks_user_id_users_id_fk": { + "name": "mission_tasks_user_id_users_id_fk", + "tableFrom": "mission_tasks", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.missions": { + "name": "missions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'planning'" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "milestones": { + "name": "milestones", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "missions_project_id_idx": { + "name": "missions_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "missions_user_id_idx": { + "name": "missions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "missions_project_id_projects_id_fk": { + "name": "missions_project_id_projects_id_fk", + "tableFrom": "missions", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "missions_user_id_users_id_fk": { + "name": "missions_user_id_users_id_fk", + "tableFrom": "missions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.preferences": { + "name": "preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'general'" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mutable": { + "name": "mutable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "preferences_user_id_idx": { + "name": "preferences_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "preferences_user_key_idx": { + "name": "preferences_user_key_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "preferences_user_id_users_id_fk": { + "name": "preferences_user_id_users_id_fk", + "tableFrom": "preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owner_type": { + "name": "owner_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "projects_owner_id_users_id_fk": { + "name": "projects_owner_id_users_id_fk", + "tableFrom": "projects", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "projects_team_id_teams_id_fk": { + "name": "projects_team_id_teams_id_fk", + "tableFrom": "projects", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_credentials": { + "name": "provider_credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_type": { + "name": "credential_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_value": { + "name": "encrypted_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "provider_credentials_user_provider_idx": { + "name": "provider_credentials_user_provider_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "provider_credentials_user_id_idx": { + "name": "provider_credentials_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "provider_credentials_user_id_users_id_fk": { + "name": "provider_credentials_user_id_users_id_fk", + "tableFrom": "provider_credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routing_rules": { + "name": "routing_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "conditions": { + "name": "conditions", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "routing_rules_scope_priority_idx": { + "name": "routing_rules_scope_priority_idx", + "columns": [ + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routing_rules_user_id_idx": { + "name": "routing_rules_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routing_rules_enabled_idx": { + "name": "routing_rules_enabled_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routing_rules_user_id_users_id_fk": { + "name": "routing_rules_user_id_users_id_fk", + "tableFrom": "routing_rules", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sessions_user_id_idx": { + "name": "sessions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_expires_at_idx": { + "name": "sessions_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skills": { + "name": "skills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'custom'" + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "installed_by": { + "name": "installed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skills_enabled_idx": { + "name": "skills_enabled_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skills_installed_by_users_id_fk": { + "name": "skills_installed_by_users_id_fk", + "tableFrom": "skills", + "tableTo": "users", + "columnsFrom": [ + "installed_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "skills_name_unique": { + "name": "skills_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.summarization_jobs": { + "name": "summarization_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "logs_processed": { + "name": "logs_processed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "insights_created": { + "name": "insights_created", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "summarization_jobs_status_idx": { + "name": "summarization_jobs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tasks": { + "name": "tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'not-started'" + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'medium'" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "mission_id": { + "name": "mission_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "assignee": { + "name": "assignee", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "due_date": { + "name": "due_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tasks_project_id_idx": { + "name": "tasks_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_mission_id_idx": { + "name": "tasks_mission_id_idx", + "columns": [ + { + "expression": "mission_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_status_idx": { + "name": "tasks_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tasks_project_id_projects_id_fk": { + "name": "tasks_project_id_projects_id_fk", + "tableFrom": "tasks", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tasks_mission_id_missions_id_fk": { + "name": "tasks_mission_id_missions_id_fk", + "tableFrom": "tasks", + "tableTo": "missions", + "columnsFrom": [ + "mission_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_members": { + "name": "team_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "team_members_team_user_idx": { + "name": "team_members_team_user_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "team_members_team_id_teams_id_fk": { + "name": "team_members_team_id_teams_id_fk", + "tableFrom": "team_members", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_members_user_id_users_id_fk": { + "name": "team_members_user_id_users_id_fk", + "tableFrom": "team_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_members_invited_by_users_id_fk": { + "name": "team_members_invited_by_users_id_fk", + "tableFrom": "team_members", + "tableTo": "users", + "columnsFrom": [ + "invited_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.teams": { + "name": "teams", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "manager_id": { + "name": "manager_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "teams_owner_id_users_id_fk": { + "name": "teams_owner_id_users_id_fk", + "tableFrom": "teams", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "teams_manager_id_users_id_fk": { + "name": "teams_manager_id_users_id_fk", + "tableFrom": "teams", + "tableTo": "users", + "columnsFrom": [ + "manager_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "teams_slug_unique": { + "name": "teams_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.interaction_checkpoints": { + "name": "interaction_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "compaction_epoch": { + "name": "compaction_epoch", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "interaction_checkpoints_session_idempotency_idx": { + "name": "interaction_checkpoints_session_idempotency_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "checkpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "interaction_checkpoints_session_epoch_idx": { + "name": "interaction_checkpoints_session_epoch_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "compaction_epoch", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "interaction_checkpoints_session_id_interaction_sessions_id_fk": { + "name": "interaction_checkpoints_session_id_interaction_sessions_id_fk", + "tableFrom": "interaction_checkpoints", + "tableTo": "interaction_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.interaction_handoffs": { + "name": "interaction_handoffs", + "schema": "", + "columns": { + "handoff_id": { + "name": "handoff_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "destination": { + "name": "destination", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "correlation_id": { + "name": "correlation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "interaction_handoff_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "interaction_handoffs_session_status_idx": { + "name": "interaction_handoffs_session_status_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "interaction_handoffs_session_id_interaction_sessions_id_fk": { + "name": "interaction_handoffs_session_id_interaction_sessions_id_fk", + "tableFrom": "interaction_handoffs", + "tableTo": "interaction_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.interaction_inbox": { + "name": "interaction_inbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "correlation_id": { + "name": "correlation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "interaction_inbox_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "interaction_inbox_session_idempotency_idx": { + "name": "interaction_inbox_session_idempotency_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "interaction_inbox_session_status_created_idx": { + "name": "interaction_inbox_session_status_created_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "interaction_inbox_session_id_interaction_sessions_id_fk": { + "name": "interaction_inbox_session_id_interaction_sessions_id_fk", + "tableFrom": "interaction_inbox", + "tableTo": "interaction_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.interaction_outbox": { + "name": "interaction_outbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "correlation_id": { + "name": "correlation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "interaction_outbox_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "interaction_outbox_session_idempotency_idx": { + "name": "interaction_outbox_session_idempotency_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "interaction_outbox_session_status_created_idx": { + "name": "interaction_outbox_session_status_created_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "interaction_outbox_session_id_interaction_sessions_id_fk": { + "name": "interaction_outbox_session_id_interaction_sessions_id_fk", + "tableFrom": "interaction_outbox", + "tableTo": "interaction_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.interaction_sessions": { + "name": "interaction_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "runtime_session_id": { + "name": "runtime_session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "interaction_sessions_owner_id_users_id_fk": { + "name": "interaction_sessions_owner_id_users_id_fk", + "tableFrom": "interaction_sessions", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tickets": { + "name": "tickets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'medium'" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tickets_status_idx": { + "name": "tickets_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verifications": { + "name": "verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.backlog_status": { + "name": "backlog_status", + "schema": "public", + "values": [ + "ready", + "claimed", + "blocked", + "done" + ] + }, + "public.grant_status": { + "name": "grant_status", + "schema": "public", + "values": [ + "pending", + "active", + "revoked", + "expired" + ] + }, + "public.peer_state": { + "name": "peer_state", + "schema": "public", + "values": [ + "pending", + "active", + "suspended", + "revoked" + ] + }, + "public.interaction_handoff_status": { + "name": "interaction_handoff_status", + "schema": "public", + "values": [ + "pending", + "accepted" + ] + }, + "public.interaction_inbox_status": { + "name": "interaction_inbox_status", + "schema": "public", + "values": [ + "pending", + "processing", + "processed" + ] + }, + "public.interaction_outbox_status": { + "name": "interaction_outbox_status", + "schema": "public", + "values": [ + "pending", + "processing", + "delivered" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/0014_snapshot.json b/packages/db/drizzle/meta/0014_snapshot.json new file mode 100644 index 00000000..b60ca23c --- /dev/null +++ b/packages/db/drizzle/meta/0014_snapshot.json @@ -0,0 +1,4220 @@ +{ + "id": "48124a40-a224-4d1c-ab8f-b3dab554b1ac", + "prevId": "0cd8e1a3-a7f6-4c39-ac19-7cc0f9c02164", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "accounts_provider_account_idx": { + "name": "accounts_provider_account_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "accounts_user_id_idx": { + "name": "accounts_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.admin_tokens": { + "name": "admin_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'admin'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "admin_tokens_user_id_idx": { + "name": "admin_tokens_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "admin_tokens_hash_idx": { + "name": "admin_tokens_hash_idx", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "admin_tokens_user_id_users_id_fk": { + "name": "admin_tokens_user_id_users_id_fk", + "tableFrom": "admin_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_logs": { + "name": "agent_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'info'" + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'general'" + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "tier": { + "name": "tier", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'hot'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "summarized_at": { + "name": "summarized_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "agent_logs_session_tier_idx": { + "name": "agent_logs_session_tier_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_logs_user_id_idx": { + "name": "agent_logs_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_logs_tier_created_at_idx": { + "name": "agent_logs_tier_created_at_idx", + "columns": [ + { + "expression": "tier", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_logs_user_id_users_id_fk": { + "name": "agent_logs_user_id_users_id_fk", + "tableFrom": "agent_logs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agents": { + "name": "agents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "system_prompt": { + "name": "system_prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_tools": { + "name": "allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "skills": { + "name": "skills", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_system": { + "name": "is_system", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agents_project_id_idx": { + "name": "agents_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agents_owner_id_idx": { + "name": "agents_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agents_is_system_idx": { + "name": "agents_is_system_idx", + "columns": [ + { + "expression": "is_system", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agents_project_id_projects_id_fk": { + "name": "agents_project_id_projects_id_fk", + "tableFrom": "agents", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "agents_owner_id_users_id_fk": { + "name": "agents_owner_id_users_id_fk", + "tableFrom": "agents", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.appreciations": { + "name": "appreciations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "from_user": { + "name": "from_user", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "to_user": { + "name": "to_user", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.backlog": { + "name": "backlog", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "backlog_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'ready'" + }, + "depends_on": { + "name": "depends_on", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "claim_owner": { + "name": "claim_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "claim_ttl_seconds": { + "name": "claim_ttl_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "acceptance": { + "name": "acceptance", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "backlog_status_priority_idx": { + "name": "backlog_status_priority_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "backlog_status_claimed_at_idx": { + "name": "backlog_status_claimed_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "claimed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "backlog_idempotency_key_idx": { + "name": "backlog_idempotency_key_idx", + "columns": [ + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.conversations": { + "name": "conversations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "conversations_user_archived_idx": { + "name": "conversations_user_archived_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversations_project_id_idx": { + "name": "conversations_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversations_agent_id_idx": { + "name": "conversations_agent_id_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "conversations_user_id_users_id_fk": { + "name": "conversations_user_id_users_id_fk", + "tableFrom": "conversations", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "conversations_project_id_projects_id_fk": { + "name": "conversations_project_id_projects_id_fk", + "tableFrom": "conversations", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "conversations_agent_id_agents_id_fk": { + "name": "conversations_agent_id_agents_id_fk", + "tableFrom": "conversations", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.events": { + "name": "events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "events_type_idx": { + "name": "events_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "events_date_idx": { + "name": "events_date_idx", + "columns": [ + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.federation_audit_log": { + "name": "federation_audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "peer_id": { + "name": "peer_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "subject_user_id": { + "name": "subject_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_id": { + "name": "grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "verb": { + "name": "verb", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "result_count": { + "name": "result_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "denied_reason": { + "name": "denied_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "query_hash": { + "name": "query_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bytes_out": { + "name": "bytes_out", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "federation_audit_log_peer_created_at_idx": { + "name": "federation_audit_log_peer_created_at_idx", + "columns": [ + { + "expression": "peer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "federation_audit_log_subject_created_at_idx": { + "name": "federation_audit_log_subject_created_at_idx", + "columns": [ + { + "expression": "subject_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "federation_audit_log_created_at_idx": { + "name": "federation_audit_log_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "federation_audit_log_peer_id_federation_peers_id_fk": { + "name": "federation_audit_log_peer_id_federation_peers_id_fk", + "tableFrom": "federation_audit_log", + "tableTo": "federation_peers", + "columnsFrom": [ + "peer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "federation_audit_log_subject_user_id_users_id_fk": { + "name": "federation_audit_log_subject_user_id_users_id_fk", + "tableFrom": "federation_audit_log", + "tableTo": "users", + "columnsFrom": [ + "subject_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "federation_audit_log_grant_id_federation_grants_id_fk": { + "name": "federation_audit_log_grant_id_federation_grants_id_fk", + "tableFrom": "federation_audit_log", + "tableTo": "federation_grants", + "columnsFrom": [ + "grant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.federation_enrollment_tokens": { + "name": "federation_enrollment_tokens", + "schema": "", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "grant_id": { + "name": "grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "peer_id": { + "name": "peer_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "used_at": { + "name": "used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "federation_enrollment_tokens_grant_id_federation_grants_id_fk": { + "name": "federation_enrollment_tokens_grant_id_federation_grants_id_fk", + "tableFrom": "federation_enrollment_tokens", + "tableTo": "federation_grants", + "columnsFrom": [ + "grant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "federation_enrollment_tokens_peer_id_federation_peers_id_fk": { + "name": "federation_enrollment_tokens_peer_id_federation_peers_id_fk", + "tableFrom": "federation_enrollment_tokens", + "tableTo": "federation_peers", + "columnsFrom": [ + "peer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.federation_grants": { + "name": "federation_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "subject_user_id": { + "name": "subject_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "peer_id": { + "name": "peer_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "grant_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_reason": { + "name": "revoked_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "federation_grants_subject_status_idx": { + "name": "federation_grants_subject_status_idx", + "columns": [ + { + "expression": "subject_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "federation_grants_peer_status_idx": { + "name": "federation_grants_peer_status_idx", + "columns": [ + { + "expression": "peer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "federation_grants_subject_user_id_users_id_fk": { + "name": "federation_grants_subject_user_id_users_id_fk", + "tableFrom": "federation_grants", + "tableTo": "users", + "columnsFrom": [ + "subject_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "federation_grants_peer_id_federation_peers_id_fk": { + "name": "federation_grants_peer_id_federation_peers_id_fk", + "tableFrom": "federation_grants", + "tableTo": "federation_peers", + "columnsFrom": [ + "peer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.federation_peers": { + "name": "federation_peers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "common_name": { + "name": "common_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cert_pem": { + "name": "cert_pem", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cert_serial": { + "name": "cert_serial", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cert_not_after": { + "name": "cert_not_after", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "client_key_pem": { + "name": "client_key_pem", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "peer_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "endpoint_url": { + "name": "endpoint_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "federation_peers_cert_serial_idx": { + "name": "federation_peers_cert_serial_idx", + "columns": [ + { + "expression": "cert_serial", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "federation_peers_state_idx": { + "name": "federation_peers_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "federation_peers_common_name_unique": { + "name": "federation_peers_common_name_unique", + "nullsNotDistinct": false, + "columns": [ + "common_name" + ] + }, + "federation_peers_cert_serial_unique": { + "name": "federation_peers_cert_serial_unique", + "nullsNotDistinct": false, + "columns": [ + "cert_serial" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.insights": { + "name": "insights", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'agent'" + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'general'" + }, + "relevance_score": { + "name": "relevance_score", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "decayed_at": { + "name": "decayed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "insights_user_id_idx": { + "name": "insights_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "insights_category_idx": { + "name": "insights_category_idx", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "insights_relevance_idx": { + "name": "insights_relevance_idx", + "columns": [ + { + "expression": "relevance_score", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "insights_user_id_users_id_fk": { + "name": "insights_user_id_users_id_fk", + "tableFrom": "insights", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.messages": { + "name": "messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "conversation_id": { + "name": "conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "messages_conversation_id_idx": { + "name": "messages_conversation_id_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "messages_conversation_id_conversations_id_fk": { + "name": "messages_conversation_id_conversations_id_fk", + "tableFrom": "messages", + "tableTo": "conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mission_tasks": { + "name": "mission_tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "mission_id": { + "name": "mission_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'not-started'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pr": { + "name": "pr", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mission_tasks_mission_id_idx": { + "name": "mission_tasks_mission_id_idx", + "columns": [ + { + "expression": "mission_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mission_tasks_task_id_idx": { + "name": "mission_tasks_task_id_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mission_tasks_user_id_idx": { + "name": "mission_tasks_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mission_tasks_status_idx": { + "name": "mission_tasks_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mission_tasks_mission_id_missions_id_fk": { + "name": "mission_tasks_mission_id_missions_id_fk", + "tableFrom": "mission_tasks", + "tableTo": "missions", + "columnsFrom": [ + "mission_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mission_tasks_task_id_tasks_id_fk": { + "name": "mission_tasks_task_id_tasks_id_fk", + "tableFrom": "mission_tasks", + "tableTo": "tasks", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mission_tasks_user_id_users_id_fk": { + "name": "mission_tasks_user_id_users_id_fk", + "tableFrom": "mission_tasks", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.missions": { + "name": "missions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'planning'" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "milestones": { + "name": "milestones", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "missions_project_id_idx": { + "name": "missions_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "missions_user_id_idx": { + "name": "missions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "missions_project_id_projects_id_fk": { + "name": "missions_project_id_projects_id_fk", + "tableFrom": "missions", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "missions_user_id_users_id_fk": { + "name": "missions_user_id_users_id_fk", + "tableFrom": "missions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.preferences": { + "name": "preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'general'" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mutable": { + "name": "mutable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "preferences_user_id_idx": { + "name": "preferences_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "preferences_user_key_idx": { + "name": "preferences_user_key_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "preferences_user_id_users_id_fk": { + "name": "preferences_user_id_users_id_fk", + "tableFrom": "preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owner_type": { + "name": "owner_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "projects_owner_id_users_id_fk": { + "name": "projects_owner_id_users_id_fk", + "tableFrom": "projects", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "projects_team_id_teams_id_fk": { + "name": "projects_team_id_teams_id_fk", + "tableFrom": "projects", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_credentials": { + "name": "provider_credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_type": { + "name": "credential_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_value": { + "name": "encrypted_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "provider_credentials_user_provider_idx": { + "name": "provider_credentials_user_provider_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "provider_credentials_user_id_idx": { + "name": "provider_credentials_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "provider_credentials_user_id_users_id_fk": { + "name": "provider_credentials_user_id_users_id_fk", + "tableFrom": "provider_credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routing_rules": { + "name": "routing_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "conditions": { + "name": "conditions", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "routing_rules_scope_priority_idx": { + "name": "routing_rules_scope_priority_idx", + "columns": [ + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routing_rules_user_id_idx": { + "name": "routing_rules_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routing_rules_enabled_idx": { + "name": "routing_rules_enabled_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routing_rules_user_id_users_id_fk": { + "name": "routing_rules_user_id_users_id_fk", + "tableFrom": "routing_rules", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sessions_user_id_idx": { + "name": "sessions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_expires_at_idx": { + "name": "sessions_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skills": { + "name": "skills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'custom'" + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "installed_by": { + "name": "installed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skills_enabled_idx": { + "name": "skills_enabled_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skills_installed_by_users_id_fk": { + "name": "skills_installed_by_users_id_fk", + "tableFrom": "skills", + "tableTo": "users", + "columnsFrom": [ + "installed_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "skills_name_unique": { + "name": "skills_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.summarization_jobs": { + "name": "summarization_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "logs_processed": { + "name": "logs_processed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "insights_created": { + "name": "insights_created", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "summarization_jobs_status_idx": { + "name": "summarization_jobs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tasks": { + "name": "tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'not-started'" + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'medium'" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "mission_id": { + "name": "mission_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "assignee": { + "name": "assignee", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "due_date": { + "name": "due_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tasks_project_id_idx": { + "name": "tasks_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_mission_id_idx": { + "name": "tasks_mission_id_idx", + "columns": [ + { + "expression": "mission_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_status_idx": { + "name": "tasks_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tasks_project_id_projects_id_fk": { + "name": "tasks_project_id_projects_id_fk", + "tableFrom": "tasks", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tasks_mission_id_missions_id_fk": { + "name": "tasks_mission_id_missions_id_fk", + "tableFrom": "tasks", + "tableTo": "missions", + "columnsFrom": [ + "mission_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_members": { + "name": "team_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "team_members_team_user_idx": { + "name": "team_members_team_user_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "team_members_team_id_teams_id_fk": { + "name": "team_members_team_id_teams_id_fk", + "tableFrom": "team_members", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_members_user_id_users_id_fk": { + "name": "team_members_user_id_users_id_fk", + "tableFrom": "team_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_members_invited_by_users_id_fk": { + "name": "team_members_invited_by_users_id_fk", + "tableFrom": "team_members", + "tableTo": "users", + "columnsFrom": [ + "invited_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.teams": { + "name": "teams", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "manager_id": { + "name": "manager_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "teams_owner_id_users_id_fk": { + "name": "teams_owner_id_users_id_fk", + "tableFrom": "teams", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "teams_manager_id_users_id_fk": { + "name": "teams_manager_id_users_id_fk", + "tableFrom": "teams", + "tableTo": "users", + "columnsFrom": [ + "manager_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "teams_slug_unique": { + "name": "teams_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.interaction_checkpoints": { + "name": "interaction_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "compaction_epoch": { + "name": "compaction_epoch", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "interaction_checkpoints_session_idempotency_idx": { + "name": "interaction_checkpoints_session_idempotency_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "checkpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "interaction_checkpoints_session_epoch_idx": { + "name": "interaction_checkpoints_session_epoch_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "compaction_epoch", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "interaction_checkpoints_session_id_interaction_sessions_id_fk": { + "name": "interaction_checkpoints_session_id_interaction_sessions_id_fk", + "tableFrom": "interaction_checkpoints", + "tableTo": "interaction_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.interaction_handoffs": { + "name": "interaction_handoffs", + "schema": "", + "columns": { + "handoff_id": { + "name": "handoff_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "destination": { + "name": "destination", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "correlation_id": { + "name": "correlation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "interaction_handoff_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "interaction_handoffs_session_status_idx": { + "name": "interaction_handoffs_session_status_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "interaction_handoffs_session_id_interaction_sessions_id_fk": { + "name": "interaction_handoffs_session_id_interaction_sessions_id_fk", + "tableFrom": "interaction_handoffs", + "tableTo": "interaction_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.interaction_inbox": { + "name": "interaction_inbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "correlation_id": { + "name": "correlation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "interaction_inbox_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "interaction_inbox_session_idempotency_idx": { + "name": "interaction_inbox_session_idempotency_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "interaction_inbox_session_status_created_idx": { + "name": "interaction_inbox_session_status_created_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "interaction_inbox_session_id_interaction_sessions_id_fk": { + "name": "interaction_inbox_session_id_interaction_sessions_id_fk", + "tableFrom": "interaction_inbox", + "tableTo": "interaction_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.interaction_outbox": { + "name": "interaction_outbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "correlation_id": { + "name": "correlation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "interaction_outbox_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "interaction_outbox_session_idempotency_idx": { + "name": "interaction_outbox_session_idempotency_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "interaction_outbox_session_status_created_idx": { + "name": "interaction_outbox_session_status_created_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "interaction_outbox_session_id_interaction_sessions_id_fk": { + "name": "interaction_outbox_session_id_interaction_sessions_id_fk", + "tableFrom": "interaction_outbox", + "tableTo": "interaction_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.interaction_sessions": { + "name": "interaction_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "runtime_session_id": { + "name": "runtime_session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "interaction_sessions_owner_id_users_id_fk": { + "name": "interaction_sessions_owner_id_users_id_fk", + "tableFrom": "interaction_sessions", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tickets": { + "name": "tickets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'medium'" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tickets_status_idx": { + "name": "tickets_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verifications": { + "name": "verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.backlog_status": { + "name": "backlog_status", + "schema": "public", + "values": [ + "ready", + "claimed", + "blocked", + "done" + ] + }, + "public.grant_status": { + "name": "grant_status", + "schema": "public", + "values": [ + "pending", + "active", + "revoked", + "expired" + ] + }, + "public.peer_state": { + "name": "peer_state", + "schema": "public", + "values": [ + "pending", + "active", + "suspended", + "revoked" + ] + }, + "public.interaction_handoff_status": { + "name": "interaction_handoff_status", + "schema": "public", + "values": [ + "pending", + "accepted" + ] + }, + "public.interaction_inbox_status": { + "name": "interaction_inbox_status", + "schema": "public", + "values": [ + "pending", + "processing", + "processed" + ] + }, + "public.interaction_outbox_status": { + "name": "interaction_outbox_status", + "schema": "public", + "values": [ + "pending", + "processing", + "delivered" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/0015_snapshot.json b/packages/db/drizzle/meta/0015_snapshot.json new file mode 100644 index 00000000..ccb49fb7 --- /dev/null +++ b/packages/db/drizzle/meta/0015_snapshot.json @@ -0,0 +1,4244 @@ +{ + "id": "1a0a53b1-2dd8-4ea9-8d59-3e92ccca6c6a", + "prevId": "48124a40-a224-4d1c-ab8f-b3dab554b1ac", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "accounts_provider_account_idx": { + "name": "accounts_provider_account_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "accounts_user_id_idx": { + "name": "accounts_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.admin_tokens": { + "name": "admin_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'admin'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "admin_tokens_user_id_idx": { + "name": "admin_tokens_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "admin_tokens_hash_idx": { + "name": "admin_tokens_hash_idx", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "admin_tokens_user_id_users_id_fk": { + "name": "admin_tokens_user_id_users_id_fk", + "tableFrom": "admin_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_logs": { + "name": "agent_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'info'" + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'general'" + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "tier": { + "name": "tier", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'hot'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "summarized_at": { + "name": "summarized_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "agent_logs_session_tier_idx": { + "name": "agent_logs_session_tier_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_logs_user_id_idx": { + "name": "agent_logs_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_logs_tier_created_at_idx": { + "name": "agent_logs_tier_created_at_idx", + "columns": [ + { + "expression": "tier", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_logs_user_id_users_id_fk": { + "name": "agent_logs_user_id_users_id_fk", + "tableFrom": "agent_logs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agents": { + "name": "agents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "system_prompt": { + "name": "system_prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_tools": { + "name": "allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "skills": { + "name": "skills", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_system": { + "name": "is_system", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agents_project_id_idx": { + "name": "agents_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agents_owner_id_idx": { + "name": "agents_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agents_is_system_idx": { + "name": "agents_is_system_idx", + "columns": [ + { + "expression": "is_system", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agents_project_id_projects_id_fk": { + "name": "agents_project_id_projects_id_fk", + "tableFrom": "agents", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "agents_owner_id_users_id_fk": { + "name": "agents_owner_id_users_id_fk", + "tableFrom": "agents", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.appreciations": { + "name": "appreciations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "from_user": { + "name": "from_user", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "to_user": { + "name": "to_user", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.backlog": { + "name": "backlog", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "backlog_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'ready'" + }, + "depends_on": { + "name": "depends_on", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "claim_owner": { + "name": "claim_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "claim_ttl_seconds": { + "name": "claim_ttl_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "acceptance": { + "name": "acceptance", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "backlog_status_priority_idx": { + "name": "backlog_status_priority_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "backlog_status_claimed_at_idx": { + "name": "backlog_status_claimed_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "claimed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "backlog_idempotency_key_idx": { + "name": "backlog_idempotency_key_idx", + "columns": [ + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.conversations": { + "name": "conversations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "conversations_user_archived_idx": { + "name": "conversations_user_archived_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversations_project_id_idx": { + "name": "conversations_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversations_agent_id_idx": { + "name": "conversations_agent_id_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "conversations_user_id_users_id_fk": { + "name": "conversations_user_id_users_id_fk", + "tableFrom": "conversations", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "conversations_project_id_projects_id_fk": { + "name": "conversations_project_id_projects_id_fk", + "tableFrom": "conversations", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "conversations_agent_id_agents_id_fk": { + "name": "conversations_agent_id_agents_id_fk", + "tableFrom": "conversations", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.events": { + "name": "events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "events_type_idx": { + "name": "events_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "events_date_idx": { + "name": "events_date_idx", + "columns": [ + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.federation_audit_log": { + "name": "federation_audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "peer_id": { + "name": "peer_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "subject_user_id": { + "name": "subject_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_id": { + "name": "grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "verb": { + "name": "verb", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "result_count": { + "name": "result_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "denied_reason": { + "name": "denied_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "query_hash": { + "name": "query_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bytes_out": { + "name": "bytes_out", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "federation_audit_log_peer_created_at_idx": { + "name": "federation_audit_log_peer_created_at_idx", + "columns": [ + { + "expression": "peer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "federation_audit_log_subject_created_at_idx": { + "name": "federation_audit_log_subject_created_at_idx", + "columns": [ + { + "expression": "subject_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "federation_audit_log_created_at_idx": { + "name": "federation_audit_log_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "federation_audit_log_peer_id_federation_peers_id_fk": { + "name": "federation_audit_log_peer_id_federation_peers_id_fk", + "tableFrom": "federation_audit_log", + "tableTo": "federation_peers", + "columnsFrom": [ + "peer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "federation_audit_log_subject_user_id_users_id_fk": { + "name": "federation_audit_log_subject_user_id_users_id_fk", + "tableFrom": "federation_audit_log", + "tableTo": "users", + "columnsFrom": [ + "subject_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "federation_audit_log_grant_id_federation_grants_id_fk": { + "name": "federation_audit_log_grant_id_federation_grants_id_fk", + "tableFrom": "federation_audit_log", + "tableTo": "federation_grants", + "columnsFrom": [ + "grant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.federation_enrollment_tokens": { + "name": "federation_enrollment_tokens", + "schema": "", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "grant_id": { + "name": "grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "peer_id": { + "name": "peer_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "used_at": { + "name": "used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "federation_enrollment_tokens_grant_id_federation_grants_id_fk": { + "name": "federation_enrollment_tokens_grant_id_federation_grants_id_fk", + "tableFrom": "federation_enrollment_tokens", + "tableTo": "federation_grants", + "columnsFrom": [ + "grant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "federation_enrollment_tokens_peer_id_federation_peers_id_fk": { + "name": "federation_enrollment_tokens_peer_id_federation_peers_id_fk", + "tableFrom": "federation_enrollment_tokens", + "tableTo": "federation_peers", + "columnsFrom": [ + "peer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.federation_grants": { + "name": "federation_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "subject_user_id": { + "name": "subject_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "peer_id": { + "name": "peer_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "grant_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_reason": { + "name": "revoked_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "federation_grants_subject_status_idx": { + "name": "federation_grants_subject_status_idx", + "columns": [ + { + "expression": "subject_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "federation_grants_peer_status_idx": { + "name": "federation_grants_peer_status_idx", + "columns": [ + { + "expression": "peer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "federation_grants_subject_user_id_users_id_fk": { + "name": "federation_grants_subject_user_id_users_id_fk", + "tableFrom": "federation_grants", + "tableTo": "users", + "columnsFrom": [ + "subject_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "federation_grants_peer_id_federation_peers_id_fk": { + "name": "federation_grants_peer_id_federation_peers_id_fk", + "tableFrom": "federation_grants", + "tableTo": "federation_peers", + "columnsFrom": [ + "peer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.federation_peers": { + "name": "federation_peers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "common_name": { + "name": "common_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cert_pem": { + "name": "cert_pem", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cert_serial": { + "name": "cert_serial", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cert_not_after": { + "name": "cert_not_after", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "client_key_pem": { + "name": "client_key_pem", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "peer_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "endpoint_url": { + "name": "endpoint_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "federation_peers_cert_serial_idx": { + "name": "federation_peers_cert_serial_idx", + "columns": [ + { + "expression": "cert_serial", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "federation_peers_state_idx": { + "name": "federation_peers_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "federation_peers_common_name_unique": { + "name": "federation_peers_common_name_unique", + "nullsNotDistinct": false, + "columns": [ + "common_name" + ] + }, + "federation_peers_cert_serial_unique": { + "name": "federation_peers_cert_serial_unique", + "nullsNotDistinct": false, + "columns": [ + "cert_serial" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.insights": { + "name": "insights", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'agent'" + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'general'" + }, + "relevance_score": { + "name": "relevance_score", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "decayed_at": { + "name": "decayed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "insights_user_id_idx": { + "name": "insights_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "insights_category_idx": { + "name": "insights_category_idx", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "insights_relevance_idx": { + "name": "insights_relevance_idx", + "columns": [ + { + "expression": "relevance_score", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "insights_user_id_users_id_fk": { + "name": "insights_user_id_users_id_fk", + "tableFrom": "insights", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.interaction_checkpoints": { + "name": "interaction_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_digest": { + "name": "content_digest", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "compaction_epoch": { + "name": "compaction_epoch", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "interaction_checkpoints_session_idempotency_idx": { + "name": "interaction_checkpoints_session_idempotency_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "checkpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "interaction_checkpoints_session_epoch_idx": { + "name": "interaction_checkpoints_session_epoch_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "compaction_epoch", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "interaction_checkpoints_session_id_interaction_sessions_id_fk": { + "name": "interaction_checkpoints_session_id_interaction_sessions_id_fk", + "tableFrom": "interaction_checkpoints", + "tableTo": "interaction_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.interaction_handoffs": { + "name": "interaction_handoffs", + "schema": "", + "columns": { + "handoff_id": { + "name": "handoff_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "destination": { + "name": "destination", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "correlation_id": { + "name": "correlation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "interaction_handoff_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "interaction_handoffs_session_status_idx": { + "name": "interaction_handoffs_session_status_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "interaction_handoffs_session_id_interaction_sessions_id_fk": { + "name": "interaction_handoffs_session_id_interaction_sessions_id_fk", + "tableFrom": "interaction_handoffs", + "tableTo": "interaction_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.interaction_inbox": { + "name": "interaction_inbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "correlation_id": { + "name": "correlation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_digest": { + "name": "content_digest", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "interaction_inbox_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "interaction_inbox_session_idempotency_idx": { + "name": "interaction_inbox_session_idempotency_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "interaction_inbox_session_status_created_idx": { + "name": "interaction_inbox_session_status_created_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "interaction_inbox_session_id_interaction_sessions_id_fk": { + "name": "interaction_inbox_session_id_interaction_sessions_id_fk", + "tableFrom": "interaction_inbox", + "tableTo": "interaction_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.interaction_outbox": { + "name": "interaction_outbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "correlation_id": { + "name": "correlation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_digest": { + "name": "content_digest", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "interaction_outbox_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "interaction_outbox_session_idempotency_idx": { + "name": "interaction_outbox_session_idempotency_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "interaction_outbox_session_status_created_idx": { + "name": "interaction_outbox_session_status_created_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "interaction_outbox_session_id_interaction_sessions_id_fk": { + "name": "interaction_outbox_session_id_interaction_sessions_id_fk", + "tableFrom": "interaction_outbox", + "tableTo": "interaction_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.interaction_sessions": { + "name": "interaction_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "agent_name": { + "name": "agent_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "runtime_session_id": { + "name": "runtime_session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "interaction_sessions_owner_id_users_id_fk": { + "name": "interaction_sessions_owner_id_users_id_fk", + "tableFrom": "interaction_sessions", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.messages": { + "name": "messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "conversation_id": { + "name": "conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "messages_conversation_id_idx": { + "name": "messages_conversation_id_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "messages_conversation_id_conversations_id_fk": { + "name": "messages_conversation_id_conversations_id_fk", + "tableFrom": "messages", + "tableTo": "conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mission_tasks": { + "name": "mission_tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "mission_id": { + "name": "mission_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'not-started'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pr": { + "name": "pr", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mission_tasks_mission_id_idx": { + "name": "mission_tasks_mission_id_idx", + "columns": [ + { + "expression": "mission_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mission_tasks_task_id_idx": { + "name": "mission_tasks_task_id_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mission_tasks_user_id_idx": { + "name": "mission_tasks_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mission_tasks_status_idx": { + "name": "mission_tasks_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mission_tasks_mission_id_missions_id_fk": { + "name": "mission_tasks_mission_id_missions_id_fk", + "tableFrom": "mission_tasks", + "tableTo": "missions", + "columnsFrom": [ + "mission_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mission_tasks_task_id_tasks_id_fk": { + "name": "mission_tasks_task_id_tasks_id_fk", + "tableFrom": "mission_tasks", + "tableTo": "tasks", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mission_tasks_user_id_users_id_fk": { + "name": "mission_tasks_user_id_users_id_fk", + "tableFrom": "mission_tasks", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.missions": { + "name": "missions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'planning'" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "milestones": { + "name": "milestones", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "missions_project_id_idx": { + "name": "missions_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "missions_user_id_idx": { + "name": "missions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "missions_project_id_projects_id_fk": { + "name": "missions_project_id_projects_id_fk", + "tableFrom": "missions", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "missions_user_id_users_id_fk": { + "name": "missions_user_id_users_id_fk", + "tableFrom": "missions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.preferences": { + "name": "preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'general'" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mutable": { + "name": "mutable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "preferences_user_id_idx": { + "name": "preferences_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "preferences_user_key_idx": { + "name": "preferences_user_key_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "preferences_user_id_users_id_fk": { + "name": "preferences_user_id_users_id_fk", + "tableFrom": "preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owner_type": { + "name": "owner_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "projects_owner_id_users_id_fk": { + "name": "projects_owner_id_users_id_fk", + "tableFrom": "projects", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "projects_team_id_teams_id_fk": { + "name": "projects_team_id_teams_id_fk", + "tableFrom": "projects", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_credentials": { + "name": "provider_credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_type": { + "name": "credential_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_value": { + "name": "encrypted_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "provider_credentials_user_provider_idx": { + "name": "provider_credentials_user_provider_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "provider_credentials_user_id_idx": { + "name": "provider_credentials_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "provider_credentials_user_id_users_id_fk": { + "name": "provider_credentials_user_id_users_id_fk", + "tableFrom": "provider_credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routing_rules": { + "name": "routing_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "conditions": { + "name": "conditions", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "routing_rules_scope_priority_idx": { + "name": "routing_rules_scope_priority_idx", + "columns": [ + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routing_rules_user_id_idx": { + "name": "routing_rules_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routing_rules_enabled_idx": { + "name": "routing_rules_enabled_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routing_rules_user_id_users_id_fk": { + "name": "routing_rules_user_id_users_id_fk", + "tableFrom": "routing_rules", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sessions_user_id_idx": { + "name": "sessions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_expires_at_idx": { + "name": "sessions_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skills": { + "name": "skills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'custom'" + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "installed_by": { + "name": "installed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skills_enabled_idx": { + "name": "skills_enabled_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skills_installed_by_users_id_fk": { + "name": "skills_installed_by_users_id_fk", + "tableFrom": "skills", + "tableTo": "users", + "columnsFrom": [ + "installed_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "skills_name_unique": { + "name": "skills_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.summarization_jobs": { + "name": "summarization_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "logs_processed": { + "name": "logs_processed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "insights_created": { + "name": "insights_created", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "summarization_jobs_status_idx": { + "name": "summarization_jobs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tasks": { + "name": "tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'not-started'" + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'medium'" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "mission_id": { + "name": "mission_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "assignee": { + "name": "assignee", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "due_date": { + "name": "due_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tasks_project_id_idx": { + "name": "tasks_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_mission_id_idx": { + "name": "tasks_mission_id_idx", + "columns": [ + { + "expression": "mission_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_status_idx": { + "name": "tasks_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tasks_project_id_projects_id_fk": { + "name": "tasks_project_id_projects_id_fk", + "tableFrom": "tasks", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tasks_mission_id_missions_id_fk": { + "name": "tasks_mission_id_missions_id_fk", + "tableFrom": "tasks", + "tableTo": "missions", + "columnsFrom": [ + "mission_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_members": { + "name": "team_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "team_members_team_user_idx": { + "name": "team_members_team_user_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "team_members_team_id_teams_id_fk": { + "name": "team_members_team_id_teams_id_fk", + "tableFrom": "team_members", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_members_user_id_users_id_fk": { + "name": "team_members_user_id_users_id_fk", + "tableFrom": "team_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_members_invited_by_users_id_fk": { + "name": "team_members_invited_by_users_id_fk", + "tableFrom": "team_members", + "tableTo": "users", + "columnsFrom": [ + "invited_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.teams": { + "name": "teams", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "manager_id": { + "name": "manager_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "teams_owner_id_users_id_fk": { + "name": "teams_owner_id_users_id_fk", + "tableFrom": "teams", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "teams_manager_id_users_id_fk": { + "name": "teams_manager_id_users_id_fk", + "tableFrom": "teams", + "tableTo": "users", + "columnsFrom": [ + "manager_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "teams_slug_unique": { + "name": "teams_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tickets": { + "name": "tickets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'medium'" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tickets_status_idx": { + "name": "tickets_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verifications": { + "name": "verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.backlog_status": { + "name": "backlog_status", + "schema": "public", + "values": [ + "ready", + "claimed", + "blocked", + "done" + ] + }, + "public.grant_status": { + "name": "grant_status", + "schema": "public", + "values": [ + "pending", + "active", + "revoked", + "expired" + ] + }, + "public.interaction_handoff_status": { + "name": "interaction_handoff_status", + "schema": "public", + "values": [ + "pending", + "accepted" + ] + }, + "public.interaction_inbox_status": { + "name": "interaction_inbox_status", + "schema": "public", + "values": [ + "pending", + "processing", + "processed" + ] + }, + "public.interaction_outbox_status": { + "name": "interaction_outbox_status", + "schema": "public", + "values": [ + "pending", + "processing", + "delivered" + ] + }, + "public.peer_state": { + "name": "peer_state", + "schema": "public", + "values": [ + "pending", + "active", + "suspended", + "revoked" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index fb09f9d8..dcdb944d 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -85,6 +85,34 @@ "when": 1782310438919, "tag": "0011_bitter_gateway", "breakpoints": true + }, + { + "idx": 12, + "version": "7", + "when": 1783911983447, + "tag": "0012_interaction_durable_state", + "breakpoints": true + }, + { + "idx": 13, + "version": "7", + "when": 1783913232578, + "tag": "0013_interaction_checkpoint_history", + "breakpoints": true + }, + { + "idx": 14, + "version": "7", + "when": 1783913398006, + "tag": "0014_interaction_outbox_channel_scope", + "breakpoints": true + }, + { + "idx": 15, + "version": "7", + "when": 1783942610000, + "tag": "0015_interaction_checkpoint_payload_digest", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 7f343ce2..76809d21 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -487,6 +487,120 @@ export const agentLogs = pgTable( ], ); +// ─── Tess durable session state ───────────────────────────────────────────── +// PostgreSQL is canonical for restart-safe Tess session recovery. The state +// machine lives in @mosaicstack/agent; these records are its durable adapter. + +export const interactionInboxStatusEnum = pgEnum('interaction_inbox_status', [ + 'pending', + 'processing', + 'processed', +]); +export const interactionOutboxStatusEnum = pgEnum('interaction_outbox_status', [ + 'pending', + 'processing', + 'delivered', +]); +export const interactionHandoffStatusEnum = pgEnum('interaction_handoff_status', [ + 'pending', + 'accepted', +]); + +export const interactionSessions = pgTable('interaction_sessions', { + id: text('id').primaryKey(), + agentName: text('agent_name').notNull(), + tenantId: text('tenant_id').notNull(), + ownerId: text('owner_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + providerId: text('provider_id').notNull(), + runtimeSessionId: text('runtime_session_id').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), +}); + +export const interactionInbox = pgTable( + 'interaction_inbox', + { + id: uuid('id').primaryKey().defaultRandom(), + sessionId: text('session_id') + .notNull() + .references(() => interactionSessions.id, { onDelete: 'cascade' }), + idempotencyKey: text('idempotency_key').notNull(), + correlationId: text('correlation_id').notNull(), + content: text('content').notNull(), + contentDigest: text('content_digest').notNull(), + status: interactionInboxStatusEnum('status').notNull().default('pending'), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [ + uniqueIndex('interaction_inbox_session_idempotency_idx').on(t.sessionId, t.idempotencyKey), + index('interaction_inbox_session_status_created_idx').on(t.sessionId, t.status, t.createdAt), + ], +); + +export const interactionOutbox = pgTable( + 'interaction_outbox', + { + id: uuid('id').primaryKey().defaultRandom(), + sessionId: text('session_id') + .notNull() + .references(() => interactionSessions.id, { onDelete: 'cascade' }), + idempotencyKey: text('idempotency_key').notNull(), + correlationId: text('correlation_id').notNull(), + channelId: text('channel_id').notNull(), + kind: text('kind').notNull(), + content: text('content').notNull(), + contentDigest: text('content_digest').notNull(), + status: interactionOutboxStatusEnum('status').notNull().default('pending'), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [ + uniqueIndex('interaction_outbox_session_idempotency_idx').on(t.sessionId, t.idempotencyKey), + index('interaction_outbox_session_status_created_idx').on(t.sessionId, t.status, t.createdAt), + ], +); + +export const interactionCheckpoints = pgTable( + 'interaction_checkpoints', + { + id: uuid('id').primaryKey().defaultRandom(), + sessionId: text('session_id') + .notNull() + .references(() => interactionSessions.id, { onDelete: 'cascade' }), + checkpointId: text('checkpoint_id').notNull(), + contentDigest: text('content_digest').notNull(), + cursor: text('cursor').notNull(), + summary: text('summary').notNull(), + compactionEpoch: integer('compaction_epoch').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [ + uniqueIndex('interaction_checkpoints_session_idempotency_idx').on(t.sessionId, t.checkpointId), + index('interaction_checkpoints_session_epoch_idx').on(t.sessionId, t.compactionEpoch), + ], +); + +export const interactionHandoffs = pgTable( + 'interaction_handoffs', + { + handoffId: text('handoff_id').primaryKey(), + sessionId: text('session_id') + .notNull() + .references(() => interactionSessions.id, { onDelete: 'cascade' }), + destination: text('destination').notNull(), + correlationId: text('correlation_id').notNull(), + checkpointId: text('checkpoint_id').notNull(), + status: interactionHandoffStatusEnum('status').notNull().default('pending'), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [index('interaction_handoffs_session_status_idx').on(t.sessionId, t.status)], +); + // ─── Skills ───────────────────────────────────────────────────────────────── export const skills = pgTable( From 8246ee01372e95fe95134829f48b99805be215d7 Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Mon, 13 Jul 2026 05:52:41 +0000 Subject: [PATCH 022/152] feat(tess): add generic interaction CLI (#731) --- apps/gateway/src/agent/agent.module.ts | 9 +- .../src/agent/interaction.controller.test.ts | 70 ++++++ .../src/agent/interaction.controller.ts | 192 +++++++++++++++++ .../src/agent/tess-durable-session.service.ts | 18 +- packages/mosaic/src/cli.ts | 5 + .../mosaic/src/commands/interaction.test.ts | 22 ++ packages/mosaic/src/commands/interaction.ts | 200 ++++++++++++++++++ packages/mosaic/src/tui/gateway-api.ts | 132 ++++++++++++ 8 files changed, 646 insertions(+), 2 deletions(-) create mode 100644 apps/gateway/src/agent/interaction.controller.test.ts create mode 100644 apps/gateway/src/agent/interaction.controller.ts create mode 100644 packages/mosaic/src/commands/interaction.test.ts create mode 100644 packages/mosaic/src/commands/interaction.ts diff --git a/apps/gateway/src/agent/agent.module.ts b/apps/gateway/src/agent/agent.module.ts index 8e90ef28..f563bcc7 100644 --- a/apps/gateway/src/agent/agent.module.ts +++ b/apps/gateway/src/agent/agent.module.ts @@ -9,6 +9,7 @@ import { SkillLoaderService } from './skill-loader.service.js'; import { ProvidersController } from './providers.controller.js'; import { SessionsController } from './sessions.controller.js'; import { AgentConfigsController } from './agent-configs.controller.js'; +import { InteractionController } from './interaction.controller.js'; import { RoutingController } from './routing/routing.controller.js'; import { TessDurableSessionRepository } from './tess-durable-session.repository.js'; import { TessDurableSessionService } from './tess-durable-session.service.js'; @@ -54,7 +55,13 @@ import { RuntimeProviderService, AgentService, ], - controllers: [ProvidersController, SessionsController, AgentConfigsController, RoutingController], + controllers: [ + ProvidersController, + SessionsController, + AgentConfigsController, + InteractionController, + RoutingController, + ], exports: [ AgentService, ProviderService, diff --git a/apps/gateway/src/agent/interaction.controller.test.ts b/apps/gateway/src/agent/interaction.controller.test.ts new file mode 100644 index 00000000..8e7c3822 --- /dev/null +++ b/apps/gateway/src/agent/interaction.controller.test.ts @@ -0,0 +1,70 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { InteractionController } from './interaction.controller.js'; + +describe('InteractionController', (): void => { + afterEach(() => vi.restoreAllMocks()); + + it('honors a differently named configured instance without a code change', async () => { + const prior = process.env['MOSAIC_AGENT_NAME']; + process.env['MOSAIC_AGENT_NAME'] = 'Nova'; + const runtime = { listSessions: vi.fn().mockResolvedValue([]) }; + const controller = new InteractionController(runtime as never, {} as never); + + await expect( + controller.sessions('Nova', 'fleet', { id: 'owner', tenantId: 'team' }, 'corr-1'), + ).resolves.toEqual([]); + await expect( + controller.sessions('Other', 'fleet', { id: 'owner', tenantId: 'team' }, 'corr-1'), + ).rejects.toThrow('Interaction agent is not configured'); + + if (prior === undefined) delete process.env['MOSAIC_AGENT_NAME']; + else process.env['MOSAIC_AGENT_NAME'] = prior; + }); + + it('rejects a request without the non-simple correlation header', async () => { + process.env['MOSAIC_AGENT_NAME'] = 'Nova'; + const controller = new InteractionController({ listSessions: vi.fn() } as never, {} as never); + + await expect(controller.sessions('Nova', 'fleet', { id: 'owner' })).rejects.toThrow( + 'X-Correlation-Id is required', + ); + }); + + it('rejects an invalid attach mode before invoking a provider', async () => { + process.env['MOSAIC_AGENT_NAME'] = 'Nova'; + const controller = new InteractionController({ attach: vi.fn() } as never, {} as never); + + await expect( + controller.attach('Nova', 'durable-1', { mode: 'write' as never }, { id: 'owner' }, 'corr-1'), + ).rejects.toThrow('Interaction attach mode is invalid'); + }); + + it('uses the durable session identity and runtime registry for an approved stop', async () => { + process.env['MOSAIC_AGENT_NAME'] = 'Nova'; + const runtime = { terminate: vi.fn().mockResolvedValue(undefined) }; + const durable = { + getSnapshot: vi.fn().mockResolvedValue({ + identity: { agentName: 'Nova', providerId: 'fleet', runtimeSessionId: 'runtime-1' }, + }), + }; + const controller = new InteractionController(runtime as never, durable as never); + + await controller.stop( + 'Nova', + 'durable-1', + { approvalRef: 'approval-1' }, + { id: 'owner' }, + 'corr-1', + ); + + expect(runtime.terminate).toHaveBeenCalledWith( + 'fleet', + 'runtime-1', + 'approval-1', + expect.objectContaining({ + correlationId: 'corr-1', + actorScope: { userId: 'owner', tenantId: 'owner' }, + }), + ); + }); +}); diff --git a/apps/gateway/src/agent/interaction.controller.ts b/apps/gateway/src/agent/interaction.controller.ts new file mode 100644 index 00000000..51036608 --- /dev/null +++ b/apps/gateway/src/agent/interaction.controller.ts @@ -0,0 +1,192 @@ +import { + Body, + Controller, + ForbiddenException, + Get, + Headers, + Inject, + Param, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import type { RuntimeAttachMode } from '@mosaicstack/types'; +import { AuthGuard } from '../auth/auth.guard.js'; +import { CurrentUser } from '../auth/current-user.decorator.js'; +import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js'; +import { TessDurableSessionService } from './tess-durable-session.service.js'; +import { + RuntimeProviderService, + type RuntimeProviderRequestContext, +} from './runtime-provider-registry.service.js'; + +/** + * Authenticated HTTP boundary for operator interaction clients. Identity is + * selected from deployment configuration, never a client-side command name. + */ +@Controller('api/interaction/:agentName') +@UseGuards(AuthGuard) +export class InteractionController { + constructor( + @Inject(RuntimeProviderService) private readonly runtime: RuntimeProviderService, + @Inject(TessDurableSessionService) private readonly durable: TessDurableSessionService, + ) {} + + @Get('sessions') + async sessions( + @Param('agentName') agentName: string, + @Query('provider') providerId: string, + @CurrentUser() user: AuthenticatedUserLike, + @Headers('x-correlation-id') correlationId?: string, + ) { + this.assertConfiguredAgent(agentName); + return this.runtime.listSessions( + this.requiredProvider(providerId), + this.context(user, correlationId), + ); + } + + @Get('tree') + async tree( + @Param('agentName') agentName: string, + @Query('provider') providerId: string, + @CurrentUser() user: AuthenticatedUserLike, + @Headers('x-correlation-id') correlationId?: string, + ) { + this.assertConfiguredAgent(agentName); + return this.runtime.getSessionTree( + this.requiredProvider(providerId), + this.context(user, correlationId), + ); + } + + @Post('sessions/:sessionId/attach') + async attach( + @Param('agentName') agentName: string, + @Param('sessionId') sessionId: string, + @Body() body: { mode?: RuntimeAttachMode } = {}, + @CurrentUser() user: AuthenticatedUserLike, + @Headers('x-correlation-id') correlationId?: string, + ) { + this.assertConfiguredAgent(agentName); + const context = this.context(user, correlationId); + const mode = body.mode ?? 'read'; + if (mode !== 'read' && mode !== 'control') { + throw new ForbiddenException('Interaction attach mode is invalid'); + } + const snapshot = await this.durable.getSnapshot(sessionId, context); + this.assertSessionAgent(snapshot.identity.agentName, agentName); + return this.runtime.attach( + snapshot.identity.providerId, + snapshot.identity.runtimeSessionId, + mode, + context, + ); + } + + @Post('sessions/:sessionId/send') + async send( + @Param('agentName') agentName: string, + @Param('sessionId') sessionId: string, + @Body() body: { content?: string; idempotencyKey?: string } = {}, + @CurrentUser() user: AuthenticatedUserLike, + @Headers('x-correlation-id') correlationId?: string, + ) { + this.assertConfiguredAgent(agentName); + if (!body.content?.trim() || !body.idempotencyKey?.trim()) { + throw new ForbiddenException('Content and idempotency key are required'); + } + const context = this.context(user, correlationId); + const snapshot = await this.durable.getSnapshot(sessionId, context); + this.assertSessionAgent(snapshot.identity.agentName, agentName); + const input = { + sessionId, + content: body.content, + idempotencyKey: body.idempotencyKey, + correlationId: context.correlationId, + context, + }; + await this.durable.queueProviderSend(input); + await this.durable.dispatchProviderOutbox(sessionId, input); + return { status: 'queued', sessionId }; + } + + @Post('sessions/:sessionId/stop') + async stop( + @Param('agentName') agentName: string, + @Param('sessionId') sessionId: string, + @Body() body: { approvalRef?: string } = {}, + @CurrentUser() user: AuthenticatedUserLike, + @Headers('x-correlation-id') correlationId?: string, + ) { + this.assertConfiguredAgent(agentName); + if (!body.approvalRef?.trim()) + throw new ForbiddenException('Exact-action approval is required'); + const context = this.context(user, correlationId); + const snapshot = await this.durable.getSnapshot(sessionId, context); + this.assertSessionAgent(snapshot.identity.agentName, agentName); + await this.runtime.terminate( + snapshot.identity.providerId, + snapshot.identity.runtimeSessionId, + body.approvalRef, + context, + ); + return { status: 'stopped', sessionId }; + } + + @Post('sessions/:sessionId/recover') + async recover( + @Param('agentName') agentName: string, + @Param('sessionId') sessionId: string, + @CurrentUser() user: AuthenticatedUserLike, + @Headers('x-correlation-id') correlationId?: string, + ) { + this.assertConfiguredAgent(agentName); + const context = this.context(user, correlationId); + const snapshot = await this.durable.getSnapshot(sessionId, context); + this.assertSessionAgent(snapshot.identity.agentName, agentName); + await this.durable.recoverProviderSession(sessionId, { + sessionId, + content: '', + idempotencyKey: `recovery:${context.correlationId}`, + correlationId: context.correlationId, + context, + }); + return { status: 'recovered', sessionId }; + } + + private context( + user: AuthenticatedUserLike, + correlationId?: string, + ): RuntimeProviderRequestContext { + const requestCorrelationId = correlationId?.trim(); + // This non-simple request header is mandatory for mutations. Browser + // cross-origin requests cannot set it without a CORS preflight, and the + // gateway's allowlist rejects untrusted origins before the handler runs. + if (!requestCorrelationId) { + throw new ForbiddenException('X-Correlation-Id is required'); + } + return { + actorScope: scopeFromUser(user), + channelId: 'cli', + correlationId: requestCorrelationId, + }; + } + + private assertConfiguredAgent(agentName: string): void { + const configured = process.env['MOSAIC_AGENT_NAME']?.trim(); + if (!configured || configured !== agentName) { + throw new ForbiddenException('Interaction agent is not configured for this request'); + } + } + + private assertSessionAgent(sessionAgentName: string, agentName: string): void { + if (sessionAgentName !== agentName) + throw new ForbiddenException('Interaction session identity mismatch'); + } + + private requiredProvider(providerId: string): string { + if (!providerId?.trim()) throw new ForbiddenException('Runtime provider is required'); + return providerId; + } +} diff --git a/apps/gateway/src/agent/tess-durable-session.service.ts b/apps/gateway/src/agent/tess-durable-session.service.ts index 095448b3..2dc3619e 100644 --- a/apps/gateway/src/agent/tess-durable-session.service.ts +++ b/apps/gateway/src/agent/tess-durable-session.service.ts @@ -2,7 +2,10 @@ import { ForbiddenException, Inject, Injectable } from '@nestjs/common'; import { DurableSessionCoordinator } from '@mosaicstack/agent'; import type { TessProviderOutboxDto } from './tess-durable-session.dto.js'; import { TessDurableSessionRepository } from './tess-durable-session.repository.js'; -import { RuntimeProviderService } from './runtime-provider-registry.service.js'; +import { + RuntimeProviderService, + type RuntimeProviderRequestContext, +} from './runtime-provider-registry.service.js'; /** * Scoped gateway boundary for the canonical Tess state machine. It deliberately @@ -61,6 +64,19 @@ export class TessDurableSessionService { ); } + /** Read durable identity/state only after deriving and checking the server-side actor scope. */ + async getSnapshot(sessionId: string, context: RuntimeProviderRequestContext) { + const snapshot = await this.coordinator.snapshot(sessionId); + this.assertScope(snapshot.identity.ownerId, snapshot.identity.tenantId, { + sessionId, + content: '', + idempotencyKey: 'read-only', + correlationId: context.correlationId, + context, + }); + return snapshot; + } + /** Startup/recovery-only path; normal queue/dispatch methods never requeue live work. */ async recoverProviderSession(sessionId: string, input: TessProviderOutboxDto): Promise { const snapshot = await this.coordinator.snapshot(sessionId); diff --git a/packages/mosaic/src/cli.ts b/packages/mosaic/src/cli.ts index 4381b844..8fc5fade 100644 --- a/packages/mosaic/src/cli.ts +++ b/packages/mosaic/src/cli.ts @@ -12,6 +12,7 @@ import { registerQueueCommand } from '@mosaicstack/queue'; import { registerStorageCommand } from '@mosaicstack/storage'; import { registerTelemetryCommand } from './commands/telemetry.js'; import { registerAgentCommand } from './commands/agent.js'; +import { registerInteractionCommand } from './commands/interaction.js'; import { registerConfigCommand } from './commands/config.js'; import { registerFleetCommand } from './commands/fleet.js'; import { registerMissionCommand } from './commands/mission.js'; @@ -352,6 +353,10 @@ registerFederationCommand(program); registerAgentCommand(program); +// ─── interaction ─────────────────────────────────────────────────────── + +registerInteractionCommand(program); + // ─── fleet ───────────────────────────────────────────────────────────── registerFleetCommand(program); diff --git a/packages/mosaic/src/commands/interaction.test.ts b/packages/mosaic/src/commands/interaction.test.ts new file mode 100644 index 00000000..79cd774e --- /dev/null +++ b/packages/mosaic/src/commands/interaction.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { Command } from 'commander'; +import { registerInteractionCommand } from './interaction.js'; + +describe('generic interaction CLI', (): void => { + it('registers every required interaction verb without an instance-specific command name', () => { + const program = new Command(); + const command = registerInteractionCommand(program); + expect(command.name()).toBe('interaction'); + expect(command.commands.map((item) => item.name()).sort()).toEqual([ + 'attach', + 'chat', + 'health', + 'recover', + 'send', + 'sessions', + 'status', + 'stop', + 'tree', + ]); + }); +}); diff --git a/packages/mosaic/src/commands/interaction.ts b/packages/mosaic/src/commands/interaction.ts new file mode 100644 index 00000000..8b53a70f --- /dev/null +++ b/packages/mosaic/src/commands/interaction.ts @@ -0,0 +1,200 @@ +import { randomUUID } from 'node:crypto'; +import type { Command } from 'commander'; +import { withAuth } from './with-auth.js'; +import { + attachInteractionSession, + fetchInteractionHealth, + fetchInteractionSessions, + fetchInteractionStatus, + fetchInteractionTree, + recoverInteractionSession, + sendInteractionMessage, + stopInteractionSession, +} from '../tui/gateway-api.js'; + +interface InteractionOptions { + gateway: string; + agent?: string; + correlationId?: string; +} + +function configuredAgentName(options: InteractionOptions): string { + const name = options.agent?.trim() || process.env['MOSAIC_AGENT_NAME']?.trim(); + if (!name) throw new Error('Interaction agent name is required (--agent or MOSAIC_AGENT_NAME)'); + return name; +} + +async function authenticatedGateway(options: InteractionOptions) { + return withAuth(options.gateway); +} + +async function authRequest(options: InteractionOptions) { + const auth = await authenticatedGateway(options); + return { + gateway: auth.gateway, + cookie: auth.cookie, + agentName: configuredAgentName(options), + correlationId: options.correlationId?.trim() || randomUUID(), + }; +} + +function options(command: Command): Command { + return command + .option('-g, --gateway ', 'Gateway URL', 'http://localhost:14242') + .option('--agent ', 'Configured interaction agent name') + .option('--correlation-id ', 'Correlation ID for audit tracing'); +} + +function print(value: unknown): void { + console.log(JSON.stringify(value, null, 2)); +} + +/** + * Generic interaction command surface. The deployed instance name is data + * (`--agent` / MOSAIC_AGENT_NAME), not a source-code command literal. + */ +export function registerInteractionCommand(program: Command): Command { + const command = program + .command('interaction') + .description('Operate a configured durable interaction agent') + .configureHelp({ sortSubcommands: true }); + + options( + command + .command('status') + .description('Show credential-safe effective policy and provider status'), + ).action(async (opts: InteractionOptions) => { + const auth = await authenticatedGateway(opts); + print(await fetchInteractionStatus(auth.gateway, auth.cookie)); + }); + + options( + command + .command('health') + .description('Show readiness without configuration or credential data'), + ).action(async (opts: InteractionOptions) => { + print(await fetchInteractionHealth(opts.gateway)); + }); + + options( + command + .command('sessions ') + .description('List provider sessions visible to this actor'), + ).action(async (provider: string, opts: InteractionOptions) => { + const request = await authRequest(opts); + print( + await fetchInteractionSessions(request.gateway, request.cookie, { + ...request, + providerId: provider, + }), + ); + }); + + options( + command.command('tree ').description('Show the provider session hierarchy'), + ).action(async (provider: string, opts: InteractionOptions) => { + const request = await authRequest(opts); + print( + await fetchInteractionTree(request.gateway, request.cookie, { + ...request, + providerId: provider, + }), + ); + }); + + options( + command.command('attach ').description('Create a scoped read or write attachment'), + ) + .option('--control', 'Request control mode (provider policy may deny it)') + .action(async (sessionId: string, opts: InteractionOptions & { control?: boolean }) => { + const request = await authRequest(opts); + print( + await attachInteractionSession(request.gateway, request.cookie, { + ...request, + sessionId, + mode: opts.control ? 'control' : 'read', + }), + ); + }); + + const send = options( + command + .command('send ') + .description('Durably queue and dispatch a message'), + ) + .option('--idempotency-key ', 'Stable idempotency key') + .action( + async ( + sessionId: string, + message: string, + opts: InteractionOptions & { idempotencyKey?: string }, + ) => { + const request = await authRequest(opts); + print( + await sendInteractionMessage(request.gateway, request.cookie, { + ...request, + sessionId, + content: message, + idempotencyKey: opts.idempotencyKey?.trim() || randomUUID(), + }), + ); + }, + ); + void send; + + options( + command + .command('chat ') + .description('Send a chat message through the durable interaction session'), + ) + .option('--idempotency-key ', 'Stable idempotency key') + .action( + async ( + sessionId: string, + message: string, + opts: InteractionOptions & { idempotencyKey?: string }, + ) => { + const request = await authRequest(opts); + print( + await sendInteractionMessage(request.gateway, request.cookie, { + ...request, + sessionId, + content: message, + idempotencyKey: opts.idempotencyKey?.trim() || randomUUID(), + }), + ); + }, + ); + + options( + command + .command('stop ') + .description('Terminate a session using a one-time exact-action approval'), + ) + .requiredOption('--approval ', 'Durable exact-action approval reference') + .action(async (sessionId: string, opts: InteractionOptions & { approval: string }) => { + const request = await authRequest(opts); + print( + await stopInteractionSession(request.gateway, request.cookie, { + ...request, + sessionId, + approvalRef: opts.approval, + }), + ); + }); + + options( + command + .command('recover ') + .description( + 'Recover durable inbox/checkpoint state without replaying ambiguous outbox effects', + ), + ).action(async (sessionId: string, opts: InteractionOptions) => { + const request = await authRequest(opts); + print( + await recoverInteractionSession(request.gateway, request.cookie, { ...request, sessionId }), + ); + }); + + return command; +} diff --git a/packages/mosaic/src/tui/gateway-api.ts b/packages/mosaic/src/tui/gateway-api.ts index 34147aba..d5ad4dd6 100644 --- a/packages/mosaic/src/tui/gateway-api.ts +++ b/packages/mosaic/src/tui/gateway-api.ts @@ -361,6 +361,138 @@ export async function deleteMission( } } +// ── Authenticated interaction runtime endpoints ── + +export interface InteractionRequest { + agentName: string; + correlationId?: string; +} + +function interactionHeaders(sessionCookie: string, gatewayUrl: string, correlationId?: string) { + return { + ...jsonHeaders(sessionCookie, gatewayUrl), + ...(correlationId ? { 'X-Correlation-Id': correlationId } : {}), + }; +} + +function interactionPath(agentName: string, suffix: string): string { + return `/api/interaction/${encodeURIComponent(agentName)}${suffix}`; +} + +export async function fetchInteractionSessions( + gatewayUrl: string, + sessionCookie: string, + request: InteractionRequest & { providerId: string }, +): Promise { + const params = new URLSearchParams({ provider: request.providerId }); + const res = await fetch( + `${gatewayUrl}${interactionPath(request.agentName, `/sessions?${params}`)}`, + { + headers: interactionHeaders(sessionCookie, gatewayUrl, request.correlationId), + }, + ); + return handleResponse(res, 'Failed to list interaction sessions'); +} + +export async function fetchInteractionTree( + gatewayUrl: string, + sessionCookie: string, + request: InteractionRequest & { providerId: string }, +): Promise { + const params = new URLSearchParams({ provider: request.providerId }); + const res = await fetch(`${gatewayUrl}${interactionPath(request.agentName, `/tree?${params}`)}`, { + headers: interactionHeaders(sessionCookie, gatewayUrl, request.correlationId), + }); + return handleResponse(res, 'Failed to get interaction session tree'); +} + +export async function attachInteractionSession( + gatewayUrl: string, + sessionCookie: string, + request: InteractionRequest & { sessionId: string; mode?: 'read' | 'control' }, +): Promise { + const res = await fetch( + `${gatewayUrl}${interactionPath(request.agentName, `/sessions/${encodeURIComponent(request.sessionId)}/attach`)}`, + { + method: 'POST', + headers: interactionHeaders(sessionCookie, gatewayUrl, request.correlationId), + body: JSON.stringify({ mode: request.mode ?? 'read' }), + }, + ); + return handleResponse(res, 'Failed to attach interaction session'); +} + +export async function sendInteractionMessage( + gatewayUrl: string, + sessionCookie: string, + request: InteractionRequest & { sessionId: string; content: string; idempotencyKey: string }, +): Promise<{ status: string; sessionId: string }> { + const res = await fetch( + `${gatewayUrl}${interactionPath(request.agentName, `/sessions/${encodeURIComponent(request.sessionId)}/send`)}`, + { + method: 'POST', + headers: interactionHeaders(sessionCookie, gatewayUrl, request.correlationId), + body: JSON.stringify({ content: request.content, idempotencyKey: request.idempotencyKey }), + }, + ); + return handleResponse<{ status: string; sessionId: string }>( + res, + 'Failed to send interaction message', + ); +} + +export async function stopInteractionSession( + gatewayUrl: string, + sessionCookie: string, + request: InteractionRequest & { sessionId: string; approvalRef: string }, +): Promise<{ status: string; sessionId: string }> { + const res = await fetch( + `${gatewayUrl}${interactionPath(request.agentName, `/sessions/${encodeURIComponent(request.sessionId)}/stop`)}`, + { + method: 'POST', + headers: interactionHeaders(sessionCookie, gatewayUrl, request.correlationId), + body: JSON.stringify({ approvalRef: request.approvalRef }), + }, + ); + return handleResponse<{ status: string; sessionId: string }>( + res, + 'Failed to stop interaction session', + ); +} + +export async function recoverInteractionSession( + gatewayUrl: string, + sessionCookie: string, + request: InteractionRequest & { sessionId: string }, +): Promise<{ status: string; sessionId: string }> { + const res = await fetch( + `${gatewayUrl}${interactionPath(request.agentName, `/sessions/${encodeURIComponent(request.sessionId)}/recover`)}`, + { + method: 'POST', + headers: interactionHeaders(sessionCookie, gatewayUrl, request.correlationId), + }, + ); + return handleResponse<{ status: string; sessionId: string }>( + res, + 'Failed to recover interaction session', + ); +} + +export async function fetchInteractionStatus( + gatewayUrl: string, + sessionCookie: string, +): Promise { + const res = await fetch(`${gatewayUrl}/api/providers/status`, { + headers: headers(sessionCookie, gatewayUrl), + }); + return handleResponse(res, 'Failed to get effective interaction policy'); +} + +export async function fetchInteractionHealth(gatewayUrl: string): Promise { + const res = await fetch(`${gatewayUrl}/health/ready`); + return handleResponse(res, 'Failed to get interaction readiness'); +} + // ── Conversation Message types ── export interface ConversationMessage { From 84d884b932b2f6d8c7058076ad2366cb5b70bb89 Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Mon, 13 Jul 2026 09:02:45 +0000 Subject: [PATCH 023/152] feat(#709): add configured Discord interaction binding (#730) --- apps/gateway/src/chat/chat.gateway.ts | 153 ++++++++- .../plugin/discord-ingress.security.spec.ts | 302 +++++++++++++++++- apps/gateway/src/plugin/plugin.module.ts | 5 +- plugins/discord/src/index.ts | 170 +++++++++- 4 files changed, 623 insertions(+), 7 deletions(-) diff --git a/apps/gateway/src/chat/chat.gateway.ts b/apps/gateway/src/chat/chat.gateway.ts index 7b4f0b3b..f3fd1b59 100644 --- a/apps/gateway/src/chat/chat.gateway.ts +++ b/apps/gateway/src/chat/chat.gateway.ts @@ -1,4 +1,5 @@ -import { Inject, Logger } from '@nestjs/common'; +import { createHash } from 'node:crypto'; +import { Inject, Logger, Optional } from '@nestjs/common'; import { WebSocketGateway, WebSocketServer, @@ -13,6 +14,9 @@ import { Server, Socket } from 'socket.io'; import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent'; import { verifyDiscordIngressEnvelope, + parseDiscordInteractionBindings, + resolveDiscordInteractionActorId, + resolveDiscordInteractionBinding, type DiscordIngressEnvelope, type DiscordIngressPayload, } from '@mosaicstack/discord-plugin'; @@ -28,6 +32,7 @@ import type { AbortPayload, } from '@mosaicstack/types'; import { AgentService, type ConversationHistoryMessage } from '../agent/agent.service.js'; +import { RuntimeProviderService } from '../agent/runtime-provider-registry.service.js'; import { AUTH } from '../auth/auth.tokens.js'; import { scopeFromUser, @@ -37,6 +42,7 @@ import { import { BRAIN } from '../brain/brain.tokens.js'; import { CommandRegistryService } from '../commands/command-registry.service.js'; import { CommandExecutorService } from '../commands/command-executor.service.js'; +import { CommandAuthorizationService } from '../commands/command-authorization.service.js'; import { RoutingEngineService } from '../agent/routing/routing-engine.service.js'; import { v4 as uuid } from 'uuid'; import { ChatSocketMessageDto } from './chat.dto.js'; @@ -122,6 +128,12 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa @Inject(CommandRegistryService) private readonly commandRegistry: CommandRegistryService, @Inject(CommandExecutorService) private readonly commandExecutor: CommandExecutorService, @Inject(RoutingEngineService) private readonly routingEngine: RoutingEngineService, + @Optional() + @Inject(CommandAuthorizationService) + private readonly commandAuthorization: CommandAuthorizationService | null = null, + @Optional() + @Inject(RuntimeProviderService) + private readonly runtimeRegistry: RuntimeProviderService | null = null, ) {} afterInit(): void { @@ -637,9 +649,130 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa * Creates it if absent — safe to call concurrently since a duplicate insert * would fail on the PK constraint and be caught here. */ + @SubscribeMessage('discord:approve') + async handleDiscordApproval( + @ConnectedSocket() client: Socket, + @MessageBody() envelope: DiscordIngressEnvelope, + ): Promise { + if (!client.data.discordService) return; + const ingress = this.resolveDiscordIngress(client, envelope, 'approve'); + const actionParts = ingress?.content.match(/^\/approve\s+([^\s]+)\s+([^\s]+)$/i); + const tenantId = process.env['DISCORD_SERVICE_TENANT_ID']?.trim(); + if (!ingress || !actionParts || !tenantId || !this.commandAuthorization) return; + const binding = resolveDiscordInteractionBinding( + parseDiscordInteractionBindings(process.env['DISCORD_INTERACTION_BINDINGS']), + ingress.guildId, + ingress.channelId, + ingress.userId, + 'approve', + ); + const actorId = binding && resolveDiscordInteractionActorId(binding, ingress.userId); + const agentName = process.env['MOSAIC_AGENT_NAME']?.trim(); + if (!actorId || !agentName || binding.instanceId !== agentName) { + this.logger.warn( + `Rejected Discord approval without a matching runtime agent from ${client.id}`, + ); + client.emit('discord:approval', { + correlationId: ingress.correlationId, + success: false, + approvalId: undefined, + expiresAt: undefined, + }); + return; + } + const providerId = actionParts[1]!; + const sessionId = actionParts[2]!; + const approval = await this.commandAuthorization.createRuntimeTerminationApproval({ + providerId, + sessionId, + actorId, + tenantId, + channelId: ingress.channelId, + correlationId: this.discordRuntimeActionCorrelation( + binding.instanceId, + ingress, + providerId, + sessionId, + ), + agentName, + }); + client.emit('discord:approval', { + correlationId: ingress.correlationId, + success: approval !== null, + approvalId: approval?.approvalId, + expiresAt: approval?.expiresAt, + }); + } + + @SubscribeMessage('discord:stop') + async handleDiscordStop( + @ConnectedSocket() client: Socket, + @MessageBody() envelope: DiscordIngressEnvelope, + ): Promise { + if (!client.data.discordService) return; + const ingress = this.resolveDiscordIngress(client, envelope, 'stop'); + const actionParts = ingress?.content.match(/^\/stop\s+([^\s]+)\s+([^\s]+)\s+([^\s]+)$/i); + const tenantId = process.env['DISCORD_SERVICE_TENANT_ID']?.trim(); + if (!ingress || !actionParts || !tenantId || !this.runtimeRegistry) return; + const binding = resolveDiscordInteractionBinding( + parseDiscordInteractionBindings(process.env['DISCORD_INTERACTION_BINDINGS']), + ingress.guildId, + ingress.channelId, + ingress.userId, + 'stop', + ); + const actorId = binding && resolveDiscordInteractionActorId(binding, ingress.userId); + if (!actorId) return; + const providerId = actionParts[1]!; + const sessionId = actionParts[2]!; + + try { + // RuntimeProviderService consumes the durable approval exactly once using the + // provisioned approving-admin identity, never the Discord service account. + await this.runtimeRegistry.terminate(providerId, sessionId, actionParts[3]!, { + actorScope: { + userId: actorId, + tenantId, + }, + channelId: ingress.channelId, + correlationId: this.discordRuntimeActionCorrelation( + binding.instanceId, + ingress, + providerId, + sessionId, + ), + }); + client.emit('discord:stop', { correlationId: ingress.correlationId, success: true }); + } catch { + client.emit('discord:stop', { correlationId: ingress.correlationId, success: false }); + } + } + + /** + * Correlates the immutable termination target rather than either Discord message. + * Approval and stop are distinct ingress events, but must consume the same seven-field action. + */ + private discordRuntimeActionCorrelation( + instanceId: string, + ingress: DiscordIngressPayload, + providerId: string, + sessionId: string, + ): string { + const target = [ + instanceId, + ingress.guildId, + ingress.channelId, + ingress.conversationId, + providerId, + sessionId, + ]; + return `discord-action:v1:${createHash('sha256').update(JSON.stringify(target)).digest('hex')}`; + } + private resolveDiscordIngress( client: Socket, envelope: DiscordIngressEnvelope, + operation: 'send' | 'approve' | 'stop' = 'send', ): DiscordIngressPayload | null { const payload = verifyDiscordIngressEnvelope( envelope, @@ -654,6 +787,24 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa this.logger.warn(`Rejected invalid Discord ingress envelope from ${client.id}`); return null; } + try { + const binding = resolveDiscordInteractionBinding( + parseDiscordInteractionBindings(process.env['DISCORD_INTERACTION_BINDINGS']), + payload.guildId, + payload.channelId, + payload.userId, + operation, + ); + if (!binding) { + this.logger.warn(`Rejected unpaired Discord ingress from ${client.id}`); + return null; + } + } catch { + this.logger.warn( + `Rejected Discord ingress without valid binding configuration from ${client.id}`, + ); + return null; + } if (!this.discordReplayProtector.claim(payload.messageId)) { this.logger.warn( `Rejected replayed Discord message=${payload.messageId} correlation=${payload.correlationId}`, diff --git a/apps/gateway/src/plugin/discord-ingress.security.spec.ts b/apps/gateway/src/plugin/discord-ingress.security.spec.ts index 05301703..3ceb840a 100644 --- a/apps/gateway/src/plugin/discord-ingress.security.spec.ts +++ b/apps/gateway/src/plugin/discord-ingress.security.spec.ts @@ -1,13 +1,126 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { createDiscordIngressEnvelope, verifyDiscordIngressEnvelope, + DiscordPlugin, type DiscordIngressPayload, + parseDiscordInteractionBindings, + resolveDiscordInteractionActorId, + resolveDiscordInteractionBinding, } from '@mosaicstack/discord-plugin'; +import { RuntimeProviderService } from '../agent/runtime-provider-registry.service.js'; +import { ChatGateway } from '../chat/chat.gateway.js'; +import { CommandAuthorizationService } from '../commands/command-authorization.service.js'; import { validateDiscordServiceToken } from '../chat/chat.gateway-auth.js'; import { DiscordReplayProtector } from './discord-replay-protector.js'; const SERVICE_TOKEN = 'test-service-token'; +const ENV_KEYS = [ + 'DISCORD_SERVICE_TOKEN', + 'DISCORD_SERVICE_USER_ID', + 'DISCORD_SERVICE_TENANT_ID', + 'DISCORD_INTERACTION_BINDINGS', + 'DISCORD_ALLOWED_GUILD_IDS', + 'DISCORD_ALLOWED_CHANNEL_IDS', + 'DISCORD_ALLOWED_USER_IDS', + 'MOSAIC_AGENT_NAME', +] as const; +const savedEnv = new Map(); + +function configureDiscordEnv(role: 'admin' | 'member' = 'admin'): void { + for (const key of ENV_KEYS) savedEnv.set(key, process.env[key]); + process.env['DISCORD_SERVICE_TOKEN'] = SERVICE_TOKEN; + process.env['DISCORD_SERVICE_USER_ID'] = 'discord-service'; + process.env['DISCORD_SERVICE_TENANT_ID'] = 'tenant-discord'; + process.env['MOSAIC_AGENT_NAME'] = 'Nova'; + process.env['DISCORD_ALLOWED_GUILD_IDS'] = 'guild-001'; + process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001'; + process.env['DISCORD_ALLOWED_USER_IDS'] = 'user-001'; + process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([ + { + instanceId: 'Nova', + guildId: 'guild-001', + channelId: 'channel-001', + pairedUsers: { + 'user-001': { + role: role === 'admin' ? 'admin' : 'operator', + mosaicUserId: 'mosaic-admin-001', + }, + }, + }, + ]); +} + +afterEach((): void => { + for (const key of ENV_KEYS) { + const value = savedEnv.get(key); + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + savedEnv.clear(); +}); + +function commandAuthorization(role: 'admin' | 'member'): CommandAuthorizationService { + const entries = new Map(); + const db = { + select: () => ({ from: () => ({ where: () => ({ limit: async () => [{ role }] }) }) }), + }; + const redis = { + get: async (key: string) => entries.get(key) ?? null, + set: async (key: string, value: string) => entries.set(key, value), + del: async (key: string) => Number(entries.delete(key)), + }; + return new CommandAuthorizationService(db as never, redis); +} + +function discordGateway(role: 'admin' | 'member'): { + gateway: ChatGateway; + client: { data: { discordService: boolean }; emit: ReturnType }; + consumedActions: Array<{ actorId: string; correlationId: string }>; +} { + const authorization = commandAuthorization(role); + const consumedActions: Array<{ actorId: string; correlationId: string }> = []; + const runtimeRegistry = new RuntimeProviderService( + { + require: () => ({ + capabilities: async () => ({ supported: ['session.terminate'] }), + terminate: async () => undefined, + }), + } as never, + { record: async () => undefined } as never, + { + consume: async (approvalId, action) => { + consumedActions.push({ actorId: action.actorId, correlationId: action.correlationId }); + return authorization.consumeRuntimeTerminationApproval(approvalId, action); + }, + }, + ); + return { + gateway: new ChatGateway( + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + authorization, + runtimeRegistry, + ), + client: { data: { discordService: true }, emit: vi.fn() }, + consumedActions, + }; +} + +function ingressEnvelope( + content: string, + messageId: string, + overrides: Partial = {}, +): ReturnType { + return createDiscordIngressEnvelope( + createPayload({ content, messageId, ...overrides }), + SERVICE_TOKEN, + ); +} function createPayload(overrides: Partial = {}): DiscordIngressPayload { return { @@ -23,6 +136,42 @@ function createPayload(overrides: Partial = {}): DiscordI } describe('Discord ingress security', () => { + it('keeps legacy role-only bindings valid while withholding privileged actor identity', () => { + const [binding] = parseDiscordInteractionBindings( + JSON.stringify([ + { + instanceId: 'Nova', + guildId: 'guild-001', + channelId: 'channel-001', + pairedUsers: { 'user-001': 'admin' }, + }, + ]), + ); + expect( + resolveDiscordInteractionBinding([binding!], 'guild-001', 'channel-001', 'user-001', 'send'), + ).toEqual(binding); + expect(resolveDiscordInteractionActorId(binding!, 'user-001')).toBeNull(); + }); + + it('binds a differently named configured interaction instance without code changes', () => { + const binding = resolveDiscordInteractionBinding( + [ + { + instanceId: 'Nova', + guildId: 'guild-001', + channelId: 'channel-001', + pairedUsers: { 'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' } }, + }, + ], + 'guild-001', + 'channel-001', + 'user-001', + 'send', + ); + + expect(binding?.instanceId).toBe('Nova'); + }); + it('accepts only the configured Discord service identity', () => { expect(validateDiscordServiceToken(SERVICE_TOKEN, SERVICE_TOKEN)).toBe(true); expect(validateDiscordServiceToken('wrong-service-token', SERVICE_TOKEN)).toBe(false); @@ -86,4 +235,155 @@ describe('Discord ingress security', () => { expect(replayProtector.claim('discord-message-003')).toBe(true); expect(replayProtector.size).toBe(2); }); + + it('consumes the exact target once when approval and stop are separate Discord messages', async () => { + configureDiscordEnv(); + const { gateway, client, consumedActions } = discordGateway('admin'); + await gateway.handleDiscordApproval( + client as never, + ingressEnvelope('/approve fleet runtime-1', 'approve-message', { + correlationId: 'approval-ingress-correlation', + }), + ); + const approval = client.emit.mock.calls.find( + ([event]) => event === 'discord:approval', + )?.[1] as { + approvalId: string; + success: boolean; + }; + expect(approval.success).toBe(true); + + await gateway.handleDiscordStop( + client as never, + ingressEnvelope(`/stop fleet runtime-1 ${approval.approvalId}`, 'stop-message', { + correlationId: 'stop-ingress-correlation', + }), + ); + expect(client.emit).toHaveBeenCalledWith('discord:stop', { + correlationId: 'stop-ingress-correlation', + success: true, + }); + expect(consumedActions).toEqual([ + { + actorId: 'mosaic-admin-001', + correlationId: expect.stringMatching(/^discord-action:v1:/), + }, + ]); + }); + + it('rejects approval when the binding targets a different runtime agent', async () => { + configureDiscordEnv(); + process.env['MOSAIC_AGENT_NAME'] = 'Other'; + const { gateway, client } = discordGateway('admin'); + + await gateway.handleDiscordApproval( + client as never, + ingressEnvelope('/approve fleet runtime-1', 'mismatched-agent-approve'), + ); + + expect(client.emit).toHaveBeenCalledWith('discord:approval', { + correlationId: 'correlation-001', + success: false, + approvalId: undefined, + expiresAt: undefined, + }); + }); + + it('rejects unpaired and non-admin Discord users for approval and stop', async () => { + configureDiscordEnv(); + const { gateway, client } = discordGateway('member'); + await gateway.handleDiscordApproval( + client as never, + ingressEnvelope('/approve fleet runtime-1', 'member-approve'), + ); + expect(client.emit).toHaveBeenCalledWith('discord:approval', { + correlationId: 'correlation-001', + success: false, + approvalId: undefined, + expiresAt: undefined, + }); + + process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([]); + await gateway.handleDiscordStop( + client as never, + ingressEnvelope('/stop fleet runtime-1 forged', 'unpaired-stop'), + ); + expect(client.emit).not.toHaveBeenCalledWith('discord:stop', expect.anything()); + }); + + it('rejects replaying a Discord-created termination approval', async () => { + configureDiscordEnv(); + const { gateway, client } = discordGateway('admin'); + await gateway.handleDiscordApproval( + client as never, + ingressEnvelope('/approve fleet runtime-1', 'replay-approve', { + correlationId: 'replay-approval-correlation', + }), + ); + const approval = client.emit.mock.calls.find( + ([event]) => event === 'discord:approval', + )?.[1] as { + approvalId: string; + }; + await gateway.handleDiscordStop( + client as never, + ingressEnvelope(`/stop fleet runtime-1 ${approval.approvalId}`, 'replay-stop-one', { + correlationId: 'replay-stop-correlation-one', + }), + ); + await gateway.handleDiscordStop( + client as never, + ingressEnvelope(`/stop fleet runtime-1 ${approval.approvalId}`, 'replay-stop-two', { + correlationId: 'replay-stop-correlation-two', + }), + ); + const stopResults = client.emit.mock.calls.filter(([event]) => event === 'discord:stop'); + expect(stopResults.map(([, result]) => (result as { success: boolean }).success)).toEqual([ + true, + false, + ]); + }); + + it('accepts a thread message through its allowed bound parent channel', () => { + const emitted = vi.fn(); + const plugin = new DiscordPlugin({ + token: 'unused', + gatewayUrl: 'http://unused', + serviceToken: SERVICE_TOKEN, + allowedGuildIds: ['guild-001'], + allowedChannelIds: ['channel-001'], + allowedUserIds: ['user-001'], + interactionBindings: [ + { + instanceId: 'Nova', + guildId: 'guild-001', + channelId: 'channel-001', + pairedUsers: { 'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' } }, + }, + ], + }); + const internals = plugin as unknown as { + client: { user: { id: string } }; + socket: { connected: boolean; emit: ReturnType }; + handleDiscordMessage(message: unknown): void; + }; + internals.client = { user: { id: 'bot-001' } }; + internals.socket = { connected: true, emit: emitted }; + internals.handleDiscordMessage({ + id: 'thread-message', + guildId: 'guild-001', + channelId: 'thread-001', + author: { id: 'user-001', bot: false }, + mentions: { has: () => true }, + content: '<@bot-001> hello from thread', + channel: { parentId: 'channel-001' }, + attachments: new Map(), + }); + + const [, envelope] = emitted.mock.calls[0] as [ + string, + ReturnType, + ]; + expect(verifyDiscordIngressEnvelope(envelope, SERVICE_TOKEN)?.channelId).toBe('channel-001'); + }); }); diff --git a/apps/gateway/src/plugin/plugin.module.ts b/apps/gateway/src/plugin/plugin.module.ts index dc059ef5..fdf13cf9 100644 --- a/apps/gateway/src/plugin/plugin.module.ts +++ b/apps/gateway/src/plugin/plugin.module.ts @@ -6,7 +6,7 @@ import { type OnModuleDestroy, type OnModuleInit, } from '@nestjs/common'; -import { DiscordPlugin } from '@mosaicstack/discord-plugin'; +import { DiscordPlugin, parseDiscordInteractionBindings } from '@mosaicstack/discord-plugin'; import { TelegramPlugin } from '@mosaicstack/telegram-plugin'; import { PluginService } from './plugin.service.js'; import type { IChannelPlugin } from './plugin.interface.js'; @@ -85,6 +85,9 @@ function createPluginRegistry(): IChannelPlugin[] { allowedGuildIds: requiredDiscordAllowlist('DISCORD_ALLOWED_GUILD_IDS'), allowedChannelIds: requiredDiscordAllowlist('DISCORD_ALLOWED_CHANNEL_IDS'), allowedUserIds: requiredDiscordAllowlist('DISCORD_ALLOWED_USER_IDS'), + interactionBindings: parseDiscordInteractionBindings( + process.env['DISCORD_INTERACTION_BINDINGS'], + ), }), ), ); diff --git a/plugins/discord/src/index.ts b/plugins/discord/src/index.ts index d25e5559..d111e879 100644 --- a/plugins/discord/src/index.ts +++ b/plugins/discord/src/index.ts @@ -12,6 +12,129 @@ export interface DiscordPluginConfig { allowedGuildIds: readonly string[]; allowedChannelIds: readonly string[]; allowedUserIds: readonly string[]; + /** Provisioned interaction bindings; instance identity is configuration, never code. */ + interactionBindings?: readonly DiscordInteractionBinding[]; +} + +export type DiscordInteractionOperation = 'bind' | 'attach' | 'send' | 'approve' | 'stop'; +export type DiscordInteractionRole = 'viewer' | 'operator' | 'admin'; + +/** A provisioned Discord-to-Mosaic identity pairing. */ +export interface DiscordInteractionUserBinding { + role: DiscordInteractionRole; + /** Required for privileged approval and stop operations. */ + mosaicUserId?: string; +} + +/** Legacy role-only pairings remain valid for non-privileged Discord ingress. */ +export type DiscordInteractionPairing = DiscordInteractionRole | DiscordInteractionUserBinding; + +export interface DiscordInteractionBinding { + instanceId: string; + guildId: string; + channelId: string; + /** Pairing roster keyed by Discord user ID. */ + pairedUsers: Readonly>; +} + +const operationRoles: Readonly< + Record +> = { + bind: ['admin'], + attach: ['operator', 'admin'], + send: ['operator', 'admin'], + approve: ['admin'], + stop: ['admin'], +}; + +/** Resolves a configuration-owned binding and applies pairing/RBAC before ingress. */ +export function resolveDiscordInteractionBinding( + bindings: readonly DiscordInteractionBinding[], + guildId: string, + channelId: string, + userId: string, + operation: DiscordInteractionOperation, +): DiscordInteractionBinding | null { + const binding = bindings.find( + (candidate) => candidate.guildId === guildId && candidate.channelId === channelId, + ); + if (!binding) return null; + const pairing = binding.pairedUsers[userId]; + const role = typeof pairing === 'string' ? pairing : pairing?.role; + return role && operationRoles[operation].includes(role) ? binding : null; +} + +/** Resolves the provisioned Mosaic identity for an already-authorized Discord user. */ +export function resolveDiscordInteractionActorId( + binding: DiscordInteractionBinding, + discordUserId: string, +): string | null { + const pairing = binding.pairedUsers[discordUserId]; + if (typeof pairing === 'string') return null; + const mosaicUserId = pairing?.mosaicUserId?.trim(); + return mosaicUserId || null; +} + +/** Parses provisioned binding roster JSON and rejects malformed or empty data. */ +export function parseDiscordInteractionBindings( + value: string | undefined, +): DiscordInteractionBinding[] { + if (!value) throw new Error('DISCORD_INTERACTION_BINDINGS is required when Discord is enabled'); + const parsed: unknown = JSON.parse(value); + if (!Array.isArray(parsed) || parsed.length === 0) { + throw new Error('DISCORD_INTERACTION_BINDINGS must be a non-empty JSON array'); + } + return parsed.map((binding: unknown): DiscordInteractionBinding => { + if (typeof binding !== 'object' || binding === null) + throw new Error('Invalid Discord interaction binding'); + const candidate = binding as Partial; + if ( + !candidate.instanceId || + !candidate.guildId || + !candidate.channelId || + !candidate.pairedUsers || + typeof candidate.pairedUsers !== 'object' + ) { + throw new Error('Invalid Discord interaction binding'); + } + const pairedUsers = Object.fromEntries( + Object.entries(candidate.pairedUsers).map(([discordUserId, pairing]: [string, unknown]) => { + if (!discordUserId.trim()) { + throw new Error('Invalid Discord interaction user binding'); + } + if (typeof pairing === 'string') { + if (!['viewer', 'operator', 'admin'].includes(pairing)) { + throw new Error('Invalid Discord interaction user binding'); + } + return [discordUserId, pairing]; + } + if (typeof pairing !== 'object' || pairing === null) { + throw new Error('Invalid Discord interaction user binding'); + } + const userBinding = pairing as Partial; + if ( + !userBinding.role || + !['viewer', 'operator', 'admin'].includes(userBinding.role) || + (userBinding.mosaicUserId !== undefined && !userBinding.mosaicUserId.trim()) + ) { + throw new Error('Invalid Discord interaction user binding'); + } + return [ + discordUserId, + { + role: userBinding.role, + ...(userBinding.mosaicUserId ? { mosaicUserId: userBinding.mosaicUserId } : {}), + }, + ]; + }), + ) as Record; + return { + instanceId: candidate.instanceId, + guildId: candidate.guildId, + channelId: candidate.channelId, + pairedUsers, + }; + }); } export interface DiscordIngressPayload { @@ -22,6 +145,15 @@ export interface DiscordIngressPayload { userId: string; conversationId: string; content: string; + threadId?: string; + attachments?: readonly DiscordAttachment[]; +} + +export interface DiscordAttachment { + id: string; + name: string; + url: string; + contentType: string | null; } export interface DiscordIngressEnvelope { @@ -44,6 +176,8 @@ function signedPayload(payload: DiscordIngressPayload): string { payload.userId, payload.conversationId, payload.content, + payload.threadId ?? '', + JSON.stringify(payload.attachments ?? []), ].join('\n'); } @@ -214,7 +348,18 @@ export class DiscordPlugin { } const channelId = message.channelId; - const conversationId = this.channelConversations.get(channelId) ?? `discord-${channelId}`; + const parentChannelId = 'parentId' in message.channel ? message.channel.parentId : null; + const bindingChannelId = parentChannelId ?? channelId; + const binding = resolveDiscordInteractionBinding( + this.config.interactionBindings ?? [], + message.guildId, + bindingChannelId, + message.author.id, + 'send', + ); + if (!binding) return; + const conversationId = + this.channelConversations.get(channelId) ?? `${binding.instanceId}:discord:${channelId}`; this.channelConversations.set(channelId, conversationId); const envelope = createDiscordIngressEnvelope( @@ -222,22 +367,39 @@ export class DiscordPlugin { correlationId: randomUUID(), messageId: message.id, guildId: message.guildId, - channelId, + channelId: bindingChannelId, userId: message.author.id, conversationId, content, + threadId: parentChannelId ? channelId : undefined, + attachments: Array.from(message.attachments.values()).map((attachment) => ({ + id: attachment.id, + name: attachment.name, + url: attachment.url, + contentType: attachment.contentType, + })), }, this.config.serviceToken, ); - this.socket.emit('message', envelope); + this.socket.emit( + content.startsWith('/approve ') + ? 'discord:approve' + : content.startsWith('/stop ') + ? 'discord:stop' + : 'message', + envelope, + ); } private isAllowedMessage(message: DiscordMessage): boolean { const guildId = message.guildId; + const parentChannelId = 'parentId' in message.channel ? message.channel.parentId : null; + // Threads inherit their authorization boundary from their configured parent. + const authorizationChannelId = parentChannelId ?? message.channelId; return ( guildId !== null && includesId(this.config.allowedGuildIds, guildId) && - includesId(this.config.allowedChannelIds, message.channelId) && + includesId(this.config.allowedChannelIds, authorizationChannelId) && includesId(this.config.allowedUserIds, message.author.id) ); } From 0b621660c8690e7b89d5df8fbde69c634619fe9f Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Mon, 13 Jul 2026 10:05:29 +0000 Subject: [PATCH 024/152] feat(tess): wire durable interaction surfaces (#732) --- .../tess-cross-surface.integration.test.ts | 159 +++++++++++++++++ .../runtime-provider-registry.service.test.ts | 21 +++ .../src/agent/interaction.controller.test.ts | 163 ++++++++++++++++++ .../src/agent/interaction.controller.ts | 89 +++++++++- .../agent/runtime-approval-denied.filter.ts | 13 ++ .../runtime-provider-registry.service.ts | 15 +- .../agent/tess-durable-session.repository.ts | 19 +- .../src/agent/tess-durable-session.service.ts | 16 +- apps/gateway/src/chat/chat.gateway.ts | 116 ++++++++++--- .../plugin/discord-ingress.security.spec.ts | 76 ++++++-- docs/scratchpads/tess-20260712.md | 8 + .../agent/src/tess-durable-session.test.ts | 17 ++ packages/agent/src/tess-durable-session.ts | 10 +- .../mosaic/src/commands/interaction.test.ts | 1 + packages/mosaic/src/commands/interaction.ts | 48 +++++- packages/mosaic/src/tui/gateway-api.ts | 69 ++++++++ 16 files changed, 782 insertions(+), 58 deletions(-) create mode 100644 apps/gateway/src/__tests__/integration/tess-cross-surface.integration.test.ts create mode 100644 apps/gateway/src/agent/runtime-approval-denied.filter.ts diff --git a/apps/gateway/src/__tests__/integration/tess-cross-surface.integration.test.ts b/apps/gateway/src/__tests__/integration/tess-cross-surface.integration.test.ts new file mode 100644 index 00000000..ae43c9af --- /dev/null +++ b/apps/gateway/src/__tests__/integration/tess-cross-surface.integration.test.ts @@ -0,0 +1,159 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { InMemoryDurableSessionStore } from '@mosaicstack/agent'; +import { + createDiscordIngressEnvelope, + type DiscordIngressPayload, +} from '@mosaicstack/discord-plugin'; +import { InteractionController } from '../../agent/interaction.controller.js'; +import { RuntimeProviderService } from '../../agent/runtime-provider-registry.service.js'; +import { TessDurableSessionService } from '../../agent/tess-durable-session.service.js'; +import { ChatGateway } from '../../chat/chat.gateway.js'; +import { CommandAuthorizationService } from '../../commands/command-authorization.service.js'; + +const SERVICE_TOKEN = 'test-discord-service-token'; +const envKeys = [ + 'DISCORD_SERVICE_TOKEN', + 'DISCORD_SERVICE_TENANT_ID', + 'DISCORD_INTERACTION_BINDINGS', + 'DISCORD_ALLOWED_GUILD_IDS', + 'DISCORD_ALLOWED_CHANNEL_IDS', + 'DISCORD_ALLOWED_USER_IDS', + 'MOSAIC_AGENT_NAME', +] as const; +const priorEnv = new Map(); + +function payload(content: string, messageId: string, correlationId: string): DiscordIngressPayload { + return { + content, + messageId, + correlationId, + guildId: 'guild-1', + channelId: 'channel-1', + userId: 'discord-admin-1', + conversationId: 'conversation-1', + }; +} + +function authorization(): CommandAuthorizationService { + const entries = new Map(); + return new CommandAuthorizationService( + { + select: () => ({ + from: () => ({ where: () => ({ limit: async () => [{ role: 'admin' }] }) }), + }), + } as never, + { + get: async (key: string) => entries.get(key) ?? null, + set: async (key: string, value: string) => entries.set(key, value), + del: async (key: string) => Number(entries.delete(key)), + }, + ); +} + +describe('Tess Discord/CLI durable-session integration', () => { + afterEach(() => { + for (const key of envKeys) { + const value = priorEnv.get(key); + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + priorEnv.clear(); + }); + + it('enrolls through the CLI surface then resolves the same durable session from Discord', async () => { + for (const key of envKeys) priorEnv.set(key, process.env[key]); + process.env['MOSAIC_AGENT_NAME'] = 'Nova'; + process.env['DISCORD_SERVICE_TOKEN'] = SERVICE_TOKEN; + process.env['DISCORD_SERVICE_TENANT_ID'] = 'tenant-1'; + process.env['DISCORD_ALLOWED_GUILD_IDS'] = 'guild-1'; + process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-1'; + process.env['DISCORD_ALLOWED_USER_IDS'] = 'discord-admin-1'; + process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([ + { + instanceId: 'Nova', + guildId: 'guild-1', + channelId: 'channel-1', + pairedUsers: { + 'discord-admin-1': { role: 'admin', mosaicUserId: 'mosaic-admin-1' }, + }, + }, + ]); + + const durable = new TessDurableSessionService( + new InMemoryDurableSessionStore() as never, + {} as never, + ); + const enrollmentRuntime = { + listSessions: vi.fn().mockResolvedValue([{ id: 'runtime-1' }]), + }; + const controller = new InteractionController(enrollmentRuntime as never, durable); + await controller.enroll( + 'Nova', + 'conversation-1', + { providerId: 'fleet', runtimeSessionId: 'runtime-1' }, + { id: 'mosaic-admin-1', tenantId: 'tenant-1' }, + 'cli-enrollment-correlation', + ); + + const authz = authorization(); + const terminated = vi.fn().mockResolvedValue(undefined); + const runtime = new RuntimeProviderService( + { + require: () => ({ + capabilities: async () => ({ supported: ['session.terminate'] }), + terminate: terminated, + }), + } as never, + { record: async () => undefined } as never, + { + consume: (approvalId, action) => + authz.consumeRuntimeTerminationApproval(approvalId, action), + }, + ); + const gateway = new ChatGateway( + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + authz, + runtime, + durable, + ); + const client = { data: { discordService: true }, emit: vi.fn() }; + + await gateway.handleDiscordApproval( + client as never, + createDiscordIngressEnvelope( + payload('/approve', 'approve-1', 'discord-approve-correlation'), + SERVICE_TOKEN, + ), + ); + const approval = client.emit.mock.calls.find( + ([event]) => event === 'discord:approval', + )?.[1] as { + approvalId: string; + success: boolean; + }; + expect(approval.success).toBe(true); + + await gateway.handleDiscordStop( + client as never, + createDiscordIngressEnvelope( + payload(`/stop ${approval.approvalId}`, 'stop-1', 'discord-stop-correlation'), + SERVICE_TOKEN, + ), + ); + + expect(terminated).toHaveBeenCalledWith( + 'runtime-1', + approval.approvalId, + expect.objectContaining({ actorId: 'mosaic-admin-1' }), + ); + expect(client.emit).toHaveBeenCalledWith('discord:stop', { + correlationId: 'discord-stop-correlation', + success: true, + }); + }); +}); diff --git a/apps/gateway/src/agent/__tests__/runtime-provider-registry.service.test.ts b/apps/gateway/src/agent/__tests__/runtime-provider-registry.service.test.ts index a70eafe5..11c57f6e 100644 --- a/apps/gateway/src/agent/__tests__/runtime-provider-registry.service.test.ts +++ b/apps/gateway/src/agent/__tests__/runtime-provider-registry.service.test.ts @@ -37,6 +37,7 @@ class RecordingRuntimeProvider implements AgentRuntimeProvider { readonly sentMessages: RuntimeMessage[] = []; terminateCalls = 0; throwAfterSend = false; + throwAuthorization = false; constructor(private readonly supported: RuntimeCapability[]) {} @@ -76,6 +77,9 @@ class RecordingRuntimeProvider implements AgentRuntimeProvider { ): Promise { this.receivedScopes.push(scope); this.sentMessages.push(message); + if (this.throwAuthorization) { + throw Object.assign(new Error('provider authorization denied'), { code: 'forbidden' }); + } if (this.throwAfterSend) { throw new Error('provider acknowledgement failed'); } @@ -295,6 +299,23 @@ describe('RuntimeProviderService security boundary', (): void => { }); }); + it('records a provider authorization rejection as denied rather than provider failure', async (): Promise => { + const provider = new RecordingRuntimeProvider(['session.send']); + provider.throwAuthorization = true; + const audit = new RecordingAuditSink(); + const service = makeService(provider, audit); + + await expect( + service.sendMessage( + 'fleet', + 'session-1', + { content: 'hello', idempotencyKey: 'key-1' }, + CONTEXT, + ), + ).rejects.toThrow(/provider authorization denied/); + expect(audit.events.at(-1)).toMatchObject({ outcome: 'denied', errorCode: 'policy_denied' }); + }); + it('persists only metadata-only runtime audit fields', async (): Promise => { let persisted: unknown; const ingest = async (entry: unknown): Promise => { diff --git a/apps/gateway/src/agent/interaction.controller.test.ts b/apps/gateway/src/agent/interaction.controller.test.ts index 8e7c3822..db0dad47 100644 --- a/apps/gateway/src/agent/interaction.controller.test.ts +++ b/apps/gateway/src/agent/interaction.controller.test.ts @@ -1,9 +1,27 @@ +import { firstValueFrom } from 'rxjs'; import { afterEach, describe, expect, it, vi } from 'vitest'; +import { RuntimeApprovalDeniedError } from './runtime-provider-registry.service.js'; +import { RuntimeApprovalDeniedFilter } from './runtime-approval-denied.filter.js'; import { InteractionController } from './interaction.controller.js'; describe('InteractionController', (): void => { afterEach(() => vi.restoreAllMocks()); + it('maps a denied runtime approval to Fastify HTTP 403', () => { + const send = vi.fn(); + const status = vi.fn().mockReturnValue({ send }); + const response = { status }; + const host = { switchToHttp: () => ({ getResponse: () => response }) }; + + new RuntimeApprovalDeniedFilter().catch(new RuntimeApprovalDeniedError(), host as never); + + expect(status).toHaveBeenCalledWith(403); + expect(send).toHaveBeenCalledWith({ + statusCode: 403, + message: 'Runtime termination approval denied', + }); + }); + it('honors a differently named configured instance without a code change', async () => { const prior = process.env['MOSAIC_AGENT_NAME']; process.env['MOSAIC_AGENT_NAME'] = 'Nova'; @@ -30,6 +48,36 @@ describe('InteractionController', (): void => { ); }); + it('enrolls a visible runtime session under the cross-surface conversation handle', async () => { + process.env['MOSAIC_AGENT_NAME'] = 'Nova'; + const runtime = { + listSessions: vi.fn().mockResolvedValue([{ id: 'runtime-1' }]), + }; + const durable = { enroll: vi.fn().mockResolvedValue(undefined) }; + const controller = new InteractionController(runtime as never, durable as never); + + await expect( + controller.enroll( + 'Nova', + 'conversation-1', + { providerId: 'fleet', runtimeSessionId: 'runtime-1' }, + { id: 'owner', tenantId: 'team' }, + 'corr-1', + ), + ).resolves.toEqual({ status: 'enrolled', sessionId: 'conversation-1' }); + expect(durable.enroll).toHaveBeenCalledWith( + { + agentName: 'Nova', + sessionId: 'conversation-1', + tenantId: 'team', + ownerId: 'owner', + providerId: 'fleet', + runtimeSessionId: 'runtime-1', + }, + expect.objectContaining({ correlationId: 'corr-1' }), + ); + }); + it('rejects an invalid attach mode before invoking a provider', async () => { process.env['MOSAIC_AGENT_NAME'] = 'Nova'; const controller = new InteractionController({ attach: vi.fn() } as never, {} as never); @@ -39,6 +87,121 @@ describe('InteractionController', (): void => { ).rejects.toThrow('Interaction attach mode is invalid'); }); + it('resumes a durable session by attaching and streaming its runtime events', async () => { + process.env['MOSAIC_AGENT_NAME'] = 'Nova'; + const runtimeEvent = { + type: 'message.delta' as const, + sessionId: 'runtime-1', + cursor: 'cursor-1', + occurredAt: '2026-07-13T00:00:00.000Z', + content: 'resumed', + }; + const runtime = { + attach: vi.fn().mockResolvedValue({ attachmentId: 'attach-1', sessionId: 'runtime-1' }), + streamSession: vi.fn(async function* () { + yield runtimeEvent; + }), + }; + const durable = { + getSnapshot: vi.fn().mockResolvedValue({ + identity: { agentName: 'Nova', providerId: 'fleet', runtimeSessionId: 'runtime-1' }, + }), + }; + const controller = new InteractionController(runtime as never, durable as never); + + await controller.attach('Nova', 'conversation-1', { mode: 'read' }, { id: 'owner' }, 'corr-1'); + await expect( + firstValueFrom( + controller.stream('Nova', 'conversation-1', undefined, { id: 'owner' }, 'corr-1'), + ), + ).resolves.toEqual({ data: runtimeEvent }); + + expect(runtime.attach).toHaveBeenCalledWith( + 'fleet', + 'runtime-1', + 'read', + expect.objectContaining({ correlationId: 'corr-1' }), + ); + expect(runtime.streamSession).toHaveBeenCalledWith( + 'fleet', + 'runtime-1', + undefined, + expect.objectContaining({ correlationId: 'corr-1' }), + ); + }); + + it('does not create a runtime stream after the SSE subscriber disconnects during snapshot lookup', async () => { + process.env['MOSAIC_AGENT_NAME'] = 'Nova'; + let resolveSnapshot!: (value: { identity: Record }) => void; + const snapshot = new Promise<{ identity: Record }>((resolve) => { + resolveSnapshot = resolve; + }); + const runtime = { streamSession: vi.fn() }; + const durable = { getSnapshot: vi.fn().mockReturnValue(snapshot) }; + const controller = new InteractionController(runtime as never, durable as never); + + const subscription = controller + .stream('Nova', 'conversation-1', undefined, { id: 'owner' }, 'corr-1') + .subscribe(); + subscription.unsubscribe(); + resolveSnapshot({ + identity: { agentName: 'Nova', providerId: 'fleet', runtimeSessionId: 'runtime-1' }, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(runtime.streamSession).not.toHaveBeenCalled(); + }); + + it.each([ + ['wrong actor', { getSnapshot: vi.fn().mockRejectedValue(new Error('scope mismatch')) }], + [ + 'session-agent mismatch', + { + getSnapshot: vi.fn().mockResolvedValue({ + identity: { agentName: 'Other', providerId: 'fleet', runtimeSessionId: 'runtime-1' }, + }), + }, + ], + ])('denies a CLI stop for %s', async (_reason, durable) => { + process.env['MOSAIC_AGENT_NAME'] = 'Nova'; + const runtime = { terminate: vi.fn().mockResolvedValue(undefined) }; + const controller = new InteractionController(runtime as never, durable as never); + + await expect( + controller.stop( + 'Nova', + 'durable-1', + { approvalRef: 'approval-1' }, + { id: 'owner' }, + 'corr-1', + ), + ).rejects.toBeDefined(); + expect(runtime.terminate).not.toHaveBeenCalled(); + }); + + it('surfaces a denied runtime approval to the CLI interaction surface', async () => { + process.env['MOSAIC_AGENT_NAME'] = 'Nova'; + const runtime = { + terminate: vi.fn().mockRejectedValue(new Error('Runtime termination approval denied')), + }; + const durable = { + getSnapshot: vi.fn().mockResolvedValue({ + identity: { agentName: 'Nova', providerId: 'fleet', runtimeSessionId: 'runtime-1' }, + }), + }; + const controller = new InteractionController(runtime as never, durable as never); + + await expect( + controller.stop( + 'Nova', + 'durable-1', + { approvalRef: 'approval-1' }, + { id: 'owner' }, + 'corr-1', + ), + ).rejects.toThrow('Runtime termination approval denied'); + }); + it('uses the durable session identity and runtime registry for an approved stop', async () => { process.env['MOSAIC_AGENT_NAME'] = 'Nova'; const runtime = { terminate: vi.fn().mockResolvedValue(undefined) }; diff --git a/apps/gateway/src/agent/interaction.controller.ts b/apps/gateway/src/agent/interaction.controller.ts index 51036608..cc247fd1 100644 --- a/apps/gateway/src/agent/interaction.controller.ts +++ b/apps/gateway/src/agent/interaction.controller.ts @@ -4,17 +4,21 @@ import { ForbiddenException, Get, Headers, + Sse, Inject, Param, Post, Query, UseGuards, + UseFilters, } from '@nestjs/common'; -import type { RuntimeAttachMode } from '@mosaicstack/types'; +import type { RuntimeAttachMode, RuntimeStreamEvent } from '@mosaicstack/types'; +import { Observable } from 'rxjs'; import { AuthGuard } from '../auth/auth.guard.js'; import { CurrentUser } from '../auth/current-user.decorator.js'; import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js'; import { TessDurableSessionService } from './tess-durable-session.service.js'; +import { RuntimeApprovalDeniedFilter } from './runtime-approval-denied.filter.js'; import { RuntimeProviderService, type RuntimeProviderRequestContext, @@ -26,6 +30,7 @@ import { */ @Controller('api/interaction/:agentName') @UseGuards(AuthGuard) +@UseFilters(RuntimeApprovalDeniedFilter) export class InteractionController { constructor( @Inject(RuntimeProviderService) private readonly runtime: RuntimeProviderService, @@ -60,6 +65,41 @@ export class InteractionController { ); } + /** + * Bind an existing, authorized runtime session to the stable conversation ID. + * This is the lifecycle boundary where both runtime identifiers are known. + */ + @Post('sessions/:sessionId/enroll') + async enroll( + @Param('agentName') agentName: string, + @Param('sessionId') sessionId: string, + @Body() body: { providerId?: string; runtimeSessionId?: string } = {}, + @CurrentUser() user: AuthenticatedUserLike, + @Headers('x-correlation-id') correlationId?: string, + ) { + this.assertConfiguredAgent(agentName); + const providerId = this.requiredProvider(body.providerId ?? ''); + const runtimeSessionId = body.runtimeSessionId?.trim(); + if (!runtimeSessionId) throw new ForbiddenException('Runtime session identity is required'); + const context = this.context(user, correlationId); + const sessions = await this.runtime.listSessions(providerId, context); + if (!sessions.some((session): boolean => session.id === runtimeSessionId)) { + throw new ForbiddenException('Runtime session is not visible to this actor'); + } + await this.durable.enroll( + { + agentName, + sessionId, + tenantId: context.actorScope.tenantId, + ownerId: context.actorScope.userId, + providerId, + runtimeSessionId, + }, + context, + ); + return { status: 'enrolled', sessionId }; + } + @Post('sessions/:sessionId/attach') async attach( @Param('agentName') agentName: string, @@ -84,6 +124,53 @@ export class InteractionController { ); } + @Sse('sessions/:sessionId/stream') + stream( + @Param('agentName') agentName: string, + @Param('sessionId') sessionId: string, + @Query('cursor') cursor: string | undefined, + @CurrentUser() user: AuthenticatedUserLike, + @Headers('x-correlation-id') correlationId?: string, + ): Observable<{ data: RuntimeStreamEvent }> { + this.assertConfiguredAgent(agentName); + const context = this.context(user, correlationId); + return new Observable((subscriber) => { + let iterator: AsyncIterator | undefined; + let cancelled = false; + void (async (): Promise => { + try { + const snapshot = await this.durable.getSnapshot(sessionId, context); + if (cancelled || subscriber.closed) return; + this.assertSessionAgent(snapshot.identity.agentName, agentName); + iterator = this.runtime + .streamSession( + snapshot.identity.providerId, + snapshot.identity.runtimeSessionId, + cursor?.trim() || undefined, + context, + ) + [Symbol.asyncIterator](); + if (cancelled || subscriber.closed) { + await iterator.return?.(); + return; + } + while (!cancelled && !subscriber.closed) { + const next = await iterator.next(); + if (next.done || cancelled || subscriber.closed) break; + subscriber.next({ data: next.value }); + } + if (!subscriber.closed) subscriber.complete(); + } catch (error: unknown) { + if (!subscriber.closed) subscriber.error(error); + } + })(); + return (): void => { + cancelled = true; + void iterator?.return?.(); + }; + }); + } + @Post('sessions/:sessionId/send') async send( @Param('agentName') agentName: string, diff --git a/apps/gateway/src/agent/runtime-approval-denied.filter.ts b/apps/gateway/src/agent/runtime-approval-denied.filter.ts new file mode 100644 index 00000000..22aae385 --- /dev/null +++ b/apps/gateway/src/agent/runtime-approval-denied.filter.ts @@ -0,0 +1,13 @@ +import { Catch, type ArgumentsHost, type ExceptionFilter } from '@nestjs/common'; +import { RuntimeApprovalDeniedError } from './runtime-provider-registry.service.js'; + +/** Maps a consumed/missing runtime approval to a stable HTTP authorization response. */ +@Catch(RuntimeApprovalDeniedError) +export class RuntimeApprovalDeniedFilter implements ExceptionFilter { + catch(_exception: RuntimeApprovalDeniedError, host: ArgumentsHost): void { + const response = host.switchToHttp().getResponse<{ + status(code: number): { send(body: { statusCode: number; message: string }): void }; + }>(); + response.status(403).send({ statusCode: 403, message: 'Runtime termination approval denied' }); + } +} diff --git a/apps/gateway/src/agent/runtime-provider-registry.service.ts b/apps/gateway/src/agent/runtime-provider-registry.service.ts index cb16a29e..b824bb04 100644 --- a/apps/gateway/src/agent/runtime-provider-registry.service.ts +++ b/apps/gateway/src/agent/runtime-provider-registry.service.ts @@ -77,7 +77,7 @@ function configuredAgentName(): string { return agentName; } -class RuntimeApprovalDeniedError extends Error { +export class RuntimeApprovalDeniedError extends Error { constructor() { super('Runtime termination approval denied'); } @@ -301,7 +301,7 @@ export class RuntimeProviderService { return result; } catch (error: unknown) { const durationMs = Date.now() - startedAt; - if (invocationStarted && !(error instanceof RuntimeApprovalDeniedError)) { + if (invocationStarted && !this.isAuthorizationDenied(error)) { await this.recordFailure(providerId, operation, scope, resourceId, durationMs); } else { await this.record( @@ -360,6 +360,17 @@ export class RuntimeProviderService { } } + private isAuthorizationDenied(error: unknown): boolean { + return ( + error instanceof RuntimeApprovalDeniedError || + error instanceof ForbiddenException || + (typeof error === 'object' && + error !== null && + 'code' in error && + (error as { code?: unknown }).code === 'forbidden') + ); + } + private provider(providerId: string): AgentRuntimeProvider { try { return this.registry.require(providerId); diff --git a/apps/gateway/src/agent/tess-durable-session.repository.ts b/apps/gateway/src/agent/tess-durable-session.repository.ts index 8d2f52ce..52d853bb 100644 --- a/apps/gateway/src/agent/tess-durable-session.repository.ts +++ b/apps/gateway/src/agent/tess-durable-session.repository.ts @@ -50,9 +50,20 @@ export class TessDurableSessionRepository implements DurableSessionStore { .onConflictDoNothing(); const existing = await this.session(identity.sessionId); - if (!existing || !sameIdentity(existing, identity)) { + if (!existing || !sameEnrollmentScope(existing, identity)) { throw new Error(`Durable Tess session identity conflict: ${identity.sessionId}`); } + // A recovered/re-enrolled runtime can receive a new provider session ID; + // the conversation handle and owner scope remain immutable. + if ( + existing.providerId !== identity.providerId || + existing.runtimeSessionId !== identity.runtimeSessionId + ) { + await this.db + .update(interactionSessions) + .set({ providerId: identity.providerId, runtimeSessionId: identity.runtimeSessionId }) + .where(eq(interactionSessions.id, identity.sessionId)); + } } async snapshot(sessionId: string): Promise { @@ -427,14 +438,12 @@ function matchesCheckpointDigest(stored: string, digest: string): boolean { return stored === digest; } -function sameIdentity(left: DurableSessionIdentity, right: DurableSessionIdentity): boolean { +function sameEnrollmentScope(left: DurableSessionIdentity, right: DurableSessionIdentity): boolean { return ( left.agentName === right.agentName && left.sessionId === right.sessionId && left.tenantId === right.tenantId && - left.ownerId === right.ownerId && - left.providerId === right.providerId && - left.runtimeSessionId === right.runtimeSessionId + left.ownerId === right.ownerId ); } diff --git a/apps/gateway/src/agent/tess-durable-session.service.ts b/apps/gateway/src/agent/tess-durable-session.service.ts index 2dc3619e..340694fa 100644 --- a/apps/gateway/src/agent/tess-durable-session.service.ts +++ b/apps/gateway/src/agent/tess-durable-session.service.ts @@ -1,5 +1,5 @@ import { ForbiddenException, Inject, Injectable } from '@nestjs/common'; -import { DurableSessionCoordinator } from '@mosaicstack/agent'; +import { DurableSessionCoordinator, type DurableSessionIdentity } from '@mosaicstack/agent'; import type { TessProviderOutboxDto } from './tess-durable-session.dto.js'; import { TessDurableSessionRepository } from './tess-durable-session.repository.js'; import { @@ -23,6 +23,20 @@ export class TessDurableSessionService { this.coordinator = new DurableSessionCoordinator(repository); } + /** Enroll a verified runtime session under the stable cross-surface conversation handle. */ + async enroll( + identity: DurableSessionIdentity, + context: RuntimeProviderRequestContext, + ): Promise { + if ( + identity.ownerId !== context.actorScope.userId || + identity.tenantId !== context.actorScope.tenantId + ) { + throw new ForbiddenException('Durable Tess enrollment scope mismatch'); + } + await this.coordinator.create(identity); + } + async queueProviderSend(input: TessProviderOutboxDto): Promise { const snapshot = await this.coordinator.snapshot(input.sessionId); this.assertScope(snapshot.identity.ownerId, snapshot.identity.tenantId, input); diff --git a/apps/gateway/src/chat/chat.gateway.ts b/apps/gateway/src/chat/chat.gateway.ts index f3fd1b59..a7549e4c 100644 --- a/apps/gateway/src/chat/chat.gateway.ts +++ b/apps/gateway/src/chat/chat.gateway.ts @@ -32,7 +32,12 @@ import type { AbortPayload, } from '@mosaicstack/types'; import { AgentService, type ConversationHistoryMessage } from '../agent/agent.service.js'; -import { RuntimeProviderService } from '../agent/runtime-provider-registry.service.js'; +import { + RUNTIME_PROVIDER_AUDIT_SINK, + RuntimeProviderService, + type RuntimeAuditSink, +} from '../agent/runtime-provider-registry.service.js'; +import { TessDurableSessionService } from '../agent/tess-durable-session.service.js'; import { AUTH } from '../auth/auth.tokens.js'; import { scopeFromUser, @@ -134,6 +139,12 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa @Optional() @Inject(RuntimeProviderService) private readonly runtimeRegistry: RuntimeProviderService | null = null, + @Optional() + @Inject(TessDurableSessionService) + private readonly durableSessions: TessDurableSessionService | null = null, + @Optional() + @Inject(RUNTIME_PROVIDER_AUDIT_SINK) + private readonly runtimeAudit: RuntimeAuditSink | null = null, ) {} afterInit(): void { @@ -656,9 +667,16 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa ): Promise { if (!client.data.discordService) return; const ingress = this.resolveDiscordIngress(client, envelope, 'approve'); - const actionParts = ingress?.content.match(/^\/approve\s+([^\s]+)\s+([^\s]+)$/i); + const isApprovalCommand = /^\/approve\s*$/i.test(ingress?.content ?? ''); const tenantId = process.env['DISCORD_SERVICE_TENANT_ID']?.trim(); - if (!ingress || !actionParts || !tenantId || !this.commandAuthorization) return; + if ( + !ingress || + !isApprovalCommand || + !tenantId || + !this.commandAuthorization || + !this.durableSessions + ) + return; const binding = resolveDiscordInteractionBinding( parseDiscordInteractionBindings(process.env['DISCORD_INTERACTION_BINDINGS']), ingress.guildId, @@ -680,22 +698,63 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa }); return; } - const providerId = actionParts[1]!; - const sessionId = actionParts[2]!; + let snapshot; + try { + snapshot = await this.durableSessions.getSnapshot(ingress.conversationId, { + actorScope: { userId: actorId, tenantId }, + channelId: ingress.channelId, + correlationId: ingress.correlationId, + }); + } catch { + client.emit('discord:approval', { + correlationId: ingress.correlationId, + success: false, + approvalId: undefined, + expiresAt: undefined, + }); + return; + } + if (snapshot.identity.agentName !== agentName) { + client.emit('discord:approval', { + correlationId: ingress.correlationId, + success: false, + approvalId: undefined, + expiresAt: undefined, + }); + return; + } const approval = await this.commandAuthorization.createRuntimeTerminationApproval({ - providerId, - sessionId, + providerId: snapshot.identity.providerId, + sessionId: snapshot.identity.runtimeSessionId, actorId, tenantId, channelId: ingress.channelId, correlationId: this.discordRuntimeActionCorrelation( binding.instanceId, ingress, - providerId, - sessionId, + snapshot.identity.providerId, + snapshot.identity.runtimeSessionId, ), agentName, }); + if (!approval) { + await this.runtimeAudit?.record({ + providerId: snapshot.identity.providerId, + operation: 'session.terminate', + outcome: 'denied', + actorId, + tenantId, + channelId: ingress.channelId, + correlationId: this.discordRuntimeActionCorrelation( + binding.instanceId, + ingress, + snapshot.identity.providerId, + snapshot.identity.runtimeSessionId, + ), + resourceId: snapshot.identity.runtimeSessionId, + errorCode: 'policy_denied', + }); + } client.emit('discord:approval', { correlationId: ingress.correlationId, success: approval !== null, @@ -711,9 +770,10 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa ): Promise { if (!client.data.discordService) return; const ingress = this.resolveDiscordIngress(client, envelope, 'stop'); - const actionParts = ingress?.content.match(/^\/stop\s+([^\s]+)\s+([^\s]+)\s+([^\s]+)$/i); + const approvalRef = /^\/stop\s+([^\s]+)$/i.exec(ingress?.content ?? '')?.[1]; const tenantId = process.env['DISCORD_SERVICE_TENANT_ID']?.trim(); - if (!ingress || !actionParts || !tenantId || !this.runtimeRegistry) return; + if (!ingress || !approvalRef || !tenantId || !this.runtimeRegistry || !this.durableSessions) + return; const binding = resolveDiscordInteractionBinding( parseDiscordInteractionBindings(process.env['DISCORD_INTERACTION_BINDINGS']), ingress.guildId, @@ -723,25 +783,31 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa ); const actorId = binding && resolveDiscordInteractionActorId(binding, ingress.userId); if (!actorId) return; - const providerId = actionParts[1]!; - const sessionId = actionParts[2]!; try { + const context = { + actorScope: { userId: actorId, tenantId }, + channelId: ingress.channelId, + correlationId: ingress.correlationId, + }; + const snapshot = await this.durableSessions.getSnapshot(ingress.conversationId, context); + if (snapshot.identity.agentName !== binding.instanceId) throw new Error('agent mismatch'); // RuntimeProviderService consumes the durable approval exactly once using the // provisioned approving-admin identity, never the Discord service account. - await this.runtimeRegistry.terminate(providerId, sessionId, actionParts[3]!, { - actorScope: { - userId: actorId, - tenantId, + await this.runtimeRegistry.terminate( + snapshot.identity.providerId, + snapshot.identity.runtimeSessionId, + approvalRef, + { + ...context, + correlationId: this.discordRuntimeActionCorrelation( + binding.instanceId, + ingress, + snapshot.identity.providerId, + snapshot.identity.runtimeSessionId, + ), }, - channelId: ingress.channelId, - correlationId: this.discordRuntimeActionCorrelation( - binding.instanceId, - ingress, - providerId, - sessionId, - ), - }); + ); client.emit('discord:stop', { correlationId: ingress.correlationId, success: true }); } catch { client.emit('discord:stop', { correlationId: ingress.correlationId, success: false }); diff --git a/apps/gateway/src/plugin/discord-ingress.security.spec.ts b/apps/gateway/src/plugin/discord-ingress.security.spec.ts index 3ceb840a..0c8bb46f 100644 --- a/apps/gateway/src/plugin/discord-ingress.security.spec.ts +++ b/apps/gateway/src/plugin/discord-ingress.security.spec.ts @@ -77,9 +77,17 @@ function discordGateway(role: 'admin' | 'member'): { gateway: ChatGateway; client: { data: { discordService: boolean }; emit: ReturnType }; consumedActions: Array<{ actorId: string; correlationId: string }>; + durable: { getSnapshot: ReturnType }; + audit: { record: ReturnType }; } { const authorization = commandAuthorization(role); const consumedActions: Array<{ actorId: string; correlationId: string }> = []; + const durable = { + getSnapshot: vi.fn().mockResolvedValue({ + identity: { agentName: 'Nova', providerId: 'fleet', runtimeSessionId: 'runtime-1' }, + }), + }; + const audit = { record: vi.fn().mockResolvedValue(undefined) }; const runtimeRegistry = new RuntimeProviderService( { require: () => ({ @@ -105,9 +113,13 @@ function discordGateway(role: 'admin' | 'member'): { {} as never, authorization, runtimeRegistry, + durable as never, + audit as never, ), client: { data: { discordService: true }, emit: vi.fn() }, consumedActions, + durable, + audit, }; } @@ -241,7 +253,7 @@ describe('Discord ingress security', () => { const { gateway, client, consumedActions } = discordGateway('admin'); await gateway.handleDiscordApproval( client as never, - ingressEnvelope('/approve fleet runtime-1', 'approve-message', { + ingressEnvelope('/approve', 'approve-message', { correlationId: 'approval-ingress-correlation', }), ); @@ -255,7 +267,7 @@ describe('Discord ingress security', () => { await gateway.handleDiscordStop( client as never, - ingressEnvelope(`/stop fleet runtime-1 ${approval.approvalId}`, 'stop-message', { + ingressEnvelope(`/stop ${approval.approvalId}`, 'stop-message', { correlationId: 'stop-ingress-correlation', }), ); @@ -271,14 +283,13 @@ describe('Discord ingress security', () => { ]); }); - it('rejects approval when the binding targets a different runtime agent', async () => { + it('audits a Discord mint-side authorization denial', async () => { configureDiscordEnv(); - process.env['MOSAIC_AGENT_NAME'] = 'Other'; - const { gateway, client } = discordGateway('admin'); + const { gateway, client, audit } = discordGateway('member'); await gateway.handleDiscordApproval( client as never, - ingressEnvelope('/approve fleet runtime-1', 'mismatched-agent-approve'), + ingressEnvelope('/approve', 'denied-approve'), ); expect(client.emit).toHaveBeenCalledWith('discord:approval', { @@ -287,14 +298,57 @@ describe('Discord ingress security', () => { approvalId: undefined, expiresAt: undefined, }); + expect(audit.record).toHaveBeenCalledWith( + expect.objectContaining({ + outcome: 'denied', + operation: 'session.terminate', + errorCode: 'policy_denied', + }), + ); }); + it.each([ + [ + 'binding', + () => { + process.env['MOSAIC_AGENT_NAME'] = 'Other'; + }, + ], + [ + 'durable session', + (durable: { getSnapshot: ReturnType }) => { + durable.getSnapshot.mockResolvedValueOnce({ + identity: { agentName: 'Other', providerId: 'fleet', runtimeSessionId: 'runtime-1' }, + }); + }, + ], + ])( + 'rejects approval when the %s targets a different runtime agent', + async (_source, configure) => { + configureDiscordEnv(); + const { gateway, client, durable } = discordGateway('admin'); + configure(durable); + + await gateway.handleDiscordApproval( + client as never, + ingressEnvelope('/approve', 'mismatched-agent-approve'), + ); + + expect(client.emit).toHaveBeenCalledWith('discord:approval', { + correlationId: 'correlation-001', + success: false, + approvalId: undefined, + expiresAt: undefined, + }); + }, + ); + it('rejects unpaired and non-admin Discord users for approval and stop', async () => { configureDiscordEnv(); const { gateway, client } = discordGateway('member'); await gateway.handleDiscordApproval( client as never, - ingressEnvelope('/approve fleet runtime-1', 'member-approve'), + ingressEnvelope('/approve', 'member-approve'), ); expect(client.emit).toHaveBeenCalledWith('discord:approval', { correlationId: 'correlation-001', @@ -306,7 +360,7 @@ describe('Discord ingress security', () => { process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([]); await gateway.handleDiscordStop( client as never, - ingressEnvelope('/stop fleet runtime-1 forged', 'unpaired-stop'), + ingressEnvelope('/stop forged', 'unpaired-stop'), ); expect(client.emit).not.toHaveBeenCalledWith('discord:stop', expect.anything()); }); @@ -316,7 +370,7 @@ describe('Discord ingress security', () => { const { gateway, client } = discordGateway('admin'); await gateway.handleDiscordApproval( client as never, - ingressEnvelope('/approve fleet runtime-1', 'replay-approve', { + ingressEnvelope('/approve', 'replay-approve', { correlationId: 'replay-approval-correlation', }), ); @@ -327,13 +381,13 @@ describe('Discord ingress security', () => { }; await gateway.handleDiscordStop( client as never, - ingressEnvelope(`/stop fleet runtime-1 ${approval.approvalId}`, 'replay-stop-one', { + ingressEnvelope(`/stop ${approval.approvalId}`, 'replay-stop-one', { correlationId: 'replay-stop-correlation-one', }), ); await gateway.handleDiscordStop( client as never, - ingressEnvelope(`/stop fleet runtime-1 ${approval.approvalId}`, 'replay-stop-two', { + ingressEnvelope(`/stop ${approval.approvalId}`, 'replay-stop-two', { correlationId: 'replay-stop-correlation-two', }), ); diff --git a/docs/scratchpads/tess-20260712.md b/docs/scratchpads/tess-20260712.md index 819d5c5d..5f21ca9f 100644 --- a/docs/scratchpads/tess-20260712.md +++ b/docs/scratchpads/tess-20260712.md @@ -56,3 +56,11 @@ **Final focused review:** PASS. Deterministic audit validated all task repository roots with zero missing paths; no planning placeholders remained; security prerequisites still gate Tess exposure; observability traceability is explicit. **Current gate:** planning PR must merge to `main` with terminal-green CI before any source-code worker starts. + +## 2026-07-13 — M3 cross-surface delivery + +**Branch:** `feat/tess-m3-integration` from `main` at `84d884b9`. + +**Delivered:** Stable Discord `conversationId` enrollment after a visible provider/runtime session is known; idempotent provider-session rebinding that preserves agent/tenant/owner scope; Discord approval/stop target resolution through the durable snapshot; SSE runtime streaming after CLI attach; denial/audit parity including provider authorization denials and HTTP 403 approval-denial mapping. + +**Evidence:** Gateway targeted suite: 37 tests passed; Mosaic CLI interaction test passed; agent durable-session test passed; gateway and CLI typechecks passed; changed-file format and whitespace checks passed. Codex security review found no confident vulnerability. Code review identified a Fastify exception-response mismatch and two UX/acknowledgement issues; all were corrected before the final validation run. diff --git a/packages/agent/src/tess-durable-session.test.ts b/packages/agent/src/tess-durable-session.test.ts index 64e5ca85..3cadaf43 100644 --- a/packages/agent/src/tess-durable-session.test.ts +++ b/packages/agent/src/tess-durable-session.test.ts @@ -64,6 +64,23 @@ describe('DurableSessionCoordinator', () => { expect(recovered.handoffs).toMatchObject([{ handoffId: 'handoff-1', status: 'pending' }]); }); + it('rebinds a recovered runtime while preserving the immutable conversation owner scope', async () => { + const coordinator = new DurableSessionCoordinator(new InMemoryDurableSessionStore()); + await coordinator.create(IDENTITY); + await coordinator.create({ + ...IDENTITY, + providerId: 'fleet-next', + runtimeSessionId: 'nova-next', + }); + + await expect(coordinator.snapshot(IDENTITY.sessionId)).resolves.toMatchObject({ + identity: { ...IDENTITY, providerId: 'fleet-next', runtimeSessionId: 'nova-next' }, + }); + await expect(coordinator.create({ ...IDENTITY, ownerId: 'other-owner' })).rejects.toThrow( + /identity conflict/, + ); + }); + it('deduplicates duplicate ingress and never reprocesses an inbox record after restart or compaction', async () => { const store = new InMemoryDurableSessionStore(); const firstProcess = new DurableSessionCoordinator(store); diff --git a/packages/agent/src/tess-durable-session.ts b/packages/agent/src/tess-durable-session.ts index 039fe069..adc84c6a 100644 --- a/packages/agent/src/tess-durable-session.ts +++ b/packages/agent/src/tess-durable-session.ts @@ -256,9 +256,11 @@ export class InMemoryDurableSessionStore implements DurableSessionStore { async create(identity: DurableSessionIdentity): Promise { const existing = this.sessions.get(identity.sessionId); if (existing) { - if (!identitiesEqual(existing.identity, identity)) { + if (!sameEnrollmentScope(existing.identity, identity)) { throw new Error(`Durable Tess session identity conflict: ${identity.sessionId}`); } + existing.identity.providerId = identity.providerId; + existing.identity.runtimeSessionId = identity.runtimeSessionId; return; } this.sessions.set(identity.sessionId, { @@ -416,14 +418,12 @@ export class InMemoryDurableSessionStore implements DurableSessionStore { } } -function identitiesEqual(left: DurableSessionIdentity, right: DurableSessionIdentity): boolean { +function sameEnrollmentScope(left: DurableSessionIdentity, right: DurableSessionIdentity): boolean { return ( left.agentName === right.agentName && left.sessionId === right.sessionId && left.tenantId === right.tenantId && - left.ownerId === right.ownerId && - left.providerId === right.providerId && - left.runtimeSessionId === right.runtimeSessionId + left.ownerId === right.ownerId ); } diff --git a/packages/mosaic/src/commands/interaction.test.ts b/packages/mosaic/src/commands/interaction.test.ts index 79cd774e..856a3750 100644 --- a/packages/mosaic/src/commands/interaction.test.ts +++ b/packages/mosaic/src/commands/interaction.test.ts @@ -10,6 +10,7 @@ describe('generic interaction CLI', (): void => { expect(command.commands.map((item) => item.name()).sort()).toEqual([ 'attach', 'chat', + 'enroll', 'health', 'recover', 'send', diff --git a/packages/mosaic/src/commands/interaction.ts b/packages/mosaic/src/commands/interaction.ts index 8b53a70f..2e543d0f 100644 --- a/packages/mosaic/src/commands/interaction.ts +++ b/packages/mosaic/src/commands/interaction.ts @@ -3,6 +3,7 @@ import type { Command } from 'commander'; import { withAuth } from './with-auth.js'; import { attachInteractionSession, + enrollInteractionSession, fetchInteractionHealth, fetchInteractionSessions, fetchInteractionStatus, @@ -10,6 +11,7 @@ import { recoverInteractionSession, sendInteractionMessage, stopInteractionSession, + streamInteractionSession, } from '../tui/gateway-api.js'; interface InteractionOptions { @@ -103,18 +105,48 @@ export function registerInteractionCommand(program: Command): Command { }); options( - command.command('attach ').description('Create a scoped read or write attachment'), + command + .command('enroll ') + .description('Bind an authorized runtime session to a durable conversation handle'), + ).action( + async ( + sessionId: string, + providerId: string, + runtimeSessionId: string, + opts: InteractionOptions, + ) => { + const request = await authRequest(opts); + print( + await enrollInteractionSession(request.gateway, request.cookie, { + ...request, + sessionId, + providerId, + runtimeSessionId, + }), + ); + }, + ); + + options( + command + .command('attach ') + .description('Create a scoped read or write attachment and stream the session'), ) .option('--control', 'Request control mode (provider policy may deny it)') .action(async (sessionId: string, opts: InteractionOptions & { control?: boolean }) => { const request = await authRequest(opts); - print( - await attachInteractionSession(request.gateway, request.cookie, { - ...request, - sessionId, - mode: opts.control ? 'control' : 'read', - }), - ); + const attachment = await attachInteractionSession(request.gateway, request.cookie, { + ...request, + sessionId, + mode: opts.control ? 'control' : 'read', + }); + print(attachment); + for await (const event of streamInteractionSession(request.gateway, request.cookie, { + ...request, + sessionId, + })) { + print(event); + } }); const send = options( diff --git a/packages/mosaic/src/tui/gateway-api.ts b/packages/mosaic/src/tui/gateway-api.ts index d5ad4dd6..7e169bdb 100644 --- a/packages/mosaic/src/tui/gateway-api.ts +++ b/packages/mosaic/src/tui/gateway-api.ts @@ -406,6 +406,32 @@ export async function fetchInteractionTree( return handleResponse(res, 'Failed to get interaction session tree'); } +export async function enrollInteractionSession( + gatewayUrl: string, + sessionCookie: string, + request: InteractionRequest & { + sessionId: string; + providerId: string; + runtimeSessionId: string; + }, +): Promise<{ status: string; sessionId: string }> { + const res = await fetch( + `${gatewayUrl}${interactionPath(request.agentName, `/sessions/${encodeURIComponent(request.sessionId)}/enroll`)}`, + { + method: 'POST', + headers: interactionHeaders(sessionCookie, gatewayUrl, request.correlationId), + body: JSON.stringify({ + providerId: request.providerId, + runtimeSessionId: request.runtimeSessionId, + }), + }, + ); + return handleResponse<{ status: string; sessionId: string }>( + res, + 'Failed to enroll interaction session', + ); +} + export async function attachInteractionSession( gatewayUrl: string, sessionCookie: string, @@ -422,6 +448,49 @@ export async function attachInteractionSession( return handleResponse(res, 'Failed to attach interaction session'); } +export async function* streamInteractionSession( + gatewayUrl: string, + sessionCookie: string, + request: InteractionRequest & { sessionId: string; cursor?: string }, +): AsyncIterable { + const params = request.cursor?.trim() + ? `?${new URLSearchParams({ cursor: request.cursor.trim() }).toString()}` + : ''; + const res = await fetch( + `${gatewayUrl}${interactionPath(request.agentName, `/sessions/${encodeURIComponent(request.sessionId)}/stream${params}`)}`, + { headers: interactionHeaders(sessionCookie, gatewayUrl, request.correlationId) }, + ); + if (!res.ok || !res.body) { + const body = await res.text().catch(() => ''); + throw new Error(`Failed to stream interaction session (${res.status}): ${body}`); + } + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let pending = ''; + try { + for (;;) { + const chunk = await reader.read(); + if (chunk.done) break; + pending += decoder.decode(chunk.value, { stream: true }); + for (;;) { + const separator = pending.indexOf('\n\n'); + if (separator < 0) break; + const frame = pending.slice(0, separator); + pending = pending.slice(separator + 2); + const data = frame + .split('\n') + .find((line: string): boolean => line.startsWith('data:')) + ?.slice('data:'.length) + .trim(); + if (data) yield JSON.parse(data) as unknown; + } + } + } finally { + reader.releaseLock(); + } +} + export async function sendInteractionMessage( gatewayUrl: string, sessionCookie: string, From f1c6b37b463687c8a970b4b21a885b06144eab9f Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Mon, 13 Jul 2026 10:29:10 +0000 Subject: [PATCH 025/152] fix(tess): route bare Discord approvals (#733) --- .../tess-cross-surface.integration.test.ts | 51 +++++++++++++++---- plugins/discord/src/index.ts | 2 +- 2 files changed, 43 insertions(+), 10 deletions(-) diff --git a/apps/gateway/src/__tests__/integration/tess-cross-surface.integration.test.ts b/apps/gateway/src/__tests__/integration/tess-cross-surface.integration.test.ts index ae43c9af..7bb11188 100644 --- a/apps/gateway/src/__tests__/integration/tess-cross-surface.integration.test.ts +++ b/apps/gateway/src/__tests__/integration/tess-cross-surface.integration.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { InMemoryDurableSessionStore } from '@mosaicstack/agent'; import { createDiscordIngressEnvelope, + DiscordPlugin, type DiscordIngressPayload, } from '@mosaicstack/discord-plugin'; import { InteractionController } from '../../agent/interaction.controller.js'; @@ -30,7 +31,7 @@ function payload(content: string, messageId: string, correlationId: string): Dis guildId: 'guild-1', channelId: 'channel-1', userId: 'discord-admin-1', - conversationId: 'conversation-1', + conversationId: 'Nova:discord:channel-1', }; } @@ -89,7 +90,7 @@ describe('Tess Discord/CLI durable-session integration', () => { const controller = new InteractionController(enrollmentRuntime as never, durable); await controller.enroll( 'Nova', - 'conversation-1', + 'Nova:discord:channel-1', { providerId: 'fleet', runtimeSessionId: 'runtime-1' }, { id: 'mosaic-admin-1', tenantId: 'tenant-1' }, 'cli-enrollment-correlation', @@ -122,14 +123,46 @@ describe('Tess Discord/CLI durable-session integration', () => { durable, ); const client = { data: { discordService: true }, emit: vi.fn() }; + const plugin = new DiscordPlugin({ + token: 'unused', + gatewayUrl: 'http://unused', + serviceToken: SERVICE_TOKEN, + allowedGuildIds: ['guild-1'], + allowedChannelIds: ['channel-1'], + allowedUserIds: ['discord-admin-1'], + interactionBindings: [ + { + instanceId: 'Nova', + guildId: 'guild-1', + channelId: 'channel-1', + pairedUsers: { + 'discord-admin-1': { role: 'admin', mosaicUserId: 'mosaic-admin-1' }, + }, + }, + ], + }); + const pluginInternals = plugin as unknown as { + client: { user: { id: string } }; + socket: { connected: boolean; emit: ReturnType }; + handleDiscordMessage(message: unknown): void; + }; + const pluginSocket = { connected: true, emit: vi.fn() }; + pluginInternals.client = { user: { id: 'bot-1' } }; + pluginInternals.socket = pluginSocket; + pluginInternals.handleDiscordMessage({ + id: 'approve-1', + guildId: 'guild-1', + channelId: 'channel-1', + author: { id: 'discord-admin-1', bot: false }, + mentions: { has: () => true }, + content: '<@bot-1> /approve', + channel: { parentId: null }, + attachments: new Map(), + }); - await gateway.handleDiscordApproval( - client as never, - createDiscordIngressEnvelope( - payload('/approve', 'approve-1', 'discord-approve-correlation'), - SERVICE_TOKEN, - ), - ); + expect(pluginSocket.emit).toHaveBeenCalledWith('discord:approve', expect.any(Object)); + const approvalEnvelope = pluginSocket.emit.mock.calls[0]?.[1]; + await gateway.handleDiscordApproval(client as never, approvalEnvelope); const approval = client.emit.mock.calls.find( ([event]) => event === 'discord:approval', )?.[1] as { diff --git a/plugins/discord/src/index.ts b/plugins/discord/src/index.ts index d111e879..15d4997a 100644 --- a/plugins/discord/src/index.ts +++ b/plugins/discord/src/index.ts @@ -382,7 +382,7 @@ export class DiscordPlugin { this.config.serviceToken, ); this.socket.emit( - content.startsWith('/approve ') + /^\/approve$/i.test(content) ? 'discord:approve' : content.startsWith('/stop ') ? 'discord:stop' From 9e5b9188cead322607487bcf998710e7723b0610 Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Mon, 13 Jul 2026 11:29:27 +0000 Subject: [PATCH 026/152] feat(agent): add transitional Hermes runtime adapter (#734) --- docs/tess/hermes-runtime-adapter-design.md | 19 ++ .../agent/src/hermes-runtime-provider.test.ts | 55 ++++++ packages/agent/src/hermes-runtime-provider.ts | 180 ++++++++++++++++++ packages/agent/src/index.ts | 1 + 4 files changed, 255 insertions(+) create mode 100644 docs/tess/hermes-runtime-adapter-design.md create mode 100644 packages/agent/src/hermes-runtime-provider.test.ts create mode 100644 packages/agent/src/hermes-runtime-provider.ts diff --git a/docs/tess/hermes-runtime-adapter-design.md b/docs/tess/hermes-runtime-adapter-design.md new file mode 100644 index 00000000..4623c258 --- /dev/null +++ b/docs/tess/hermes-runtime-adapter-design.md @@ -0,0 +1,19 @@ +# TESS-HRM-001 — Hermes runtime adapter boundary + +## Normalized provider surface + +`HermesRuntimeProvider` implements the existing Mosaic-owned `AgentRuntimeProvider` unchanged. Its public surface is therefore `capabilities`, `health`, session list/tree, stream, send, attach/detach, and terminate, accepting only `RuntimeScope`, `RuntimeMessage`, `RuntimeSession`, `RuntimeStreamEvent`, and other types from `@mosaicstack/types`. Provider id is `runtime.hermes`. + +The provider receives a narrow injected `HermesRuntimeTransport`, whose method names and inputs may represent Hermes API operations but whose return values are explicitly private `HermesLegacy*` types defined only in `packages/agent/src/hermes-runtime-provider.ts`. Mapping functions convert those private values to Mosaic sessions, state, hierarchy, and stream events. Capability negotiation maps a supplied Hermes feature inventory onto the fixed Mosaic runtime capability vocabulary; no unknown/ambiguous legacy feature is advertised. Unsupported Mosaic operations throw the typed fail-closed `capability_unsupported` provider error before a transport call. + +## Boundary line + +**Hermes legacy schema ends at `HermesRuntimeTransport` and its private adapter-local `HermesLegacy*` definitions in `packages/agent`.** `packages/types` is never changed to contain a Hermes field, enum, identifier, session shape, status, or capability. `apps/gateway` registers/resolves the provider only through `AgentRuntimeProvider` and receives normalized values only. Identity remains server-derived `RuntimeScope` data and is passed to the injected transport as context, never reconstructed from a legacy response. + +## Initial mapping and safety posture + +- Hermes conversation/thread identifiers map to opaque Mosaic `RuntimeSession.id`; parent linkage maps only when a known parent exists. +- Hermes status strings map through a closed lookup to `RuntimeSessionState`; unknown statuses become `failed`, never a permissive active state. +- Legacy stream chunks map to `message.delta` / `message.complete`; malformed or unsupported events become a normalized `runtime.error` event. +- Send, attach, and terminate require the normalized capability first. `terminate` continues to be approval-bound by the gateway service; the adapter does not weaken gateway authority. +- Kanban, skills, memory, tools, and cron are capability-inventory entries for this transitional adapter, not additions to the core runtime contract. They are reported as explicitly unsupported until a Mosaic-owned capability contract exists. diff --git a/packages/agent/src/hermes-runtime-provider.test.ts b/packages/agent/src/hermes-runtime-provider.test.ts new file mode 100644 index 00000000..e94b8229 --- /dev/null +++ b/packages/agent/src/hermes-runtime-provider.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { RuntimeScope } from '@mosaicstack/types'; +import { HermesRuntimeProvider, type HermesRuntimeTransport } from './hermes-runtime-provider.js'; + +const scope: RuntimeScope = { actorId: 'a', tenantId: 't', channelId: 'c', correlationId: 'r' }; +const transport = (capabilities = ['session.list', 'session.tree']): HermesRuntimeTransport => ({ + capabilities: vi.fn(async () => capabilities), + health: vi.fn(async () => ({ status: 'healthy' })), + sessions: vi.fn(async () => [ + { + conversation_id: 'child', + agent_id: 'hermes-a', + parent_conversation_id: 'parent', + status: 'running', + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + }, + { + conversation_id: 'parent', + agent_id: 'hermes-a', + status: 'unknown', + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + }, + ]), + stream: async function* () {}, + send: vi.fn(), + attach: vi.fn(), + detach: vi.fn(), + terminate: vi.fn(), +}); +describe('HermesRuntimeProvider normalization boundary', () => { + it('normalizes legacy sessions without exposing legacy fields', async () => { + const provider = new HermesRuntimeProvider(transport()); + await expect(provider.listSessions(scope)).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: 'child', runtimeId: 'hermes-a', state: 'active' }), + ]), + ); + const result = await provider.listSessions(scope); + expect(result[0]).not.toHaveProperty('conversation_id'); + }); + it('forms normalized hierarchy and fails closed for unbridged operations', async () => { + const provider = new HermesRuntimeProvider(transport()); + await expect(provider.getSessionTree(scope)).resolves.toEqual([ + expect.objectContaining({ + session: expect.objectContaining({ id: 'parent', state: 'failed' }), + children: [expect.objectContaining({ session: expect.objectContaining({ id: 'child' }) })], + }), + ]); + await expect( + provider.sendMessage('parent', { content: 'x', idempotencyKey: 'i' }, scope), + ).rejects.toMatchObject({ code: 'capability_unsupported' }); + }); +}); diff --git a/packages/agent/src/hermes-runtime-provider.ts b/packages/agent/src/hermes-runtime-provider.ts new file mode 100644 index 00000000..90c7df93 --- /dev/null +++ b/packages/agent/src/hermes-runtime-provider.ts @@ -0,0 +1,180 @@ +import type { + AgentRuntimeProvider, + RuntimeAttachHandle, + RuntimeAttachMode, + RuntimeCapability, + RuntimeCapabilitySet, + RuntimeHealth, + RuntimeMessage, + RuntimeScope, + RuntimeSession, + RuntimeSessionState, + RuntimeSessionTree, + RuntimeStreamEvent, +} from '@mosaicstack/types'; + +const HERMES_PROVIDER_ID = 'runtime.hermes'; +const RUNTIME_CAPABILITIES: readonly RuntimeCapability[] = [ + 'session.list', + 'session.tree', + 'session.stream', + 'session.send', + 'session.attach', + 'session.terminate', +]; + +/** Legacy transport boundary. These shapes are intentionally adapter-local. */ +export interface HermesLegacySession { + conversation_id: string; + agent_id: string; + parent_conversation_id?: string; + status: string; + created_at: string; + updated_at: string; +} +export interface HermesRuntimeTransport { + capabilities(scope: RuntimeScope): Promise; + health(scope: RuntimeScope): Promise<{ status: string; detail?: string }>; + sessions(scope: RuntimeScope): Promise; + stream( + sessionId: string, + cursor: string | undefined, + scope: RuntimeScope, + ): AsyncIterable; + send(sessionId: string, message: RuntimeMessage, scope: RuntimeScope): Promise; + attach( + sessionId: string, + mode: RuntimeAttachMode, + scope: RuntimeScope, + ): Promise; + detach(attachmentId: string, scope: RuntimeScope): Promise; + terminate(sessionId: string, approvalRef: string, scope: RuntimeScope): Promise; +} + +export class HermesRuntimeProviderError extends Error { + constructor( + readonly code: 'capability_unsupported' | 'invalid_request', + message: string, + ) { + super(message); + this.name = HermesRuntimeProviderError.name; + } +} + +/** + * Transitional Hermes adapter. Legacy identifiers and schemas do not cross this + * boundary: callers only observe Mosaic AgentRuntimeProvider contracts. + */ +export class HermesRuntimeProvider implements AgentRuntimeProvider { + readonly id = HERMES_PROVIDER_ID; + + constructor(private readonly transport: HermesRuntimeTransport) {} + + async capabilities(scope: RuntimeScope): Promise { + const legacyCapabilities = await this.transport.capabilities(scope); + return { + supported: RUNTIME_CAPABILITIES.filter((capability) => + legacyCapabilities.includes(capability), + ), + }; + } + + async health(scope: RuntimeScope): Promise { + const health = await this.transport.health(scope); + return { + status: health.status === 'healthy' || health.status === 'degraded' ? health.status : 'down', + checkedAt: new Date().toISOString(), + ...(health.detail ? { detail: health.detail } : {}), + }; + } + + async listSessions(scope: RuntimeScope): Promise { + await this.requireCapability('session.list', scope); + return (await this.transport.sessions(scope)).map((session) => this.session(session)); + } + + async getSessionTree(scope: RuntimeScope): Promise { + await this.requireCapability('session.tree', scope); + const sessions = (await this.transport.sessions(scope)).map((session) => this.session(session)); + const nodes = new Map( + sessions.map((session) => [session.id, { session, children: [] }]), + ); + const roots: RuntimeSessionTree[] = []; + for (const session of sessions) { + const node = nodes.get(session.id)!; + const parent = session.parentSessionId ? nodes.get(session.parentSessionId) : undefined; + if (parent) parent.children.push(node); + else roots.push(node); + } + return roots; + } + + async *streamSession( + sessionId: string, + cursor: string | undefined, + scope: RuntimeScope, + ): AsyncIterable { + await this.requireCapability('session.stream', scope); + yield* this.transport.stream(sessionId, cursor, scope); + } + async sendMessage( + sessionId: string, + message: RuntimeMessage, + scope: RuntimeScope, + ): Promise { + await this.requireCapability('session.send', scope); + if (!message.content.trim()) + throw new HermesRuntimeProviderError('invalid_request', 'Message content is required'); + await this.transport.send(sessionId, message, scope); + } + async attach( + sessionId: string, + mode: RuntimeAttachMode, + scope: RuntimeScope, + ): Promise { + await this.requireCapability('session.attach', scope); + return this.transport.attach(sessionId, mode, scope); + } + async detach(attachmentId: string, scope: RuntimeScope): Promise { + await this.transport.detach(attachmentId, scope); + } + async terminate(sessionId: string, approvalRef: string, scope: RuntimeScope): Promise { + await this.requireCapability('session.terminate', scope); + if (!approvalRef.trim()) + throw new HermesRuntimeProviderError('invalid_request', 'Termination approval is required'); + await this.transport.terminate(sessionId, approvalRef, scope); + } + + private async requireCapability( + capability: RuntimeCapability, + scope: RuntimeScope, + ): Promise { + if (!(await this.capabilities(scope)).supported.includes(capability)) { + throw new HermesRuntimeProviderError( + 'capability_unsupported', + `Hermes does not bridge ${capability}`, + ); + } + } + private session(value: HermesLegacySession): RuntimeSession { + return { + id: value.conversation_id, + providerId: this.id, + runtimeId: value.agent_id, + ...(value.parent_conversation_id ? { parentSessionId: value.parent_conversation_id } : {}), + state: state(value.status), + createdAt: value.created_at, + updatedAt: value.updated_at, + }; + } +} +function state(value: string): RuntimeSessionState { + return ( + ( + { running: 'active', waiting: 'idle', starting: 'starting', stopped: 'stopped' } as Record< + string, + RuntimeSessionState + > + )[value] ?? 'failed' + ); +} diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 68d9d14f..f7b15690 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -2,4 +2,5 @@ export const VERSION = '0.0.0'; export * from './runtime-provider-registry.js'; export * from './tmux-fleet-runtime-provider.js'; +export * from './hermes-runtime-provider.js'; export * from './tess-durable-session.js'; From 76325ca3f2cd654dfc15a8d54c503a0d6292307d Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Mon, 13 Jul 2026 11:59:16 +0000 Subject: [PATCH 027/152] feat(tess): add Mos coordination boundary (#735) --- apps/gateway/src/coord/coord.module.ts | 24 +- .../gateway/src/coord/mos-coordination.dto.ts | 25 ++ .../coord/mos-coordination.service.test.ts | 217 +++++++++++++ .../src/coord/mos-coordination.service.ts | 300 ++++++++++++++++++ .../tess-m4-001-mos-coordination.md | 46 +++ docs/tess/ARCHITECTURE.md | 15 + docs/tess/MOS-COORDINATION.md | 83 +++++ docs/tess/VERIFICATION-MATRIX.md | 26 +- .../src/__tests__/mos-coordination.test.ts | 154 +++++++++ .../src/in-memory-mos-coordination-port.ts | 208 ++++++++++++ packages/coord/src/index.ts | 17 + packages/coord/src/mos-coordination.ts | 207 ++++++++++++ 12 files changed, 1307 insertions(+), 15 deletions(-) create mode 100644 apps/gateway/src/coord/mos-coordination.dto.ts create mode 100644 apps/gateway/src/coord/mos-coordination.service.test.ts create mode 100644 apps/gateway/src/coord/mos-coordination.service.ts create mode 100644 docs/scratchpads/tess-m4-001-mos-coordination.md create mode 100644 docs/tess/MOS-COORDINATION.md create mode 100644 packages/coord/src/__tests__/mos-coordination.test.ts create mode 100644 packages/coord/src/in-memory-mos-coordination-port.ts create mode 100644 packages/coord/src/mos-coordination.ts diff --git a/apps/gateway/src/coord/coord.module.ts b/apps/gateway/src/coord/coord.module.ts index d2f46e32..774cf134 100644 --- a/apps/gateway/src/coord/coord.module.ts +++ b/apps/gateway/src/coord/coord.module.ts @@ -1,10 +1,30 @@ import { Module } from '@nestjs/common'; +import { InMemoryMosCoordinationPort } from '@mosaicstack/coord'; import { CoordService } from './coord.service.js'; import { CoordController } from './coord.controller.js'; +import { + MOS_COORDINATION_CONFIG, + MOS_COORDINATION_PORT, + MosCoordinationService, +} from './mos-coordination.service.js'; @Module({ - providers: [CoordService], + providers: [ + CoordService, + { + provide: MOS_COORDINATION_PORT, + useFactory: (): InMemoryMosCoordinationPort => new InMemoryMosCoordinationPort(), + }, + { + provide: MOS_COORDINATION_CONFIG, + useFactory: () => ({ + interactionAgentId: process.env['MOSAIC_AGENT_NAME'], + orchestrationAgentId: process.env['MOSAIC_ORCHESTRATOR_AGENT_NAME'], + }), + }, + MosCoordinationService, + ], controllers: [CoordController], - exports: [CoordService], + exports: [CoordService, MosCoordinationService], }) export class CoordModule {} diff --git a/apps/gateway/src/coord/mos-coordination.dto.ts b/apps/gateway/src/coord/mos-coordination.dto.ts new file mode 100644 index 00000000..7967753a --- /dev/null +++ b/apps/gateway/src/coord/mos-coordination.dto.ts @@ -0,0 +1,25 @@ +import type { + CoordinationObservation, + CoordinationResult, + MosHandoffReceipt, +} from '@mosaicstack/coord'; + +/** Input accepted at the gateway coordination boundary. Agent identity is not caller-controlled. */ +export interface CreateMosHandoffDto { + idempotencyKey: string; + summary: string; + context?: string; + missionId?: string; +} + +export interface MosCoordinationResponseDto { + receipt: MosHandoffReceipt; +} + +export interface MosCoordinationObservationDto { + observation: CoordinationObservation; +} + +export interface MosCoordinationResultDto { + result: CoordinationResult; +} diff --git a/apps/gateway/src/coord/mos-coordination.service.test.ts b/apps/gateway/src/coord/mos-coordination.service.test.ts new file mode 100644 index 00000000..51b2a26b --- /dev/null +++ b/apps/gateway/src/coord/mos-coordination.service.test.ts @@ -0,0 +1,217 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + InMemoryMosCoordinationPort, + type MosCoordinationPort, + type MosHandoff, +} from '@mosaicstack/coord'; +import type { RuntimeProviderRequestContext } from '../agent/runtime-provider-registry.service.js'; +import { + MosCoordinationService, + type MosCoordinationConfig, + type MosCoordinationGatewayError, +} from './mos-coordination.service.js'; + +const context: RuntimeProviderRequestContext = { + actorScope: { userId: 'operator-1', tenantId: 'tenant-a' }, + channelId: 'cli', + correlationId: 'corr-1', +}; + +const config: MosCoordinationConfig = { + interactionAgentId: 'Nova', + orchestrationAgentId: 'Conductor', +}; + +function service( + port: MosCoordinationPort = new InMemoryMosCoordinationPort(), + options: { + config?: MosCoordinationConfig; + handoffIdFactory?: () => string; + } = {}, +): MosCoordinationService { + return new MosCoordinationService( + port, + options.config ?? config, + options.handoffIdFactory ?? (() => 'handoff-1'), + ); +} + +describe('MosCoordinationService authority boundary', (): void => { + it('derives identity and actor/tenant scope server-side, then round-trips the native adapter', async (): Promise => { + const adapter = new InMemoryMosCoordinationPort(); + const coordination = service(adapter); + + await expect( + coordination.handoff( + { idempotencyKey: 'request-1', summary: 'Implement the requested feature' }, + context, + ), + ).resolves.toEqual({ + handoffId: 'handoff-1', + targetAgentId: 'Conductor', + status: 'queued', + correlationId: 'corr-1', + }); + + adapter.recordActivity('handoff-1', 'running', 'Mos accepted the request'); + adapter.recordResult('handoff-1', 'completed', 'Merged by Mos'); + + const followUpContext = { ...context, correlationId: 'corr-2' }; + await expect(coordination.observe('handoff-1', followUpContext)).resolves.toMatchObject({ + targetAgentId: 'Conductor', + status: 'completed', + }); + await expect(coordination.result('handoff-1', followUpContext)).resolves.toMatchObject({ + targetAgentId: 'Conductor', + status: 'completed', + summary: 'Merged by Mos', + }); + }); + + it('fails closed without calling a port when the interaction requester is unconfigured', async (): Promise => { + const adapter = new InMemoryMosCoordinationPort(); + const handoff = vi.spyOn(adapter, 'handoff'); + const coordination = service(adapter, { + config: { interactionAgentId: '', orchestrationAgentId: 'Conductor' }, + }); + + await expect( + coordination.handoff( + { idempotencyKey: 'request-1', summary: 'Implement the requested feature' }, + context, + ), + ).rejects.toMatchObject({ + code: 'unconfigured_requester', + } satisfies Partial); + expect(handoff).not.toHaveBeenCalled(); + }); + + it('rejects self-delegation configuration before delivering work', async (): Promise => { + const adapter = new InMemoryMosCoordinationPort(); + const handoff = vi.spyOn(adapter, 'handoff'); + const coordination = service(adapter, { + config: { interactionAgentId: 'Nova', orchestrationAgentId: 'Nova' }, + }); + + await expect( + coordination.handoff( + { idempotencyKey: 'request-1', summary: 'Implement the requested feature' }, + context, + ), + ).rejects.toThrow('Interaction and orchestration identities must differ'); + expect(handoff).not.toHaveBeenCalled(); + }); + + it('denies cross-tenant observe and result before calling the adapter', async (): Promise => { + const adapter = new InMemoryMosCoordinationPort(); + const observe = vi.spyOn(adapter, 'observe'); + const result = vi.spyOn(adapter, 'result'); + const coordination = service(adapter); + await coordination.handoff( + { idempotencyKey: 'request-1', summary: 'Implement the requested feature' }, + context, + ); + + const otherTenant = { + ...context, + actorScope: { ...context.actorScope, tenantId: 'tenant-b' }, + }; + await expect(coordination.observe('handoff-1', otherTenant)).rejects.toMatchObject({ + code: 'cross_tenant_forbidden', + } satisfies Partial); + await expect(coordination.result('handoff-1', otherTenant)).rejects.toMatchObject({ + code: 'cross_tenant_forbidden', + } satisfies Partial); + expect(observe).not.toHaveBeenCalled(); + expect(result).not.toHaveBeenCalled(); + }); + + it('scopes idempotency by actor and joins concurrent retries without duplicate delivery', async (): Promise => { + let handoffSequence = 0; + let release: (() => void) | undefined; + const delivered = new Promise((resolve: () => void): void => { + release = resolve; + }); + const adapter: MosCoordinationPort = { + handoff: vi.fn(async (handoff: MosHandoff) => { + await delivered; + return { + handoffId: handoff.handoffId, + targetAgentId: handoff.targetAgentId, + status: 'queued' as const, + correlationId: handoff.scope.correlationId, + }; + }), + observe: vi.fn(), + result: vi.fn(), + }; + const coordination = service(adapter, { + handoffIdFactory: (): string => `handoff-${++handoffSequence}`, + }); + const request = { idempotencyKey: 'request-1', summary: 'Implement the requested feature' }; + + const first = coordination.handoff(request, context); + const retry = coordination.handoff(request, context); + expect(adapter.handoff).toHaveBeenCalledTimes(1); + release?.(); + await expect(Promise.all([first, retry])).resolves.toEqual([ + expect.objectContaining({ handoffId: 'handoff-1' }), + expect.objectContaining({ handoffId: 'handoff-1' }), + ]); + + await expect( + coordination.handoff(request, { + ...context, + actorScope: { ...context.actorScope, userId: 'operator-2' }, + }), + ).resolves.toMatchObject({ handoffId: 'handoff-2' }); + expect(adapter.handoff).toHaveBeenCalledTimes(2); + }); + + it('rejects idempotency-key payload drift and malformed handoff input before delivery', async (): Promise => { + const adapter = new InMemoryMosCoordinationPort(); + const handoff = vi.spyOn(adapter, 'handoff'); + const coordination = service(adapter); + await coordination.handoff( + { idempotencyKey: 'request-1', summary: 'Implement the requested feature' }, + context, + ); + + await expect( + coordination.handoff({ idempotencyKey: 'request-1', summary: 'Different work' }, context), + ).rejects.toMatchObject({ + code: 'handoff_conflict', + } satisfies Partial); + await expect( + coordination.handoff({ idempotencyKey: 'request-2', summary: '' }, context), + ).rejects.toMatchObject({ + code: 'invalid_request', + } satisfies Partial); + await expect( + coordination.handoff({ idempotencyKey: 'request-3', summary: 'x'.repeat(2_049) }, context), + ).rejects.toMatchObject({ + code: 'invalid_request', + } satisfies Partial); + expect(handoff).toHaveBeenCalledTimes(1); + }); + + it('fails closed when the port reports a target that drifts from configuration', async (): Promise => { + const adapter: MosCoordinationPort = { + handoff: vi.fn(async (handoff: MosHandoff) => ({ + handoffId: handoff.handoffId, + targetAgentId: 'Unexpected', + status: 'accepted' as const, + correlationId: handoff.scope.correlationId, + })), + observe: vi.fn(), + result: vi.fn(), + }; + + await expect( + service(adapter).handoff( + { idempotencyKey: 'request-1', summary: 'Implement the requested feature' }, + context, + ), + ).rejects.toMatchObject({ code: 'target_drift' }); + }); +}); diff --git a/apps/gateway/src/coord/mos-coordination.service.ts b/apps/gateway/src/coord/mos-coordination.service.ts new file mode 100644 index 00000000..bc4bb98f --- /dev/null +++ b/apps/gateway/src/coord/mos-coordination.service.ts @@ -0,0 +1,300 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { + MosCoordinationClient, + type CoordinationObservation, + type CoordinationResult, + type CoordinationScope, + type MosCoordinationIdentity, + type MosCoordinationPort, + type MosHandoffReceipt, +} from '@mosaicstack/coord'; +import type { RuntimeProviderRequestContext } from '../agent/runtime-provider-registry.service.js'; +import type { CreateMosHandoffDto } from './mos-coordination.dto.js'; + +export const MOS_COORDINATION_PORT = Symbol('MOS_COORDINATION_PORT'); +export const MOS_COORDINATION_CONFIG = Symbol('MOS_COORDINATION_CONFIG'); + +const HANDOFF_TRACKING_TTL_MS = 60 * 60 * 1_000; +const MAX_TRACKED_HANDOFFS = 1_000; +const MAX_IDEMPOTENCY_KEY_LENGTH = 128; +const MAX_SUMMARY_LENGTH = 2_048; +const MAX_CONTEXT_LENGTH = 8_192; +const MAX_MISSION_ID_LENGTH = 128; + +export interface MosCoordinationConfig { + interactionAgentId?: string; + orchestrationAgentId?: string; +} + +interface HandoffOwner { + actorId: string; + tenantId: string; + requesterAgentId: string; + correlationId: string; + expiresAt: number; +} + +interface NormalizedMosHandoffRequest { + idempotencyKey: string; + summary: string; + context?: string; + missionId?: string; +} + +interface TrackedHandoff { + request: NormalizedMosHandoffRequest; + receipt: Promise; + expiresAt: number; +} + +/** + * Gateway authority boundary for the interaction agent. It derives requester, + * actor, and tenant from trusted server configuration and authentication; no + * channel request can name a target or gain Mos-owned orchestration verbs. + */ +@Injectable() +export class MosCoordinationService { + private readonly owners = new Map(); + private readonly handoffsByIdempotencyKey = new Map(); + + constructor( + @Inject(MOS_COORDINATION_PORT) private readonly port: MosCoordinationPort, + @Inject(MOS_COORDINATION_CONFIG) private readonly config: MosCoordinationConfig, + private readonly handoffIdFactory: () => string = (): string => crypto.randomUUID(), + ) {} + + async handoff( + request: CreateMosHandoffDto, + context: RuntimeProviderRequestContext, + ): Promise { + this.pruneExpiredTracking(); + const normalized = this.normalizeRequest(request); + const scope = this.scope(context); + const idempotencyKey = this.idempotencyKey(normalized.idempotencyKey, scope); + const existing = this.handoffsByIdempotencyKey.get(idempotencyKey); + if (existing !== undefined) { + if (!sameRequest(existing.request, normalized)) { + throw new MosCoordinationGatewayError( + 'handoff_conflict', + 'Mos handoff idempotency key is already bound to different immutable input', + ); + } + return existing.receipt; + } + + const pending = this.deliverHandoff(this.handoffIdFactory(), normalized, scope); + const tracked: TrackedHandoff = { + request: normalized, + receipt: pending, + expiresAt: this.expiresAt(), + }; + this.handoffsByIdempotencyKey.set(idempotencyKey, tracked); + this.enforceTrackingLimit(this.handoffsByIdempotencyKey); + try { + return await pending; + } catch (error: unknown) { + if (this.handoffsByIdempotencyKey.get(idempotencyKey) === tracked) { + this.handoffsByIdempotencyKey.delete(idempotencyKey); + } + throw error; + } + } + + async observe( + handoffId: string, + context: RuntimeProviderRequestContext, + ): Promise { + this.pruneExpiredTracking(); + const scope = this.scope(context); + const owner = this.ownerFor(handoffId, scope); + return this.client().observe(handoffId, { ...scope, correlationId: owner.correlationId }); + } + + async result( + handoffId: string, + context: RuntimeProviderRequestContext, + ): Promise { + this.pruneExpiredTracking(); + const scope = this.scope(context); + const owner = this.ownerFor(handoffId, scope); + return this.client().result(handoffId, { ...scope, correlationId: owner.correlationId }); + } + + private async deliverHandoff( + handoffId: string, + request: NormalizedMosHandoffRequest, + scope: CoordinationScope, + ): Promise { + const receipt = await this.client((): string => handoffId).handoff(request, scope); + const owner: HandoffOwner = { + actorId: scope.actorId, + tenantId: scope.tenantId, + requesterAgentId: scope.requesterAgentId, + correlationId: scope.correlationId, + expiresAt: this.expiresAt(), + }; + const existing = this.owners.get(receipt.handoffId); + if (existing !== undefined && !sameOwner(existing, owner)) { + throw new MosCoordinationGatewayError( + 'handoff_conflict', + 'Mos handoff ID is already bound to a different authenticated scope', + ); + } + this.owners.set(receipt.handoffId, owner); + this.enforceTrackingLimit(this.owners); + return receipt; + } + + private client(handoffIdFactory?: () => string): MosCoordinationClient { + return new MosCoordinationClient(this.identity(), this.port, handoffIdFactory); + } + + private identity(): MosCoordinationIdentity { + const interactionAgentId = this.config.interactionAgentId?.trim(); + const orchestrationAgentId = this.config.orchestrationAgentId?.trim(); + if (!interactionAgentId) { + throw new MosCoordinationGatewayError( + 'unconfigured_requester', + 'Interaction agent identity is not configured', + ); + } + if (!orchestrationAgentId) { + throw new MosCoordinationGatewayError( + 'unconfigured_target', + 'Orchestration agent identity is not configured', + ); + } + return { interactionAgentId, orchestrationAgentId }; + } + + private scope(context: RuntimeProviderRequestContext): CoordinationScope { + const identity = this.identity(); + return Object.freeze({ + actorId: context.actorScope.userId, + tenantId: context.actorScope.tenantId, + correlationId: context.correlationId, + requesterAgentId: identity.interactionAgentId, + }); + } + + private normalizeRequest(request: CreateMosHandoffDto): NormalizedMosHandoffRequest { + if (typeof request !== 'object' || request === null) { + throw new MosCoordinationGatewayError('invalid_request', 'Mos handoff request is invalid'); + } + const idempotencyKey = this.requiredString( + request.idempotencyKey, + 'idempotency key', + MAX_IDEMPOTENCY_KEY_LENGTH, + ); + const summary = this.requiredString(request.summary, 'summary', MAX_SUMMARY_LENGTH); + const context = this.optionalString(request.context, 'context', MAX_CONTEXT_LENGTH); + const missionId = this.optionalString(request.missionId, 'mission ID', MAX_MISSION_ID_LENGTH); + return Object.freeze({ + idempotencyKey, + summary, + ...(context === undefined ? {} : { context }), + ...(missionId === undefined ? {} : { missionId }), + }); + } + + private idempotencyKey(requestKey: string, scope: CoordinationScope): string { + return `${scope.tenantId}\u0000${scope.actorId}\u0000${scope.requesterAgentId}\u0000${requestKey}`; + } + + private requiredString(value: unknown, field: string, maximumLength: number): string { + if (typeof value !== 'string') { + throw new MosCoordinationGatewayError( + 'invalid_request', + `Mos handoff ${field} must be a string`, + ); + } + const normalized = value.trim(); + if (normalized.length === 0 || normalized.length > maximumLength) { + throw new MosCoordinationGatewayError('invalid_request', `Mos handoff ${field} is invalid`); + } + return normalized; + } + + private optionalString(value: unknown, field: string, maximumLength: number): string | undefined { + if (value === undefined) return undefined; + return this.requiredString(value, field, maximumLength); + } + + private expiresAt(): number { + return Date.now() + HANDOFF_TRACKING_TTL_MS; + } + + private pruneExpiredTracking(): void { + const now = Date.now(); + for (const [key, tracked] of this.handoffsByIdempotencyKey) { + if (tracked.expiresAt <= now) this.handoffsByIdempotencyKey.delete(key); + } + for (const [key, owner] of this.owners) { + if (owner.expiresAt <= now) this.owners.delete(key); + } + } + + private enforceTrackingLimit(entries: Map): void { + while (entries.size > MAX_TRACKED_HANDOFFS) { + const oldest = entries.keys().next().value; + if (typeof oldest !== 'string') return; + entries.delete(oldest); + } + } + + private ownerFor(handoffId: string, scope: CoordinationScope): HandoffOwner { + const owner = this.owners.get(handoffId); + if (owner === undefined) { + throw new MosCoordinationGatewayError('not_found', 'Mos handoff was not found'); + } + if ( + owner.tenantId !== scope.tenantId || + owner.actorId !== scope.actorId || + owner.requesterAgentId !== scope.requesterAgentId + ) { + throw new MosCoordinationGatewayError( + 'cross_tenant_forbidden', + 'Mos handoff is outside the authenticated scope', + ); + } + return owner; + } +} + +export type MosCoordinationGatewayErrorCode = + | 'cross_tenant_forbidden' + | 'handoff_conflict' + | 'invalid_request' + | 'not_found' + | 'unconfigured_requester' + | 'unconfigured_target'; + +function sameOwner(left: HandoffOwner, right: HandoffOwner): boolean { + return ( + left.actorId === right.actorId && + left.tenantId === right.tenantId && + left.requesterAgentId === right.requesterAgentId + ); +} + +function sameRequest( + left: NormalizedMosHandoffRequest, + right: NormalizedMosHandoffRequest, +): boolean { + return ( + left.idempotencyKey === right.idempotencyKey && + left.summary === right.summary && + left.context === right.context && + left.missionId === right.missionId + ); +} + +export class MosCoordinationGatewayError extends Error { + constructor( + readonly code: MosCoordinationGatewayErrorCode, + message: string, + ) { + super(message); + this.name = MosCoordinationGatewayError.name; + } +} diff --git a/docs/scratchpads/tess-m4-001-mos-coordination.md b/docs/scratchpads/tess-m4-001-mos-coordination.md new file mode 100644 index 00000000..f2246058 --- /dev/null +++ b/docs/scratchpads/tess-m4-001-mos-coordination.md @@ -0,0 +1,46 @@ +# TESS-M4-001 — Mos Coordination + +- **Issue/task:** #710 / TESS-M4-001 +- **Branch/base:** `feat/tess-mos-coordination` rebased onto `origin/main` `f1c6b37b` +- **Budget assumption:** task estimate 25K; design-first and TDD, with package contract plus gateway boundary only. + +## Objective + +Implement a transport-neutral coordination contract allowing a configured interaction agent to hand off Mos-owned work, observe activity, and receive results while preventing it from gaining coding/general orchestration authority. + +## Plan + +1. Document the contract and enforcement-point sketch; request Mos's decision on the initial concrete transport. +2. Add `@mosaicstack/coord` typed handoff/observe/result contracts and denial errors. +3. Add a gateway service which derives actor/tenant/requester identity from trusted context/configuration and validates authority. +4. Add contract and gateway boundary tests for configurable identities, self-delegation, target drift, and cross-tenant read denial. +5. Run focused, cold-cache, baseline tests; independent review; PR lifecycle. + +## Design checkpoint — 2026-07-12 + +Created `docs/tess/MOS-COORDINATION.md`. Mos approved the design and selected the native in-process `InMemoryMosCoordinationPort` for M4. Fleet/tmux remains a documented M5 adapter seam; no Mos-side consumer is built in this task. + +## Progress checkpoint — 2026-07-13 + +- Implemented `MosCoordinationPort` with handoff/observe/result only, an authority-checking client, and deterministic native adapter in `@mosaicstack/coord`. +- Implemented the gateway `MosCoordinationService`, deriving requester identity from trusted configuration and actor/tenant/correlation from authenticated context. +- Added contract and gateway boundary tests for configurable identities, native round-trip, unconfigured requester, self-delegation, target drift, and cross-tenant observe/result denial before adapter invocation. +- Did not modify `apps/gateway/src/commands/command-authorization.service.ts`. + +## Verification + +- `pnpm --filter @mosaicstack/coord test` — PASS (16 tests after authority/idempotency remediation). +- `pnpm --filter @mosaicstack/coord build` — PASS. +- `pnpm --filter @mosaicstack/gateway test -- mos-coordination.service.test.ts` — PASS (7 tests after authority/idempotency remediation). +- Standalone gateway typecheck initially reported missing built workspace packages after fresh worktree setup; root validation builds the workspace graph and passed. +- `TURBO_FORCE=true pnpm typecheck` — PASS (42 tasks, 0 cached). +- `TURBO_FORCE=true pnpm lint` — PASS (23 tasks, 0 cached after one import-type remediation). +- `TURBO_FORCE=true pnpm format:check` — PASS. +- `TURBO_FORCE=true pnpm test` — PASS (42 tasks, 0 cached; expected existing integration skips only). + +## Review checkpoint + +- Codex code review found idempotency keys needed actor scope and concurrent retries needed an in-flight reservation; both were remediated with regression coverage. +- Codex security review found whitespace-equivalent self-delegation was accepted by the exported client; identities are now normalized before invariant checks, with regression coverage. +- Re-review added immutable payload comparison for idempotency reuse, runtime string/size validation, bounded TTL/capacity tracking for gateway and native adapter state, and fresh-correlation follow-up reads; targeted tests pass (16 coord / 7 gateway). +- Final Codex security review found no issues. PR #735 was opened from commit `7936e15d`; Woodpecker pipeline #1752 is green. diff --git a/docs/tess/ARCHITECTURE.md b/docs/tess/ARCHITECTURE.md index 31ae36d4..d268f520 100644 --- a/docs/tess/ARCHITECTURE.md +++ b/docs/tess/ARCHITECTURE.md @@ -52,6 +52,21 @@ Termination is fail-closed: a runtime approval verifier consumes a one-time, exa | Destructive, privileged, external/customer-visible action | Human approval + policy | Propose, wait for durable one-time approval, then execute idempotently | | Provider-specific unsupported action | None | Fail closed; never emulate silently | +### Mos Coordination Boundary + +`@mosaicstack/coord` exposes only the transport-neutral `MosCoordinationPort` +verbs `handoff`, `observe`, and `result`. Gateway derives the actor, tenant, +correlation, and interaction-agent identity from authenticated context plus +trusted configuration; callers never provide an orchestration target. It +rejects unconfigured identities, self-delegation, target/correlation drift, and +cross-tenant handoff reads before an adapter call. No dispatch, assignment, +review, merge, or cancellation API exists at this boundary. + +M4 uses a deterministic native in-process queue adapter to prove the handoff → +observe → result flow without coupling the contract to tmux. A fleet/tmux +adapter is deferred to the M5 live-deployment seam and must implement the same +port. + ## Session and State Model A Tess session has stable `sessionId`, `tenantId`, `ownerId`, provider/runtime identity, ingress bindings, cursor, checkpoint, inbox/outbox, and idempotency records. Discord and CLI bind to the same authorized session. Ownership is verified server-side on every list/read/attach/send/terminate operation. diff --git a/docs/tess/MOS-COORDINATION.md b/docs/tess/MOS-COORDINATION.md new file mode 100644 index 00000000..2eb3c811 --- /dev/null +++ b/docs/tess/MOS-COORDINATION.md @@ -0,0 +1,83 @@ +# Tess–Mos Coordination Contract Sketch + +**Task:** TESS-M4-001 · **PRD:** TESS-MOS-001 / AC-TESS-04 + +## Boundary + +Agent identities are deployment data. A configured interaction agent may request +Mos-owned work; the configured orchestration agent owns decomposition, worker +assignment, reviews, and merge decisions. The interaction agent receives a +correlated receipt, read-only activity projection, and terminal result. It has +no dispatch, assignment, review, merge, or cancellation operation. + +## `@mosaicstack/coord` interface + +```ts +interface CoordinationScope { + readonly actorId: string; + readonly tenantId: string; + readonly correlationId: string; + readonly requesterAgentId: string; // trusted gateway/configuration data +} + +interface MosHandoffRequest { + readonly idempotencyKey: string; + readonly summary: string; + readonly context?: string; + readonly missionId?: string; +} + +interface MosHandoffReceipt { + readonly handoffId: string; + readonly targetAgentId: string; + readonly status: 'accepted' | 'queued'; + readonly correlationId: string; +} + +interface MosHandoff { + readonly handoffId: string; + readonly targetAgentId: string; + readonly request: MosHandoffRequest; + readonly scope: CoordinationScope; +} + +interface MosCoordinationPort { + handoff(handoff: MosHandoff): Promise; + observe(handoffId: string, scope: CoordinationScope): Promise; + result(handoffId: string, scope: CoordinationScope): Promise; +} +``` + +The port deliberately omits generic orchestrator verbs. It is tenant- and +correlation-scoped; its gateway implementation obtains `actorId`, `tenantId`, +and the requester agent from trusted authentication/configuration only. + +## Enforcement point + +`apps/gateway` owns a `MosCoordinationService` boundary that compares the +trusted configured requester/target identities and rejects all of the following +before calling a transport: unconfigured requester, self-delegation, target +identity drift, cross-tenant observe/result lookup, and attempts to observe or +receive a result for a handoff outside the originating tenant. The service exposes handoff, observe, +and result only, and delegates delivery to an injected adapter. + +M4 ships a native in-process `InMemoryMosCoordinationPort` as the concrete, +deterministic adapter. It preserves the immutable handoff ID, tenant, requester +identity, and correlation ID while demonstrating the handoff → observe → result +round trip. It is a queue/port adapter, not a Mos-side consumer. + +A future fleet/tmux adapter is a documented M5 deployment seam and must +implement the same `MosCoordinationPort`; no channel client or interaction +runtime calls a transport directly. + +## Required tests + +1. A configured non-default interaction identity can hand off work to a + configured non-default orchestration identity and receive its result. +2. The gateway passes only server-derived scope/identity to the adapter. +3. Self-targeting, target drift, and cross-tenant observe/result all fail closed + without invoking the adapter. +4. The exported public contract has no worker-dispatch, assignment, review, + merge, or cancellation capability. +5. The native adapter round-trips queued work, activity, and a host-recorded + terminal result without a live fleet dependency. diff --git a/docs/tess/VERIFICATION-MATRIX.md b/docs/tess/VERIFICATION-MATRIX.md index 82864aa9..472ca7d2 100644 --- a/docs/tess/VERIFICATION-MATRIX.md +++ b/docs/tess/VERIFICATION-MATRIX.md @@ -1,18 +1,18 @@ # Tess Verification Matrix -| Acceptance criterion | Requirements | Planned evidence | Gate | -| -------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | ---------------- | -| AC-TESS-01 | TESS-PI-001, TESS-DSC-001, TESS-CLI-001 | Discord/CLI same-session integration and streaming E2E | M3-V | -| AC-TESS-02 | TESS-ARP-001, TESS-CLI-001, TESS-FLT-001 | CLI contract tests for status/sessions/tree/attach/send/stop, typed denial/error snapshots | M3-V | -| AC-TESS-03 | TESS-PI-001, TESS-OBS-001 | Clean service launch; status asserts GPT-5.6 Sol, high reasoning and effective tool policy with secret canaries absent | M2-V, M3-V | -| AC-TESS-04 | TESS-MOS-001, TESS-FLT-001 | Authority E2E: coding request creates Mos handoff; safe status runs in Tess; no competing worker claim | M4-V | -| AC-TESS-05 | TESS-HRM-001 | Hermes capability contract suite: sessions/stream/send/tree plus Kanban/skills/memory/tools/cron supported-or-denied matrix | M4-V | -| AC-TESS-06 | TESS-STA-001, TESS-SEC-008 | Kill/restart/compaction fault injection across inbox/outbox/checkpoint transitions; duplicate side-effect detector | M2-V, M5-V | -| AC-TESS-07 | TESS-SEC-001..009 | Threat-model abuse suite: authz, tenant isolation, forged identity/approval, injection, redaction, transport identity, GC scope | M1-V, M3-V, M5-V | -| AC-TESS-08 | TESS-TRN-001 | Common provider contract suite against tmux/fleet and Matrix/native; identity and replay tests | M5-V | -| AC-TESS-09 | all | `pnpm typecheck`, lint, format, unit/integration/contract/E2E; independent code and security reviews; CI URLs | Every milestone | -| AC-TESS-10 | TESS-MIG-001 | Completed capability inventory with native/adapted/deferred/rejected state, owner, cutover/rollback evidence | M5-V | -| AC-TESS-11 | TESS-PLG-001, TESS-OBS-001 | OpenAPI and user/admin/developer/plugin/ops docs, sitemap links, documentation checklist | M5-V | +| Acceptance criterion | Requirements | Planned evidence | Gate | +| -------------------- | ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | +| AC-TESS-01 | TESS-PI-001, TESS-DSC-001, TESS-CLI-001 | Discord/CLI same-session integration and streaming E2E | M3-V | +| AC-TESS-02 | TESS-ARP-001, TESS-CLI-001, TESS-FLT-001 | CLI contract tests for status/sessions/tree/attach/send/stop, typed denial/error snapshots | M3-V | +| AC-TESS-03 | TESS-PI-001, TESS-OBS-001 | Clean service launch; status asserts GPT-5.6 Sol, high reasoning and effective tool policy with secret canaries absent | M2-V, M3-V | +| AC-TESS-04 | TESS-MOS-001, TESS-FLT-001 | M4 contract/gateway native-port handoff → observe → result round trip; configurable identity, target-drift and tenant-denial tests; M4-V fleet authority qualification | M4-001, M4-V | +| AC-TESS-05 | TESS-HRM-001 | Hermes capability contract suite: sessions/stream/send/tree plus Kanban/skills/memory/tools/cron supported-or-denied matrix | M4-V | +| AC-TESS-06 | TESS-STA-001, TESS-SEC-008 | Kill/restart/compaction fault injection across inbox/outbox/checkpoint transitions; duplicate side-effect detector | M2-V, M5-V | +| AC-TESS-07 | TESS-SEC-001..009 | Threat-model abuse suite: authz, tenant isolation, forged identity/approval, injection, redaction, transport identity, GC scope | M1-V, M3-V, M5-V | +| AC-TESS-08 | TESS-TRN-001 | Common provider contract suite against tmux/fleet and Matrix/native; identity and replay tests | M5-V | +| AC-TESS-09 | all | `pnpm typecheck`, lint, format, unit/integration/contract/E2E; independent code and security reviews; CI URLs | Every milestone | +| AC-TESS-10 | TESS-MIG-001 | Completed capability inventory with native/adapted/deferred/rejected state, owner, cutover/rollback evidence | M5-V | +| AC-TESS-11 | TESS-PLG-001, TESS-OBS-001 | OpenAPI and user/admin/developer/plugin/ops docs, sitemap links, documentation checklist | M5-V | ## Security Abuse Suite Minimum diff --git a/packages/coord/src/__tests__/mos-coordination.test.ts b/packages/coord/src/__tests__/mos-coordination.test.ts new file mode 100644 index 00000000..912bdc69 --- /dev/null +++ b/packages/coord/src/__tests__/mos-coordination.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + InMemoryMosCoordinationPort, + MosCoordinationClient, + type CoordinationScope, + type MosCoordinationAuthorityError, + type MosCoordinationPort, +} from '../index.js'; + +const scope: CoordinationScope = { + actorId: 'operator-1', + tenantId: 'tenant-a', + correlationId: 'corr-1', + requesterAgentId: 'Nova', +}; + +function client( + port: MosCoordinationPort, + handoffIdFactory: () => string = (): string => 'handoff-1', +): MosCoordinationClient { + return new MosCoordinationClient( + { interactionAgentId: 'Nova', orchestrationAgentId: 'Conductor' }, + port, + handoffIdFactory, + ); +} + +describe('MosCoordinationClient', (): void => { + it('round-trips handoff, observation, and result through the native port with identities as data', async (): Promise => { + const adapter = new InMemoryMosCoordinationPort(); + const coordination = client(adapter); + + await expect( + coordination.handoff( + { idempotencyKey: 'handoff-request-1', summary: 'Implement the requested feature' }, + scope, + ), + ).resolves.toEqual({ + handoffId: 'handoff-1', + targetAgentId: 'Conductor', + status: 'queued', + correlationId: 'corr-1', + }); + + adapter.recordActivity('handoff-1', 'running', 'Mos accepted the request'); + adapter.recordResult('handoff-1', 'completed', 'Merged by Mos'); + + await expect(coordination.observe('handoff-1', scope)).resolves.toMatchObject({ + status: 'completed', + targetAgentId: 'Conductor', + activity: expect.arrayContaining([ + expect.objectContaining({ status: 'queued' }), + expect.objectContaining({ status: 'running' }), + expect.objectContaining({ status: 'completed' }), + ]), + }); + await expect(coordination.result('handoff-1', scope)).resolves.toEqual({ + handoffId: 'handoff-1', + targetAgentId: 'Conductor', + status: 'completed', + correlationId: 'corr-1', + summary: 'Merged by Mos', + }); + + expect(coordination).not.toHaveProperty('dispatch'); + expect(coordination).not.toHaveProperty('assign'); + expect(coordination).not.toHaveProperty('review'); + expect(coordination).not.toHaveProperty('merge'); + expect(coordination).not.toHaveProperty('cancel'); + }); + + it('fails closed before delivery when an unconfigured agent requests Mos work', async (): Promise => { + const adapter = new InMemoryMosCoordinationPort(); + + await expect( + client(adapter).handoff( + { idempotencyKey: 'handoff-request-1', summary: 'Implement the requested feature' }, + { ...scope, requesterAgentId: 'Untrusted' }, + ), + ).rejects.toMatchObject({ + code: 'requester_forbidden', + } satisfies Partial); + }); + + it('rejects self-delegation configuration before constructing a client', (): void => { + expect( + (): MosCoordinationClient => + new MosCoordinationClient( + { interactionAgentId: 'Nova', orchestrationAgentId: 'Nova' }, + new InMemoryMosCoordinationPort(), + ), + ).toThrow('Interaction and orchestration identities must differ'); + }); + + it('rejects whitespace-equivalent self-delegation identities', (): void => { + expect( + (): MosCoordinationClient => + new MosCoordinationClient( + { interactionAgentId: 'Nova ', orchestrationAgentId: 'Nova' }, + new InMemoryMosCoordinationPort(), + ), + ).toThrow('Interaction and orchestration identities must differ'); + }); + + it('does not expose another tenant handoff to observe or result', async (): Promise => { + const adapter = new InMemoryMosCoordinationPort(); + const coordination = client(adapter); + await coordination.handoff( + { idempotencyKey: 'handoff-request-1', summary: 'Implement the requested feature' }, + scope, + ); + + const otherTenantScope = { ...scope, tenantId: 'tenant-b' }; + await expect(coordination.observe('handoff-1', otherTenantScope)).rejects.toMatchObject({ + code: 'forbidden', + }); + await expect(coordination.result('handoff-1', otherTenantScope)).rejects.toMatchObject({ + code: 'forbidden', + }); + }); + + it('bounds native handoff retention by evicting the oldest handoff', async (): Promise => { + const adapter = new InMemoryMosCoordinationPort({ maxHandoffs: 1 }); + const first = client(adapter, (): string => 'handoff-1'); + const second = client(adapter, (): string => 'handoff-2'); + await first.handoff({ idempotencyKey: 'handoff-request-1', summary: 'First request' }, scope); + await second.handoff({ idempotencyKey: 'handoff-request-2', summary: 'Second request' }, scope); + + await expect(first.observe('handoff-1', scope)).rejects.toMatchObject({ code: 'not_found' }); + await expect(second.observe('handoff-2', scope)).resolves.toMatchObject({ status: 'queued' }); + }); + + it('fails closed when a transport reports target drift', async (): Promise => { + const adapter: MosCoordinationPort = { + handoff: vi.fn(async () => ({ + handoffId: 'handoff-1', + targetAgentId: 'Unexpected', + status: 'accepted' as const, + correlationId: 'corr-1', + })), + observe: vi.fn(), + result: vi.fn(), + }; + + await expect( + client(adapter).handoff( + { idempotencyKey: 'handoff-request-1', summary: 'Implement the requested feature' }, + scope, + ), + ).rejects.toMatchObject({ + code: 'target_drift', + } satisfies Partial); + }); +}); diff --git a/packages/coord/src/in-memory-mos-coordination-port.ts b/packages/coord/src/in-memory-mos-coordination-port.ts new file mode 100644 index 00000000..ddebbadd --- /dev/null +++ b/packages/coord/src/in-memory-mos-coordination-port.ts @@ -0,0 +1,208 @@ +import { + type CoordinationObservation, + type CoordinationResult, + type CoordinationScope, + type MosCoordinationActivity, + type MosCoordinationPort, + type MosHandoff, + type MosHandoffReceipt, + type MosHandoffStatus, +} from './mos-coordination.js'; + +const DEFAULT_HANDOFF_TTL_MS = 60 * 60 * 1_000; +const DEFAULT_MAX_HANDOFFS = 1_000; + +interface StoredHandoff { + readonly handoff: MosHandoff; + status: MosHandoffStatus; + readonly activity: MosCoordinationActivity[]; + readonly expiresAt: number; + result?: CoordinationResult; +} + +export interface InMemoryMosCoordinationPortOptions { + now?: () => Date; + handoffTtlMs?: number; + maxHandoffs?: number; +} + +/** + * Native deterministic queue/port adapter for the coordination boundary. + * It intentionally has no fleet/tmux dependency. A future deployment adapter + * implements MosCoordinationPort without changing interaction-plane callers. + */ +export class InMemoryMosCoordinationPort implements MosCoordinationPort { + private readonly handoffs = new Map(); + private readonly now: () => Date; + private readonly handoffTtlMs: number; + private readonly maxHandoffs: number; + + constructor(options: InMemoryMosCoordinationPortOptions = {}) { + this.now = options.now ?? (() => new Date()); + this.handoffTtlMs = options.handoffTtlMs ?? DEFAULT_HANDOFF_TTL_MS; + this.maxHandoffs = options.maxHandoffs ?? DEFAULT_MAX_HANDOFFS; + } + + async handoff(handoff: MosHandoff): Promise { + this.pruneExpiredHandoffs(); + const existing = this.handoffs.get(handoff.handoffId); + if (existing !== undefined) { + this.assertSameHandoff(existing.handoff, handoff); + return this.receipt(existing.handoff, existing.status); + } + + const stored: StoredHandoff = { + handoff: snapshotHandoff(handoff), + status: 'queued', + activity: [activity('queued', 'Handoff accepted by the native coordination queue', this.now)], + expiresAt: this.now().getTime() + this.handoffTtlMs, + }; + this.handoffs.set(handoff.handoffId, stored); + this.enforceHandoffLimit(); + return this.receipt(stored.handoff, stored.status); + } + + async observe(handoffId: string, scope: CoordinationScope): Promise { + this.pruneExpiredHandoffs(); + const stored = this.requireScopedHandoff(handoffId, scope); + return { + handoffId: stored.handoff.handoffId, + targetAgentId: stored.handoff.targetAgentId, + status: stored.status, + correlationId: stored.handoff.scope.correlationId, + activity: stored.activity.map(copyActivity), + }; + } + + async result(handoffId: string, scope: CoordinationScope): Promise { + this.pruneExpiredHandoffs(); + const stored = this.requireScopedHandoff(handoffId, scope); + return ( + stored.result ?? { + handoffId: stored.handoff.handoffId, + targetAgentId: stored.handoff.targetAgentId, + status: 'pending', + correlationId: stored.handoff.scope.correlationId, + } + ); + } + + /** Host-side progression seam; interaction clients never receive this capability. */ + recordActivity(handoffId: string, status: MosHandoffStatus, summary: string): void { + this.pruneExpiredHandoffs(); + const stored = this.requireHandoff(handoffId); + stored.status = status; + stored.activity.push(activity(status, summary, this.now)); + } + + /** Host-side result seam for deterministic qualification; not a Mos consumer. */ + recordResult(handoffId: string, status: 'completed' | 'failed', summary: string): void { + this.pruneExpiredHandoffs(); + const stored = this.requireHandoff(handoffId); + stored.status = status; + stored.activity.push(activity(status, summary, this.now)); + stored.result = { + handoffId: stored.handoff.handoffId, + targetAgentId: stored.handoff.targetAgentId, + status, + correlationId: stored.handoff.scope.correlationId, + summary, + }; + } + + private receipt(handoff: MosHandoff, status: MosHandoffStatus): MosHandoffReceipt { + return { + handoffId: handoff.handoffId, + targetAgentId: handoff.targetAgentId, + status: status === 'accepted' ? 'accepted' : 'queued', + correlationId: handoff.scope.correlationId, + }; + } + + private pruneExpiredHandoffs(): void { + const nowMs = this.now().getTime(); + for (const [handoffId, handoff] of this.handoffs) { + if (handoff.expiresAt <= nowMs) this.handoffs.delete(handoffId); + } + } + + private enforceHandoffLimit(): void { + while (this.handoffs.size > this.maxHandoffs) { + const oldest = this.handoffs.keys().next().value; + if (typeof oldest !== 'string') return; + this.handoffs.delete(oldest); + } + } + + private requireScopedHandoff(handoffId: string, scope: CoordinationScope): StoredHandoff { + const stored = this.requireHandoff(handoffId); + if ( + stored.handoff.scope.tenantId !== scope.tenantId || + stored.handoff.scope.actorId !== scope.actorId || + stored.handoff.scope.requesterAgentId !== scope.requesterAgentId + ) { + throw new InMemoryMosCoordinationError('forbidden', 'Handoff scope does not match'); + } + return stored; + } + + private requireHandoff(handoffId: string): StoredHandoff { + const stored = this.handoffs.get(handoffId); + if (stored === undefined) { + throw new InMemoryMosCoordinationError('not_found', 'Handoff was not found'); + } + return stored; + } + + private assertSameHandoff(existing: MosHandoff, incoming: MosHandoff): void { + if ( + existing.targetAgentId !== incoming.targetAgentId || + existing.request.idempotencyKey !== incoming.request.idempotencyKey || + existing.request.summary !== incoming.request.summary || + existing.request.context !== incoming.request.context || + existing.request.missionId !== incoming.request.missionId || + existing.scope.actorId !== incoming.scope.actorId || + existing.scope.tenantId !== incoming.scope.tenantId || + existing.scope.correlationId !== incoming.scope.correlationId || + existing.scope.requesterAgentId !== incoming.scope.requesterAgentId + ) { + throw new InMemoryMosCoordinationError( + 'conflict', + 'Handoff ID is already bound to different immutable input', + ); + } + } +} + +export type InMemoryMosCoordinationErrorCode = 'conflict' | 'forbidden' | 'not_found'; + +export class InMemoryMosCoordinationError extends Error { + constructor( + readonly code: InMemoryMosCoordinationErrorCode, + message: string, + ) { + super(message); + this.name = InMemoryMosCoordinationError.name; + } +} + +function activity( + status: MosHandoffStatus, + summary: string, + now: () => Date, +): MosCoordinationActivity { + return { occurredAt: now().toISOString(), status, summary }; +} + +function copyActivity(entry: MosCoordinationActivity): MosCoordinationActivity { + return { ...entry }; +} + +function snapshotHandoff(handoff: MosHandoff): MosHandoff { + return Object.freeze({ + handoffId: handoff.handoffId, + targetAgentId: handoff.targetAgentId, + request: Object.freeze({ ...handoff.request }), + scope: Object.freeze({ ...handoff.scope }), + }); +} diff --git a/packages/coord/src/index.ts b/packages/coord/src/index.ts index 7ac5309c..cf59bac4 100644 --- a/packages/coord/src/index.ts +++ b/packages/coord/src/index.ts @@ -2,6 +2,23 @@ export { createMission, loadMission, missionFilePath, saveMission } from './miss export { parseTasksFile, updateTaskStatus, writeTasksFile } from './tasks-file.js'; export { runTask, resumeTask } from './runner.js'; export { getMissionStatus, getTaskStatus } from './status.js'; +export { + InMemoryMosCoordinationError, + InMemoryMosCoordinationPort, +} from './in-memory-mos-coordination-port.js'; +export { MosCoordinationAuthorityError, MosCoordinationClient } from './mos-coordination.js'; +export type { + CoordinationObservation, + CoordinationResult, + CoordinationScope, + MosCoordinationActivity, + MosCoordinationIdentity, + MosCoordinationPort, + MosHandoff, + MosHandoffReceipt, + MosHandoffRequest, + MosHandoffStatus, +} from './mos-coordination.js'; export type { CreateMissionOptions, Mission, diff --git a/packages/coord/src/mos-coordination.ts b/packages/coord/src/mos-coordination.ts new file mode 100644 index 00000000..f71d2ea3 --- /dev/null +++ b/packages/coord/src/mos-coordination.ts @@ -0,0 +1,207 @@ +export type MosHandoffStatus = 'queued' | 'accepted' | 'running' | 'completed' | 'failed'; + +export interface CoordinationScope { + readonly actorId: string; + readonly tenantId: string; + readonly correlationId: string; + /** Trusted gateway/configuration identity; never supplied by a channel client. */ + readonly requesterAgentId: string; +} + +export interface MosCoordinationIdentity { + readonly interactionAgentId: string; + readonly orchestrationAgentId: string; +} + +export interface MosHandoffRequest { + readonly idempotencyKey: string; + readonly summary: string; + readonly context?: string; + readonly missionId?: string; +} + +export interface MosHandoff { + readonly handoffId: string; + readonly targetAgentId: string; + readonly request: MosHandoffRequest; + readonly scope: CoordinationScope; +} + +export interface MosHandoffReceipt { + readonly handoffId: string; + readonly targetAgentId: string; + readonly status: 'queued' | 'accepted'; + readonly correlationId: string; +} + +export interface MosCoordinationActivity { + readonly occurredAt: string; + readonly status: MosHandoffStatus; + readonly summary: string; +} + +export interface CoordinationObservation { + readonly handoffId: string; + readonly targetAgentId: string; + readonly status: MosHandoffStatus; + readonly correlationId: string; + readonly activity: readonly MosCoordinationActivity[]; +} + +export interface CoordinationResult { + readonly handoffId: string; + readonly targetAgentId: string; + readonly status: 'completed' | 'failed' | 'pending'; + readonly correlationId: string; + readonly summary?: string; +} + +/** + * Transport-neutral boundary. The interaction plane can request work and read + * its progress/result, but it cannot issue worker, review, merge, or other + * general orchestration commands. + */ +export interface MosCoordinationPort { + handoff(handoff: MosHandoff): Promise; + observe(handoffId: string, scope: CoordinationScope): Promise; + result(handoffId: string, scope: CoordinationScope): Promise; +} + +export type MosCoordinationAuthorityErrorCode = + | 'invalid_identity' + | 'requester_forbidden' + | 'target_drift' + | 'correlation_drift'; + +export class MosCoordinationAuthorityError extends Error { + constructor( + readonly code: MosCoordinationAuthorityErrorCode, + message: string, + ) { + super(message); + this.name = MosCoordinationAuthorityError.name; + } +} + +/** + * Enforces the interaction-to-orchestration authority boundary before a + * transport is reached. Identity names remain configuration data. + */ +export class MosCoordinationClient { + private readonly identity: MosCoordinationIdentity; + + constructor( + identity: MosCoordinationIdentity, + private readonly port: MosCoordinationPort, + private readonly handoffIdFactory: () => string = (): string => crypto.randomUUID(), + ) { + this.identity = normalizeIdentity(identity); + } + + async handoff(request: MosHandoffRequest, scope: CoordinationScope): Promise { + this.assertRequester(scope); + const handoff: MosHandoff = { + handoffId: this.handoffIdFactory(), + targetAgentId: this.identity.orchestrationAgentId, + request: snapshotRequest(request), + scope: snapshotScope(scope), + }; + const receipt = await this.port.handoff(handoff); + if ( + receipt.handoffId !== handoff.handoffId || + receipt.targetAgentId !== handoff.targetAgentId + ) { + throw new MosCoordinationAuthorityError( + 'target_drift', + 'Mos coordination transport returned a mismatched handoff target', + ); + } + if (receipt.correlationId !== handoff.scope.correlationId) { + throw new MosCoordinationAuthorityError( + 'correlation_drift', + 'Mos coordination transport returned a mismatched correlation ID', + ); + } + return receipt; + } + + async observe(handoffId: string, scope: CoordinationScope): Promise { + this.assertRequester(scope); + return this.assertObservation(await this.port.observe(handoffId, snapshotScope(scope)), scope); + } + + async result(handoffId: string, scope: CoordinationScope): Promise { + this.assertRequester(scope); + return this.assertResult(await this.port.result(handoffId, snapshotScope(scope)), scope); + } + + private assertRequester(scope: CoordinationScope): void { + if (scope.requesterAgentId !== this.identity.interactionAgentId) { + throw new MosCoordinationAuthorityError( + 'requester_forbidden', + 'Requester is not the configured interaction agent', + ); + } + } + + private assertObservation( + observation: CoordinationObservation, + scope: CoordinationScope, + ): CoordinationObservation { + if (observation.targetAgentId !== this.identity.orchestrationAgentId) { + throw new MosCoordinationAuthorityError( + 'target_drift', + 'Mos coordination transport returned an unexpected observation target', + ); + } + if (observation.correlationId !== scope.correlationId) { + throw new MosCoordinationAuthorityError( + 'correlation_drift', + 'Mos coordination transport returned a mismatched observation correlation ID', + ); + } + return observation; + } + + private assertResult(result: CoordinationResult, scope: CoordinationScope): CoordinationResult { + if (result.targetAgentId !== this.identity.orchestrationAgentId) { + throw new MosCoordinationAuthorityError( + 'target_drift', + 'Mos coordination transport returned an unexpected result target', + ); + } + if (result.correlationId !== scope.correlationId) { + throw new MosCoordinationAuthorityError( + 'correlation_drift', + 'Mos coordination transport returned a mismatched result correlation ID', + ); + } + return result; + } +} + +function normalizeIdentity(identity: MosCoordinationIdentity): MosCoordinationIdentity { + const interactionAgentId = identity.interactionAgentId.trim(); + const orchestrationAgentId = identity.orchestrationAgentId.trim(); + if (interactionAgentId.length === 0 || orchestrationAgentId.length === 0) { + throw new MosCoordinationAuthorityError( + 'invalid_identity', + 'Interaction and orchestration identities are required', + ); + } + if (interactionAgentId === orchestrationAgentId) { + throw new MosCoordinationAuthorityError( + 'invalid_identity', + 'Interaction and orchestration identities must differ', + ); + } + return Object.freeze({ interactionAgentId, orchestrationAgentId }); +} + +function snapshotRequest(request: MosHandoffRequest): MosHandoffRequest { + return Object.freeze({ ...request }); +} + +function snapshotScope(scope: CoordinationScope): CoordinationScope { + return Object.freeze({ ...scope }); +} From 2363f155b422f3a0f2d037b5bc3a8444354bad72 Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Mon, 13 Jul 2026 12:44:31 +0000 Subject: [PATCH 028/152] feat(memory): add operator retrieval plugin (#736) --- .../tess-m4-003-operator-plugins.md | 27 +++ docs/tess/M4-003-OPERATOR-PLUGIN-SKETCH.md | 17 ++ packages/memory/src/adapters/keyword.test.ts | 14 ++ packages/memory/src/adapters/keyword.ts | 16 +- packages/memory/src/index.ts | 7 + .../memory/src/operator-memory-plugin.test.ts | 147 +++++++++++++++++ packages/memory/src/operator-memory-plugin.ts | 154 ++++++++++++++++++ packages/memory/src/types.ts | 4 + 8 files changed, 380 insertions(+), 6 deletions(-) create mode 100644 docs/scratchpads/tess-m4-003-operator-plugins.md create mode 100644 docs/tess/M4-003-OPERATOR-PLUGIN-SKETCH.md create mode 100644 packages/memory/src/operator-memory-plugin.test.ts create mode 100644 packages/memory/src/operator-memory-plugin.ts diff --git a/docs/scratchpads/tess-m4-003-operator-plugins.md b/docs/scratchpads/tess-m4-003-operator-plugins.md new file mode 100644 index 00000000..90c690dd --- /dev/null +++ b/docs/scratchpads/tess-m4-003-operator-plugins.md @@ -0,0 +1,27 @@ +# TESS-M4-003 — Operator Plugin Foundations + +- **Task:** TESS-M4-003 / TESS-MEM-001 +- **Branch/base:** `feat/tess-operator-plugins` rebased on `origin/main` `76325ca3` +- **Scope:** first leaf-package memory/retrieval slice only; no durable inbox ownership, gateway integration, or Mosaic catalog implementation. + +## Handoff + +Coder4's uncommitted implementation was preserved first in commit `5b99c821` before review. The completion pass corrected the contract so namespace is injected configuration rather than caller-selected scope data, storage keys include tenant/owner/session via collision-safe tuple encoding, and malformed runtime scope values fail closed. + +## Delivered boundary + +- `OperatorMemoryPlugin` exposes `capture`, `search`, `recent`, `stats`, and `startupContext` through `MemoryAdapter` only. +- Scope is server-derived `{tenantId, ownerId, sessionId}`; adapter and namespace are configuration, not operation input. +- Capture redacts before persistence and records configured instance/namespace/source provenance. +- Wildcard retrieval is documented at the `MemoryAdapter` boundary and implemented by the keyword adapter. +- Startup context uses a bounded 64-result candidate window, then prioritizes project and flat-file provenance before slicing the configured output limit. +- No `Tess` identity is hardcoded in storage keys or defaults; tests use configured `Nova`. + +## Verification + +- `pnpm --filter @mosaicstack/memory test` — PASS (32 tests) +- `pnpm --filter @mosaicstack/memory typecheck` — PASS +- `pnpm --filter @mosaicstack/memory lint` — PASS +- `pnpm --filter @mosaicstack/memory build` — PASS +- Codex code review — APPROVE after remediation +- Codex security review — no findings after runtime scope-validation remediation diff --git a/docs/tess/M4-003-OPERATOR-PLUGIN-SKETCH.md b/docs/tess/M4-003-OPERATOR-PLUGIN-SKETCH.md new file mode 100644 index 00000000..5bacf6a6 --- /dev/null +++ b/docs/tess/M4-003-OPERATOR-PLUGIN-SKETCH.md @@ -0,0 +1,17 @@ +# TESS-M4-003 Operator Plugin Sketch + +## Memory/retrieval slice — TESS-MEM-001 + +Introduce a transport-neutral `OperatorMemoryPlugin` in `packages/memory`. The plugin receives a server-derived `{tenantId, ownerId, sessionId}` scope and delegates to a registered `MemoryAdapter`; adapter and namespace are injected configuration, never caller input. Its operations are `capture`, `search`, `recent`, `stats`, and `startupContext`. Results carry configured instance, provenance, and namespace metadata. Capture/redaction occurs before adapter persistence; startup context uses a bounded candidate window ordered so project/flat-file truth takes precedence within returned material. + +Registration remains replaceable-adapter based: the existing `registerMemoryAdapter(kind, factory)` / `createMemoryAdapter(config)` seam supplies the injected adapter to `createOperatorMemoryPlugin(config)`. Identity and namespace are configuration data; no interaction-agent name is embedded in keys or defaults. + +## Remaining plugin foundations — TESS-PLG-001 + +- `packages/agent`: capability descriptors for runtime bootstrap, durable inbox/state hooks, and read-only fleet diagnostics. Each capability advertises supported operations and fails closed when absent. +- `packages/mosaic`: a catalog/registration surface for GitOps, fleet diagnostics, runtime bootstrap, Discord, and MCP/skill discovery. Catalog entries describe authority, input schema, and safe/read-only status; they do not invoke provider transports directly. +- Gateway/channel adapters consume these contracts through server-derived actor/tenant context and durable session state, preserving the replaceable-adapter boundary. + +## First implementation boundary + +The first PR slice should add the operator-memory plugin contract, configuration-injected adapter seam, scope isolation, provenance-bearing retrieval, and tests for namespace isolation plus a differently named configured instance. Durable inbox/outbox remains owned by the existing `DurableSessionCoordinator`; this plugin only supplies bounded context/capture at lifecycle boundaries. diff --git a/packages/memory/src/adapters/keyword.test.ts b/packages/memory/src/adapters/keyword.test.ts index 2a0ac855..a56b8007 100644 --- a/packages/memory/src/adapters/keyword.test.ts +++ b/packages/memory/src/adapters/keyword.test.ts @@ -274,6 +274,20 @@ describe('KeywordAdapter', () => { expect(results).toHaveLength(1); }); + it('should return all scoped insights for the explicit wildcard query', async () => { + await adapter.storeInsight({ + userId: 'u1', + content: 'A literal * marker is still ordinary content', + source: 'chat', + category: 'technical', + relevanceScore: 0.7, + }); + + const results = await adapter.searchInsights('u1', '*'); + expect(results).toHaveLength(4); + expect(results.every((result) => result.score === 1)).toBe(true); + }); + it('should return empty for empty query', async () => { const results = await adapter.searchInsights('u1', ' '); expect(results).toHaveLength(0); diff --git a/packages/memory/src/adapters/keyword.ts b/packages/memory/src/adapters/keyword.ts index 75750766..ea188e7a 100644 --- a/packages/memory/src/adapters/keyword.ts +++ b/packages/memory/src/adapters/keyword.ts @@ -132,19 +132,23 @@ export class KeywordAdapter implements MemoryAdapter { opts?: { limit?: number; embedding?: number[] }, ): Promise { const limit = opts?.limit ?? 10; - const words = query - .toLowerCase() - .split(/\s+/) - .filter((w) => w.length > 0); + const normalizedQuery = query.trim(); + const matchAll = normalizedQuery === '*'; + const words = matchAll + ? [] + : normalizedQuery + .toLowerCase() + .split(/\s+/) + .filter((word) => word.length > 0); - if (words.length === 0) return []; + if (words.length === 0 && !matchAll) return []; const rows = await this.storage.find(INSIGHTS, { userId }); const scored: InsightSearchResult[] = []; for (const row of rows) { const content = row.content.toLowerCase(); - let score = 0; + let score = matchAll ? 1 : 0; for (const word of words) { if (content.includes(word)) score++; } diff --git a/packages/memory/src/index.ts b/packages/memory/src/index.ts index 97f734da..243a0276 100644 --- a/packages/memory/src/index.ts +++ b/packages/memory/src/index.ts @@ -22,6 +22,13 @@ export type { InsightSearchResult, } from './types.js'; export { createMemoryAdapter, registerMemoryAdapter } from './factory.js'; +export { + createOperatorMemoryPlugin, + type OperatorMemoryPlugin, + type OperatorMemoryScope, + type OperatorMemoryConfig, + type OperatorMemoryResult, +} from './operator-memory-plugin.js'; export { PgVectorAdapter } from './adapters/pgvector.js'; export { KeywordAdapter } from './adapters/keyword.js'; diff --git a/packages/memory/src/operator-memory-plugin.test.ts b/packages/memory/src/operator-memory-plugin.test.ts new file mode 100644 index 00000000..26649db9 --- /dev/null +++ b/packages/memory/src/operator-memory-plugin.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createOperatorMemoryPlugin } from './operator-memory-plugin.js'; +import type { Insight, InsightSearchResult, NewInsight } from './types.js'; + +function adapter() { + return { + name: 'test', + embedder: null, + storeInsight: vi.fn( + async (value: NewInsight): Promise => ({ + ...value, + id: '1', + createdAt: new Date(), + }), + ), + searchInsights: vi.fn(async (): Promise => []), + getInsight: vi.fn(), + deleteInsight: vi.fn(), + getPreference: vi.fn(), + setPreference: vi.fn(), + deletePreference: vi.fn(), + listPreferences: vi.fn(), + close: vi.fn(), + }; +} + +describe('OperatorMemoryPlugin', () => { + it('isolates configured namespace storage across server-derived tenant, owner, and session scopes', async () => { + const memory = adapter(); + const plugin = createOperatorMemoryPlugin({ + adapter: memory, + instanceId: 'Nova', + namespace: 'operator-memory', + redact: (value) => value, + }); + await plugin.capture( + { tenantId: 'tenant-a', ownerId: 'owner-a', sessionId: 'session-a' }, + { content: 'one', source: 'test', category: 'note' }, + ); + await plugin.capture( + { tenantId: 'tenant-b', ownerId: 'owner-a', sessionId: 'session-a' }, + { content: 'two', source: 'test', category: 'note' }, + ); + await plugin.capture( + { tenantId: 'tenant-a', ownerId: 'owner-a', sessionId: 'session-b' }, + { content: 'three', source: 'test', category: 'note' }, + ); + + expect( + memory.storeInsight.mock.calls.map((call: unknown[]) => (call[0] as NewInsight).userId), + ).toEqual([ + '["operator-memory","tenant-a","owner-a","session-a"]', + '["operator-memory","tenant-b","owner-a","session-a"]', + '["operator-memory","tenant-a","owner-a","session-b"]', + ]); + }); + + it('rejects an incomplete runtime scope before it can produce a shared storage key', async () => { + const memory = adapter(); + const plugin = createOperatorMemoryPlugin({ + adapter: memory, + instanceId: 'Nova', + namespace: 'operator-memory', + redact: (value) => value, + }); + + await expect( + plugin.capture( + { tenantId: 'tenant-a', ownerId: 'owner-a', sessionId: ' ' }, + { content: 'note', source: 'test', category: 'note' }, + ), + ).rejects.toThrow('Operator memory session ID is required'); + expect(memory.storeInsight).not.toHaveBeenCalled(); + }); + + it('uses a differently named configured instance in retrieval provenance', async () => { + const memory = adapter(); + memory.searchInsights.mockResolvedValue([ + { id: '1', content: 'x', score: 1, metadata: { source: 'project' } }, + ]); + const plugin = createOperatorMemoryPlugin({ + adapter: memory, + instanceId: 'Nova', + namespace: 'operator-memory', + redact: (value) => value, + }); + + const results = await plugin.search({ tenantId: 't', ownerId: 'o', sessionId: 's' }, 'x'); + + expect(results[0]?.provenance).toEqual({ + instanceId: 'Nova', + namespace: 'operator-memory', + source: 'project', + }); + }); + + it('orders startup context with project and flat-file truth before retrieved material', async () => { + const memory = adapter(); + memory.searchInsights.mockResolvedValue([ + { id: 'retrieval', content: 'retrieval', score: 1 }, + { id: 'flat-file', content: 'flat-file', score: 1, metadata: { source: 'flat-file' } }, + { id: 'project', content: 'project', score: 1, metadata: { source: 'project' } }, + ]); + const plugin = createOperatorMemoryPlugin({ + adapter: memory, + instanceId: 'Nova', + namespace: 'operator-memory', + maxStartupContext: 2, + redact: (value) => value, + }); + + const context = await plugin.startupContext({ + tenantId: 'tenant-a', + ownerId: 'owner-a', + sessionId: 'session-a', + }); + + expect(context.map((result) => result.id)).toEqual(['project', 'flat-file']); + expect(memory.searchInsights).toHaveBeenCalledWith(expect.any(String), '*', { limit: 64 }); + }); + + it('redacts content before adapter persistence and records configured provenance metadata', async () => { + const memory = adapter(); + const plugin = createOperatorMemoryPlugin({ + adapter: memory, + instanceId: 'Nova', + namespace: 'operator-memory', + redact: (value) => value.replace('secret', '[REDACTED]'), + }); + + await plugin.capture( + { tenantId: 'tenant-a', ownerId: 'owner-a', sessionId: 'session-a' }, + { content: 'secret note', source: 'project', category: 'note' }, + ); + + expect(memory.storeInsight).toHaveBeenCalledWith( + expect.objectContaining({ + content: '[REDACTED] note', + metadata: { + instanceId: 'Nova', + namespace: 'operator-memory', + source: 'project', + }, + }), + ); + }); +}); diff --git a/packages/memory/src/operator-memory-plugin.ts b/packages/memory/src/operator-memory-plugin.ts new file mode 100644 index 00000000..698d904f --- /dev/null +++ b/packages/memory/src/operator-memory-plugin.ts @@ -0,0 +1,154 @@ +import type { Insight, InsightSearchResult, MemoryAdapter } from './types.js'; + +const STARTUP_CONTEXT_CANDIDATE_LIMIT = 64; + +/** Immutable server-derived boundary; callers never choose an adapter namespace. */ +export interface OperatorMemoryScope { + readonly tenantId: string; + readonly ownerId: string; + readonly sessionId: string; +} + +export interface OperatorMemoryConfig { + /** Adapter injection is deployment/lifecycle configuration, never caller input. */ + readonly adapter: MemoryAdapter; + /** Configured agent identity; it is metadata rather than a storage key default. */ + readonly instanceId: string; + /** Configured storage partition; callers cannot select a namespace. */ + readonly namespace: string; + readonly maxStartupContext?: number; + redact(content: string): string; +} + +export interface OperatorMemoryResult extends InsightSearchResult { + provenance: { instanceId: string; namespace: string; source: string }; +} + +export interface OperatorMemoryPlugin { + capture( + scope: OperatorMemoryScope, + input: { content: string; source: string; category: string }, + ): Promise; + search( + scope: OperatorMemoryScope, + query: string, + limit?: number, + ): Promise; + recent(scope: OperatorMemoryScope, limit?: number): Promise; + stats(scope: OperatorMemoryScope): Promise<{ namespace: string; resultCount: number }>; + startupContext(scope: OperatorMemoryScope): Promise; +} + +function scopedUserId(scope: OperatorMemoryScope, namespace: string): string { + const normalizedScope = normalizeScope(scope); + // JSON tuple encoding avoids delimiter collisions between independently scoped IDs. + return JSON.stringify([ + namespace, + normalizedScope.tenantId, + normalizedScope.ownerId, + normalizedScope.sessionId, + ]); +} + +function normalizeScope(scope: OperatorMemoryScope): OperatorMemoryScope { + if (typeof scope !== 'object' || scope === null) { + throw new Error('Operator memory scope is required'); + } + return Object.freeze({ + tenantId: requiredScopeId(scope.tenantId, 'tenant ID'), + ownerId: requiredScopeId(scope.ownerId, 'owner ID'), + sessionId: requiredScopeId(scope.sessionId, 'session ID'), + }); +} + +function requiredScopeId(value: unknown, field: string): string { + if (typeof value !== 'string' || value.trim().length === 0) { + throw new Error(`Operator memory ${field} is required`); + } + return value.trim(); +} + +function compareStartupContext(left: OperatorMemoryResult, right: OperatorMemoryResult): number { + return ( + startupSourcePriority(left.provenance.source) - startupSourcePriority(right.provenance.source) + ); +} + +function startupSourcePriority(source: string): number { + if (source === 'project') return 0; + if (source === 'flat-file') return 1; + return 2; +} + +function normalizeConfig(config: OperatorMemoryConfig): OperatorMemoryConfig { + const instanceId = config.instanceId.trim(); + const namespace = config.namespace.trim(); + const maxStartupContext = config.maxStartupContext ?? 8; + if (instanceId.length === 0 || namespace.length === 0) { + throw new Error('Operator memory instance ID and namespace must be configured'); + } + if (!Number.isSafeInteger(maxStartupContext) || maxStartupContext < 1) { + throw new Error('Operator memory startup context limit must be a positive integer'); + } + return Object.freeze({ ...config, instanceId, namespace, maxStartupContext }); +} + +/** Creates a leaf-package, replaceable memory adapter facade. */ +export function createOperatorMemoryPlugin(config: OperatorMemoryConfig): OperatorMemoryPlugin { + const pluginConfig = normalizeConfig(config); + const mapResult = (result: InsightSearchResult): OperatorMemoryResult => ({ + ...result, + provenance: { + instanceId: pluginConfig.instanceId, + namespace: pluginConfig.namespace, + source: String(result.metadata?.['source'] ?? 'retrieval'), + }, + }); + const search = async ( + scope: OperatorMemoryScope, + query: string, + limit = 10, + ): Promise => + ( + await pluginConfig.adapter.searchInsights( + scopedUserId(scope, pluginConfig.namespace), + query, + { + limit, + }, + ) + ).map(mapResult); + return { + async capture(scope, input) { + return pluginConfig.adapter.storeInsight({ + userId: scopedUserId(scope, pluginConfig.namespace), + content: pluginConfig.redact(input.content), + source: input.source, + category: input.category, + relevanceScore: 1, + metadata: { + namespace: pluginConfig.namespace, + instanceId: pluginConfig.instanceId, + source: input.source, + }, + }); + }, + search, + async recent(scope, limit = 10) { + return search(scope, '*', limit); + }, + async stats(scope) { + return { + namespace: pluginConfig.namespace, + resultCount: (await search(scope, '*', 100)).length, + }; + }, + async startupContext(scope) { + const maxStartupContext = pluginConfig.maxStartupContext ?? 8; + // Prioritize authoritative sources within a bounded candidate window. + const candidateLimit = Math.max(maxStartupContext, STARTUP_CONTEXT_CANDIDATE_LIMIT); + const context = await search(scope, '*', candidateLimit); + return [...context].sort(compareStartupContext).slice(0, maxStartupContext); + }, + }; +} diff --git a/packages/memory/src/types.ts b/packages/memory/src/types.ts index 47f9bf0b..22aeb416 100644 --- a/packages/memory/src/types.ts +++ b/packages/memory/src/types.ts @@ -49,6 +49,10 @@ export interface MemoryAdapter { // Insights storeInsight(insight: NewInsight): Promise; getInsight(id: string): Promise; + /** + * Searches within one scoped user ID. The reserved `*` query returns scoped + * recent/all results rather than performing backend-specific wildcard parsing. + */ searchInsights( userId: string, query: string, From cca6aaf94764735f8bd9dc62a22fb0f7eb2d9ff9 Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Mon, 13 Jul 2026 13:14:43 +0000 Subject: [PATCH 029/152] feat(agent): add Hermes transitional capability matrix (#738) --- .../agent/src/hermes-runtime-provider.test.ts | 22 +++++++++++ packages/agent/src/hermes-runtime-provider.ts | 39 ++++++++++++++++++- .../types/src/agent/agent-runtime-provider.ts | 18 +++++++++ 3 files changed, 78 insertions(+), 1 deletion(-) diff --git a/packages/agent/src/hermes-runtime-provider.test.ts b/packages/agent/src/hermes-runtime-provider.test.ts index e94b8229..e5577ee2 100644 --- a/packages/agent/src/hermes-runtime-provider.test.ts +++ b/packages/agent/src/hermes-runtime-provider.test.ts @@ -30,6 +30,28 @@ const transport = (capabilities = ['session.list', 'session.tree']): HermesRunti terminate: vi.fn(), }); describe('HermesRuntimeProvider normalization boundary', () => { + it('returns an exhaustive fail-closed transitional capability matrix', async () => { + const provider = new HermesRuntimeProvider(transport()); + + await expect(provider.transitionalCapabilityMatrix(scope)).resolves.toEqual([ + { capability: 'kanban', status: 'unsupported' }, + { capability: 'skills', status: 'unsupported' }, + { capability: 'memory', status: 'unsupported' }, + { capability: 'tools', status: 'unsupported' }, + { capability: 'cron', status: 'unsupported' }, + ]); + }); + + it('denies unsupported transitional capabilities without calling Hermes', async () => { + const hermes = transport(); + const provider = new HermesRuntimeProvider(hermes); + + await expect(provider.assertTransitionalCapability('memory', scope)).rejects.toMatchObject({ + code: 'capability_unsupported', + }); + expect(hermes.capabilities).not.toHaveBeenCalled(); + }); + it('normalizes legacy sessions without exposing legacy fields', async () => { const provider = new HermesRuntimeProvider(transport()); await expect(provider.listSessions(scope)).resolves.toEqual( diff --git a/packages/agent/src/hermes-runtime-provider.ts b/packages/agent/src/hermes-runtime-provider.ts index 90c7df93..8e4bb51f 100644 --- a/packages/agent/src/hermes-runtime-provider.ts +++ b/packages/agent/src/hermes-runtime-provider.ts @@ -11,9 +11,19 @@ import type { RuntimeSessionState, RuntimeSessionTree, RuntimeStreamEvent, + TransitionalCapabilityInventoryEntry, + TransitionalCapabilityInventoryProvider, + TransitionalRuntimeCapability, } from '@mosaicstack/types'; const HERMES_PROVIDER_ID = 'runtime.hermes'; +const TRANSITIONAL_CAPABILITIES: readonly TransitionalRuntimeCapability[] = [ + 'kanban', + 'skills', + 'memory', + 'tools', + 'cron', +]; const RUNTIME_CAPABILITIES: readonly RuntimeCapability[] = [ 'session.list', 'session.tree', @@ -65,11 +75,38 @@ export class HermesRuntimeProviderError extends Error { * Transitional Hermes adapter. Legacy identifiers and schemas do not cross this * boundary: callers only observe Mosaic AgentRuntimeProvider contracts. */ -export class HermesRuntimeProvider implements AgentRuntimeProvider { +export class HermesRuntimeProvider + implements AgentRuntimeProvider, TransitionalCapabilityInventoryProvider +{ readonly id = HERMES_PROVIDER_ID; constructor(private readonly transport: HermesRuntimeTransport) {} + /** + * Full AC-TESS-05 migration inventory. These operations are deliberately + * unsupported until their Mosaic-owned plugin contracts exist. + */ + async transitionalCapabilityMatrix( + _scope: RuntimeScope, + ): Promise { + return TRANSITIONAL_CAPABILITIES.map((capability) => ({ capability, status: 'unsupported' })); + } + + async assertTransitionalCapability( + capability: TransitionalRuntimeCapability, + scope: RuntimeScope, + ): Promise { + const entry = (await this.transitionalCapabilityMatrix(scope)).find( + (candidate) => candidate.capability === capability, + ); + if (!entry || entry.status !== 'supported') { + throw new HermesRuntimeProviderError( + 'capability_unsupported', + `Hermes transitional capability is unsupported: ${capability}`, + ); + } + } + async capabilities(scope: RuntimeScope): Promise { const legacyCapabilities = await this.transport.capabilities(scope); return { diff --git a/packages/types/src/agent/agent-runtime-provider.ts b/packages/types/src/agent/agent-runtime-provider.ts index 4a5fb562..c5b343ae 100644 --- a/packages/types/src/agent/agent-runtime-provider.ts +++ b/packages/types/src/agent/agent-runtime-provider.ts @@ -6,6 +6,24 @@ export type RuntimeCapability = | 'session.attach' | 'session.terminate'; export type RuntimeSessionState = 'starting' | 'active' | 'idle' | 'stopped' | 'failed'; + +/** Transitional capability inventory is normalized; provider legacy vocabularies never enter core. */ +export type TransitionalRuntimeCapability = 'kanban' | 'skills' | 'memory' | 'tools' | 'cron'; +export type TransitionalCapabilityStatus = 'supported' | 'unsupported'; +export interface TransitionalCapabilityInventoryEntry { + capability: TransitionalRuntimeCapability; + status: TransitionalCapabilityStatus; +} +/** Optional extension for transitional adapters; not every runtime has Hermes inventory. */ +export interface TransitionalCapabilityInventoryProvider { + transitionalCapabilityMatrix( + scope: RuntimeScope, + ): Promise; + assertTransitionalCapability( + capability: TransitionalRuntimeCapability, + scope: RuntimeScope, + ): Promise; +} export type RuntimeAttachMode = 'read' | 'control'; /** Server-derived immutable authority context. Client identity fields are intentionally absent. */ From e2376190e5abe3d11a3744d63eb25aca061bb6fd Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Mon, 13 Jul 2026 13:14:44 +0000 Subject: [PATCH 030/152] feat(gateway): expose Mos coordination boundary (#737) --- apps/gateway/src/coord/coord.module.ts | 3 +- .../coord/mos-coordination.controller.test.ts | 36 +++++++++ .../src/coord/mos-coordination.controller.ts | 73 +++++++++++++++++++ 3 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 apps/gateway/src/coord/mos-coordination.controller.test.ts create mode 100644 apps/gateway/src/coord/mos-coordination.controller.ts diff --git a/apps/gateway/src/coord/coord.module.ts b/apps/gateway/src/coord/coord.module.ts index 774cf134..170280db 100644 --- a/apps/gateway/src/coord/coord.module.ts +++ b/apps/gateway/src/coord/coord.module.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; import { InMemoryMosCoordinationPort } from '@mosaicstack/coord'; import { CoordService } from './coord.service.js'; import { CoordController } from './coord.controller.js'; +import { MosCoordinationController } from './mos-coordination.controller.js'; import { MOS_COORDINATION_CONFIG, MOS_COORDINATION_PORT, @@ -24,7 +25,7 @@ import { }, MosCoordinationService, ], - controllers: [CoordController], + controllers: [CoordController, MosCoordinationController], exports: [CoordService, MosCoordinationService], }) export class CoordModule {} diff --git a/apps/gateway/src/coord/mos-coordination.controller.test.ts b/apps/gateway/src/coord/mos-coordination.controller.test.ts new file mode 100644 index 00000000..0bec4b75 --- /dev/null +++ b/apps/gateway/src/coord/mos-coordination.controller.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it, vi } from 'vitest'; +import { MosCoordinationController } from './mos-coordination.controller.js'; + +const user = { id: 'operator-1', tenantId: 'tenant-a' }; + +describe('MosCoordinationController', () => { + it('derives actor and tenant from the authenticated user rather than handoff input', async () => { + const coordination = { + handoff: vi.fn(async () => ({ handoffId: 'handoff-1' })), + observe: vi.fn(), + result: vi.fn(), + }; + const controller = new MosCoordinationController(coordination as never); + + await controller.handoff({ idempotencyKey: 'request-1', summary: 'Implement' }, user, 'corr-1'); + + expect(coordination.handoff).toHaveBeenCalledWith( + { idempotencyKey: 'request-1', summary: 'Implement' }, + expect.objectContaining({ + actorScope: { userId: 'operator-1', tenantId: 'tenant-a' }, + channelId: 'cli', + correlationId: 'corr-1', + }), + ); + }); + + it('requires a correlation header before invoking the coordination service', async () => { + const coordination = { handoff: vi.fn(), observe: vi.fn(), result: vi.fn() }; + const controller = new MosCoordinationController(coordination as never); + + await expect( + controller.handoff({ idempotencyKey: 'request-1', summary: 'Implement' }, user, undefined), + ).rejects.toThrow('X-Correlation-Id is required'); + expect(coordination.handoff).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/gateway/src/coord/mos-coordination.controller.ts b/apps/gateway/src/coord/mos-coordination.controller.ts new file mode 100644 index 00000000..cacfe7ba --- /dev/null +++ b/apps/gateway/src/coord/mos-coordination.controller.ts @@ -0,0 +1,73 @@ +import { + Body, + Controller, + ForbiddenException, + Get, + Headers, + Inject, + Param, + Post, + UseGuards, +} from '@nestjs/common'; +import { AuthGuard } from '../auth/auth.guard.js'; +import { CurrentUser } from '../auth/current-user.decorator.js'; +import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js'; +import type { RuntimeProviderRequestContext } from '../agent/runtime-provider-registry.service.js'; +import type { + MosCoordinationObservationDto, + MosCoordinationResponseDto, + MosCoordinationResultDto, + CreateMosHandoffDto, +} from './mos-coordination.dto.js'; +import { MosCoordinationService } from './mos-coordination.service.js'; + +/** Authenticated interaction-plane boundary for the handoff/observe/result-only Mos contract. */ +@Controller('api/coord/mos') +@UseGuards(AuthGuard) +export class MosCoordinationController { + constructor( + @Inject(MosCoordinationService) private readonly coordination: MosCoordinationService, + ) {} + + @Post('handoff') + async handoff( + @Body() request: CreateMosHandoffDto, + @CurrentUser() user: AuthenticatedUserLike, + @Headers('x-correlation-id') correlationId?: string, + ): Promise { + return { receipt: await this.coordination.handoff(request, this.context(user, correlationId)) }; + } + + @Get(':handoffId/observe') + async observe( + @Param('handoffId') handoffId: string, + @CurrentUser() user: AuthenticatedUserLike, + @Headers('x-correlation-id') correlationId?: string, + ): Promise { + return { + observation: await this.coordination.observe(handoffId, this.context(user, correlationId)), + }; + } + + @Get(':handoffId/result') + async result( + @Param('handoffId') handoffId: string, + @CurrentUser() user: AuthenticatedUserLike, + @Headers('x-correlation-id') correlationId?: string, + ): Promise { + return { result: await this.coordination.result(handoffId, this.context(user, correlationId)) }; + } + + private context( + user: AuthenticatedUserLike, + correlationId?: string, + ): RuntimeProviderRequestContext { + const requestCorrelationId = correlationId?.trim(); + if (!requestCorrelationId) throw new ForbiddenException('X-Correlation-Id is required'); + return { + actorScope: scopeFromUser(user), + channelId: 'cli', + correlationId: requestCorrelationId, + }; + } +} From 3378b857ebcc19e5658191606923986f707acd21 Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Mon, 13 Jul 2026 13:44:20 +0000 Subject: [PATCH 031/152] feat(memory): bind operator plugin to agent sessions (#739) --- .../__tests__/agent-service-ownership.test.ts | 45 +++++++++++++++++-- apps/gateway/src/agent/agent.service.ts | 23 ++++++++-- .../src/agent/tools/memory-tools.test.ts | 41 +++++++++++++++++ apps/gateway/src/agent/tools/memory-tools.ts | 32 +++++++++++-- apps/gateway/src/memory/memory.module.ts | 22 ++++++++- 5 files changed, 152 insertions(+), 11 deletions(-) create mode 100644 apps/gateway/src/agent/tools/memory-tools.test.ts diff --git a/apps/gateway/src/agent/__tests__/agent-service-ownership.test.ts b/apps/gateway/src/agent/__tests__/agent-service-ownership.test.ts index b0151550..83e03684 100644 --- a/apps/gateway/src/agent/__tests__/agent-service-ownership.test.ts +++ b/apps/gateway/src/agent/__tests__/agent-service-ownership.test.ts @@ -12,18 +12,24 @@ type AgentServiceInternals = { creating: Map>; }; -function makeService(): AgentService { +function makeService(operatorMemory: unknown = null): AgentService { return new AgentService( - {} as never, + { + getDefaultModel: vi.fn(() => null), + getRegistry: vi.fn(() => ({})), + findModel: vi.fn(), + listAvailableModels: vi.fn(() => []), + } as never, {} as never, {} as never, { available: false } as never, {} as never, - {} as never, - {} as never, + { getToolDefinitions: vi.fn(() => []) } as never, + { loadForSession: vi.fn(async () => ({ metaTools: [], promptAdditions: [] })) } as never, null, null, { collect: vi.fn().mockResolvedValue(undefined) } as never, + operatorMemory as never, ); } @@ -120,6 +126,37 @@ describe('AgentService owner/tenant scope enforcement', () => { expect(internals(service).sessions.has(CONVERSATION_ID)).toBe(false); }); + it('derives the operator-memory scope on the createSession production path', async () => { + const plugin = { capture: vi.fn(), search: vi.fn() }; + const service = makeService(plugin); + const buildTools = vi.spyOn(service as never, 'buildToolsForSandbox').mockReturnValue([]); + + // Session construction reaches the real scope derivation before the intentionally incomplete + // Pi test double rejects later in createAgentSession. + await service.createSession(CONVERSATION_ID, OWNER_SCOPE).catch(() => undefined); + + expect(buildTools).toHaveBeenCalledWith(expect.any(String), OWNER_SCOPE.userId, { + tenantId: OWNER_SCOPE.tenantId, + ownerId: OWNER_SCOPE.userId, + sessionId: CONVERSATION_ID, + }); + }); + + it('denies a foreign actor before it can obtain another session operator-memory scope', async () => { + const plugin = { capture: vi.fn(), search: vi.fn() }; + const service = makeService(plugin); + internals(service).sessions.set(CONVERSATION_ID, makeSession()); + const buildTools = vi.spyOn(service as never, 'buildToolsForSandbox'); + + await expect(service.createSession(CONVERSATION_ID, FOREIGN_SCOPE)).rejects.toBeInstanceOf( + ForbiddenException, + ); + + expect(buildTools).not.toHaveBeenCalled(); + expect(plugin.capture).not.toHaveBeenCalled(); + expect(plugin.search).not.toHaveBeenCalled(); + }); + it('checks owner/tenant scope before returning an in-flight session creation', async () => { const service = makeService(); const session = makeSession(); diff --git a/apps/gateway/src/agent/agent.service.ts b/apps/gateway/src/agent/agent.service.ts index f5b2347e..4ac42d57 100644 --- a/apps/gateway/src/agent/agent.service.ts +++ b/apps/gateway/src/agent/agent.service.ts @@ -15,9 +15,10 @@ import { type ToolDefinition, } from '@mariozechner/pi-coding-agent'; import type { Brain } from '@mosaicstack/brain'; -import type { Memory } from '@mosaicstack/memory'; +import type { Memory, OperatorMemoryPlugin } from '@mosaicstack/memory'; import { BRAIN } from '../brain/brain.tokens.js'; import { MEMORY } from '../memory/memory.tokens.js'; +import { OPERATOR_MEMORY_PLUGIN } from '../memory/memory.module.js'; import { EmbeddingService } from '../memory/embedding.service.js'; import { CoordService } from '../coord/coord.service.js'; import { ProviderService } from './provider.service.js'; @@ -135,6 +136,9 @@ export class AgentService implements OnModuleDestroy { @Inject(PreferencesService) private readonly preferencesService: PreferencesService | null, @Inject(SessionGCService) private readonly gc: SessionGCService, + @Optional() + @Inject(OPERATOR_MEMORY_PLUGIN) + private readonly operatorMemory: OperatorMemoryPlugin | null = null, ) {} /** @@ -146,6 +150,7 @@ export class AgentService implements OnModuleDestroy { private buildToolsForSandbox( sandboxDir: string, sessionUserId: string | undefined, + sessionScope?: { tenantId: string; ownerId: string; sessionId: string }, ): ToolDefinition[] { return [ ...createBrainTools(this.brain), @@ -154,6 +159,9 @@ export class AgentService implements OnModuleDestroy { this.memory, this.embeddingService.available ? this.embeddingService : null, sessionUserId, + this.operatorMemory && sessionScope + ? { plugin: this.operatorMemory, scope: sessionScope } + : undefined, ), ...createFileTools(sandboxDir), ...createGitTools(sandboxDir), @@ -228,6 +236,7 @@ export class AgentService implements OnModuleDestroy { isAdmin: options.isAdmin, agentConfigId: options.agentConfigId, userId: options.userId, + tenantId: options.tenantId, conversationHistory: options.conversationHistory, }; this.logger.log( @@ -267,7 +276,15 @@ export class AgentService implements OnModuleDestroy { } // Build per-session tools scoped to the sandbox directory and authenticated user - const sandboxTools = this.buildToolsForSandbox(sandboxDir, mergedOptions?.userId); + const sessionUserId = mergedOptions?.userId; + const sessionTenantId = this.tenantIdFor(sessionUserId, mergedOptions?.tenantId); + const sandboxTools = this.buildToolsForSandbox( + sandboxDir, + sessionUserId, + sessionUserId && sessionTenantId + ? { tenantId: sessionTenantId, ownerId: sessionUserId, sessionId } + : undefined, + ); // Combine static tools with dynamically discovered MCP client tools and skill tools const mcpTools = this.mcpClientService.getToolDefinitions(); @@ -362,7 +379,7 @@ export class AgentService implements OnModuleDestroy { sandboxDir, allowedTools, userId: mergedOptions?.userId, - tenantId: this.tenantIdFor(mergedOptions?.userId, mergedOptions?.tenantId), + tenantId: sessionTenantId, agentConfigId: mergedOptions?.agentConfigId, agentName: resolvedAgentName, metrics: { diff --git a/apps/gateway/src/agent/tools/memory-tools.test.ts b/apps/gateway/src/agent/tools/memory-tools.test.ts new file mode 100644 index 00000000..d90e7bf0 --- /dev/null +++ b/apps/gateway/src/agent/tools/memory-tools.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createMemoryTools } from './memory-tools.js'; + +describe('createMemoryTools operator retrieval binding', () => { + const memory = { + insights: { searchByEmbedding: vi.fn(), create: vi.fn() }, + preferences: { findByUserAndCategory: vi.fn(), findByUser: vi.fn(), upsert: vi.fn() }, + }; + const scope = { tenantId: 'tenant-a', ownerId: 'owner-a', sessionId: 'session-a' }; + + it('uses the configured plugin with the server-derived scope for retrieval and capture', async () => { + const plugin = { + search: vi.fn(async () => []), + capture: vi.fn(async () => ({ id: 'insight-1' })), + }; + const tools = createMemoryTools(memory as never, null, 'owner-a', { + plugin: plugin as never, + scope, + }); + + await tools + .find((tool) => tool.name === 'memory_search')! + .execute('call-1', { query: 'plans' }, undefined, undefined, {} as never); + await tools + .find((tool) => tool.name === 'memory_save_insight')! + .execute( + 'call-2', + { content: 'secret', category: 'decision' }, + undefined, + undefined, + {} as never, + ); + + expect(plugin.search).toHaveBeenCalledWith(scope, 'plans', 5); + expect(plugin.capture).toHaveBeenCalledWith(scope, { + content: 'secret', + source: 'agent', + category: 'decision', + }); + }); +}); diff --git a/apps/gateway/src/agent/tools/memory-tools.ts b/apps/gateway/src/agent/tools/memory-tools.ts index ab1809ac..ec9b744d 100644 --- a/apps/gateway/src/agent/tools/memory-tools.ts +++ b/apps/gateway/src/agent/tools/memory-tools.ts @@ -1,7 +1,11 @@ import { Type } from '@sinclair/typebox'; import type { ToolDefinition } from '@mariozechner/pi-coding-agent'; -import type { Memory } from '@mosaicstack/memory'; -import type { EmbeddingProvider } from '@mosaicstack/memory'; +import type { + EmbeddingProvider, + Memory, + OperatorMemoryPlugin, + OperatorMemoryScope, +} from '@mosaicstack/memory'; /** * Create memory tools bound to the session's authenticated userId. @@ -13,8 +17,10 @@ import type { EmbeddingProvider } from '@mosaicstack/memory'; export function createMemoryTools( memory: Memory, embeddingProvider: EmbeddingProvider | null, - /** Authenticated user ID from the session. All memory operations are scoped to this user. */ + /** Authenticated user ID from the session. All preference operations are scoped to this user. */ sessionUserId: string | undefined, + /** Optional configured retrieval plugin, bound to a server-derived session scope. */ + operatorMemory?: { plugin: OperatorMemoryPlugin; scope: OperatorMemoryScope }, ): ToolDefinition[] { /** Return an error result when no session user is bound. */ function noUserError() { @@ -46,6 +52,14 @@ export function createMemoryTools( limit?: number; }; + if (operatorMemory) { + const results = await operatorMemory.plugin.search(operatorMemory.scope, query, limit ?? 5); + return { + content: [{ type: 'text' as const, text: JSON.stringify(results, null, 2) }], + details: undefined, + }; + } + if (!embeddingProvider) { return { content: [ @@ -158,6 +172,18 @@ export function createMemoryTools( }; type Cat = 'decision' | 'learning' | 'preference' | 'fact' | 'pattern' | 'general'; + if (operatorMemory) { + const insight = await operatorMemory.plugin.capture(operatorMemory.scope, { + content, + source: 'agent', + category: category ?? 'learning', + }); + return { + content: [{ type: 'text' as const, text: JSON.stringify(insight, null, 2) }], + details: undefined, + }; + } + let embedding: number[] | null = null; if (embeddingProvider) { embedding = await embeddingProvider.embed(content); diff --git a/apps/gateway/src/memory/memory.module.ts b/apps/gateway/src/memory/memory.module.ts index 779ad40c..56e02789 100644 --- a/apps/gateway/src/memory/memory.module.ts +++ b/apps/gateway/src/memory/memory.module.ts @@ -3,8 +3,10 @@ import { createMemory, type Memory, createMemoryAdapter, + createOperatorMemoryPlugin, type MemoryAdapter, type MemoryConfig, + type OperatorMemoryPlugin, } from '@mosaicstack/memory'; import type { Db } from '@mosaicstack/db'; import type { StorageAdapter } from '@mosaicstack/storage'; @@ -14,6 +16,9 @@ import { DB, STORAGE_ADAPTER } from '../database/database.module.js'; import { MEMORY } from './memory.tokens.js'; import { MemoryController } from './memory.controller.js'; import { EmbeddingService } from './embedding.service.js'; +import { redactSensitiveContent } from '@mosaicstack/log'; + +export const OPERATOR_MEMORY_PLUGIN = 'OPERATOR_MEMORY_PLUGIN'; export const MEMORY_ADAPTER = 'MEMORY_ADAPTER'; @@ -38,9 +43,24 @@ function buildMemoryConfig(config: MosaicConfig, storageAdapter: StorageAdapter) createMemoryAdapter(buildMemoryConfig(config, storageAdapter)), inject: [MOSAIC_CONFIG, STORAGE_ADAPTER], }, + { + provide: OPERATOR_MEMORY_PLUGIN, + useFactory: (adapter: MemoryAdapter): OperatorMemoryPlugin | null => { + const instanceId = process.env['MOSAIC_OPERATOR_MEMORY_INSTANCE_ID']?.trim(); + const namespace = process.env['MOSAIC_OPERATOR_MEMORY_NAMESPACE']?.trim(); + if (!instanceId || !namespace) return null; + return createOperatorMemoryPlugin({ + adapter, + instanceId, + namespace, + redact: (content) => redactSensitiveContent(content).content, + }); + }, + inject: [MEMORY_ADAPTER], + }, EmbeddingService, ], controllers: [MemoryController], - exports: [MEMORY, MEMORY_ADAPTER, EmbeddingService], + exports: [MEMORY, MEMORY_ADAPTER, OPERATOR_MEMORY_PLUGIN, EmbeddingService], }) export class MemoryModule {} From b7b0f508e6cc02eaa120f6eaedf5923f6ccee416 Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Mon, 13 Jul 2026 14:45:08 +0000 Subject: [PATCH 032/152] feat(gateway): register Hermes runtime provider (#740) --- apps/gateway/src/agent/agent.module.ts | 11 +- .../hermes-runtime-reachability.e2e.test.ts | 144 ++++++++++++++++++ .../agent/hermes-runtime.transport.test.ts | 46 ++++++ .../src/agent/hermes-runtime.transport.ts | 121 +++++++++++++++ .../src/agent/interaction.controller.test.ts | 32 +++- .../src/agent/interaction.controller.ts | 14 ++ .../runtime-provider-registry.service.ts | 33 +++- packages/log/src/runtime-audit.ts | 3 +- 8 files changed, 399 insertions(+), 5 deletions(-) create mode 100644 apps/gateway/src/agent/hermes-runtime-reachability.e2e.test.ts create mode 100644 apps/gateway/src/agent/hermes-runtime.transport.test.ts create mode 100644 apps/gateway/src/agent/hermes-runtime.transport.ts diff --git a/apps/gateway/src/agent/agent.module.ts b/apps/gateway/src/agent/agent.module.ts index f563bcc7..0c8b1b6a 100644 --- a/apps/gateway/src/agent/agent.module.ts +++ b/apps/gateway/src/agent/agent.module.ts @@ -1,5 +1,5 @@ import { Global, Module } from '@nestjs/common'; -import { AgentRuntimeProviderRegistry } from '@mosaicstack/agent'; +import { AgentRuntimeProviderRegistry, HermesRuntimeProvider } from '@mosaicstack/agent'; import { AgentService } from './agent.service.js'; import { ProviderService } from './provider.service.js'; import { ProviderCredentialsService } from './provider-credentials.service.js'; @@ -20,6 +20,7 @@ import { GCModule } from '../gc/gc.module.js'; import { LogModule } from '../log/log.module.js'; import { CommandsModule } from '../commands/commands.module.js'; import { CommandRuntimeApprovalVerifier } from '../commands/runtime-approval-verifier.js'; +import { GatewayHermesRuntimeTransport } from './hermes-runtime.transport.js'; import { AGENT_RUNTIME_PROVIDER_REGISTRY, RUNTIME_APPROVAL_VERIFIER, @@ -28,6 +29,12 @@ import { RuntimeProviderService, } from './runtime-provider-registry.service.js'; +export function createGatewayRuntimeProviderRegistry(): AgentRuntimeProviderRegistry { + const registry = new AgentRuntimeProviderRegistry(); + registry.register(new HermesRuntimeProvider(new GatewayHermesRuntimeTransport())); + return registry; +} + @Global() @Module({ imports: [CoordModule, McpClientModule, SkillsModule, GCModule, LogModule, CommandsModule], @@ -41,7 +48,7 @@ import { TessDurableSessionService, { provide: AGENT_RUNTIME_PROVIDER_REGISTRY, - useFactory: (): AgentRuntimeProviderRegistry => new AgentRuntimeProviderRegistry(), + useFactory: createGatewayRuntimeProviderRegistry, }, RuntimeProviderAuditService, { diff --git a/apps/gateway/src/agent/hermes-runtime-reachability.e2e.test.ts b/apps/gateway/src/agent/hermes-runtime-reachability.e2e.test.ts new file mode 100644 index 00000000..625bd97e --- /dev/null +++ b/apps/gateway/src/agent/hermes-runtime-reachability.e2e.test.ts @@ -0,0 +1,144 @@ +import 'reflect-metadata'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { Global, Module } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify'; +import { HermesRuntimeProvider } from '@mosaicstack/agent'; +import { AgentModule } from './agent.module.js'; +import { AUTH } from '../auth/auth.tokens.js'; +import { AuthGuard } from '../auth/auth.guard.js'; +import { BRAIN } from '../brain/brain.tokens.js'; +import { DB } from '../database/database.module.js'; +import { CoordModule } from '../coord/coord.module.js'; +import { McpClientModule } from '../mcp-client/mcp-client.module.js'; +import { SkillsModule } from '../skills/skills.module.js'; +import { GCModule } from '../gc/gc.module.js'; +import { LogModule } from '../log/log.module.js'; +import { CommandsModule } from '../commands/commands.module.js'; +import { + AGENT_RUNTIME_PROVIDER_REGISTRY, + RUNTIME_APPROVAL_VERIFIER, + RUNTIME_PROVIDER_AUDIT_SINK, + RuntimeProviderAuditService, +} from './runtime-provider-registry.service.js'; +import { TessDurableSessionService } from './tess-durable-session.service.js'; +import { TessDurableSessionRepository } from './tess-durable-session.repository.js'; +import { AgentService } from './agent.service.js'; +import { ProviderService } from './provider.service.js'; +import { ProviderCredentialsService } from './provider-credentials.service.js'; +import { RoutingService } from './routing.service.js'; +import { RoutingEngineService } from './routing/routing-engine.service.js'; +import { SkillLoaderService } from './skill-loader.service.js'; + +const authenticatedUser = { id: 'operator-1', tenantId: 'tenant-1' }; + +@Module({}) +class EmptyAgentDependencyModule {} + +@Global() +@Module({ + providers: [ + { + provide: AUTH, + useValue: { + api: { + getSession: vi.fn(async ({ headers }: { headers: Headers }) => + headers.get('cookie') === 'session=trusted' + ? { user: authenticatedUser, session: { id: 'session-1' } } + : null, + ), + }, + }, + }, + AuthGuard, + { provide: BRAIN, useValue: {} }, + { provide: DB, useValue: {} }, + ], + exports: [AUTH, AuthGuard, BRAIN, DB], +}) +class AuthenticatedRequestModule {} + +/** + * This is deliberately an HTTP test rather than a controller unit test: it + * exercises AgentModule's actual provider factory, Nest DI, and AuthGuard. + */ +describe('Hermes runtime provider reachability', (): void => { + let app: NestFastifyApplication | undefined; + + beforeAll(async (): Promise => { + process.env['MOSAIC_AGENT_NAME'] = 'Nova'; + const moduleRef = await Test.createTestingModule({ + imports: [AuthenticatedRequestModule, AgentModule], + }) + .overrideModule(CoordModule) + .useModule(EmptyAgentDependencyModule) + .overrideModule(McpClientModule) + .useModule(EmptyAgentDependencyModule) + .overrideModule(SkillsModule) + .useModule(EmptyAgentDependencyModule) + .overrideModule(GCModule) + .useModule(EmptyAgentDependencyModule) + .overrideModule(LogModule) + .useModule(EmptyAgentDependencyModule) + .overrideModule(CommandsModule) + .useModule(EmptyAgentDependencyModule) + .overrideProvider(RuntimeProviderAuditService) + .useValue({ record: vi.fn().mockResolvedValue(undefined) }) + .overrideProvider(RUNTIME_PROVIDER_AUDIT_SINK) + .useValue({ record: vi.fn().mockResolvedValue(undefined) }) + .overrideProvider(RUNTIME_APPROVAL_VERIFIER) + .useValue({ consume: vi.fn().mockResolvedValue(false) }) + .overrideProvider(TessDurableSessionService) + .useValue({}) + .overrideProvider(TessDurableSessionRepository) + .useValue({}) + .overrideProvider(AgentService) + .useValue({}) + .overrideProvider(ProviderService) + .useValue({}) + .overrideProvider(ProviderCredentialsService) + .useValue({}) + .overrideProvider(RoutingService) + .useValue({}) + .overrideProvider(RoutingEngineService) + .useValue({}) + .overrideProvider(SkillLoaderService) + .useValue({}) + .compile(); + + app = moduleRef.createNestApplication(new FastifyAdapter()); + await app.init(); + await app.getHttpAdapter().getInstance().ready(); + }); + + afterAll(async (): Promise => { + await app?.close(); + }); + + it('requires authentication and reaches the Hermes provider registered by AgentModule', async (): Promise => { + if (!app) throw new Error('Nest application did not initialize'); + const registry = app.get(AGENT_RUNTIME_PROVIDER_REGISTRY); + expect(registry.get('runtime.hermes')).toBeInstanceOf(HermesRuntimeProvider); + + const denied = await app.inject({ + method: 'GET', + url: '/api/interaction/Nova/transitional-capabilities?provider=runtime.hermes', + headers: { 'x-correlation-id': 'correlation-1' }, + }); + expect(denied.statusCode).toBe(401); + + const response = await app.inject({ + method: 'GET', + url: '/api/interaction/Nova/transitional-capabilities?provider=runtime.hermes', + headers: { cookie: 'session=trusted', 'x-correlation-id': 'correlation-1' }, + }); + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual([ + { capability: 'kanban', status: 'unsupported' }, + { capability: 'skills', status: 'unsupported' }, + { capability: 'memory', status: 'unsupported' }, + { capability: 'tools', status: 'unsupported' }, + { capability: 'cron', status: 'unsupported' }, + ]); + }); +}); diff --git a/apps/gateway/src/agent/hermes-runtime.transport.test.ts b/apps/gateway/src/agent/hermes-runtime.transport.test.ts new file mode 100644 index 00000000..48e7ff4a --- /dev/null +++ b/apps/gateway/src/agent/hermes-runtime.transport.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it, vi } from 'vitest'; +import { GatewayHermesRuntimeTransport } from './hermes-runtime.transport.js'; + +const scope = { + actorId: 'owner-1', + tenantId: 'tenant-1', + channelId: 'cli', + correlationId: 'correlation-1', +}; + +describe('GatewayHermesRuntimeTransport', () => { + it('preserves a configured path prefix and authenticates the concrete runtime request', async () => { + const fetchFn = vi + .fn() + .mockResolvedValue(new Response(JSON.stringify(['session.list']), { status: 200 })); + const transport = new GatewayHermesRuntimeTransport( + 'https://runtime.example.test/hermes', + 'test-service-token', + fetchFn, + ); + + await expect(transport.capabilities(scope)).resolves.toEqual(['session.list']); + + expect(fetchFn).toHaveBeenCalledWith( + new URL('https://runtime.example.test/hermes/capabilities'), + expect.objectContaining({ + headers: expect.objectContaining({ + authorization: 'Bearer test-service-token', + 'x-mosaic-channel-id': 'cli', + }), + }), + ); + }); + + it('rejects non-loopback HTTP runtime endpoints before sending identity headers', async () => { + const fetchFn = vi.fn(); + const transport = new GatewayHermesRuntimeTransport( + 'http://runtime.example.test/hermes', + 'test-service-token', + fetchFn, + ); + + await expect(transport.capabilities(scope)).rejects.toThrow('requires HTTPS'); + expect(fetchFn).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/gateway/src/agent/hermes-runtime.transport.ts b/apps/gateway/src/agent/hermes-runtime.transport.ts new file mode 100644 index 00000000..c095c350 --- /dev/null +++ b/apps/gateway/src/agent/hermes-runtime.transport.ts @@ -0,0 +1,121 @@ +import type { HermesLegacySession, HermesRuntimeTransport } from '@mosaicstack/agent'; +import type { + RuntimeAttachHandle, + RuntimeAttachMode, + RuntimeMessage, + RuntimeScope, + RuntimeStreamEvent, +} from '@mosaicstack/types'; + +/** Concrete HTTP transport for a configured legacy Hermes runtime endpoint. */ +export class GatewayHermesRuntimeTransport implements HermesRuntimeTransport { + constructor( + private readonly baseUrl = process.env['MOSAIC_HERMES_RUNTIME_URL']?.trim(), + private readonly serviceToken = process.env['MOSAIC_HERMES_RUNTIME_TOKEN']?.trim(), + private readonly fetchFn: typeof fetch = fetch, + ) {} + + async capabilities(scope: RuntimeScope): Promise { + return this.request('/capabilities', scope); + } + + async health(scope: RuntimeScope): Promise<{ status: string; detail?: string }> { + return this.request<{ status: string; detail?: string }>('/health', scope); + } + + async sessions(scope: RuntimeScope): Promise { + return this.request('/sessions', scope); + } + + async *stream( + sessionId: string, + cursor: string | undefined, + scope: RuntimeScope, + ): AsyncIterable { + const params = new URLSearchParams(cursor ? { cursor } : {}); + const events = await this.request( + `/sessions/${encodeURIComponent(sessionId)}/stream?${params.toString()}`, + scope, + ); + yield* events; + } + + async send(sessionId: string, message: RuntimeMessage, scope: RuntimeScope): Promise { + await this.request(`/sessions/${encodeURIComponent(sessionId)}/messages`, scope, { + method: 'POST', + body: message, + }); + } + + async attach( + sessionId: string, + mode: RuntimeAttachMode, + scope: RuntimeScope, + ): Promise { + return this.request( + `/sessions/${encodeURIComponent(sessionId)}/attach`, + scope, + { + method: 'POST', + body: { mode }, + }, + ); + } + + async detach(attachmentId: string, scope: RuntimeScope): Promise { + await this.request(`/attachments/${encodeURIComponent(attachmentId)}`, scope, { + method: 'DELETE', + }); + } + + async terminate(sessionId: string, approvalRef: string, scope: RuntimeScope): Promise { + await this.request(`/sessions/${encodeURIComponent(sessionId)}/terminate`, scope, { + method: 'POST', + body: { approvalRef }, + }); + } + + private async request( + path: string, + scope: RuntimeScope, + init: { method?: string; body?: unknown } = {}, + ): Promise { + if (!this.baseUrl || !this.serviceToken) { + throw new Error( + 'MOSAIC_HERMES_RUNTIME_URL and MOSAIC_HERMES_RUNTIME_TOKEN must configure Hermes transport', + ); + } + const endpoint = new URL(this.baseUrl); + if (endpoint.protocol !== 'https:' && !isLoopbackHttp(endpoint)) { + throw new Error('Hermes runtime transport requires HTTPS outside loopback'); + } + const response = await this.fetchFn( + new URL(path.replace(/^\//, ''), `${endpoint.toString().replace(/\/$/, '')}/`), + { + method: init.method ?? 'GET', + headers: { + accept: 'application/json', + authorization: `Bearer ${this.serviceToken}`, + 'x-mosaic-actor-id': scope.actorId, + 'x-mosaic-tenant-id': scope.tenantId, + 'x-mosaic-channel-id': scope.channelId, + 'x-correlation-id': scope.correlationId, + ...(init.body ? { 'content-type': 'application/json' } : {}), + }, + ...(init.body ? { body: JSON.stringify(init.body) } : {}), + }, + ); + if (!response.ok) throw new Error(`Hermes runtime request failed: ${response.status}`); + if (response.status === 204) return undefined as T; + return (await response.json()) as T; + } +} + +function isLoopbackHttp(endpoint: URL): boolean { + return ( + endpoint.protocol === 'http:' && + (endpoint.hostname === 'localhost' || + endpoint.hostname === '127.0.0.1' || + endpoint.hostname === '::1') + ); +} diff --git a/apps/gateway/src/agent/interaction.controller.test.ts b/apps/gateway/src/agent/interaction.controller.test.ts index db0dad47..78030a6a 100644 --- a/apps/gateway/src/agent/interaction.controller.test.ts +++ b/apps/gateway/src/agent/interaction.controller.test.ts @@ -1,6 +1,10 @@ +import { createGatewayRuntimeProviderRegistry } from './agent.module.js'; import { firstValueFrom } from 'rxjs'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { RuntimeApprovalDeniedError } from './runtime-provider-registry.service.js'; +import { + RuntimeApprovalDeniedError, + RuntimeProviderService, +} from './runtime-provider-registry.service.js'; import { RuntimeApprovalDeniedFilter } from './runtime-approval-denied.filter.js'; import { InteractionController } from './interaction.controller.js'; @@ -39,6 +43,32 @@ describe('InteractionController', (): void => { else process.env['MOSAIC_AGENT_NAME'] = prior; }); + it('reaches the registered Hermes provider through the authenticated transitional matrix route', async () => { + process.env['MOSAIC_AGENT_NAME'] = 'Nova'; + const registry = createGatewayRuntimeProviderRegistry(); + const runtime = new RuntimeProviderService( + registry, + { record: vi.fn().mockResolvedValue(undefined) }, + { consume: vi.fn().mockResolvedValue(false) }, + ); + const controller = new InteractionController(runtime, {} as never); + + await expect( + controller.transitionalCapabilities( + 'Nova', + 'runtime.hermes', + { id: 'owner', tenantId: 'team' }, + 'corr-1', + ), + ).resolves.toEqual([ + { capability: 'kanban', status: 'unsupported' }, + { capability: 'skills', status: 'unsupported' }, + { capability: 'memory', status: 'unsupported' }, + { capability: 'tools', status: 'unsupported' }, + { capability: 'cron', status: 'unsupported' }, + ]); + }); + it('rejects a request without the non-simple correlation header', async () => { process.env['MOSAIC_AGENT_NAME'] = 'Nova'; const controller = new InteractionController({ listSessions: vi.fn() } as never, {} as never); diff --git a/apps/gateway/src/agent/interaction.controller.ts b/apps/gateway/src/agent/interaction.controller.ts index cc247fd1..04b77d56 100644 --- a/apps/gateway/src/agent/interaction.controller.ts +++ b/apps/gateway/src/agent/interaction.controller.ts @@ -51,6 +51,20 @@ export class InteractionController { ); } + @Get('transitional-capabilities') + async transitionalCapabilities( + @Param('agentName') agentName: string, + @Query('provider') providerId: string, + @CurrentUser() user: AuthenticatedUserLike, + @Headers('x-correlation-id') correlationId?: string, + ) { + this.assertConfiguredAgent(agentName); + return this.runtime.transitionalCapabilityMatrix( + this.requiredProvider(providerId), + this.context(user, correlationId), + ); + } + @Get('tree') async tree( @Param('agentName') agentName: string, diff --git a/apps/gateway/src/agent/runtime-provider-registry.service.ts b/apps/gateway/src/agent/runtime-provider-registry.service.ts index b824bb04..e62953d9 100644 --- a/apps/gateway/src/agent/runtime-provider-registry.service.ts +++ b/apps/gateway/src/agent/runtime-provider-registry.service.ts @@ -17,6 +17,8 @@ import type { RuntimeSession, RuntimeSessionTree, RuntimeStreamEvent, + TransitionalCapabilityInventoryEntry, + TransitionalCapabilityInventoryProvider, } from '@mosaicstack/types'; import type { ActorTenantScope } from '../auth/session-scope.js'; import { LOG_SERVICE } from '../log/log.tokens.js'; @@ -28,7 +30,8 @@ export const RUNTIME_APPROVAL_VERIFIER = Symbol('RUNTIME_APPROVAL_VERIFIER'); export type RuntimeProviderOperation = | RuntimeCapability | 'runtime.capabilities' - | 'runtime.health'; + | 'runtime.health' + | 'runtime.transitional-capabilities'; export type RuntimeProviderAuditOutcome = 'requested' | 'succeeded' | 'denied' | 'failed'; /** Trusted server-side context only; it intentionally excludes client-provided identity fields. */ @@ -71,6 +74,15 @@ export interface RuntimeApprovalVerifier { consume(approvalRef: string, action: RuntimeTerminationAction): Promise; } +function isTransitionalInventoryProvider( + provider: AgentRuntimeProvider, +): provider is AgentRuntimeProvider & TransitionalCapabilityInventoryProvider { + return ( + typeof (provider as Partial) + .transitionalCapabilityMatrix === 'function' + ); +} + function configuredAgentName(): string { const agentName = process.env['MOSAIC_AGENT_NAME']?.trim(); if (!agentName) throw new RuntimeApprovalDeniedError(); @@ -151,6 +163,25 @@ export class RuntimeProviderService { ); } + async transitionalCapabilityMatrix( + providerId: string, + context: RuntimeProviderRequestContext, + ): Promise { + return this.execute( + providerId, + 'runtime.transitional-capabilities', + undefined, + undefined, + context, + async (provider: AgentRuntimeProvider, scope: RuntimeScope) => { + if (!isTransitionalInventoryProvider(provider)) { + throw new NotFoundException('Runtime provider has no transitional capability inventory'); + } + return provider.transitionalCapabilityMatrix(scope); + }, + ); + } + async listSessions( providerId: string, context: RuntimeProviderRequestContext, diff --git a/packages/log/src/runtime-audit.ts b/packages/log/src/runtime-audit.ts index 12923290..66bc00f3 100644 --- a/packages/log/src/runtime-audit.ts +++ b/packages/log/src/runtime-audit.ts @@ -9,7 +9,8 @@ export type RuntimeAuditOperation = | 'session.attach' | 'session.terminate' | 'runtime.capabilities' - | 'runtime.health'; + | 'runtime.health' + | 'runtime.transitional-capabilities'; export type RuntimeAuditOutcome = 'requested' | 'succeeded' | 'denied' | 'failed'; export type RuntimeAuditErrorCode = 'policy_denied' | 'provider_error'; From f40e6ba38821360f1f0bcf1d996d3032e929b46c Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Mon, 13 Jul 2026 15:00:15 +0000 Subject: [PATCH 033/152] docs(tess): sync M4 tracking to merged reality (M4 in-progress / gate-pending) (#741) --- docs/tess/MISSION-MANIFEST.md | 2 +- docs/tess/TASKS.md | 31 ++++++++++++++++++++----------- docs/tess/VERIFICATION-MATRIX.md | 26 +++++++++++++------------- 3 files changed, 34 insertions(+), 25 deletions(-) diff --git a/docs/tess/MISSION-MANIFEST.md b/docs/tess/MISSION-MANIFEST.md index 19c8bd6b..5c3effe7 100644 --- a/docs/tess/MISSION-MANIFEST.md +++ b/docs/tess/MISSION-MANIFEST.md @@ -32,7 +32,7 @@ Ship Tess as Jason's durable Pi-native GPT-5.6 Sol high-reasoning interaction ag | TESS-M1 | #707 | Runtime contracts and security foundation | ready | AgentRuntimeProvider, normalized events/capabilities/errors, RBAC/audit contracts and contract tests merged | | TESS-M2 | #708 | Durable Pi Tess service and state | not-started | GPT-5.6 Sol high service starts, resumes, checkpoints, and passes restart/compaction tests | | TESS-M3 | #709 | Discord and CLI interaction surfaces | not-started | One durable session works through dedicated Discord binding and `mosaic tess`, including attach and approvals | -| TESS-M4 | #710 | Fleet, Mos, Hermes, memory, state, and tool plugins | not-started | Fleet/Mos boundary and transitional capability matrix demonstrated end-to-end | +| TESS-M4 | #710 | Fleet, Mos, Hermes, memory, state, and tool plugins | in-progress | Fleet/Mos boundary and transitional capability matrix demonstrated end-to-end | | TESS-M5 | #711 | Matrix/native migration, recovery, documentation, and qualification | not-started | Transport parity, migration/rollback matrix, security review, docs, greenfield and deployment validation complete | ## Success Criteria diff --git a/docs/tess/TASKS.md b/docs/tess/TASKS.md index 24b47b24..ef6363d2 100644 --- a/docs/tess/TASKS.md +++ b/docs/tess/TASKS.md @@ -19,17 +19,26 @@ | TESS-M1-003 | done | Implement tmux/fleet runtime provider and safe attach/message/terminate capability policy | #707 | codex | packages/mosaic, packages/agent | feat/tess-fleet-provider | TESS-M1-002 | 30K | TESS-FLT-001; coder0; PR #724 base=main. MERGED to main: head 355d814f (ROR 16868). HARD BOUNDARY verified: writes/control default-deny before tmux probing unless write authority permits; final exact-target authz after roster/socket/runtime verification; exact target/prefix/runtime-drift tests; read-only attach with immutable scope handles; empty-msg/default-deny/unverified-target tests; no credential material | | TESS-M1-OBS-001 | done | Implement correlation propagation, structured runtime/provider/tool audit, health/readiness and safe effective-policy status | #707 | codex | apps/gateway, packages/agent, packages/log | feat/tess-observability-terra | TESS-M1-002 | 24K | TESS-OBS-001; SOLE owner=diskhygiene-terra. MERGED to main at REBASED head: PR #726 head 53f5414 (re-ROR comment 16886, prior 16881@adfa5c06 discarded after head move), CI 1723 green. Both @mosaicstack/log barrel exports coexist (redaction + runtime-audit), metadata-only/SHA-256 audit intact, fail-closed audit-before-side-effects intact, safe status/effective-policy/readiness | | TESS-M1-V | done | Independent architecture/security review and complete contract/abuse-suite verification | #707 | mos-reviewer | apps/gateway, packages/agent, packages/log, plugins/discord | review/tess-m1 | TESS-M1-SEC-001,TESS-M1-SEC-002,TESS-M1-SEC-003,TESS-M1-SEC-004,TESS-M1-SEC-005,TESS-M1-SEC-006,TESS-M1-003,TESS-M1-OBS-001 | 20K | Gate M2 = PASS (Mos-owned independent non-author reviewer, 2026-07-12): all 5 priority seams verified live, 60+ tests green. M2 (#708) OPEN. Orchestrator did NOT run a competing lane (prior reviewer-lane dispatch recalled). Non-gating follow-up from this review captured as TESS-M1-FUP-001 (execute() authz-error mis-classification), scheduled to land before/with TESS-M3-001 — not now | -| TESS-M1-FUP-001 | not-started | RuntimeProviderService.execute() records provider-thrown authorization errors as failed/provider_error instead of denied/policy_denied | #707 | — | apps/gateway, packages/agent | — | — | 6K | NON-GATING follow-up from M1-V review (2026-07-12). Provider-thrown authz errors (e.g. FleetRuntimeProviderError 'forbidden' from M1-003 write-authority) mis-classify as outcome:'failed'/'provider_error' rather than 'denied'/'policy_denied'. Low-priority. Land BEFORE/WITH TESS-M3-001 fleet-provider registry wiring (#709), NOT now | -| TESS-M2-001 | in-progress | Add Tess roster/profile/service pinned to GPT-5.6 Sol high with fail-fast config and observable effective policy | #708 | coder0 | packages/mosaic | feat/tess-pi-service | TESS-M1-V | 22K | TESS-PI-001; explicit AC-TESS-03 test. Mos-dispatched DIRECTLY to coder0 (2026-07-12) — orchestrator tracks, does NOT dispatch a competing lane. Branch feat/tess-pi-service from fresh origin/main 86a50138; first TDD packages/mosaic/src/fleet/tess-service-profile.test.ts. NAME-AS-CONFIG INVARIANT (MISSION-MANIFEST #6, b01fdf11): agent name = config parameter (default example only), NO hardcoded key/identifier/default; profile carries name as DATA; M2 exit gate must prove a DISTINCT name provisions cleanly with ZERO code change (coder0 enforcing via a 'Nova' provisioning case). base=main, PR-open-STOP, independent non-author ROR then Mos merges. | -| TESS-M2-002 | not-started | Implement durable session identity, inbox/outbox, approval, checkpoint, handoff, compaction and restart recovery | #708 | codex | apps/gateway, packages/agent, packages/db | feat/tess-durable-state | TESS-M2-001 | 38K | TESS-STA-001, TESS-SEC-007..008; recovery TDD | -| TESS-M2-V | not-started | Clean-host Pi launch plus model/policy status and restart/compaction/duplicate-side-effect verification | #708 | sonnet | apps/gateway/src/__tests__/integration, packages/mosaic/src | review/tess-m2 | TESS-M2-002 | 18K | Gate M3; AC-TESS-03/06 | -| TESS-M3-001 | not-started | Bind dedicated Tess Discord channel with streaming, threads, attachments, pairing/RBAC and approvals | #709 | codex | plugins/discord, apps/gateway | feat/tess-discord | TESS-M2-V,TESS-M1-SEC-004 | 35K | TESS-DSC-001 | -| TESS-M3-002 | not-started | Implement `mosaic tess` chat/status/sessions/tree/attach/send/stop/health/recover CLI | #709 | codex | packages/mosaic | feat/tess-cli | TESS-M2-V | 30K | TESS-CLI-001 | -| TESS-M3-V | not-started | Discord+CLI same-session E2E, denial/approval tests, and operator-flow review | #709 | sonnet | apps/gateway/src/__tests__/integration, plugins/discord, packages/mosaic/src | review/tess-m3 | TESS-M3-001,TESS-M3-002 | 20K | Gate M4 | -| TESS-M4-001 | not-started | Implement Mos coordination handoff/observe/result contract with authority-boundary tests | #710 | codex | packages/coord, apps/gateway | feat/tess-mos-coordination | TESS-M3-V | 25K | TESS-MOS-001 | -| TESS-M4-002 | not-started | Implement transitional Hermes runtime/capability adapter | #710 | codex | packages/agent, apps/gateway | feat/tess-hermes-adapter | TESS-M3-V | 40K | TESS-HRM-001; no legacy schema in core contracts | -| TESS-M4-003 | not-started | Implement memory/retrieval, state/inbox, runtime bootstrap, fleet diagnostics and GitOps plugin foundations | #710 | codex | packages/memory, packages/agent, packages/mosaic | feat/tess-operator-plugins | TESS-M3-V | 40K | TESS-MEM-001, TESS-PLG-001 | -| TESS-M4-V | not-started | Cross-provider capability, privacy, authority and failure-path qualification | #710 | sonnet | apps/gateway/src/__tests__/integration, packages/agent | review/tess-m4 | TESS-M4-001,TESS-M4-002,TESS-M4-003 | 22K | Gate M5 | +| TESS-M1-FUP-001 | not-started | RuntimeProviderService.execute() records provider-thrown authorization errors as failed/provider_error instead of denied/policy_denied | #707 | — | apps/gateway, packages/agent | — | — | 6K | NON-GATING follow-up from M1-V review (2026-07-12). Provider-thrown authz errors (e.g. FleetRuntimeProviderError 'forbidden' from M1-003 write-authority) mis-classify as outcome:'failed'/'provider_error' rather than 'denied'/'policy_denied'. Low-priority. Land BEFORE/WITH TESS-M3-001 fleet-provider registry wiring (#709), NOT now. UPDATE 2026-07-13: did NOT ride in #730; Mos FOLDED this into TESS-M3-003 C4 (authz-error classification + RuntimeApprovalDeniedError→403 filter) — tracked there, land with M3-003. | +| TESS-M2-001 | done | Add Tess roster/profile/service pinned to GPT-5.6 Sol high with fail-fast config and observable effective policy | #708 | coder0 | packages/mosaic | feat/tess-pi-service | TESS-M1-V | 22K | TESS-PI-001; AC-TESS-03. Mos-dispatched directly to coder0. MERGED to main by Mos (2026-07-12): PR #728 head c58e86e2, ROR comment 16905, CI 1728 green. NAME-AS-CONFIG invariant VERIFIED — identity via MOSAIC_AGENT_NAME/%i/roster data, no hardcoded tess identity in impl/service/schema/tool, Tess example-only; Nova distinct-name zero-code-change provisioning test; GPT-5.6 Sol high fail-fast; credential-safe effective-policy output; no live credential material | +| TESS-M2-002 | done | Implement durable session identity, inbox/outbox, approval, checkpoint, handoff, compaction and restart recovery | #708 | coder0 | apps/gateway, packages/agent, packages/db | feat/tess-durable-state | TESS-M2-001 | 38K | TESS-STA-001, TESS-SEC-007..008; recovery TDD. Mos-dispatched directly to coder0 (2026-07-12) — orchestrator tracks, no competing lane. Branch feat/tess-durable-state from fresh origin/main e3b5113b; first recovery TDD packages/agent/src/tess-durable-session.test.ts; reuses existing provider registry + durable approval store. base=main, PR-open-STOP, independent non-author ROR then Mos merges. Next gate after merge = TESS-M2-V (M2→M3 review). PR #729 OPEN (feat(tess): persist durable session state; base=main, mergeable=true, head 102a7b606bd492201e3d731a86397a9cfe4eb998); coder0 pnpm turbo typecheck/lint/test --force 88/88 cold-cache + format green + Codex code/security remediated; scope = durable identity/inbox-outbox idempotency/immutable checkpoints/portable handoffs/PGlite close-reopen recovery/exact one-time approvals/sealed-redacted payloads/scoped dispatch. ROR routed to reviewer lane (non-author) at exact head 102a7b60. CHANGES REQUESTED (reviewer comment 16917 @102a7b60, CI pipeline 1730 RED) — 3 blockers routed to coder0: (1) CI red: gateway PGlite close/reopen durable recovery test TIMEOUT; (2) idempotency compares REDACTED payloads so distinct secrets collapse/collide — key must be over pre-redaction canonical identity; (3) durable schema/approval NAMESPACE hardcoded to tess, violates name-as-config — must derive from configured agent name (Nova zero-code-change). coder0 remediating; head will move → re-ROR required at new exact head. REMEDIATED + re-pushed: new head 444988d23bada28d3cc9ce7e588e18e052f058ff — (1) recovery suite uses one shared PGlite fixture, close/reopen AC completes ~0.5s (was 30s timeout); (2) pre-redaction SHA-256 payload digest added to idempotency conflict checks (distinct secrets no longer collapse); (3) hardcoded tess durable DB objects/approval prefix replaced with generic interaction naming + agent-bound approval namespace. push-hook typecheck/lint/format green; CI pipeline 1731 GREEN on new head 444988d2. Head confirmed unchanged at green SHA; re-ROR routed to reviewer (non-author) at exact head 444988d2 — prior REQUEST CHANGES 16917 void at old head. RE-ROR = REQUEST CHANGES (reviewer comment 16925 @444988d2; CI 1731 green + recovery test passes 1515ms not skipped). 2 residual payload-digest blockers routed to coder0: (1) CHECKPOINT idempotency still collapses — pre-redaction digest fix reached inbox/outbox but NOT checkpoint path; distinct sensitive checkpoint payloads under same sessionId+checkpointId collapse; needs pre-redaction digest column on checkpoint conflict check; (2) digest is UNKEYED plaintext SHA-256 over sensitive payloads → allows offline plaintext confirmation; switch to KEYED HMAC-SHA256 with config-sourced fail-fast secret (never hardcoded/logged), applied to inbox/outbox + checkpoint. coder0 remediating; head will move → re-ROR at new exact head. ROUND-2 REMEDIATED + pushed: new head cbdc38af954d49016b5fdc4a6dab0497317b6a1c — checkpoint now persists pre-redaction HMAC digest; inbox/outbox switched to VERSIONED HMAC; distinct-secret + delimiter-collision regressions added; migration safe for pre-existing checkpoint rows and legacy checkpoints FAIL CLOSED (cannot prove original payload); Codex security review no findings; format/diff green, PGlite recovery executes 1.765s. CI pipeline 1732 TERMINAL GREEN (success) on cbdc38af. RE-ROR = APPROVE — canonical VERIFIED APPROVE reviewer-of-record [W-jarvis:reviewer] head cbdc38af954d49016b5fdc4a6dab0497317b6a1c (visible Gitea comment 16933). Head confirmed unchanged at ROR target + PR mergeable=true. Reviewer verified checkpoint HMAC digest + collision regressions, recovery no-duplicate-side-effect, sealed/redacted at-rest payloads, scoped dispatch, agent-bound generic namespace; CI 1732 steps ci-postgres/install/sanitization/typecheck/lint/format/test all green; no merge by reviewer (pr-review wrapper self-approve blocked → recorded as verified PR comment). MERGED to main by Mos — merge_commit 99a2d0fc, final APPROVE at head cbdc38af (3-round reviewer loop 16917→16925→16933). M2 build phase CONVERGED (M2-001 #728 + M2-002 #729). TESS-M2-V (M2→M3 gate, AC-TESS-03/06) now running as Mos-owned independent Sonnet reviewer (same arrangement as M1-V) — orchestrator does NOT launch a competing review lane; Mos reports verdict. On M2-V PASS, M3 (#709) tasks TESS-M3-001/002 unblock. | +| TESS-M2-V | done | Clean-host Pi launch plus model/policy status and restart/compaction/duplicate-side-effect verification | #708 | mos-reviewer | apps/gateway/src/__tests__/integration, packages/mosaic/src | review/tess-m2 | TESS-M2-002 | 18K | Gate M3 = PASS (Mos-owned independent Sonnet reviewer at merged main 99a2d0fc, 2026-07-13). All 5 criteria verified with live test runs: model/policy fail-fast + credential-safe; restart recovery exactly-once; keyed-HMAC pre-redaction idempotency; compaction/handoff survival; name-as-config (interaction_* everywhere, Nova tests). Orchestrator did NOT run a competing lane (same as M1-V). M3 (#709) UNBLOCKED. Non-gating follow-ups from this review captured as TESS-M2-FUP-001/002/003 below. | +| TESS-M2-FUP-001 | not-started | Delete dead unkeyed-sha256 OR-branch in tess-durable-session.repository.ts matchesContentDigest (:417-422) to match strict matchesCheckpointDigest | #708 | — | apps/gateway | — | — | 4K | NON-GATING follow-up from M2-V review (2026-07-13). Dead OR-branch NOT exploitable (all writers hmac:v1:-prefixed, content_digest NOT NULL from migration 0012) but weakens exactness guarantee. Low-priority. SCHEDULE TO LAND WITH M3 work. | +| TESS-M2-FUP-002 | not-started | Add literal approval+compaction combined integration test | #708 | — | apps/gateway/src/__tests__/integration | — | — | 4K | NON-GATING follow-up from M2-V review (2026-07-13). Subsystems currently tested separately; what matters IS covered. OPTIONAL, low-priority. | +| TESS-M2-FUP-003 | not-started | Cosmetic rename pass: 'Tess' in class/file/log names (TessDurableSessionRepository etc.) so Nova deployment logs don't say Tess | #708 | — | apps/gateway, packages/agent | — | — | 5K | NON-GATING follow-up from M2-V review (2026-07-13). Cosmetic only — functional name-as-config already correct (interaction_* data path). Low-priority. SCHEDULE TO LAND WITH M3 work (now M3-003 window). Related cosmetic logged separately as TESS-M2-FUP-004. | +| TESS-M2-FUP-004 | not-started | Cosmetic: sourceLabel ?? 'tess' default literal @packages/agent/src/tmux-fleet-runtime-provider.ts:142 | #708 | — | packages/agent | — | — | 2K | NON-GATING cosmetic follow-up (pre-existing from #722/#724), logged per Mos 2026-07-13. Same class as TESS-M2-FUP-003 (name-as-config cosmetic; functional data path already generic). Low-priority — fold into a cosmetic-rename pass alongside M2-FUP-003. | +| TESS-M3-001 | done | Bind dedicated Tess Discord channel with streaming, threads, attachments, pairing/RBAC and approvals | #709 | coder4 | plugins/discord, apps/gateway | feat/tess-discord | TESS-M2-V,TESS-M1-SEC-004 | 35K | TESS-DSC-001. Mos DIRECT-DISPATCHED to coder4 on feat/tess-discord (base=main, 2026-07-13) — orchestrator is DYOR-loaded so Mos dispatched to avoid double-dispatch; orchestrator TRACKS only, no competing lane. PR-open-STOP, independent non-author ROR then Mos merges. Land TESS-M1-FUP-001 (execute() authz-error mapping) + TESS-M2-FUP-001/003 with this M3 work. PR #730 OPEN (feat(#709): add configured Discord interaction binding; base=main, mergeable=true, head 25ed3676550649d805737ddd97cec08d49873662; config-owned Discord bindings w/ pairing/RBAC, thread metadata, attachments, streaming correlation, authenticated ingress reuse). NOTE: coder4 did not report PR-open to orchestrator; picked up from reviewer ROR. CI pipeline 1734 GREEN on head. ROR = REQUEST CHANGES (reviewer comment 16951 @25ed3676) — 2 functional blockers routed to coder4: (1) THREAD/SUB-SESSION ROUTING: allowedChannelIds check tests the THREAD id before parent-channel binding resolution → thread messages in a bound channel are wrongly rejected; must resolve parent-channel binding FIRST then evaluate allowlist against bound parent + add bound-channel-thread test; (2) APPROVAL NOT WIRED TO M2 DURABLE STORE: Discord approval op defined but no path invokes the durable M2 exact-action approval store (only send wired) — must route Discord approval through the #729 durable exact-action approval surface (one-time/exact-action consume, no replay) + denial + exact-action approval tests. Root-cause fixes only (no allowlist-loosen, no approval stub). coder4 remediating; head will move → re-ROR required at new exact head after CI terminal green. ROUND-1 REMEDIATED + pushed: new head 689d5b68706fd5f5b190b0e4d988efc59fb51acb, CI pipeline 1736 GREEN. RE-ROR = REQUEST CHANGES (reviewer comment 16968 @689d5b68) — 2 residuals routed to coder4: (1) thread allowlist directionally fixed but REQUESTED regression test absent — add explicit 'message in thread of bound channel is ACCEPTED' test; (2) CORE BLOCKER: discord:approve still calls commandExecutor.createApproval (generic slash-command approval), NOT the M2 durable exact-action store — must call createRuntimeTerminationApproval writing agent::command-approval exact-action key (durable one-time consume, no replay), plus Discord one-shot tests: approval consumes exact action once + second attempt rejected, and denial-path rejection. Root-cause only (no aliasing generic path). coder4 remediating; head will move → re-ROR at new exact head after CI terminal green. ROUND-2 REMEDIATED + pushed: head moved (via 34b82bd2), CI green — but ROUND-3 RE-ROR = REQUEST CHANGES (reviewer comment 16973 @34b82bd257154ece2f51d29df698b01150cb9a2b, CI pipeline 1738 green). Progress: discord:approve now CALLS createRuntimeTerminationApproval — but STILL WRONG: it binds the approval to DISCORD_SERVICE_USER_ID + approval-message correlation/channel, making a Discord-SILO record NOT consumable by the CLI/runtime stop exact-action path. CORE M3 AC = ONE durable exact-action approval consumable cross-surface (Discord OR CLI OR runtime); exact-action KEY must be agent::command-approval for the specific pending command/termination action (same key CLI 'mosaic tess stop'/runtime-stop consumes); Discord actor + message correlation belong in METADATA, not the key. Routed to coder4 (round-3): re-key to agent+action; add tests (a) discord:approve happy consumes exact action, (b) SAME approval consumable cross-surface by CLI/runtime stop, (c) one-shot replay rejected, (d) denial rejects, (e) thread-under-parent accept. THIRD round on the same durable-approval seam (16951→16968→16973); reviewer holding cross-surface exact-action boundary firm. coder4 remediating; re-ROR at new exact head after CI terminal green. ROUND-3 REMEDIATED + pushed: head a4c70c5a71a4a73f24f04060a301703ba5ac7530, CI pipeline 1739 GREEN — but ROUND-4 RE-ROR = REQUEST CHANGES (reviewer comment 16978 @a4c70c5a). coder4 OVER-CORRECTED: removed actor/tenant/channel/correlation from runtimeActionDigest/consume (WEAKENS command-authorization exactness — the merged M2 contract digests all 7 fields), still NO Discord-origin consume/terminate path for the minted approval, required Discord-path tests still absent (approve happy+denial, one-shot replay, thread-under-parent accept). ROUND-5 ROUTED to coder4 with the FROZEN merged contract extracted from origin/main:apps/gateway/src/commands/command-authorization.service.ts: runtimeActionDigest is sha256 over EXACTLY {providerId,sessionId,actorId,tenantId,channelId,correlationId,agentName} — do NOT modify/strip (revert round-4 change); consume also re-checks actorId+tenantId, requires approver role=admin, one-shot redis.del; store key interaction:command-approval::. Cross-surface = Discord mint and CLI/runtime stop present the SAME 7 fields of the TARGET pending termination (NOT the Discord message's own channel/correlation, NOT DISCORD_SERVICE_USER_ID); actorId = approving admin's resolved user id. Add the missing Discord-origin consume/terminate invocation (via runtime-approval-verifier.ts adapter) + 5 tests. FOURTH round same seam (16951→16968→16973→16978); reviewer holding boundary firm. coder4 remediating; re-ROR at new exact head after CI terminal green. ROUND-5: writer RE-ROUTED coder4→coder0 (Mos-dispatched follow-up; coder0 is original author of the M2 durable approval store, so the ideal lane to wire the Discord path to it — orchestrator did not have this re-route tracked, flagged to Mos for confirmation, single-writer stand-down requested from coder4). coder0 pushed head 6af68e76de3657d837875b1c9d0f5c9678429e59 (branch tip confirmed), CI Woodpecker pipeline 1742 = SUCCESS (terminal green). Scope: approve-gated stop command (provider/session/approval args), one-shot durable approval consumption, explicit stop authorization (Discord-origin consume/terminate path now present), 4 security regressions; command-authorization.service.ts UNCHANGED (frozen 7-field runtimeActionDigest preserved — round-4 field-stripping reverted). ROUND-5 ROR routed to reviewer (non-author) at exact head 6af68e76; prior round-4 REQUEST CHANGES 16978 void at old head. ROUND-5 RE-ROR = REQUEST CHANGES (reviewer comment 16986 @6af68e76, CI 1742 green). PROGRESS: 7-field digest preserved, thread routing + tests present, no live creds. RESIDUAL (actor-identity seam) routed to coder0 (round-6): (1) approval/stop still binds actorId = DISCORD_SERVICE_USER_ID (bot) — must be the RESOLVED APPROVING ADMIN's mosaic user id at both mint and consume (consume requires approval.actorId===action.actorId AND resolveRole(actorId)==='admin'; bot is not the approver); (2) stop consumes as service actor — must present the same approving-admin actorId bound at mint; (3) approve→stop tests share one fixture correlation, masking production separate-message flow — must model approval message + stop command as separate correlations resolving to the SAME target action 7-field identity (admin A approves T → stop consumes T once as A → replay rejected). Root-cause only (do not make the bot admin). FIFTH round on the seam (16951→16968→16973→16978→16986); reviewer holding actor boundary firm. coder0 remediating; re-ROR at new exact head after CI terminal green. ROUND-6 REMEDIATED + pushed: head 533e9702591436810160dfb1cf8a42a222cfb7c7 (branch tip confirmed), CI Woodpecker pipeline 1743 = SUCCESS (terminal green). Scope: stable target correlation + real RuntimeProviderService consume coverage; approval agent binding matches MOSAIC_AGENT_NAME with explicit mismatch denial; command-authorization.service.ts unchanged. ROUND-6 ROR routed to reviewer (non-author) at exact head 533e9702; prior round-5 REQUEST CHANGES 16986 void at old head. ROUND-6 RE-ROR = APPROVE — canonical VERIFIED APPROVE reviewer-of-record [W-jarvis:reviewer] head 533e9702591436810160dfb1cf8a42a222cfb7c7 (visible Gitea comment 16991, CI 1743 success). Reviewer verified: actorId mint+stop consume uses RESOLVED mosaic admin (not DISCORD_SERVICE_USER_ID, not bot-made-admin); RuntimeProviderService consumes via verifier adapter; separate ingress correlations map to stable target action + one-shot replay rejected; 7-field digest intact; thread-under-parent test present; no live creds. pr-review wrapper self-approve blocked → recorded as verified PR comment. Head confirmed UNCHANGED at ROR target 533e9702 + PR mergeable=true, base=main. SIX-round durable cross-surface exact-action approval seam CLOSED (16951→16968→16973→16978→16986→16991); writer re-routed coder4→coder0 (Mos-dispatched) mid-flight. #730 MERGEABLE (independent non-author ROR + green CI at exact head). HARD STOP — Mos owns merge. MERGED to main by Mos — squash commit 84d884b9, approved head 533e9702, branch feat/tess-discord deleted, independent Sonnet ROR APPROVE (16991). M3 BUILD PHASE CONVERGED (M3-001 #730 + M3-002 #731 both merged). Writer re-routed coder4→coder0 mid-flight (Mos-dispatched); coder0 confirmed stand-down cleanup (dropped superseded WIP stash), on clean main 84d884b9, idle. NOTE: M3-001 #730 scope was Discord binding + durable approval; TESS-M1-FUP-001 (execute() authz-error mapping) + TESS-M2-FUP-001/003 did NOT ride in this PR — they remain not-started follow-ups to schedule (flagged to Mos). TESS-M3-V (M3→M4 gate) now READY — Mos dispatching as Mos-owned independent Sonnet checkpoint against merged main; orchestrator tracks, no competing lane. | +| TESS-M3-002 | done | Implement `mosaic tess` chat/status/sessions/tree/attach/send/stop/health/recover CLI | #709 | diskhygiene-terra | packages/mosaic | feat/tess-cli | TESS-M2-V | 30K | TESS-CLI-001. Mos DIRECT-DISPATCHED to diskhygiene-terra on feat/tess-cli (base=main, 2026-07-13) — orchestrator TRACKS only, no competing lane. PR-open-STOP, independent non-author ROR then Mos merges. Intake initially blocked on wrapper (issue-view.sh bare positional → 'Unknown option: 709'); orchestrator relayed -i flag fix. PR #731 OPEN (feat(tess): add generic interaction CLI; base=main, mergeable=true, head de74e46a0640c44c6b7294c24669496980761c92, fresh origin/main base 99a2d0fc). Scope: generic mosaic interaction command chat/status/sessions/tree/attach/send/stop/health/recover (NO instance-specific literal); --agent/MOSAIC_AGENT_NAME + Nova name-as-config test; authenticated gateway durable/provider boundary — server-derived actor scope, required non-simple correlation header, identity binding, durable recovery, registry routing, exact-action approval stop; status/health safe. Terra: full cold-cache pnpm turbo typecheck/lint/test --force + gates green, Codex security clean after remediation. CI pipeline 1735 TERMINAL GREEN (success) on de74e46a. ROR = APPROVE — canonical VERIFIED APPROVE reviewer-of-record [W-jarvis:reviewer] head de74e46a0640c44c6b7294c24669496980761c92 (visible Gitea comment 16959). Head confirmed unchanged at ROR target + mergeable=true. Reviewer verified generic mosaic interaction CLI verbs, --agent/MOSAIC_AGENT_NAME name-as-config, server-derived actor scope + required correlation, durable identity/recovery path, registry routing, exact-action approval stop, no live creds; no merge by reviewer (self-approve wrapper blocked → recorded as verified PR comment). MERGED to main by Mos — merge_commit 8246ee01 (independent Sonnet ROR 16959, all criteria + name-as-config Nova test verified). TESS-M3-002 DONE. TESS-M3-V (M3→M4 gate) advances once M3-001 #730 also merges — Mos-owned reviewer, no competing lane from orchestrator. | +| TESS-M3-003 | done | Cross-surface durable session integration + functional attach + denial/audit parity | #709 | coder0 | apps/gateway, plugins/discord, packages/mosaic, packages/agent | feat/tess-m3-integration | TESS-M3-001,TESS-M3-002 | 30K | Mos-DISPATCHED to coder0 (base=main, 2026-07-13) after M3-V gate = FAIL (integration unwired). Scope: C1 wire DurableSessionCoordinator.create into session-start + Discord resolves via durable snapshot + cross-surface E2E (AC-TESS-01); C2 expose streamSession over HTTP+CLI for functional attach + success test; C4 CLI denial spec + Discord mint-side durable audit + FOLD TESS-M1-FUP-001 (execute() authz-error classification) + RuntimeApprovalDeniedError→403 filter. PR-open-STOP, independent non-author ROR then Mos merges. M3-V RE-GATES after this lands. C1 design question raised by coder0 (origin/main has NO runtime-provider session-start/create op — only AgentService/Pi chat createSession, an LLM conversation, not a RuntimeProvider session carrying providerId/runtimeSessionId) — RESOLVED by Mos synthesis ruling (2026-07-13): conversationId is the durable handle; DurableSessionCoordinator.create happens at RUNTIME ENROLLMENT once providerId/runtimeSessionId are known; BOTH surfaces (Discord + CLI) snapshot it. coder0 PROCEEDING: C2/C4 in parallel now + implementing the C1 enrollment boundary per the ruling. PR-open-STOP; orchestrator serializes CI + routes independent non-author ROR at exact head; Mos merges. UPDATE 2026-07-13: **PR #732 OPEN** (base=main), head 451f7e04ec6c45adb12007dd7131da6a2b199962. coder0 stopped at PR creation, holding (no force-push). Local forced validation green (typecheck/lint/format:check/test: gateway 590 pass/11 skip, mosaic 640 pass), Codex code+security clean, command-authorization.service.ts byte-identical to origin/main. CI pipeline 1745 running — orchestrator serializing; on green-at-exact-head, independent non-author ROR routes to reviewer; HARD STOP for Mos merge. UPDATE 2026-07-13 (CI-GREEN): pipeline 1745 (event=pull_request, refs/pull/732/head, exact head 451f7e04) = SUCCESS; PR mergeable=true, head unmoved. Independent non-author ROR ROUTED to reviewer at exact head 451f7e04 (author coder0). Awaiting ROR verdict; HARD STOP for Mos merge; M3-V re-gates on merge. UPDATE 2026-07-13 (ROR round 1 = REQUEST CHANGES @ 451f7e04, Gitea comment 17007, CI 1745 green): ONE real blocker — Discord approve path UNREACHABLE in production. ChatGateway.handleDiscordApproval accepts only bare '/approve' (^/approve\\s*$), but plugins/discord/src/index.ts routes to discord:approve only on startsWith('/approve ') — so bare /approve → normal message (dead), '/approve x' → gateway-rejected; AC-TESS-01 mint/cross-surface flow can't fire; tests bypass DiscordPlugin.handleDiscordMessage so they mask it (same integration-unwired class M3-V flagged). Positives HELD: command-authz byte-identical (hash a9f829e7), 7-field digest intact, actor=resolved admin (not DISCORD_SERVICE_USER_ID), durable/stream/denial/audit coverage present, no live creds. Root-cause routed to coder0: reconcile plugin-routing predicate ↔ gateway accept-grammar + add end-to-end test through real handleDiscordMessage (no bypass). Fix push MOVES head → invalidates ROR, re-serialize CI + re-ROR at new exact head. UPDATE 2026-07-13 (RESOLUTION — MERGED by Mos): before coder0's fix was pushed, Mos ran a combined M3-V re-review at the SAME head 451f7e04 ([W-jarvis:reviewer-sonnet], Gitea comment 17009 = VERIFIED APPROVE + M3-V GATE PASS) and MERGED #732 → main squash commit **0b621660** ("feat(tess): wire durable interaction surfaces"). Mos milestone signal: M3-001/002/003 done, M3-V PASS, M3 COMPLETE, advancing to M4. ⚠️ ORCHESTRATOR RECONCILIATION: the two independent ROR reads at 451f7e04 CONFLICT on one concrete mechanism — my reviewer (17007) flagged the Discord approve path unreachable; Mos's re-review (17009) validated the gateway handler + integration test but that test bypasses DiscordPlugin.handleDiscordMessage. Orchestrator VERIFIED against merged main 0b621660 (read-only git show/grep): the plugin-routing defect is STILL LIVE — see TESS-M3-FUP-005. Surfaced to Mos as a fast-follow; coder0's uncommitted fix held pending Mos disposition. | +| TESS-M3-V | pass | Discord+CLI same-session E2E, denial/approval tests, and operator-flow review | #709 | mos-sonnet | apps/gateway/src/__tests__/integration, plugins/discord, packages/mosaic/src | review/tess-m3 | TESS-M3-001,TESS-M3-002,TESS-M3-003 | 20K | Gate M4. FIRST RUN = FAIL (Mos-owned independent Sonnet checkpoint against merged main 84d884b9, 2026-07-13): integration UNWIRED — Discord/CLI not cross-surface functional against durable sessions; attach/denial/audit parity incomplete. Remediation = TESS-M3-003 (coder0). SECOND RUN = **PASS** (Mos-owned combined re-review+re-gate at head 451f7e04, [W-jarvis:reviewer-sonnet] Gitea comment 17009, 2026-07-13): C1 cross-surface durable session, C2 functional attach, C4 denial/audit parity all closed; hard constraint (command-authz byte-identical, 7-field digest untouched) intact; C3/C5 no regression. Mos MERGED #732 → main 0b621660 and declared M3 milestone COMPLETE, advancing to M4. ⚠️ CAVEAT (orchestrator-verified live defect, NON-GATING per Mos): the M3-V integration test does not exercise DiscordPlugin.handleDiscordMessage, so the production Discord approve-path routing defect survived the gate — logged as TESS-M3-FUP-005 (fast-follow, surfaced to Mos). M4 (#710) UNBLOCKED (dep TESS-M3-V met). | +| TESS-M3-FUP-005 | done | Discord approve/stop production routing predicate mismatch — approve path unreachable | #709 | coder0 | plugins/discord, apps/gateway | feat/tess-m3-integration (rebased onto 0b621660) | TESS-M3-003 | 8K | **MERGED by Mos** → main squash commit **f1c6b37b** ("fix(tess): route bare Discord approvals (#733)"), 2026-07-13; post-merge main CI pipeline 1750 = SUCCESS. Verified-live Discord approve-path defect CLOSED — merged cross-surface mint path now functional end-to-end. HISTORY: **PR #733 OPEN** (base=main), head ed1d985c90e24ca3046bd570a556802927d80ed8 (rebased onto merged main 0b621660 to clear a Gitea conflict, force-with-lease; supersedes pre-rebase a02f526d whose pipeline 1747 was killed). pr-diff confirms clean 2-file scope: plugins/discord/src/index.ts + tess-cross-surface.integration.test.ts. coder0: bare /approve now routes to discord:approve; E2E invokes the REAL DiscordPlugin.handleDiscordMessage proving approve→stop against the durable conversation handle; forced typecheck/lint/format + targeted 17/17 green; command-authz zero-diff from main. CI pipeline 1748 (pull_request, refs/pull/733/head, commit ed1d985c) = **SUCCESS**. Independent non-author ROR **COMPLETE**: reviewer [W-jarvis:reviewer] VERIFIED APPROVE at exact head ed1d985c (Gitea comment 17017) — command-authz byte-identical hash a9f829e7 + 7-field digest intact, bare /approve REAL DiscordPlugin route fixed, gateway uses durable getSnapshot(conversationId), anti-masking divergent test inputs present, no Tess literal, no live creds. Head UNMOVED (ed1d985c), base main, mergeable=true. **MERGEABLE — HARD STOP for Mos merge** (2026-07-13). On merge → FUP-005 done; makes the merged Discord approve surface functional end-to-end. | ⚠️ VERIFIED-LIVE DEFECT on merged main 0b621660 (orchestrator read-only git show/grep, 2026-07-13). Gateway apps/gateway/src/chat/chat.gateway.ts:670 accepts approval ONLY as bare '/approve' (regex /^\\/approve\\s*$/i). Plugin plugins/discord/src/index.ts trims content (:339-341) then routes to discord:approve ONLY when content.startsWith('/approve ') (:385, requires a space+arg). NET: trimmed bare '/approve' fails the plugin predicate → emitted as normal message → gateway approval handler never fires (DEAD); '/approve x' passes the plugin but the gateway regex rejects it. So the production Discord approve path (AC-TESS-01 cross-surface mint) is UNREACHABLE end-to-end. M3-V PASSED because its integration test drives the gateway handler directly and bypasses DiscordPlugin.handleDiscordMessage, so the plugin predicate was never exercised (my reviewer 17007 caught it; Mos re-review 17009 validated the handler+test, not the plugin predicate). '/stop' likely same class (plugin needs startsWith('/stop ') args; confirm against gateway stop grammar). FIX: reconcile plugin routing predicate ↔ gateway accept-grammar (route bare '/approve' and snapshot-resolved '/stop ') + add an E2E test through the REAL DiscordPlugin.handleDiscordMessage (no bypass) proving mint fires. coder0 HAS this fix uncommitted in its worktree (from the round-1 remediation, pre-empted by the merge) — needs a fresh branch off main 0b621660 as a follow-up PR. AWAITING Mos disposition (fast-follow now vs after M4). Standard gates: PR-open-STOP, independent non-author ROR at exact head, Mos merges. | +| TESS-M4-001 | done | Implement Mos coordination handoff/observe/result contract with authority-boundary tests | #710 | coder0 | packages/coord, apps/gateway | feat/tess-mos-coordination | TESS-M3-V | 25K | **MERGED by Mos** → main squash **76325ca3** ("feat(tess): add Mos coordination boundary (#735)"), 2026-07-13 — merge = native-in-process transport ACCEPTED (contract transport-neutral). TESS-MOS-001. Mos-DISPATCHED 2026-07-13 to coder0 DESIGN-FIRST. UPDATE 2026-07-13: coder0 wrote docs/tess/MOS-COORDINATION.md; design checkpoint surfaced to Mos with the transport-adapter question (existing fleet/tmux Mos-authority channel vs dedicated native queue/HTTP). coder0 PROCEEDED (ahead of the Mos transport ruling) choosing a **native in-process adapter** and opened **PR #735** (base=main), head 7936e15d3ae137c91c88efdab4bb09b863a2195d. Impl: transport-NEUTRAL handoff/observe/result contract (MosCoordinationPort); deterministic native in-process InMemoryMosCoordinationPort; gateway derives actor/tenant/requester from trusted context/config; fail-closed for unconfigured-requester, self-delegation, target-drift, cross-tenant observe/result; NO public orchestrator verbs; **NO fleet/tmux transport, NO Mos-side consumer**; command-authorization byte-identical hash a9f829e7; no live creds; no hardcoded Tess identity. Local forced cold-cache typecheck/lint/format/test green (42 tasks); Codex security no findings. CI pipeline 1752 (pull_request, refs/pull/735/head, commit 7936e15d) = **SUCCESS**; head UNMOVED, mergeable=true. Independent non-author ROR **COMPLETE**: reviewer [W-jarvis:reviewer] VERIFIED APPROVE at exact head 7936e15d (Gitea comment 17032) — verified MosCoordinationPort=handoff/observe/result only, gateway-derived authority, fail-closed denial coverage, native in-process port (no tmux/Mos consumer), command-authz byte-identical a9f829e7, no live creds, no Tess literal. ⚠️ HEAD MOVED 2026-07-13 (ROR 17032 INVALIDATED): coder0 pushed one post-ROR commit → new head **5022911f84dd7ac30f40df31a53f6cd31a51728f** (commit "docs(tess): record M4 verification", parent 7936e15d). Orchestrator-verified sole delta = a single scratchpad doc docs/scratchpads/tess-m4-001-mos-coordination.md, ZERO code/test diff. New CI pipeline 1754 (pull_request, refs/pull/735/head, commit 5022911f) = **SUCCESS**; mergeable=true, head now 5022911f. Comment 17032 @ 7936e15d no longer at exact head → re-serialize + re-ROR REQUIRED. Fast delta RE-ROR **COMPLETE**: reviewer [W-jarvis:reviewer] VERIFIED APPROVE at exact head 5022911f (Gitea comment 17036) — confirmed 5022911f is direct child of prior-reviewed 7936e15d, sole two-dot delta = the 3-line scratchpad doc, no code/test diff, command-authz byte-identical a9f829e7, CI 1754 success. Head UNMOVED (5022911f), mergeable=true. **MERGEABLE at 5022911f — HARD STOP for Mos merge** (2026-07-13). MERGE = Mos ACCEPTING the native-in-process transport choice (contract stays transport-neutral; a fleet/tmux or native-queue/HTTP consumer can be added later without contract churn); if Mos wants a different FIRST adapter, hold merge + route rework to coder0. | +| TESS-M4-002 | done | Implement transitional Hermes runtime/capability adapter | #710 | coder3 | packages/agent, apps/gateway | feat/tess-hermes-adapter | TESS-M3-V | 40K | **MERGED by Mos** → main squash **9e5b9188** ("feat(agent): add transitional Hermes runtime adapter (#734)"), 2026-07-13 — Mos merge = **option (a) ACCEPTED**; post-merge main CI 1753. TESS-HRM-001; no legacy schema in core contracts. Mos-DISPATCHED 2026-07-13 to coder3 DESIGN-FIRST (contract sketch + questions to Mos before build). Goes in-progress as PR opens; PR-open-STOP → serialize CI + independent non-author ROR at exact head → Mos merges. UPDATE 2026-07-13: coder3 ACTIVE — fresh worktree/branch feat/tess-hermes-adapter off origin/main; boundary sketch at docs/tess/hermes-runtime-adapter-design.md. DESIGN QUESTION surfaced to Mos (coder3 HELD at design-only until ruling): AC-TESS-05 wants approved capability across Kanban/skills/memory/tools/cron, but AgentRuntimeProvider models only SESSION capabilities. (a) adapter-local Hermes inventory/health marks those as explicit UNSUPPORTED, real ops deferred to their Mosaic-owned plugin contracts (coder3 default, preserves hard no-legacy-core-contract rule); vs (b) an existing Mosaic-owned non-runtime capability contract this adapter must implement. Orchestrator recommends (a) to Mos as the conservative boundary-preserving path. UPDATE 2026-07-13: coder3 PROCEEDED WITH (a) and opened **PR #734** (base=main), head 47b8a145ac43688499d275a54b434a52551c1abd — ahead of the Mos (a/b) ruling (design-hold was placed; coder3's original msg said it would proceed with (a) unless directed). Hermes adapter normalized behind packages/agent boundary; core types unchanged, unsupported ops fail-closed, tests prove no legacy field leak; focused tests/typecheck/lint pass; cold-cache turbo typecheck+build 46/46 0-cached. mergeable=true. CI pipeline 1751 (pull_request, refs/pull/734/head, commit 47b8a145) = **SUCCESS**; head UNMOVED, mergeable=true. Independent non-author ROR **COMPLETE**: reviewer [W-jarvis:reviewer] VERIFIED APPROVE at exact head 47b8a145 (Gitea comment 17027) — verified core AgentRuntimeProvider/runtime types UNCHANGED, adapter normalizes Hermes legacy shapes behind packages/agent boundary, unsupported runtime ops FAIL CLOSED via capability_unsupported BEFORE transport side effects (Kanban/skills/memory/tools/cron deferred under option (a)), no live creds, no hardcoded Tess agent identifier. Head UNMOVED, mergeable=true. **MERGEABLE — HARD STOP for Mos merge** (2026-07-13). ⚠️ MERGE GATED on Mos confirming option (a) is accepted (implementation == (a)); if Mos rules (b), #734 needs rework. HARD STOP for Mos merge. | +| TESS-M4-003 | done | Implement memory/retrieval, state/inbox, runtime bootstrap, fleet diagnostics and GitOps plugin foundations | #710 | coder0 | packages/memory, packages/agent, packages/mosaic | feat/tess-operator-plugins | TESS-M3-V | 40K | **MERGED by Mos** → main squash **2363f155** ("feat(memory): add operator retrieval plugin (#736)"), 2026-07-13. ⚠️ SCOPE GAP surfaced by Mos: #736 delivered ONLY the leaf @mosaicstack/memory operator-retrieval slice; **TESS-PLG-001 (packages/mosaic catalog/registration) was silently DEFERRED by the author and never surfaced in MISSION-MANIFEST/VERIFICATION-MATRIX** → now tracked explicitly as its own row (see TESS-PLG-001 below) and folded into TESS-M4-W-001. State/inbox/runtime-bootstrap/fleet-diagnostics/GitOps foundations remain follow-on (not in #736). TESS-MEM-001, TESS-PLG-001. Mos HELD 1 beat (2026-07-13) for a well-conditioned lane. UPDATE 2026-07-13: coder0 TOOK OVER M4-003 (preserved coder4 WIP first, then rebased on latest main) and opened **PR #736** (base=main), head a1d63ca8ed07610828e9c51a213fffe9123b3de4. ⚠️ AUTHORIZATION FLAG to Mos: M4-003 was on Mos 1-beat HOLD; confirm this takeover/dispatch was Mos-authorized before merge. Scope delivered: LEAF @mosaicstack/memory operator retrieval plugin — config-injected adapter/namespace, runtime-validated server-derived tenant/owner/session scope, redaction-before-persist, provenance, bounded startup prioritization, wildcard adapter contract, namespace/different-instance tests. NO gateway/catalog/durable-inbox or command-authorization changes. Forced cold-cache typecheck/lint/format/test green (42 tasks); Woodpecker 1756 green; Codex code+security clean. CI pipeline 1756 (pull_request, refs/pull/736/head, commit a1d63ca8) = **SUCCESS**; mergeable=true. Independent non-author ROR COMPLETE: reviewer VERIFIED APPROVE at exact head a1d63ca8 (Gitea comment 17044) — leaf packages/memory/doc only (no gateway/catalog/durable-inbox), command-authz byte-identical a9f829e7, runtime scope validation before storage keying, config-injected adapter/namespace/instance metadata, redaction-before-persist + provenance, scoped wildcard adapter contract, namespace/different-instance tests, no live creds, no Tess literal. Head verified UNMOVED at a1d63ca8, base main, mergeable=true. **MERGEABLE — reported to Mos, HARD STOP for Mos merge.** NOTE: M4-003 scope here is the memory-plugin slice; state/inbox/runtime-bootstrap/fleet-diagnostics/GitOps foundations may be follow-on slices — confirm with Mos whether #736 fully closes M4-003 or is slice 1. | +| TESS-M4-W-001 | in-progress | M4-V remediation — gateway reachability SPINE: register runtime provider into AGENT_RUNTIME_PROVIDER_REGISTRY + wire Mos-coordination consumer + wire operator-memory-plugin consumer (make merged M4 deliverables reachable end-to-end); FOLDS IN minimal TESS-PLG-001 catalog/registration | #710 | coder0 | apps/gateway, packages/mosaic, packages/agent | feat/tess-m4w-reachability-spine | TESS-M4-003 | 30K | **Mos-DISPATCHED 2026-07-13** (remediation). Root cause: M4-V holistic review @ origin/main **2363f155** found the three merged M4 deliverables unit-green but NOT reachable end-to-end (no gateway wiring/consumers; providers never registered into the registry). **SPLIT into 3 sub-parts by coder0 (integrity-honest):** **(#2 Mos-coordination consumer) = DELIVERED as PR #737** (head f7b95f60, base main, "feat(gateway): expose Mos coordination boundary") — real AuthGuard Mos handoff/observe/result consumer, authenticated actor/tenant + required correlation derivation, service authority unchanged, gateway target test/typecheck/lint pass; **CI 1758 SUCCESS** (repo 47, commit==head); head verified UNMOVED at f7b95f60666a4abbbad9a08669637b19fa87c430, base main, mergeable=true. **Independent non-author ROR COMPLETE**: reviewer VERIFIED APPROVE at exact head f7b95f60 (Gitea comment 17054) — clean partial scope confirmed (AuthGuard Mos handoff/observe/result controller + module registration only; no runtime-provider registration / operator-memory consumer; actor/tenant from CurrentUser/scopeFromUser + required X-Correlation-Id before service invocation; MosCoordinationService unchanged; command-authz byte-identical a9f829e7; no live creds/no Tess literal). **#737 MERGEABLE — reported to Mos, HARD STOP for Mos merge (partial slice; land-vs-hold-for-full-spine is Mos's disposition call).** **(#1 runtime-provider registration) + (operator-memory consumer) = BLOCKED, NOT in #737.** coder0 could not truthfully complete them in this slice and REFUSED to fake with deny/unavailable stubs: gateway has **no concrete Hermes transport** and **no gateway-side tmux transport/authority wiring** to register a real provider; OperatorMemory consumer needs **session tenant/owner/session propagation currently ABSENT from AgentService's memory-tools boundary**. ⚠️ **DESIGN RULING ESCALATED TO MOS** (architecture, not resolvable from repo): how to wire provider-registration + memory-scope propagation when no concrete transport exists yet — new remediation slice / re-scope / accept #737 as incremental. TESS-PLG-001 (folded here) is part of the blocked #1 registration path. Command-authz byte-identical a9f829e7. **UPDATE 2026-07-13: #737 MERGED by Mos → main e2376190 ("feat(gateway): expose Mos coordination boundary (#737)").** **Operator-memory consumer sub-part UNBLOCKED + DELIVERED as PR #739** ("feat(memory): bind operator plugin to agent sessions", base main off e2376190, live head 31a59738089f0784428833fc5a0192c6c7c43261, mergeable=true) — coder0 resolved the session-scope-propagation blocker WITHOUT stubbing: gateway bootstrap configures plugin only with MOSAIC_OPERATOR_MEMORY_INSTANCE_ID + MOSAIC_OPERATOR_MEMORY_NAMESPACE, AgentService derives {tenantId,ownerId,sessionId} server-side and binds search/capture tools. Cold-cache root typecheck/lint/format/test (42 tasks) green; security review clean (Codex Optional-import finding = false positive, pre-existing, typecheck passed). **CI 1764 SUCCESS** (repo 47, commit==head); head verified UNMOVED at 31a59738089f0784428833fc5a0192c6c7c43261, base main, mergeable=true. **Independent non-author ROR ROUTED to reviewer at exact head 31a59738089f** (coder0 authored → reviewer is non-author) — asked reviewer to confirm scope is server-derived/non-client-controllable + no cross-tenant leak, and to independently verify the Codex Optional-import finding is a false positive. (Head reconcile CLOSED: coder0 confirmed 31a597380c55… was a transcription typo; live+frozen head is 31a59738089f0784428833fc5a0192c6c7c43261, working tree clean.) **UPDATE 2026-07-13: reviewer REQUEST CHANGES at head 31a59738089f (Gitea comment 17069) — NOT mergeable.** Production code CONFIRMED correct (plugin route wired, config env namespace/instance only, no live creds/no Tess literal, command-authz byte-identical a9f829e7; Codex Optional-import finding = false positive, import present). **Two TEST-COVERAGE blockers:** (1) tests BYPASS production scope derivation — they call createMemoryTools with a PREBUILT scope, never exercising the real createSession→buildToolsForSandbox server-side {tenantId,ownerId,sessionId} derivation; (2) NO divergent cross-tenant/cross-owner ISOLATION/DENIAL test proving a foreign actor cannot reuse a session / reach another operator-memory scope before the plugin call. Routed back to coder0 (integrity: harden real coverage, do NOT weaken assertion). Any new commit MOVES head → invalidates ROR → re-serialize CI + re-ROR at new head. **UPDATE 2026-07-13 (remediated): coder0 pushed hardened tests, NEW frozen head c26b3b775279575276c6ebe8955a9146bfc61413** — added createSession→buildToolsForSandbox PRODUCTION-PATH assertion of derived {tenantId,ownerId,sessionId}; added foreign-actor reuse DENIAL test asserting rejection occurs BEFORE scope/tool construction and before any plugin call. Cold-cache root typecheck/lint/format/test green (42 tasks). Old ROR at 31a59738089f + CI 1764 SUPERSEDED. Re-serialized: **CI 1765 SUCCESS** at c26b3b775279 (ref refs/pull/739/head, commit==head); head verified UNMOVED at c26b3b775279575276c6ebe8955a9146bfc61413, base main, mergeable=true. **Fresh independent non-author ROR RE-ROUTED to reviewer at exact head c26b3b775279** — asked reviewer to confirm both 17069 blockers genuinely closed (prod-path derivation exercised + cross-tenant denial before plugin call, assertion not weakened). **Independent non-author re-ROR COMPLETE**: reviewer VERIFIED APPROVE at exact head c26b3b775279 (Gitea comment 17074) — both 17069 blockers CONFIRMED closed: production createSession→buildToolsForSandbox scope-derivation test asserts {tenantId,ownerId,sessionId}; foreign-scope reuse rejects BEFORE tool construction and BEFORE plugin search/capture; production wiring reachable via MemoryModule env-configured plugin → AgentService injection → memory_search/memory_save_insight plugin path; command-authz byte-identical a9f829e7; Optional import present; no live creds/no Tess literal. Head verified UNMOVED at c26b3b775279, base main, mergeable=true. **#739 MERGEABLE — reported to Mos, HARD STOP for Mos merge.** This lands the operator-memory-consumer sub-part of W-001; REMAINING W-001 gap = only (#1) runtime-provider registration. **REMAINING blocked: (#1) runtime-provider registration into AGENT_RUNTIME_PROVIDER_REGISTRY** — still needs Mos A/B/C design ruling (no concrete Hermes transport yet). So after #739 lands, W-001 = Mos-consumer (#737 merged) + memory-consumer (#739) DONE; only the provider-registration linchpin remains. **UPDATE 2026-07-13: #739 MERGED by Mos → main squash 3378b857eb ("feat(memory): bind operator plugin to agent sessions (#739)"); post-merge main push pipeline 1766 running. W-001 spine now 2-of-3 sub-parts MERGED (Mos-consumer #737 e2376190 + operator-memory-consumer #739 3378b857); ONLY remaining W-001 gap = (#1) runtime-provider registration into AGENT_RUNTIME_PROVIDER_REGISTRY — still BLOCKED on Mos A/B/C design ruling (no concrete Hermes transport; TESS-PLG-001 folded here). coder0 idle/ready to build #1 on ruling.** **UPDATE 2026-07-13: (#1) DELIVERED as PR #740 "feat(gateway): register Hermes runtime provider"** (base main 3378b857, exact live head 127a69ea11ccc36516c78c2007cbe52fbf63ad30 verified unmoved, mergeable=true). coder0 resolved the A/B/C escalation by BUILDING a concrete transport (⚠️ design-direction flagged to Mos for confirm-before-merge): agent.module.ts explicit registry.register(new HermesRuntimeProvider(new GatewayHermesRuntimeTransport())); GatewayHermesRuntimeTransport = server-configured URL+service token, HTTPS-except-loopback, prefixed-URL preserving, forwards full scope incl channel; AuthGuard interaction transitional-capabilities route through RuntimeProviderService + live controller→service→registered-provider reachability test. Cold-cache typecheck/lint/format/test green (42 tasks); Codex path-prefix+channel-header findings remediated, security clean. **CI pipeline 1767 (repo 47, refs/pull/740/head, commit==head) = SUCCESS**; head verified UNMOVED at 127a69ea11ccc36516c78c2007cbe52fbf63ad30 post-CI, base main, mergeable=true. **Independent non-author ROR ROUTED to reviewer at exact head 127a69ea11cc** (coder0 authored → reviewer non-author) — asked reviewer to verify REAL E2E reachability (provider actually in registry + reachability test exercises registered provider, not mock), transport security (HTTPS-except-loopback, no token leak), command-authz byte-identical a9f829e7, no live creds/no Tess literal, Codex findings genuinely remediated. Awaiting reviewer disposition; any new commit moves head → re-serialize + re-ROR. **UPDATE 2026-07-13: reviewer REQUEST CHANGES at head 127a69ea11cc (Gitea comment 17088) — NOT mergeable.** CI 1767 green; command-authz byte-identical a9f829e7 CONFIRMED; production positives CONFIRMED (module factory registers Hermes provider; concrete transport HTTPS/prefix/channel headers; no live creds/no Tess literal). **Blocker (reachability-integrity):** the required live-guarded reachability proof is MISSING — test directly calls controller.transitionalCapabilities + manually constructs RuntimeProviderService/createGatewayRuntimeProviderRegistry; it does NOT exercise live GET /api/interaction/:agentName/transitional-capabilities, Nest DI through AgentModule, or the AuthGuard request path, so it can pass even if injected gateway registry/route wiring is broken (defeats the M4-V E2E-reachability point). Routed back to coder0 (integrity: add genuine Nest-e2e live-guarded reachability test through real DI+route+AuthGuard asserting reach of the registered Hermes provider; do NOT weaken/stub/mock around it; keep command-authz a9f829e7). Old ROR 17088 + CI 1767 will be SUPERSEDED by the remediation head → re-serialize CI + re-ROR at new head. **UPDATE 2026-07-13 (remediated): coder0 pushed the live-guarded reachability test, NEW frozen head a7e5d377e38b40275884a7df6ee35c55c5859e43** (live Gitea head independently verified, base main 3378b857, mergeable=true) — added apps/gateway/src/agent/hermes-runtime-reachability.e2e.test.ts: imports REAL AgentModule (preserves actual AGENT_RUNTIME_PROVIDER_REGISTRY factory + RuntimeProviderService), boots Fastify/Nest, unauth HTTP GET /api/interaction/Nova/transitional-capabilities?provider=runtime.hermes asserts 401 via ACTUAL AuthGuard, authed GET asserts 200 + all five Hermes entries, asserts DI registry resolves HermesRuntimeProvider; only unrelated peripheral modules harness-replaced to avoid DB/queue startup — NO route/guard/DI-registry/runtime-service/provider mock; existing controller unit test retained; command-authz untouched (byte-identical a9f829e7 remains). Cold-cache root typecheck/lint/format/test green 42/42 (gateway 53 files/606 tests). Old ROR 17088 + CI 1767 SUPERSEDED. Re-serializing: **CI 1768 (repo 47, refs/pull/740/head, commit==head a7e5d377) running** — poll in flight; on green → re-route non-author ROR at exact head a7e5d377. **UPDATE 2026-07-13: CI 1768 SETTLED SUCCESS** (repo 47, refs/pull/740/head, commit==head a7e5d377e38b40275884a7df6ee35c55c5859e43); head independently verified UNMOVED at a7e5d377 (live Gitea, NOT worker-reported), base main 3378b857, mergeable=true. **Independent non-author re-ROR COMPLETE: reviewer VERIFIED APPROVE at exact head a7e5d377e38b40275884a7df6ee35c55c5859e43 (Gitea comment 17094).** Prior 17088 blocker CONFIRMED closed — the new hermes-runtime-reachability.e2e.test.ts boots real Nest/Fastify AgentModule and exercises unauth 401 via the ACTUAL AuthGuard + authed HTTP GET /api/interaction/:agentName/transitional-capabilities through the live route→controller→RuntimeProviderService→registered Hermes provider, and asserts DI registry resolves HermesRuntimeProvider (no route/guard/DI/service/provider mock); transport concrete, HTTPS-except-loopback, path-prefix + channel header covered; command-authz byte-identical a9f829e7 CONFIRMED; no live creds/no Tess literal. Head verified UNMOVED at a7e5d377, base main, mergeable=true. **#740 MERGEABLE — the (#1) runtime-provider-registration linchpin — reported to Mos, HARD STOP for Mos merge.** ⚠️ Design-direction (concrete GatewayHermesRuntimeTransport built to resolve the A/B/C escalation) flagged to Mos for confirm-before-merge. On #740 merge, W-001 spine = 3-of-3 sub-parts landed (Mos-consumer #737 + memory-consumer #739 + provider-registration #740) → M4-V re-fire eligible. | +| TESS-M4-W-002 | done | M4-V remediation — Hermes capability MATRIX (AC-TESS-05): approved-capability coverage across Kanban/skills/memory/tools/cron for the Hermes adapter | #710 | coder3 | packages/agent, apps/gateway | feat/tess-m4w-hermes-matrix | TESS-M4-002 | 22K | **Mos-DISPATCHED 2026-07-13** (remediation, in flight). Extends the M4-002 option-(a) adapter (merged 9e5b9188) with the AC-TESS-05 capability matrix. UPDATE 2026-07-13: coder3 STARTED — fresh worktree off origin/main 2363f155, TDD failing-matrix-tests-first. Orchestrator TRACKS; on PR-open → serialize CI + independent non-author ROR at EXACT head → HARD STOP for Mos merge. No legacy schema into core contracts; command-authz byte-identical a9f829e7. UPDATE 2026-07-13: **PR #738 OPENED** (base main, head 582c6db2088223fd8dd2105005391b5034c992ac, "feat(agent): add Hermes transitional capability matrix") — normalized exhaustive five-entry matrix (kanban/skills/memory/tools/cron), all explicit unsupported, fails CLOSED before transport; tests 4/4, security review clean, cold-cache 46 successful/0 cached, normalized optional TransitionalCapabilityInventoryProvider (no legacy schema). **CI 1759 SUCCESS** (repo 47, commit==head); head verified UNMOVED at 582c6db2, base main, mergeable=true. **Independent non-author ROR COMPLETE**: reviewer VERIFIED APPROVE at exact head 582c6db2 (Gitea comment 17057) — normalized exhaustive five-entry transitional matrix (kanban/skills/memory/tools/cron) all unsupported; assertTransitionalCapability fails CLOSED with capability_unsupported before Hermes transport; only normalized optional TransitionalCapabilityInventoryProvider added to core (no legacy schema leak); command-authz byte-identical a9f829e7; no live creds/no Tess literal. Head verified UNMOVED at 582c6db2, base main, mergeable=true. **#738 MERGEABLE — reported to Mos, HARD STOP for Mos merge.** This is the COMPLETE matrix deliverable (unlike #737's partial spine). **UPDATE 2026-07-13: #738 MERGED by Mos → merge_commit cca6aaf9. TESS-M4-W-002 DONE.** | +| TESS-PLG-001 | in-progress | packages/mosaic plugin catalog / registration (operator plugins registered + discoverable) — was silently deferred by M4-003 author; now VISIBLE | #710 | coder0 | packages/mosaic | feat/tess-m4w-reachability-spine | TESS-M4-003 | (folded) | ⚠️ Surfaced by Mos 2026-07-13 as an invisible gap: M4-003/#736 delivered the memory plugin but NOT its catalog/registration in packages/mosaic; never appeared in MISSION-MANIFEST/VERIFICATION-MATRIX. PLACEMENT DECISION (orchestrator, per Mos "your call"): **FOLD minimal registration into TESS-M4-W-001** (coder0's reachability spine already does registry wiring — same author closes their own gap, keeps it in one lane). This row exists for LEDGER VISIBILITY so the gap is tracked, not re-hidden. If M4-W-001 scope grows too large, split back out as a standalone lane. Manifest/matrix update to follow. | +| TESS-M4-V | failed | Cross-provider capability, privacy, authority and failure-path qualification | #710 | sonnet | apps/gateway/src/__tests__/integration, packages/agent | review/tess-m4 | TESS-M4-001,TESS-M4-002,TESS-M4-003,TESS-M4-W-001,TESS-M4-W-002 | 22K | **FAILED 2026-07-13** — independent holistic review @ origin/main **2363f155**: all three M4 deliverables (#734/#735/#736) unit-green but **NOT reachable end-to-end** (providers never registered into AGENT_RUNTIME_PROVIDER_REGISTRY; Mos-coordination + operator-memory consumers unwired; TESS-PLG-001 catalog/registration silently deferred). Remediation TESS-M4-W (W-001 spine coder0 + W-002 Hermes matrix coder3) now in flight. **Mos re-fires M4-V ONLY after the spine + matrix land.** Gate M5 (M5 stays behind M4-V; live-deploy = Jason-reserved). | | TESS-M5-001 | not-started | Implement Matrix/native runtime provider behind common contracts and parity suite | #711 | codex | packages/mosaic, packages/agent | feat/tess-matrix-provider | TESS-M4-V | 30K | TESS-TRN-001 | | TESS-M5-002 | not-started | Complete migration inventory, cutover, rollback, retention and deprecation evidence | #711 | sonnet | docs/tess | feat/tess-migration-docs | TESS-M4-V | 18K | TESS-MIG-001 | | TESS-M5-003 | not-started | Complete OpenAPI, user/admin/developer/plugin/operations docs and checklist | #711 | codex | docs | feat/tess-docs | TESS-M5-001,TESS-M5-002 | 22K | Documentation hard gate | diff --git a/docs/tess/VERIFICATION-MATRIX.md b/docs/tess/VERIFICATION-MATRIX.md index 472ca7d2..d89b23c2 100644 --- a/docs/tess/VERIFICATION-MATRIX.md +++ b/docs/tess/VERIFICATION-MATRIX.md @@ -1,18 +1,18 @@ # Tess Verification Matrix -| Acceptance criterion | Requirements | Planned evidence | Gate | -| -------------------- | ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | -| AC-TESS-01 | TESS-PI-001, TESS-DSC-001, TESS-CLI-001 | Discord/CLI same-session integration and streaming E2E | M3-V | -| AC-TESS-02 | TESS-ARP-001, TESS-CLI-001, TESS-FLT-001 | CLI contract tests for status/sessions/tree/attach/send/stop, typed denial/error snapshots | M3-V | -| AC-TESS-03 | TESS-PI-001, TESS-OBS-001 | Clean service launch; status asserts GPT-5.6 Sol, high reasoning and effective tool policy with secret canaries absent | M2-V, M3-V | -| AC-TESS-04 | TESS-MOS-001, TESS-FLT-001 | M4 contract/gateway native-port handoff → observe → result round trip; configurable identity, target-drift and tenant-denial tests; M4-V fleet authority qualification | M4-001, M4-V | -| AC-TESS-05 | TESS-HRM-001 | Hermes capability contract suite: sessions/stream/send/tree plus Kanban/skills/memory/tools/cron supported-or-denied matrix | M4-V | -| AC-TESS-06 | TESS-STA-001, TESS-SEC-008 | Kill/restart/compaction fault injection across inbox/outbox/checkpoint transitions; duplicate side-effect detector | M2-V, M5-V | -| AC-TESS-07 | TESS-SEC-001..009 | Threat-model abuse suite: authz, tenant isolation, forged identity/approval, injection, redaction, transport identity, GC scope | M1-V, M3-V, M5-V | -| AC-TESS-08 | TESS-TRN-001 | Common provider contract suite against tmux/fleet and Matrix/native; identity and replay tests | M5-V | -| AC-TESS-09 | all | `pnpm typecheck`, lint, format, unit/integration/contract/E2E; independent code and security reviews; CI URLs | Every milestone | -| AC-TESS-10 | TESS-MIG-001 | Completed capability inventory with native/adapted/deferred/rejected state, owner, cutover/rollback evidence | M5-V | -| AC-TESS-11 | TESS-PLG-001, TESS-OBS-001 | OpenAPI and user/admin/developer/plugin/ops docs, sitemap links, documentation checklist | M5-V | +| Acceptance criterion | Requirements | Planned evidence | Gate | +| -------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | +| AC-TESS-01 | TESS-PI-001, TESS-DSC-001, TESS-CLI-001 | Discord/CLI same-session integration and streaming E2E | M3-V | +| AC-TESS-02 | TESS-ARP-001, TESS-CLI-001, TESS-FLT-001 | CLI contract tests for status/sessions/tree/attach/send/stop, typed denial/error snapshots | M3-V | +| AC-TESS-03 | TESS-PI-001, TESS-OBS-001 | Clean service launch; status asserts GPT-5.6 Sol, high reasoning and effective tool policy with secret canaries absent | M2-V, M3-V | +| AC-TESS-04 | TESS-MOS-001, TESS-FLT-001 | M4 contract/gateway native-port handoff → observe → result round trip; configurable identity, target-drift and tenant-denial tests; M4-V fleet authority qualification | M4-001, M4-V | +| AC-TESS-05 | TESS-HRM-001, TESS-MEM-001 | Hermes capability contract suite: sessions/stream/send/tree plus Kanban/skills/memory/tools/cron supported-or-denied matrix; operator-memory plugin (TESS-MEM-001) reachable end-to-end — env-configured plugin registered + AgentService session-bound server-derived {tenantId,ownerId,sessionId} scoped search/capture, cross-tenant reuse denied before plugin call (M4-W-001 spine: #736 plugin + #739 consumer) | M4-V | +| AC-TESS-06 | TESS-STA-001, TESS-SEC-008 | Kill/restart/compaction fault injection across inbox/outbox/checkpoint transitions; duplicate side-effect detector | M2-V, M5-V | +| AC-TESS-07 | TESS-SEC-001..009 | Threat-model abuse suite: authz, tenant isolation, forged identity/approval, injection, redaction, transport identity, GC scope | M1-V, M3-V, M5-V | +| AC-TESS-08 | TESS-TRN-001 | Common provider contract suite against tmux/fleet and Matrix/native; identity and replay tests | M5-V | +| AC-TESS-09 | all | `pnpm typecheck`, lint, format, unit/integration/contract/E2E; independent code and security reviews; CI URLs | Every milestone | +| AC-TESS-10 | TESS-MIG-001 | Completed capability inventory with native/adapted/deferred/rejected state, owner, cutover/rollback evidence | M5-V | +| AC-TESS-11 | TESS-PLG-001, TESS-OBS-001 | OpenAPI and user/admin/developer/plugin/ops docs, sitemap links, documentation checklist | M5-V | ## Security Abuse Suite Minimum From 5789711ee08622c72e886e5d9e7a3889d552f694 Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Mon, 13 Jul 2026 15:14:29 +0000 Subject: [PATCH 034/152] docs(tess): add migration evidence set (#742) --- docs/tess/M5-MIGRATION-CUTOVER.md | 12 ++++++++++++ docs/tess/M5-MIGRATION-INVENTORY.md | 11 +++++++++++ docs/tess/M5-MIGRATION-RETENTION-DEPRECATION.md | 14 ++++++++++++++ docs/tess/M5-MIGRATION-ROLLBACK.md | 10 ++++++++++ 4 files changed, 47 insertions(+) create mode 100644 docs/tess/M5-MIGRATION-CUTOVER.md create mode 100644 docs/tess/M5-MIGRATION-INVENTORY.md create mode 100644 docs/tess/M5-MIGRATION-RETENTION-DEPRECATION.md create mode 100644 docs/tess/M5-MIGRATION-ROLLBACK.md diff --git a/docs/tess/M5-MIGRATION-CUTOVER.md b/docs/tess/M5-MIGRATION-CUTOVER.md new file mode 100644 index 00000000..cd5a7150 --- /dev/null +++ b/docs/tess/M5-MIGRATION-CUTOVER.md @@ -0,0 +1,12 @@ +# TESS-MIG-001 — Cutover Procedure + +This procedure is evidence-bound. It does not authorize a production cutover until the M5 qualification gate records the required validation. + +1. Confirm the gateway has the explicitly registered `runtime.hermes` adapter (`apps/gateway/src/agent/agent.module.ts`) and provider reachability evidence (`apps/gateway/src/agent/hermes-runtime-reachability.e2e.test.ts`). +2. Query the normalized runtime capability surface, not a Hermes API directly. Confirm the session capabilities required for the operation are advertised. +3. Query the transitional matrix through `RuntimeProviderService.transitionalCapabilityMatrix` (`apps/gateway/src/agent/runtime-provider-registry.service.ts`). Kanban, skills, memory, tools, and cron must remain `unsupported`; stop rather than route those operations through Hermes. +4. Route new memory activity through the Mosaic operator-memory plugin path; there is no landed Hermes memory import. +5. Use `MosCoordinationService` for orchestration handoff. Tess does not take Mos authority. +6. Record the qualification evidence and only then update an external deployment/channel binding through its separately authorized operational process. + +No claim here authorizes bulk transcript copying, data-schema migration, or enabling an unsupported transitional capability. diff --git a/docs/tess/M5-MIGRATION-INVENTORY.md b/docs/tess/M5-MIGRATION-INVENTORY.md new file mode 100644 index 00000000..05358aa6 --- /dev/null +++ b/docs/tess/M5-MIGRATION-INVENTORY.md @@ -0,0 +1,11 @@ +# TESS-MIG-001 — Hermes → Mosaic Evidence Inventory + +Hermes is a reference adapter, not a Mosaic core dependency. `packages/agent/src/hermes-runtime-provider.ts` contains the adapter-local `HermesLegacySession` and converts it to core `RuntimeSession`; `packages/types/src/agent/agent-runtime-provider.ts` contains only normalized contracts. `apps/gateway/src/agent/agent.module.ts` explicitly registers the adapter, while `apps/gateway/src/agent/runtime-provider-registry.service.ts` exposes it only through the runtime registry. + +| Reference concern | Landed Mosaic evidence | State | +| ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------- | +| sessions, hierarchy, streaming, send/attach/terminate | `HermesRuntimeProvider` plus `hermes-runtime-provider.test.ts` | adapted | +| Kanban, skills, memory, tools, cron | normalized matrix in `HermesRuntimeProvider.transitionalCapabilityMatrix`; each is `unsupported` and `assertTransitionalCapability` denies before a transport call | deferred / fail-closed | +| operator memory | `packages/memory/src/operator-memory-plugin.ts`, constructed by `apps/gateway/src/memory/memory.module.ts` and session-scoped by `apps/gateway/src/agent/agent.service.ts` | native Mosaic path | +| orchestration handoff | `apps/gateway/src/coord/mos-coordination.service.ts` retains authenticated handoff/observe/result ownership checks | native Mosaic path | +| transcripts, profiles, preferences | no Hermes importer/schema mapping landed | no automatic migration | diff --git a/docs/tess/M5-MIGRATION-RETENTION-DEPRECATION.md b/docs/tess/M5-MIGRATION-RETENTION-DEPRECATION.md new file mode 100644 index 00000000..9f63dc89 --- /dev/null +++ b/docs/tess/M5-MIGRATION-RETENTION-DEPRECATION.md @@ -0,0 +1,14 @@ +# TESS-MIG-001 — Retention and Legacy Deprecation Policy + +## Retention + +- Hermes is not a Mosaic persistence authority. The adapter maps runtime behavior only; it does not import or persist Hermes legacy session shapes. +- Mosaic operator memory is scoped by tenant, owner, and session in `packages/memory/src/operator-memory-plugin.ts`; gateway session ownership is derived before that plugin is made available in `apps/gateway/src/agent/agent.service.ts`. +- Existing Hermes archives remain in their source system under its existing retention policy. This project has no landed automatic transcript, profile, or preference migration. +- Any future import requires an explicit, scoped design and redaction/provenance evidence; it must not extend `packages/types` with Hermes schema. + +## Deprecation + +- Session adapter use remains transitional until M5 qualification demonstrates the normalized provider path. +- Kanban, skills, memory, tools, and cron are not deprecated into a Hermes bridge: they remain explicitly unsupported until their Mosaic-owned contracts are implemented and qualified. +- A future deprecation change must remove the external binding first, retain rollback evidence, and then remove the adapter in a separately reviewed code change. It must not silently replace or widen a registered provider. diff --git a/docs/tess/M5-MIGRATION-ROLLBACK.md b/docs/tess/M5-MIGRATION-ROLLBACK.md new file mode 100644 index 00000000..34ec7008 --- /dev/null +++ b/docs/tess/M5-MIGRATION-ROLLBACK.md @@ -0,0 +1,10 @@ +# TESS-MIG-001 — Rollback Procedure + +Rollback is configuration/binding reversal, not a database rollback: no Hermes schema migration or automatic data import is implemented by the landed adapter. + +1. Stop sending new traffic to the Mosaic Hermes adapter by reverting the external runtime/channel binding through its authorized deployment process. +2. Keep the gateway registration and core contracts unchanged unless a reviewed code rollback is required; `AgentRuntimeProviderRegistry` registration is explicit and non-replacing (`packages/agent/src/runtime-provider-registry.ts`). +3. Do not replay an unsupported Kanban, skills, memory, tools, or cron operation. The transitional matrix is intentionally fail-closed. +4. Preserve Mosaic audit, session, and operator-memory records under their normal scoped retention rules; do not copy them into Hermes as a rollback shortcut. +5. For an in-flight coordination request, use the owned handoff observation/result flow in `MosCoordinationService`; do not create a second orchestrator path. +6. Capture the binding reversal, affected scope, correlation IDs, and reason in the approved operational record before retrying a cutover. From c6e3cfbd95985724e15543228f75f592db8e4be4 Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Mon, 13 Jul 2026 15:29:33 +0000 Subject: [PATCH 035/152] =?UTF-8?q?docs(tess):=20ledger=20sync=20=E2=80=94?= =?UTF-8?q?=20W-001=203-of-3=20merged,=20M5-002=20approved,=20M5-001=20in?= =?UTF-8?q?=20TDD=20(#743)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/tess/TASKS.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/tess/TASKS.md b/docs/tess/TASKS.md index ef6363d2..ea8c32b3 100644 --- a/docs/tess/TASKS.md +++ b/docs/tess/TASKS.md @@ -35,11 +35,11 @@ | TESS-M4-001 | done | Implement Mos coordination handoff/observe/result contract with authority-boundary tests | #710 | coder0 | packages/coord, apps/gateway | feat/tess-mos-coordination | TESS-M3-V | 25K | **MERGED by Mos** → main squash **76325ca3** ("feat(tess): add Mos coordination boundary (#735)"), 2026-07-13 — merge = native-in-process transport ACCEPTED (contract transport-neutral). TESS-MOS-001. Mos-DISPATCHED 2026-07-13 to coder0 DESIGN-FIRST. UPDATE 2026-07-13: coder0 wrote docs/tess/MOS-COORDINATION.md; design checkpoint surfaced to Mos with the transport-adapter question (existing fleet/tmux Mos-authority channel vs dedicated native queue/HTTP). coder0 PROCEEDED (ahead of the Mos transport ruling) choosing a **native in-process adapter** and opened **PR #735** (base=main), head 7936e15d3ae137c91c88efdab4bb09b863a2195d. Impl: transport-NEUTRAL handoff/observe/result contract (MosCoordinationPort); deterministic native in-process InMemoryMosCoordinationPort; gateway derives actor/tenant/requester from trusted context/config; fail-closed for unconfigured-requester, self-delegation, target-drift, cross-tenant observe/result; NO public orchestrator verbs; **NO fleet/tmux transport, NO Mos-side consumer**; command-authorization byte-identical hash a9f829e7; no live creds; no hardcoded Tess identity. Local forced cold-cache typecheck/lint/format/test green (42 tasks); Codex security no findings. CI pipeline 1752 (pull_request, refs/pull/735/head, commit 7936e15d) = **SUCCESS**; head UNMOVED, mergeable=true. Independent non-author ROR **COMPLETE**: reviewer [W-jarvis:reviewer] VERIFIED APPROVE at exact head 7936e15d (Gitea comment 17032) — verified MosCoordinationPort=handoff/observe/result only, gateway-derived authority, fail-closed denial coverage, native in-process port (no tmux/Mos consumer), command-authz byte-identical a9f829e7, no live creds, no Tess literal. ⚠️ HEAD MOVED 2026-07-13 (ROR 17032 INVALIDATED): coder0 pushed one post-ROR commit → new head **5022911f84dd7ac30f40df31a53f6cd31a51728f** (commit "docs(tess): record M4 verification", parent 7936e15d). Orchestrator-verified sole delta = a single scratchpad doc docs/scratchpads/tess-m4-001-mos-coordination.md, ZERO code/test diff. New CI pipeline 1754 (pull_request, refs/pull/735/head, commit 5022911f) = **SUCCESS**; mergeable=true, head now 5022911f. Comment 17032 @ 7936e15d no longer at exact head → re-serialize + re-ROR REQUIRED. Fast delta RE-ROR **COMPLETE**: reviewer [W-jarvis:reviewer] VERIFIED APPROVE at exact head 5022911f (Gitea comment 17036) — confirmed 5022911f is direct child of prior-reviewed 7936e15d, sole two-dot delta = the 3-line scratchpad doc, no code/test diff, command-authz byte-identical a9f829e7, CI 1754 success. Head UNMOVED (5022911f), mergeable=true. **MERGEABLE at 5022911f — HARD STOP for Mos merge** (2026-07-13). MERGE = Mos ACCEPTING the native-in-process transport choice (contract stays transport-neutral; a fleet/tmux or native-queue/HTTP consumer can be added later without contract churn); if Mos wants a different FIRST adapter, hold merge + route rework to coder0. | | TESS-M4-002 | done | Implement transitional Hermes runtime/capability adapter | #710 | coder3 | packages/agent, apps/gateway | feat/tess-hermes-adapter | TESS-M3-V | 40K | **MERGED by Mos** → main squash **9e5b9188** ("feat(agent): add transitional Hermes runtime adapter (#734)"), 2026-07-13 — Mos merge = **option (a) ACCEPTED**; post-merge main CI 1753. TESS-HRM-001; no legacy schema in core contracts. Mos-DISPATCHED 2026-07-13 to coder3 DESIGN-FIRST (contract sketch + questions to Mos before build). Goes in-progress as PR opens; PR-open-STOP → serialize CI + independent non-author ROR at exact head → Mos merges. UPDATE 2026-07-13: coder3 ACTIVE — fresh worktree/branch feat/tess-hermes-adapter off origin/main; boundary sketch at docs/tess/hermes-runtime-adapter-design.md. DESIGN QUESTION surfaced to Mos (coder3 HELD at design-only until ruling): AC-TESS-05 wants approved capability across Kanban/skills/memory/tools/cron, but AgentRuntimeProvider models only SESSION capabilities. (a) adapter-local Hermes inventory/health marks those as explicit UNSUPPORTED, real ops deferred to their Mosaic-owned plugin contracts (coder3 default, preserves hard no-legacy-core-contract rule); vs (b) an existing Mosaic-owned non-runtime capability contract this adapter must implement. Orchestrator recommends (a) to Mos as the conservative boundary-preserving path. UPDATE 2026-07-13: coder3 PROCEEDED WITH (a) and opened **PR #734** (base=main), head 47b8a145ac43688499d275a54b434a52551c1abd — ahead of the Mos (a/b) ruling (design-hold was placed; coder3's original msg said it would proceed with (a) unless directed). Hermes adapter normalized behind packages/agent boundary; core types unchanged, unsupported ops fail-closed, tests prove no legacy field leak; focused tests/typecheck/lint pass; cold-cache turbo typecheck+build 46/46 0-cached. mergeable=true. CI pipeline 1751 (pull_request, refs/pull/734/head, commit 47b8a145) = **SUCCESS**; head UNMOVED, mergeable=true. Independent non-author ROR **COMPLETE**: reviewer [W-jarvis:reviewer] VERIFIED APPROVE at exact head 47b8a145 (Gitea comment 17027) — verified core AgentRuntimeProvider/runtime types UNCHANGED, adapter normalizes Hermes legacy shapes behind packages/agent boundary, unsupported runtime ops FAIL CLOSED via capability_unsupported BEFORE transport side effects (Kanban/skills/memory/tools/cron deferred under option (a)), no live creds, no hardcoded Tess agent identifier. Head UNMOVED, mergeable=true. **MERGEABLE — HARD STOP for Mos merge** (2026-07-13). ⚠️ MERGE GATED on Mos confirming option (a) is accepted (implementation == (a)); if Mos rules (b), #734 needs rework. HARD STOP for Mos merge. | | TESS-M4-003 | done | Implement memory/retrieval, state/inbox, runtime bootstrap, fleet diagnostics and GitOps plugin foundations | #710 | coder0 | packages/memory, packages/agent, packages/mosaic | feat/tess-operator-plugins | TESS-M3-V | 40K | **MERGED by Mos** → main squash **2363f155** ("feat(memory): add operator retrieval plugin (#736)"), 2026-07-13. ⚠️ SCOPE GAP surfaced by Mos: #736 delivered ONLY the leaf @mosaicstack/memory operator-retrieval slice; **TESS-PLG-001 (packages/mosaic catalog/registration) was silently DEFERRED by the author and never surfaced in MISSION-MANIFEST/VERIFICATION-MATRIX** → now tracked explicitly as its own row (see TESS-PLG-001 below) and folded into TESS-M4-W-001. State/inbox/runtime-bootstrap/fleet-diagnostics/GitOps foundations remain follow-on (not in #736). TESS-MEM-001, TESS-PLG-001. Mos HELD 1 beat (2026-07-13) for a well-conditioned lane. UPDATE 2026-07-13: coder0 TOOK OVER M4-003 (preserved coder4 WIP first, then rebased on latest main) and opened **PR #736** (base=main), head a1d63ca8ed07610828e9c51a213fffe9123b3de4. ⚠️ AUTHORIZATION FLAG to Mos: M4-003 was on Mos 1-beat HOLD; confirm this takeover/dispatch was Mos-authorized before merge. Scope delivered: LEAF @mosaicstack/memory operator retrieval plugin — config-injected adapter/namespace, runtime-validated server-derived tenant/owner/session scope, redaction-before-persist, provenance, bounded startup prioritization, wildcard adapter contract, namespace/different-instance tests. NO gateway/catalog/durable-inbox or command-authorization changes. Forced cold-cache typecheck/lint/format/test green (42 tasks); Woodpecker 1756 green; Codex code+security clean. CI pipeline 1756 (pull_request, refs/pull/736/head, commit a1d63ca8) = **SUCCESS**; mergeable=true. Independent non-author ROR COMPLETE: reviewer VERIFIED APPROVE at exact head a1d63ca8 (Gitea comment 17044) — leaf packages/memory/doc only (no gateway/catalog/durable-inbox), command-authz byte-identical a9f829e7, runtime scope validation before storage keying, config-injected adapter/namespace/instance metadata, redaction-before-persist + provenance, scoped wildcard adapter contract, namespace/different-instance tests, no live creds, no Tess literal. Head verified UNMOVED at a1d63ca8, base main, mergeable=true. **MERGEABLE — reported to Mos, HARD STOP for Mos merge.** NOTE: M4-003 scope here is the memory-plugin slice; state/inbox/runtime-bootstrap/fleet-diagnostics/GitOps foundations may be follow-on slices — confirm with Mos whether #736 fully closes M4-003 or is slice 1. | -| TESS-M4-W-001 | in-progress | M4-V remediation — gateway reachability SPINE: register runtime provider into AGENT_RUNTIME_PROVIDER_REGISTRY + wire Mos-coordination consumer + wire operator-memory-plugin consumer (make merged M4 deliverables reachable end-to-end); FOLDS IN minimal TESS-PLG-001 catalog/registration | #710 | coder0 | apps/gateway, packages/mosaic, packages/agent | feat/tess-m4w-reachability-spine | TESS-M4-003 | 30K | **Mos-DISPATCHED 2026-07-13** (remediation). Root cause: M4-V holistic review @ origin/main **2363f155** found the three merged M4 deliverables unit-green but NOT reachable end-to-end (no gateway wiring/consumers; providers never registered into the registry). **SPLIT into 3 sub-parts by coder0 (integrity-honest):** **(#2 Mos-coordination consumer) = DELIVERED as PR #737** (head f7b95f60, base main, "feat(gateway): expose Mos coordination boundary") — real AuthGuard Mos handoff/observe/result consumer, authenticated actor/tenant + required correlation derivation, service authority unchanged, gateway target test/typecheck/lint pass; **CI 1758 SUCCESS** (repo 47, commit==head); head verified UNMOVED at f7b95f60666a4abbbad9a08669637b19fa87c430, base main, mergeable=true. **Independent non-author ROR COMPLETE**: reviewer VERIFIED APPROVE at exact head f7b95f60 (Gitea comment 17054) — clean partial scope confirmed (AuthGuard Mos handoff/observe/result controller + module registration only; no runtime-provider registration / operator-memory consumer; actor/tenant from CurrentUser/scopeFromUser + required X-Correlation-Id before service invocation; MosCoordinationService unchanged; command-authz byte-identical a9f829e7; no live creds/no Tess literal). **#737 MERGEABLE — reported to Mos, HARD STOP for Mos merge (partial slice; land-vs-hold-for-full-spine is Mos's disposition call).** **(#1 runtime-provider registration) + (operator-memory consumer) = BLOCKED, NOT in #737.** coder0 could not truthfully complete them in this slice and REFUSED to fake with deny/unavailable stubs: gateway has **no concrete Hermes transport** and **no gateway-side tmux transport/authority wiring** to register a real provider; OperatorMemory consumer needs **session tenant/owner/session propagation currently ABSENT from AgentService's memory-tools boundary**. ⚠️ **DESIGN RULING ESCALATED TO MOS** (architecture, not resolvable from repo): how to wire provider-registration + memory-scope propagation when no concrete transport exists yet — new remediation slice / re-scope / accept #737 as incremental. TESS-PLG-001 (folded here) is part of the blocked #1 registration path. Command-authz byte-identical a9f829e7. **UPDATE 2026-07-13: #737 MERGED by Mos → main e2376190 ("feat(gateway): expose Mos coordination boundary (#737)").** **Operator-memory consumer sub-part UNBLOCKED + DELIVERED as PR #739** ("feat(memory): bind operator plugin to agent sessions", base main off e2376190, live head 31a59738089f0784428833fc5a0192c6c7c43261, mergeable=true) — coder0 resolved the session-scope-propagation blocker WITHOUT stubbing: gateway bootstrap configures plugin only with MOSAIC_OPERATOR_MEMORY_INSTANCE_ID + MOSAIC_OPERATOR_MEMORY_NAMESPACE, AgentService derives {tenantId,ownerId,sessionId} server-side and binds search/capture tools. Cold-cache root typecheck/lint/format/test (42 tasks) green; security review clean (Codex Optional-import finding = false positive, pre-existing, typecheck passed). **CI 1764 SUCCESS** (repo 47, commit==head); head verified UNMOVED at 31a59738089f0784428833fc5a0192c6c7c43261, base main, mergeable=true. **Independent non-author ROR ROUTED to reviewer at exact head 31a59738089f** (coder0 authored → reviewer is non-author) — asked reviewer to confirm scope is server-derived/non-client-controllable + no cross-tenant leak, and to independently verify the Codex Optional-import finding is a false positive. (Head reconcile CLOSED: coder0 confirmed 31a597380c55… was a transcription typo; live+frozen head is 31a59738089f0784428833fc5a0192c6c7c43261, working tree clean.) **UPDATE 2026-07-13: reviewer REQUEST CHANGES at head 31a59738089f (Gitea comment 17069) — NOT mergeable.** Production code CONFIRMED correct (plugin route wired, config env namespace/instance only, no live creds/no Tess literal, command-authz byte-identical a9f829e7; Codex Optional-import finding = false positive, import present). **Two TEST-COVERAGE blockers:** (1) tests BYPASS production scope derivation — they call createMemoryTools with a PREBUILT scope, never exercising the real createSession→buildToolsForSandbox server-side {tenantId,ownerId,sessionId} derivation; (2) NO divergent cross-tenant/cross-owner ISOLATION/DENIAL test proving a foreign actor cannot reuse a session / reach another operator-memory scope before the plugin call. Routed back to coder0 (integrity: harden real coverage, do NOT weaken assertion). Any new commit MOVES head → invalidates ROR → re-serialize CI + re-ROR at new head. **UPDATE 2026-07-13 (remediated): coder0 pushed hardened tests, NEW frozen head c26b3b775279575276c6ebe8955a9146bfc61413** — added createSession→buildToolsForSandbox PRODUCTION-PATH assertion of derived {tenantId,ownerId,sessionId}; added foreign-actor reuse DENIAL test asserting rejection occurs BEFORE scope/tool construction and before any plugin call. Cold-cache root typecheck/lint/format/test green (42 tasks). Old ROR at 31a59738089f + CI 1764 SUPERSEDED. Re-serialized: **CI 1765 SUCCESS** at c26b3b775279 (ref refs/pull/739/head, commit==head); head verified UNMOVED at c26b3b775279575276c6ebe8955a9146bfc61413, base main, mergeable=true. **Fresh independent non-author ROR RE-ROUTED to reviewer at exact head c26b3b775279** — asked reviewer to confirm both 17069 blockers genuinely closed (prod-path derivation exercised + cross-tenant denial before plugin call, assertion not weakened). **Independent non-author re-ROR COMPLETE**: reviewer VERIFIED APPROVE at exact head c26b3b775279 (Gitea comment 17074) — both 17069 blockers CONFIRMED closed: production createSession→buildToolsForSandbox scope-derivation test asserts {tenantId,ownerId,sessionId}; foreign-scope reuse rejects BEFORE tool construction and BEFORE plugin search/capture; production wiring reachable via MemoryModule env-configured plugin → AgentService injection → memory_search/memory_save_insight plugin path; command-authz byte-identical a9f829e7; Optional import present; no live creds/no Tess literal. Head verified UNMOVED at c26b3b775279, base main, mergeable=true. **#739 MERGEABLE — reported to Mos, HARD STOP for Mos merge.** This lands the operator-memory-consumer sub-part of W-001; REMAINING W-001 gap = only (#1) runtime-provider registration. **REMAINING blocked: (#1) runtime-provider registration into AGENT_RUNTIME_PROVIDER_REGISTRY** — still needs Mos A/B/C design ruling (no concrete Hermes transport yet). So after #739 lands, W-001 = Mos-consumer (#737 merged) + memory-consumer (#739) DONE; only the provider-registration linchpin remains. **UPDATE 2026-07-13: #739 MERGED by Mos → main squash 3378b857eb ("feat(memory): bind operator plugin to agent sessions (#739)"); post-merge main push pipeline 1766 running. W-001 spine now 2-of-3 sub-parts MERGED (Mos-consumer #737 e2376190 + operator-memory-consumer #739 3378b857); ONLY remaining W-001 gap = (#1) runtime-provider registration into AGENT_RUNTIME_PROVIDER_REGISTRY — still BLOCKED on Mos A/B/C design ruling (no concrete Hermes transport; TESS-PLG-001 folded here). coder0 idle/ready to build #1 on ruling.** **UPDATE 2026-07-13: (#1) DELIVERED as PR #740 "feat(gateway): register Hermes runtime provider"** (base main 3378b857, exact live head 127a69ea11ccc36516c78c2007cbe52fbf63ad30 verified unmoved, mergeable=true). coder0 resolved the A/B/C escalation by BUILDING a concrete transport (⚠️ design-direction flagged to Mos for confirm-before-merge): agent.module.ts explicit registry.register(new HermesRuntimeProvider(new GatewayHermesRuntimeTransport())); GatewayHermesRuntimeTransport = server-configured URL+service token, HTTPS-except-loopback, prefixed-URL preserving, forwards full scope incl channel; AuthGuard interaction transitional-capabilities route through RuntimeProviderService + live controller→service→registered-provider reachability test. Cold-cache typecheck/lint/format/test green (42 tasks); Codex path-prefix+channel-header findings remediated, security clean. **CI pipeline 1767 (repo 47, refs/pull/740/head, commit==head) = SUCCESS**; head verified UNMOVED at 127a69ea11ccc36516c78c2007cbe52fbf63ad30 post-CI, base main, mergeable=true. **Independent non-author ROR ROUTED to reviewer at exact head 127a69ea11cc** (coder0 authored → reviewer non-author) — asked reviewer to verify REAL E2E reachability (provider actually in registry + reachability test exercises registered provider, not mock), transport security (HTTPS-except-loopback, no token leak), command-authz byte-identical a9f829e7, no live creds/no Tess literal, Codex findings genuinely remediated. Awaiting reviewer disposition; any new commit moves head → re-serialize + re-ROR. **UPDATE 2026-07-13: reviewer REQUEST CHANGES at head 127a69ea11cc (Gitea comment 17088) — NOT mergeable.** CI 1767 green; command-authz byte-identical a9f829e7 CONFIRMED; production positives CONFIRMED (module factory registers Hermes provider; concrete transport HTTPS/prefix/channel headers; no live creds/no Tess literal). **Blocker (reachability-integrity):** the required live-guarded reachability proof is MISSING — test directly calls controller.transitionalCapabilities + manually constructs RuntimeProviderService/createGatewayRuntimeProviderRegistry; it does NOT exercise live GET /api/interaction/:agentName/transitional-capabilities, Nest DI through AgentModule, or the AuthGuard request path, so it can pass even if injected gateway registry/route wiring is broken (defeats the M4-V E2E-reachability point). Routed back to coder0 (integrity: add genuine Nest-e2e live-guarded reachability test through real DI+route+AuthGuard asserting reach of the registered Hermes provider; do NOT weaken/stub/mock around it; keep command-authz a9f829e7). Old ROR 17088 + CI 1767 will be SUPERSEDED by the remediation head → re-serialize CI + re-ROR at new head. **UPDATE 2026-07-13 (remediated): coder0 pushed the live-guarded reachability test, NEW frozen head a7e5d377e38b40275884a7df6ee35c55c5859e43** (live Gitea head independently verified, base main 3378b857, mergeable=true) — added apps/gateway/src/agent/hermes-runtime-reachability.e2e.test.ts: imports REAL AgentModule (preserves actual AGENT_RUNTIME_PROVIDER_REGISTRY factory + RuntimeProviderService), boots Fastify/Nest, unauth HTTP GET /api/interaction/Nova/transitional-capabilities?provider=runtime.hermes asserts 401 via ACTUAL AuthGuard, authed GET asserts 200 + all five Hermes entries, asserts DI registry resolves HermesRuntimeProvider; only unrelated peripheral modules harness-replaced to avoid DB/queue startup — NO route/guard/DI-registry/runtime-service/provider mock; existing controller unit test retained; command-authz untouched (byte-identical a9f829e7 remains). Cold-cache root typecheck/lint/format/test green 42/42 (gateway 53 files/606 tests). Old ROR 17088 + CI 1767 SUPERSEDED. Re-serializing: **CI 1768 (repo 47, refs/pull/740/head, commit==head a7e5d377) running** — poll in flight; on green → re-route non-author ROR at exact head a7e5d377. **UPDATE 2026-07-13: CI 1768 SETTLED SUCCESS** (repo 47, refs/pull/740/head, commit==head a7e5d377e38b40275884a7df6ee35c55c5859e43); head independently verified UNMOVED at a7e5d377 (live Gitea, NOT worker-reported), base main 3378b857, mergeable=true. **Independent non-author re-ROR COMPLETE: reviewer VERIFIED APPROVE at exact head a7e5d377e38b40275884a7df6ee35c55c5859e43 (Gitea comment 17094).** Prior 17088 blocker CONFIRMED closed — the new hermes-runtime-reachability.e2e.test.ts boots real Nest/Fastify AgentModule and exercises unauth 401 via the ACTUAL AuthGuard + authed HTTP GET /api/interaction/:agentName/transitional-capabilities through the live route→controller→RuntimeProviderService→registered Hermes provider, and asserts DI registry resolves HermesRuntimeProvider (no route/guard/DI/service/provider mock); transport concrete, HTTPS-except-loopback, path-prefix + channel header covered; command-authz byte-identical a9f829e7 CONFIRMED; no live creds/no Tess literal. Head verified UNMOVED at a7e5d377, base main, mergeable=true. **#740 MERGEABLE — the (#1) runtime-provider-registration linchpin — reported to Mos, HARD STOP for Mos merge.** ⚠️ Design-direction (concrete GatewayHermesRuntimeTransport built to resolve the A/B/C escalation) flagged to Mos for confirm-before-merge. On #740 merge, W-001 spine = 3-of-3 sub-parts landed (Mos-consumer #737 + memory-consumer #739 + provider-registration #740) → M4-V re-fire eligible. | +| TESS-M4-W-001 | in-progress | M4-V remediation — gateway reachability SPINE: register runtime provider into AGENT_RUNTIME_PROVIDER_REGISTRY + wire Mos-coordination consumer + wire operator-memory-plugin consumer (make merged M4 deliverables reachable end-to-end); FOLDS IN minimal TESS-PLG-001 catalog/registration | #710 | coder0 | apps/gateway, packages/mosaic, packages/agent | feat/tess-m4w-reachability-spine | TESS-M4-003 | 30K | **Mos-DISPATCHED 2026-07-13** (remediation). Root cause: M4-V holistic review @ origin/main **2363f155** found the three merged M4 deliverables unit-green but NOT reachable end-to-end (no gateway wiring/consumers; providers never registered into the registry). **SPLIT into 3 sub-parts by coder0 (integrity-honest):** **(#2 Mos-coordination consumer) = DELIVERED as PR #737** (head f7b95f60, base main, "feat(gateway): expose Mos coordination boundary") — real AuthGuard Mos handoff/observe/result consumer, authenticated actor/tenant + required correlation derivation, service authority unchanged, gateway target test/typecheck/lint pass; **CI 1758 SUCCESS** (repo 47, commit==head); head verified UNMOVED at f7b95f60666a4abbbad9a08669637b19fa87c430, base main, mergeable=true. **Independent non-author ROR COMPLETE**: reviewer VERIFIED APPROVE at exact head f7b95f60 (Gitea comment 17054) — clean partial scope confirmed (AuthGuard Mos handoff/observe/result controller + module registration only; no runtime-provider registration / operator-memory consumer; actor/tenant from CurrentUser/scopeFromUser + required X-Correlation-Id before service invocation; MosCoordinationService unchanged; command-authz byte-identical a9f829e7; no live creds/no Tess literal). **#737 MERGEABLE — reported to Mos, HARD STOP for Mos merge (partial slice; land-vs-hold-for-full-spine is Mos's disposition call).** **(#1 runtime-provider registration) + (operator-memory consumer) = BLOCKED, NOT in #737.** coder0 could not truthfully complete them in this slice and REFUSED to fake with deny/unavailable stubs: gateway has **no concrete Hermes transport** and **no gateway-side tmux transport/authority wiring** to register a real provider; OperatorMemory consumer needs **session tenant/owner/session propagation currently ABSENT from AgentService's memory-tools boundary**. ⚠️ **DESIGN RULING ESCALATED TO MOS** (architecture, not resolvable from repo): how to wire provider-registration + memory-scope propagation when no concrete transport exists yet — new remediation slice / re-scope / accept #737 as incremental. TESS-PLG-001 (folded here) is part of the blocked #1 registration path. Command-authz byte-identical a9f829e7. **UPDATE 2026-07-13: #737 MERGED by Mos → main e2376190 ("feat(gateway): expose Mos coordination boundary (#737)").** **Operator-memory consumer sub-part UNBLOCKED + DELIVERED as PR #739** ("feat(memory): bind operator plugin to agent sessions", base main off e2376190, live head 31a59738089f0784428833fc5a0192c6c7c43261, mergeable=true) — coder0 resolved the session-scope-propagation blocker WITHOUT stubbing: gateway bootstrap configures plugin only with MOSAIC_OPERATOR_MEMORY_INSTANCE_ID + MOSAIC_OPERATOR_MEMORY_NAMESPACE, AgentService derives {tenantId,ownerId,sessionId} server-side and binds search/capture tools. Cold-cache root typecheck/lint/format/test (42 tasks) green; security review clean (Codex Optional-import finding = false positive, pre-existing, typecheck passed). **CI 1764 SUCCESS** (repo 47, commit==head); head verified UNMOVED at 31a59738089f0784428833fc5a0192c6c7c43261, base main, mergeable=true. **Independent non-author ROR ROUTED to reviewer at exact head 31a59738089f** (coder0 authored → reviewer is non-author) — asked reviewer to confirm scope is server-derived/non-client-controllable + no cross-tenant leak, and to independently verify the Codex Optional-import finding is a false positive. (Head reconcile CLOSED: coder0 confirmed 31a597380c55… was a transcription typo; live+frozen head is 31a59738089f0784428833fc5a0192c6c7c43261, working tree clean.) **UPDATE 2026-07-13: reviewer REQUEST CHANGES at head 31a59738089f (Gitea comment 17069) — NOT mergeable.** Production code CONFIRMED correct (plugin route wired, config env namespace/instance only, no live creds/no Tess literal, command-authz byte-identical a9f829e7; Codex Optional-import finding = false positive, import present). **Two TEST-COVERAGE blockers:** (1) tests BYPASS production scope derivation — they call createMemoryTools with a PREBUILT scope, never exercising the real createSession→buildToolsForSandbox server-side {tenantId,ownerId,sessionId} derivation; (2) NO divergent cross-tenant/cross-owner ISOLATION/DENIAL test proving a foreign actor cannot reuse a session / reach another operator-memory scope before the plugin call. Routed back to coder0 (integrity: harden real coverage, do NOT weaken assertion). Any new commit MOVES head → invalidates ROR → re-serialize CI + re-ROR at new head. **UPDATE 2026-07-13 (remediated): coder0 pushed hardened tests, NEW frozen head c26b3b775279575276c6ebe8955a9146bfc61413** — added createSession→buildToolsForSandbox PRODUCTION-PATH assertion of derived {tenantId,ownerId,sessionId}; added foreign-actor reuse DENIAL test asserting rejection occurs BEFORE scope/tool construction and before any plugin call. Cold-cache root typecheck/lint/format/test green (42 tasks). Old ROR at 31a59738089f + CI 1764 SUPERSEDED. Re-serialized: **CI 1765 SUCCESS** at c26b3b775279 (ref refs/pull/739/head, commit==head); head verified UNMOVED at c26b3b775279575276c6ebe8955a9146bfc61413, base main, mergeable=true. **Fresh independent non-author ROR RE-ROUTED to reviewer at exact head c26b3b775279** — asked reviewer to confirm both 17069 blockers genuinely closed (prod-path derivation exercised + cross-tenant denial before plugin call, assertion not weakened). **Independent non-author re-ROR COMPLETE**: reviewer VERIFIED APPROVE at exact head c26b3b775279 (Gitea comment 17074) — both 17069 blockers CONFIRMED closed: production createSession→buildToolsForSandbox scope-derivation test asserts {tenantId,ownerId,sessionId}; foreign-scope reuse rejects BEFORE tool construction and BEFORE plugin search/capture; production wiring reachable via MemoryModule env-configured plugin → AgentService injection → memory_search/memory_save_insight plugin path; command-authz byte-identical a9f829e7; Optional import present; no live creds/no Tess literal. Head verified UNMOVED at c26b3b775279, base main, mergeable=true. **#739 MERGEABLE — reported to Mos, HARD STOP for Mos merge.** This lands the operator-memory-consumer sub-part of W-001; REMAINING W-001 gap = only (#1) runtime-provider registration. **REMAINING blocked: (#1) runtime-provider registration into AGENT_RUNTIME_PROVIDER_REGISTRY** — still needs Mos A/B/C design ruling (no concrete Hermes transport yet). So after #739 lands, W-001 = Mos-consumer (#737 merged) + memory-consumer (#739) DONE; only the provider-registration linchpin remains. **UPDATE 2026-07-13: #739 MERGED by Mos → main squash 3378b857eb ("feat(memory): bind operator plugin to agent sessions (#739)"); post-merge main push pipeline 1766 running. W-001 spine now 2-of-3 sub-parts MERGED (Mos-consumer #737 e2376190 + operator-memory-consumer #739 3378b857); ONLY remaining W-001 gap = (#1) runtime-provider registration into AGENT_RUNTIME_PROVIDER_REGISTRY — still BLOCKED on Mos A/B/C design ruling (no concrete Hermes transport; TESS-PLG-001 folded here). coder0 idle/ready to build #1 on ruling.** **UPDATE 2026-07-13: (#1) DELIVERED as PR #740 "feat(gateway): register Hermes runtime provider"** (base main 3378b857, exact live head 127a69ea11ccc36516c78c2007cbe52fbf63ad30 verified unmoved, mergeable=true). coder0 resolved the A/B/C escalation by BUILDING a concrete transport (⚠️ design-direction flagged to Mos for confirm-before-merge): agent.module.ts explicit registry.register(new HermesRuntimeProvider(new GatewayHermesRuntimeTransport())); GatewayHermesRuntimeTransport = server-configured URL+service token, HTTPS-except-loopback, prefixed-URL preserving, forwards full scope incl channel; AuthGuard interaction transitional-capabilities route through RuntimeProviderService + live controller→service→registered-provider reachability test. Cold-cache typecheck/lint/format/test green (42 tasks); Codex path-prefix+channel-header findings remediated, security clean. **CI pipeline 1767 (repo 47, refs/pull/740/head, commit==head) = SUCCESS**; head verified UNMOVED at 127a69ea11ccc36516c78c2007cbe52fbf63ad30 post-CI, base main, mergeable=true. **Independent non-author ROR ROUTED to reviewer at exact head 127a69ea11cc** (coder0 authored → reviewer non-author) — asked reviewer to verify REAL E2E reachability (provider actually in registry + reachability test exercises registered provider, not mock), transport security (HTTPS-except-loopback, no token leak), command-authz byte-identical a9f829e7, no live creds/no Tess literal, Codex findings genuinely remediated. Awaiting reviewer disposition; any new commit moves head → re-serialize + re-ROR. **UPDATE 2026-07-13: reviewer REQUEST CHANGES at head 127a69ea11cc (Gitea comment 17088) — NOT mergeable.** CI 1767 green; command-authz byte-identical a9f829e7 CONFIRMED; production positives CONFIRMED (module factory registers Hermes provider; concrete transport HTTPS/prefix/channel headers; no live creds/no Tess literal). **Blocker (reachability-integrity):** the required live-guarded reachability proof is MISSING — test directly calls controller.transitionalCapabilities + manually constructs RuntimeProviderService/createGatewayRuntimeProviderRegistry; it does NOT exercise live GET /api/interaction/:agentName/transitional-capabilities, Nest DI through AgentModule, or the AuthGuard request path, so it can pass even if injected gateway registry/route wiring is broken (defeats the M4-V E2E-reachability point). Routed back to coder0 (integrity: add genuine Nest-e2e live-guarded reachability test through real DI+route+AuthGuard asserting reach of the registered Hermes provider; do NOT weaken/stub/mock around it; keep command-authz a9f829e7). Old ROR 17088 + CI 1767 will be SUPERSEDED by the remediation head → re-serialize CI + re-ROR at new head. **UPDATE 2026-07-13 (remediated): coder0 pushed the live-guarded reachability test, NEW frozen head a7e5d377e38b40275884a7df6ee35c55c5859e43** (live Gitea head independently verified, base main 3378b857, mergeable=true) — added apps/gateway/src/agent/hermes-runtime-reachability.e2e.test.ts: imports REAL AgentModule (preserves actual AGENT_RUNTIME_PROVIDER_REGISTRY factory + RuntimeProviderService), boots Fastify/Nest, unauth HTTP GET /api/interaction/Nova/transitional-capabilities?provider=runtime.hermes asserts 401 via ACTUAL AuthGuard, authed GET asserts 200 + all five Hermes entries, asserts DI registry resolves HermesRuntimeProvider; only unrelated peripheral modules harness-replaced to avoid DB/queue startup — NO route/guard/DI-registry/runtime-service/provider mock; existing controller unit test retained; command-authz untouched (byte-identical a9f829e7 remains). Cold-cache root typecheck/lint/format/test green 42/42 (gateway 53 files/606 tests). Old ROR 17088 + CI 1767 SUPERSEDED. Re-serializing: **CI 1768 (repo 47, refs/pull/740/head, commit==head a7e5d377) running** — poll in flight; on green → re-route non-author ROR at exact head a7e5d377. **UPDATE 2026-07-13: CI 1768 SETTLED SUCCESS** (repo 47, refs/pull/740/head, commit==head a7e5d377e38b40275884a7df6ee35c55c5859e43); head independently verified UNMOVED at a7e5d377 (live Gitea, NOT worker-reported), base main 3378b857, mergeable=true. **Independent non-author re-ROR COMPLETE: reviewer VERIFIED APPROVE at exact head a7e5d377e38b40275884a7df6ee35c55c5859e43 (Gitea comment 17094).** Prior 17088 blocker CONFIRMED closed — the new hermes-runtime-reachability.e2e.test.ts boots real Nest/Fastify AgentModule and exercises unauth 401 via the ACTUAL AuthGuard + authed HTTP GET /api/interaction/:agentName/transitional-capabilities through the live route→controller→RuntimeProviderService→registered Hermes provider, and asserts DI registry resolves HermesRuntimeProvider (no route/guard/DI/service/provider mock); transport concrete, HTTPS-except-loopback, path-prefix + channel header covered; command-authz byte-identical a9f829e7 CONFIRMED; no live creds/no Tess literal. Head verified UNMOVED at a7e5d377, base main, mergeable=true. **#740 MERGEABLE — the (#1) runtime-provider-registration linchpin — reported to Mos, HARD STOP for Mos merge.** ⚠️ Design-direction (concrete GatewayHermesRuntimeTransport built to resolve the A/B/C escalation) flagged to Mos for confirm-before-merge. On #740 merge, W-001 spine = 3-of-3 sub-parts landed (Mos-consumer #737 + memory-consumer #739 + provider-registration #740) → M4-V re-fire eligible. **UPDATE 2026-07-13: #740 MERGED by Mos → main b7b0f508 ("feat(gateway): register Hermes runtime provider (#740)"). W-001 SPINE NOW 3-OF-3 MERGED (Mos-consumer #737 e2376190 + operator-memory-consumer #739 3378b857 + provider-registration linchpin #740 b7b0f508) — the M4-V reachability remediation code work is COMPLETE. GATE: TESS-M4-V re-fire is now eligible and Mos-owned — this row stays in-progress until M4-V re-fires green (unit-green was never the bar; end-to-end reachability is). ⚠️ main push pipeline 1772 (for the #740 merge to main) FAILED at the `build` step — quality gates (typecheck/lint/format/test) all GREEN, failure is downstream at build/publish (recurring infra/ENOSPC pattern); flagged to Mos as Mos-owned, does not block docs-only PRs. Prior doc-sync ledger PR #741 MERGED → main f40e6ba3 ("docs(tess): sync M4 tracking to merged reality (M4 in-progress / gate-pending)"); ledger writes resumed on fresh branch docs/tess-ledger-sync-m2 off f40e6ba3.** | | TESS-M4-W-002 | done | M4-V remediation — Hermes capability MATRIX (AC-TESS-05): approved-capability coverage across Kanban/skills/memory/tools/cron for the Hermes adapter | #710 | coder3 | packages/agent, apps/gateway | feat/tess-m4w-hermes-matrix | TESS-M4-002 | 22K | **Mos-DISPATCHED 2026-07-13** (remediation, in flight). Extends the M4-002 option-(a) adapter (merged 9e5b9188) with the AC-TESS-05 capability matrix. UPDATE 2026-07-13: coder3 STARTED — fresh worktree off origin/main 2363f155, TDD failing-matrix-tests-first. Orchestrator TRACKS; on PR-open → serialize CI + independent non-author ROR at EXACT head → HARD STOP for Mos merge. No legacy schema into core contracts; command-authz byte-identical a9f829e7. UPDATE 2026-07-13: **PR #738 OPENED** (base main, head 582c6db2088223fd8dd2105005391b5034c992ac, "feat(agent): add Hermes transitional capability matrix") — normalized exhaustive five-entry matrix (kanban/skills/memory/tools/cron), all explicit unsupported, fails CLOSED before transport; tests 4/4, security review clean, cold-cache 46 successful/0 cached, normalized optional TransitionalCapabilityInventoryProvider (no legacy schema). **CI 1759 SUCCESS** (repo 47, commit==head); head verified UNMOVED at 582c6db2, base main, mergeable=true. **Independent non-author ROR COMPLETE**: reviewer VERIFIED APPROVE at exact head 582c6db2 (Gitea comment 17057) — normalized exhaustive five-entry transitional matrix (kanban/skills/memory/tools/cron) all unsupported; assertTransitionalCapability fails CLOSED with capability_unsupported before Hermes transport; only normalized optional TransitionalCapabilityInventoryProvider added to core (no legacy schema leak); command-authz byte-identical a9f829e7; no live creds/no Tess literal. Head verified UNMOVED at 582c6db2, base main, mergeable=true. **#738 MERGEABLE — reported to Mos, HARD STOP for Mos merge.** This is the COMPLETE matrix deliverable (unlike #737's partial spine). **UPDATE 2026-07-13: #738 MERGED by Mos → merge_commit cca6aaf9. TESS-M4-W-002 DONE.** | | TESS-PLG-001 | in-progress | packages/mosaic plugin catalog / registration (operator plugins registered + discoverable) — was silently deferred by M4-003 author; now VISIBLE | #710 | coder0 | packages/mosaic | feat/tess-m4w-reachability-spine | TESS-M4-003 | (folded) | ⚠️ Surfaced by Mos 2026-07-13 as an invisible gap: M4-003/#736 delivered the memory plugin but NOT its catalog/registration in packages/mosaic; never appeared in MISSION-MANIFEST/VERIFICATION-MATRIX. PLACEMENT DECISION (orchestrator, per Mos "your call"): **FOLD minimal registration into TESS-M4-W-001** (coder0's reachability spine already does registry wiring — same author closes their own gap, keeps it in one lane). This row exists for LEDGER VISIBILITY so the gap is tracked, not re-hidden. If M4-W-001 scope grows too large, split back out as a standalone lane. Manifest/matrix update to follow. | | TESS-M4-V | failed | Cross-provider capability, privacy, authority and failure-path qualification | #710 | sonnet | apps/gateway/src/__tests__/integration, packages/agent | review/tess-m4 | TESS-M4-001,TESS-M4-002,TESS-M4-003,TESS-M4-W-001,TESS-M4-W-002 | 22K | **FAILED 2026-07-13** — independent holistic review @ origin/main **2363f155**: all three M4 deliverables (#734/#735/#736) unit-green but **NOT reachable end-to-end** (providers never registered into AGENT_RUNTIME_PROVIDER_REGISTRY; Mos-coordination + operator-memory consumers unwired; TESS-PLG-001 catalog/registration silently deferred). Remediation TESS-M4-W (W-001 spine coder0 + W-002 Hermes matrix coder3) now in flight. **Mos re-fires M4-V ONLY after the spine + matrix land.** Gate M5 (M5 stays behind M4-V; live-deploy = Jason-reserved). | -| TESS-M5-001 | not-started | Implement Matrix/native runtime provider behind common contracts and parity suite | #711 | codex | packages/mosaic, packages/agent | feat/tess-matrix-provider | TESS-M4-V | 30K | TESS-TRN-001 | -| TESS-M5-002 | not-started | Complete migration inventory, cutover, rollback, retention and deprecation evidence | #711 | sonnet | docs/tess | feat/tess-migration-docs | TESS-M4-V | 18K | TESS-MIG-001 | +| TESS-M5-001 | in-progress | Implement Matrix/native runtime provider behind common contracts and parity suite | #711 | coder0 | packages/mosaic, packages/agent | feat/tess-matrix-provider | TESS-M4-V | 30K | TESS-TRN-001. **Mos-DISPATCHED to coder0 2026-07-13** (advancing to M5, same M4-V dependency-reconciliation caveat as M5-002). Design sketch (branch feat/tess-matrix-provider off origin/main b7b0f508): MatrixNativeRuntimeProvider in packages/agent over a narrow MatrixRuntimeTransport contract + MatrixNativeRuntimeTransport in packages/mosaic (Mosaic adapter owns Matrix HTTP/auth/identity/room mechanics; agent provider owns common provider behavior only). Parity suite runs the SAME provider-contract scenarios against factory fixtures for existing tmux/fleet AND Matrix/native; Matrix native declares only operations concretely wired (no fake reachability, no Matrix default promotion); no gateway/Discord changes; command-authz to remain byte-identical a9f829e7. **In TDD — no PR yet.** On PR-open: freeze head → serialize CI (one-at-a-time on repo 47) → independent non-author ROR at exact head → HARD STOP for Mos merge. | +| TESS-M5-002 | in-progress | Complete migration inventory, cutover, rollback, retention and deprecation evidence | #711 | coder3 | docs/tess | feat/tess-migration-docs | TESS-M4-V | 18K | TESS-MIG-001. **Mos-DISPATCHED to coder3 2026-07-13** ("M4 complete; advancing to M5") — dispatched AHEAD of TESS-M4-V passing; the M4-V-status-vs-#710-CLOSED dependency reconciliation is pending Mos ruling (tracked, not orchestrator-decided). **DELIVERED as PR #742** — 4 new files docs/tess/M5-MIGRATION-{INVENTORY,CUTOVER,ROLLBACK,RETENTION-DEPRECATION}.md, base main b7b0f508, frozen head b5e9d0e528a50aae2e916cc7202bacc2e8db67dd. Docs-only; tracking-control trio (MISSION-MANIFEST/TASKS/VERIFICATION-MATRIX) UNTOUCHED; command-authz byte-identical a9f829e7; no live creds. **CI pipeline 1773 SUCCESS** (repo 47, refs/pull/742/head, commit==head). **Independent non-author ROR COMPLETE: reviewer VERIFIED APPROVE at exact head b5e9d0e528a50aae2e916cc7202bacc2e8db67dd (Gitea comment 17108)** — evidence claims verified to trace to landed Hermes adapter / capability matrix, gateway registry/reachability, operator-memory scope path, Mos coordination boundary; docs do NOT over-claim transcript/profile import, schema migration, unsupported-capability enablement, production cutover, or deprecation completion. Head independently verified UNMOVED at b5e9d0e528a5 post-ROR (live Gitea), base main, mergeable=true. **#742 MERGEABLE — reported to Mos, HARD STOP for Mos merge.** | | TESS-M5-003 | not-started | Complete OpenAPI, user/admin/developer/plugin/operations docs and checklist | #711 | codex | docs | feat/tess-docs | TESS-M5-001,TESS-M5-002 | 22K | Documentation hard gate | | TESS-M5-V | not-started | Full baseline, contract, integration, Discord/CLI E2E, security review, recovery drill and rollback qualification | #711 | sonnet | apps/gateway, packages/agent, plugins/discord, packages/mosaic | review/tess-final | TESS-M5-003 | 35K | Maps AC-TESS-01..11 to evidence | From 6345dbfcf26272a3fb7ed96bfa80d9429fdcc188 Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Mon, 13 Jul 2026 15:29:37 +0000 Subject: [PATCH 036/152] feat(agent): add Matrix native runtime provider (#744) --- packages/agent/src/index.ts | 1 + .../matrix-native-runtime-provider.test.ts | 153 ++++++++ .../src/matrix-native-runtime-provider.ts | 351 +++++++++++++++++ .../agent/src/runtime-provider-parity.test.ts | 176 +++++++++ .../matrix-native-runtime-transport.test.ts | 182 +++++++++ .../fleet/matrix-native-runtime-transport.ts | 367 ++++++++++++++++++ packages/mosaic/src/index.ts | 1 + 7 files changed, 1231 insertions(+) create mode 100644 packages/agent/src/matrix-native-runtime-provider.test.ts create mode 100644 packages/agent/src/matrix-native-runtime-provider.ts create mode 100644 packages/agent/src/runtime-provider-parity.test.ts create mode 100644 packages/mosaic/src/fleet/matrix-native-runtime-transport.test.ts create mode 100644 packages/mosaic/src/fleet/matrix-native-runtime-transport.ts diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index f7b15690..702d9d9a 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -3,4 +3,5 @@ export const VERSION = '0.0.0'; export * from './runtime-provider-registry.js'; export * from './tmux-fleet-runtime-provider.js'; export * from './hermes-runtime-provider.js'; +export * from './matrix-native-runtime-provider.js'; export * from './tess-durable-session.js'; diff --git a/packages/agent/src/matrix-native-runtime-provider.test.ts b/packages/agent/src/matrix-native-runtime-provider.test.ts new file mode 100644 index 00000000..01631dc0 --- /dev/null +++ b/packages/agent/src/matrix-native-runtime-provider.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { RuntimeScope, RuntimeStreamEvent } from '@mosaicstack/types'; +import { + type MatrixRuntimeSession, + type MatrixRuntimeTransport, + MatrixNativeRuntimeProvider, + type MatrixReadAuthority, + type MatrixWriteAuthority, +} from './matrix-native-runtime-provider.js'; + +const scope: RuntimeScope = { + actorId: 'operator-1', + tenantId: 'tenant-a', + channelId: 'matrix-control', + correlationId: 'corr-1', +}; + +const session: MatrixRuntimeSession = { + id: 'native-1', + runtimeId: '@worker:example.test', + state: 'active', + createdAt: '2026-07-13T00:00:00.000Z', + updatedAt: '2026-07-13T00:00:00.000Z', +}; + +function transport(): MatrixRuntimeTransport { + return { + health: vi.fn(async () => ({ + status: 'healthy' as const, + checkedAt: '2026-07-13T00:00:00.000Z', + })), + listSessions: vi.fn(async () => [session]), + verifySession: vi.fn(async (sessionId) => { + if (sessionId !== session.id) throw new Error('unexpected session'); + return session; + }), + stream: vi.fn(async function* (): AsyncIterable { + yield { + type: 'message.delta', + sessionId: 'native-1', + cursor: 'cursor-1', + occurredAt: '2026-07-13T00:00:00.000Z', + content: 'hello', + }; + }), + send: vi.fn(async () => undefined), + terminate: vi.fn(async () => undefined), + }; +} + +function readAuthority(): MatrixReadAuthority { + return { canRead: vi.fn(async () => true) }; +} + +function writeAuthority(): MatrixWriteAuthority { + return { + canWrite: vi.fn(async () => true), + assertAuthorized: vi.fn(async () => undefined), + }; +} + +describe('MatrixNativeRuntimeProvider contract boundary', (): void => { + it('advertises the concrete Matrix operations and returns only normalized sessions', async (): Promise => { + const provider = new MatrixNativeRuntimeProvider({ + transport: transport(), + readAuthority: readAuthority(), + }); + + await expect(provider.capabilities(scope)).resolves.toEqual({ + supported: [ + 'session.list', + 'session.tree', + 'session.stream', + 'session.send', + 'session.attach', + 'session.terminate', + ], + }); + await expect(provider.listSessions(scope)).resolves.toEqual([ + expect.objectContaining({ id: 'native-1', providerId: 'runtime.matrix' }), + ]); + }); + + it('binds read attachments to immutable scope and rejects control mode before transport access', async (): Promise => { + const matrix = transport(); + const provider = new MatrixNativeRuntimeProvider({ + transport: matrix, + readAuthority: readAuthority(), + attachmentIdFactory: () => 'attachment-1', + now: () => new Date('2026-07-13T00:00:00.000Z'), + }); + + await expect(provider.attach('native-1', 'control', scope)).rejects.toMatchObject({ + code: 'forbidden', + }); + expect(matrix.verifySession).not.toHaveBeenCalled(); + + await provider.attach('native-1', 'read', scope); + await expect( + provider.detach('attachment-1', { ...scope, tenantId: 'other' }), + ).rejects.toMatchObject({ + code: 'forbidden', + }); + }); + + it('validates messages and applies exact Matrix authority after bound-session verification', async (): Promise => { + const matrix = transport(); + const writes = writeAuthority(); + const provider = new MatrixNativeRuntimeProvider({ + transport: matrix, + readAuthority: readAuthority(), + writeAuthority: writes, + }); + + await expect( + provider.sendMessage('native-1', { content: '', idempotencyKey: 'msg-1' }, scope), + ).rejects.toMatchObject({ + code: 'invalid_request', + }); + expect(matrix.verifySession).not.toHaveBeenCalled(); + + await provider.sendMessage('native-1', { content: 'hello', idempotencyKey: 'msg-1' }, scope); + expect(writes.assertAuthorized).toHaveBeenCalledWith({ + operation: 'session.send', + sessionId: 'native-1', + scope, + }); + expect(matrix.send).toHaveBeenCalledWith( + 'native-1', + { content: 'hello', idempotencyKey: 'msg-1' }, + scope, + ); + }); + + it('streams only after exact read authorization', async (): Promise => { + const matrix = transport(); + const provider = new MatrixNativeRuntimeProvider({ + transport: matrix, + readAuthority: readAuthority(), + }); + + await expect(collect(provider.streamSession('native-1', 'cursor-0', scope))).resolves.toEqual([ + expect.objectContaining({ type: 'message.delta', sessionId: 'native-1' }), + ]); + expect(matrix.stream).toHaveBeenCalledWith('native-1', 'cursor-0', scope); + }); +}); + +async function collect(stream: AsyncIterable): Promise { + const values: RuntimeStreamEvent[] = []; + for await (const value of stream) values.push(value); + return values; +} diff --git a/packages/agent/src/matrix-native-runtime-provider.ts b/packages/agent/src/matrix-native-runtime-provider.ts new file mode 100644 index 00000000..33985142 --- /dev/null +++ b/packages/agent/src/matrix-native-runtime-provider.ts @@ -0,0 +1,351 @@ +import { randomUUID } from 'node:crypto'; +import type { + AgentRuntimeProvider, + RuntimeAttachHandle, + RuntimeAttachMode, + RuntimeCapabilitySet, + RuntimeHealth, + RuntimeMessage, + RuntimeScope, + RuntimeSession, + RuntimeSessionTree, + RuntimeStreamEvent, +} from '@mosaicstack/types'; + +const MATRIX_PROVIDER_ID = 'runtime.matrix'; +const ATTACHMENT_TTL_MS = 5 * 60 * 1_000; + +/** A verified native runtime session. Matrix room and event details remain transport-local. */ +export interface MatrixRuntimeSession { + id: string; + runtimeId: string; + parentSessionId?: string; + state: RuntimeSession['state']; + createdAt: string; + updatedAt: string; +} + +/** + * Narrow native Matrix boundary. The concrete Mosaic transport owns homeserver + * authentication, exact room mapping, Matrix identity checks, and replay cursors. + */ +export interface MatrixRuntimeTransport { + health(scope: RuntimeScope): Promise; + listSessions(scope: RuntimeScope): Promise; + verifySession(sessionId: string, scope: RuntimeScope): Promise; + stream( + sessionId: string, + cursor: string | undefined, + scope: RuntimeScope, + ): AsyncIterable; + send(sessionId: string, message: RuntimeMessage, scope: RuntimeScope): Promise; + terminate(sessionId: string, approvalRef: string, scope: RuntimeScope): Promise; +} + +export type MatrixRuntimeProviderErrorCode = + | 'capability_unsupported' + | 'forbidden' + | 'invalid_request' + | 'not_found'; + +export class MatrixRuntimeProviderError extends Error { + constructor( + readonly code: MatrixRuntimeProviderErrorCode, + message: string, + ) { + super(message); + this.name = MatrixRuntimeProviderError.name; + } +} + +export type MatrixReadOperation = + | 'runtime.health' + | 'session.list' + | 'session.tree' + | 'session.stream' + | 'session.attach'; + +export interface MatrixReadAuthority { + canRead(input: { + operation: MatrixReadOperation; + scope: RuntimeScope; + sessionId?: string; + }): Promise; +} + +export interface MatrixWriteAuthority { + canWrite(input: { + operation: 'session.send' | 'session.terminate'; + sessionId: string; + scope: RuntimeScope; + approvalRef?: string; + }): Promise; + assertAuthorized(input: { + operation: 'session.send' | 'session.terminate'; + sessionId: string; + scope: RuntimeScope; + approvalRef?: string; + }): Promise; +} + +export interface MatrixNativeRuntimeProviderOptions { + transport: MatrixRuntimeTransport; + readAuthority?: MatrixReadAuthority; + writeAuthority?: MatrixWriteAuthority; + attachmentIdFactory?: () => string; + now?: () => Date; + attachmentTtlMs?: number; +} + +interface Attachment { + sessionId: string; + scope: RuntimeScope; + expiresAtMs: number; +} + +class DenyMatrixReadAuthority implements MatrixReadAuthority { + async canRead(): Promise { + return false; + } +} + +class DenyMatrixWriteAuthority implements MatrixWriteAuthority { + async canWrite(): Promise { + return false; + } + + async assertAuthorized(): Promise { + throw new MatrixRuntimeProviderError( + 'forbidden', + 'Matrix runtime writes require Mos authority', + ); + } +} + +/** + * Native Matrix adapter behind the Mosaic runtime contract. It accepts only + * stable session IDs; room identifiers, Matrix event schemas, and credentials + * are deliberately confined to the concrete transport implementation. + */ +export class MatrixNativeRuntimeProvider implements AgentRuntimeProvider { + readonly id = MATRIX_PROVIDER_ID; + private readonly readAuthority: MatrixReadAuthority; + private readonly writeAuthority: MatrixWriteAuthority; + private readonly attachmentIdFactory: () => string; + private readonly now: () => Date; + private readonly attachmentTtlMs: number; + private readonly attachments = new Map(); + + constructor(private readonly options: MatrixNativeRuntimeProviderOptions) { + this.readAuthority = options.readAuthority ?? new DenyMatrixReadAuthority(); + this.writeAuthority = options.writeAuthority ?? new DenyMatrixWriteAuthority(); + this.attachmentIdFactory = options.attachmentIdFactory ?? randomUUID; + this.now = options.now ?? (() => new Date()); + this.attachmentTtlMs = options.attachmentTtlMs ?? ATTACHMENT_TTL_MS; + } + + async capabilities(_scope: RuntimeScope): Promise { + return { + supported: [ + 'session.list', + 'session.tree', + 'session.stream', + 'session.send', + 'session.attach', + 'session.terminate', + ], + }; + } + + async health(scope: RuntimeScope): Promise { + await this.assertRead('runtime.health', undefined, scope); + return this.options.transport.health(scope); + } + + async listSessions(scope: RuntimeScope): Promise { + await this.assertRead('session.list', undefined, scope); + const sessions = await this.options.transport.listSessions(scope); + const visible = await Promise.all( + sessions.map((session) => + this.readAuthority.canRead({ operation: 'session.list', sessionId: session.id, scope }), + ), + ); + return sessions + .filter((_session, index) => visible[index] === true) + .map((session) => this.runtimeSession(session)); + } + + async getSessionTree(scope: RuntimeScope): Promise { + await this.assertRead('session.tree', undefined, scope); + const sessions = await this.options.transport.listSessions(scope); + const visible = await Promise.all( + sessions.map((session) => + this.readAuthority.canRead({ operation: 'session.tree', sessionId: session.id, scope }), + ), + ); + const runtimeSessions = sessions + .filter((_session, index) => visible[index] === true) + .map((session) => this.runtimeSession(session)); + const nodes = new Map( + runtimeSessions.map((session) => [session.id, { session, children: [] }]), + ); + const roots: RuntimeSessionTree[] = []; + for (const session of runtimeSessions) { + const node = nodes.get(session.id)!; + const parent = session.parentSessionId ? nodes.get(session.parentSessionId) : undefined; + if (parent) parent.children.push(node); + else roots.push(node); + } + return roots; + } + + async *streamSession( + sessionId: string, + cursor: string | undefined, + scope: RuntimeScope, + ): AsyncIterable { + await this.assertRead('session.stream', sessionId, scope); + const session = await this.options.transport.verifySession(sessionId, scope); + await this.assertRead('session.stream', session.id, scope); + yield* this.options.transport.stream(session.id, cursor, scope); + } + + async sendMessage( + sessionId: string, + message: RuntimeMessage, + scope: RuntimeScope, + ): Promise { + if (!message.content.trim()) { + throw new MatrixRuntimeProviderError( + 'invalid_request', + 'Matrix runtime message content is required', + ); + } + await this.assertWritePermitted('session.send', sessionId, scope); + const session = await this.options.transport.verifySession(sessionId, scope); + await this.assertWriteAuthorized('session.send', session.id, scope); + await this.options.transport.send(session.id, message, scope); + } + + async attach( + sessionId: string, + mode: RuntimeAttachMode, + scope: RuntimeScope, + ): Promise { + if (mode !== 'read') { + throw new MatrixRuntimeProviderError('forbidden', 'Matrix control attach is not permitted'); + } + await this.assertRead('session.attach', sessionId, scope); + const session = await this.options.transport.verifySession(sessionId, scope); + await this.assertRead('session.attach', session.id, scope); + const nowMs = this.now().getTime(); + this.pruneExpired(nowMs); + const attachmentId = this.attachmentIdFactory(); + const expiresAtMs = nowMs + this.attachmentTtlMs; + this.attachments.set(attachmentId, { + sessionId: session.id, + scope: snapshotScope(scope), + expiresAtMs, + }); + return { + attachmentId, + sessionId: session.id, + mode, + expiresAt: new Date(expiresAtMs).toISOString(), + }; + } + + async detach(attachmentId: string, scope: RuntimeScope): Promise { + const attachment = this.attachments.get(attachmentId); + if (!attachment) + throw new MatrixRuntimeProviderError('not_found', 'Matrix attachment is not active'); + if (this.now().getTime() >= attachment.expiresAtMs) { + this.attachments.delete(attachmentId); + throw new MatrixRuntimeProviderError('forbidden', 'Matrix attachment has expired'); + } + if (!sameScope(attachment.scope, scope)) { + throw new MatrixRuntimeProviderError('forbidden', 'Matrix attachment scope does not match'); + } + this.attachments.delete(attachmentId); + } + + async terminate(sessionId: string, approvalRef: string, scope: RuntimeScope): Promise { + if (!approvalRef.trim()) { + throw new MatrixRuntimeProviderError( + 'invalid_request', + 'Matrix termination approval is required', + ); + } + await this.assertWritePermitted('session.terminate', sessionId, scope, approvalRef); + const session = await this.options.transport.verifySession(sessionId, scope); + await this.assertWriteAuthorized('session.terminate', session.id, scope, approvalRef); + await this.options.transport.terminate(session.id, approvalRef, scope); + } + + private runtimeSession(session: MatrixRuntimeSession): RuntimeSession { + return { ...session, providerId: this.id }; + } + + private async assertRead( + operation: MatrixReadOperation, + sessionId: string | undefined, + scope: RuntimeScope, + ): Promise { + const allowed = await this.readAuthority.canRead({ + operation, + scope, + ...(sessionId ? { sessionId } : {}), + }); + if (!allowed) + throw new MatrixRuntimeProviderError('forbidden', 'Matrix runtime read is not authorized'); + } + + private async assertWritePermitted( + operation: 'session.send' | 'session.terminate', + sessionId: string, + scope: RuntimeScope, + approvalRef?: string, + ): Promise { + const allowed = await this.writeAuthority.canWrite({ + operation, + sessionId, + scope, + ...(approvalRef ? { approvalRef } : {}), + }); + if (!allowed) + throw new MatrixRuntimeProviderError('forbidden', 'Matrix runtime write is not authorized'); + } + + private async assertWriteAuthorized( + operation: 'session.send' | 'session.terminate', + sessionId: string, + scope: RuntimeScope, + approvalRef?: string, + ): Promise { + await this.writeAuthority.assertAuthorized({ + operation, + sessionId, + scope, + ...(approvalRef ? { approvalRef } : {}), + }); + } + + private pruneExpired(nowMs: number): void { + for (const [id, attachment] of this.attachments) { + if (attachment.expiresAtMs <= nowMs) this.attachments.delete(id); + } + } +} + +function snapshotScope(scope: RuntimeScope): RuntimeScope { + return Object.freeze({ ...scope }); +} + +function sameScope(left: RuntimeScope, right: RuntimeScope): boolean { + return ( + left.actorId === right.actorId && + left.tenantId === right.tenantId && + left.channelId === right.channelId && + left.correlationId === right.correlationId + ); +} diff --git a/packages/agent/src/runtime-provider-parity.test.ts b/packages/agent/src/runtime-provider-parity.test.ts new file mode 100644 index 00000000..e99a76f9 --- /dev/null +++ b/packages/agent/src/runtime-provider-parity.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { AgentRuntimeProvider, RuntimeScope } from '@mosaicstack/types'; +import { + MatrixNativeRuntimeProvider, + type MatrixRuntimeTransport, +} from './matrix-native-runtime-provider.js'; +import { + TmuxFleetRuntimeProvider, + type FleetRuntimeTransport, +} from './tmux-fleet-runtime-provider.js'; + +const scope: RuntimeScope = { + actorId: 'operator-1', + tenantId: 'tenant-a', + channelId: 'cli', + correlationId: 'corr-1', +}; + +interface ProviderFixture { + name: string; + provider: AgentRuntimeProvider; + providerId: string; + verifySession: unknown; + send: unknown; +} + +function fixtures(): ProviderFixture[] { + const fleetTransport: FleetRuntimeTransport = { + verifySession: vi.fn(async () => ({ + id: 'session-1', + runtimeId: 'native-1', + socketName: 'fleet', + })), + listSessions: vi.fn(async () => [ + { id: 'session-1', runtimeId: 'native-1', socketName: 'fleet' }, + ]), + sendMessage: vi.fn(async () => undefined), + terminate: vi.fn(async () => undefined), + }; + const fleet = new TmuxFleetRuntimeProvider({ + transport: fleetTransport, + readAuthority: { canRead: vi.fn(async () => true) }, + writeAuthority: { + canWrite: vi.fn(async () => true), + assertAuthorized: vi.fn(async () => undefined), + }, + attachmentIdFactory: () => 'attachment-1', + now: () => new Date('2026-07-13T00:00:00.000Z'), + }); + + const matrixTransport: MatrixRuntimeTransport = { + health: vi.fn(async () => ({ + status: 'healthy' as const, + checkedAt: '2026-07-13T00:00:00.000Z', + })), + verifySession: vi.fn(async () => ({ + id: 'session-1', + runtimeId: 'native-1', + state: 'active' as const, + createdAt: '2026-07-13T00:00:00.000Z', + updatedAt: '2026-07-13T00:00:00.000Z', + })), + listSessions: vi.fn(async () => [ + { + id: 'session-1', + runtimeId: 'native-1', + state: 'active' as const, + createdAt: '2026-07-13T00:00:00.000Z', + updatedAt: '2026-07-13T00:00:00.000Z', + }, + ]), + stream: async function* () {}, + send: vi.fn(async () => undefined), + terminate: vi.fn(async () => undefined), + }; + const matrix = new MatrixNativeRuntimeProvider({ + transport: matrixTransport, + readAuthority: { canRead: vi.fn(async () => true) }, + writeAuthority: { + canWrite: vi.fn(async () => true), + assertAuthorized: vi.fn(async () => undefined), + }, + attachmentIdFactory: () => 'attachment-1', + now: () => new Date('2026-07-13T00:00:00.000Z'), + }); + + return [ + { + name: 'tmux/fleet', + provider: fleet, + providerId: 'fleet.tmux', + verifySession: fleetTransport.verifySession, + send: fleetTransport.sendMessage, + }, + { + name: 'Matrix/native', + provider: matrix, + providerId: 'runtime.matrix', + verifySession: matrixTransport.verifySession, + send: matrixTransport.send, + }, + ]; +} + +/** Shared contract tests for the migration-safe provider intersection. */ +describe('tmux/fleet and Matrix/native provider parity', (): void => { + it.each(fixtures())( + '%s exposes the shared runtime operations', + async (fixture): Promise => { + await expect(fixture.provider.capabilities(scope)).resolves.toEqual( + expect.objectContaining({ + supported: expect.arrayContaining([ + 'session.list', + 'session.tree', + 'session.send', + 'session.attach', + 'session.terminate', + ]), + }), + ); + await expect(fixture.provider.listSessions(scope)).resolves.toEqual([ + expect.objectContaining({ + id: 'session-1', + providerId: fixture.providerId, + runtimeId: 'native-1', + }), + ]); + }, + ); + + it.each(fixtures())( + '%s rejects empty messages before touching its transport', + async (fixture): Promise => { + await expect( + fixture.provider.sendMessage( + 'session-1', + { content: '', idempotencyKey: 'message-1' }, + scope, + ), + ).rejects.toMatchObject({ code: 'invalid_request' }); + expect(fixture.verifySession).not.toHaveBeenCalled(); + expect(fixture.send).not.toHaveBeenCalled(); + }, + ); + + it.each(fixtures())( + '%s rejects an empty termination approval before touching its transport', + async (fixture): Promise => { + await expect(fixture.provider.terminate('session-1', '', scope)).rejects.toMatchObject({ + code: 'invalid_request', + }); + expect(fixture.verifySession).not.toHaveBeenCalled(); + }, + ); + + it.each(fixtures())( + '%s creates a read-only handle bound to immutable scope', + async (fixture): Promise => { + await expect(fixture.provider.attach('session-1', 'control', scope)).rejects.toMatchObject({ + code: 'forbidden', + }); + expect(fixture.verifySession).not.toHaveBeenCalled(); + + await expect(fixture.provider.attach('session-1', 'read', scope)).resolves.toEqual( + expect.objectContaining({ + attachmentId: 'attachment-1', + sessionId: 'session-1', + mode: 'read', + }), + ); + await expect( + fixture.provider.detach('attachment-1', { ...scope, actorId: 'operator-2' }), + ).rejects.toMatchObject({ code: 'forbidden' }); + }, + ); +}); diff --git a/packages/mosaic/src/fleet/matrix-native-runtime-transport.test.ts b/packages/mosaic/src/fleet/matrix-native-runtime-transport.test.ts new file mode 100644 index 00000000..7b698cd7 --- /dev/null +++ b/packages/mosaic/src/fleet/matrix-native-runtime-transport.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { RuntimeScope } from '@mosaicstack/types'; +import { + MatrixNativeRuntimeTransport, + type MatrixFetchLike, +} from './matrix-native-runtime-transport.js'; + +const scope: RuntimeScope = { + actorId: 'operator-1', + tenantId: 'tenant-a', + channelId: 'cli', + correlationId: 'corr-1', +}; + +const bindings = [ + { + id: 'native-1', + runtimeId: '@native-worker:example.test', + roomId: '!room:example.test', + remoteUserId: '@native-worker:example.test', + state: 'active' as const, + createdAt: '2026-07-13T00:00:00.000Z', + updatedAt: '2026-07-13T00:00:00.000Z', + }, +]; + +function response( + status: number, + body: unknown = {}, +): ReturnType extends Promise ? T : never { + return { + ok: status >= 200 && status < 300, + status, + json: async () => body, + text: async () => JSON.stringify(body), + } as never; +} + +function transport(fetchImpl: MatrixFetchLike): MatrixNativeRuntimeTransport { + return new MatrixNativeRuntimeTransport({ + homeserverUrl: 'https://matrix.example.test/base', + accessToken: 'test-token', + userId: '@mosaic:example.test', + sessions: bindings, + fetchImpl, + }); +} + +describe('MatrixNativeRuntimeTransport', (): void => { + it('uses only configured session-to-room bindings and idempotency-derived Matrix transactions', async (): Promise => { + const fetchImpl = vi + .fn() + .mockResolvedValueOnce(response(200, { user_id: '@mosaic:example.test' })) + .mockResolvedValueOnce(response(200, { event_id: '$sent' })); + const matrix = transport(fetchImpl); + + await matrix.send('native-1', { content: 'hello', idempotencyKey: 'message-1' }, scope); + + expect(fetchImpl).toHaveBeenNthCalledWith( + 1, + 'https://matrix.example.test/base/_matrix/client/v3/account/whoami', + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: 'Bearer test-token' }), + }), + ); + const [url, init] = fetchImpl.mock.calls[1]!; + expect(url).toMatch( + /^https:\/\/matrix\.example\.test\/base\/_matrix\/client\/v3\/rooms\/!room%3Aexample\.test\/send\/m\.room\.message\/mosaic-send-[A-Za-z0-9_-]+$/, + ); + expect(init?.method).toBe('PUT'); + expect(JSON.parse(init?.body ?? '')).toEqual({ + msgtype: 'm.text', + body: 'hello', + 'mosaic.runtime.v1': { + session_id: 'native-1', + runtime_id: '@native-worker:example.test', + actor_id: 'operator-1', + tenant_id: 'tenant-a', + channel_id: 'cli', + correlation_id: 'corr-1', + idempotency_key: 'message-1', + }, + }); + }); + + it('sends a termination only to the bound room with the exact approval reference', async (): Promise => { + const fetchImpl = vi + .fn() + .mockResolvedValueOnce(response(200, { user_id: '@mosaic:example.test' })) + .mockResolvedValueOnce(response(200, { event_id: '$terminated' })); + const matrix = transport(fetchImpl); + + await matrix.terminate('native-1', 'approval-1', scope); + + const [url, init] = fetchImpl.mock.calls[1]!; + expect(url).toMatch( + /^https:\/\/matrix\.example\.test\/base\/_matrix\/client\/v3\/rooms\/!room%3Aexample\.test\/send\/mosaic\.runtime\.terminate\/mosaic-terminate-[A-Za-z0-9_-]+$/, + ); + expect(JSON.parse(init?.body ?? '')).toEqual({ + session_id: 'native-1', + runtime_id: '@native-worker:example.test', + actor_id: 'operator-1', + tenant_id: 'tenant-a', + channel_id: 'cli', + correlation_id: 'corr-1', + approval_ref: 'approval-1', + }); + }); + + it('fails closed when Matrix whoami does not match the configured native identity', async (): Promise => { + const fetchImpl = vi + .fn() + .mockResolvedValue(response(200, { user_id: '@other:example.test' })); + const matrix = transport(fetchImpl); + + await expect(matrix.listSessions(scope)).rejects.toMatchObject({ code: 'forbidden' }); + }); + + it('rejects unbound session IDs without using caller-supplied Matrix room data', async (): Promise => { + const fetchImpl = vi + .fn() + .mockResolvedValue(response(200, { user_id: '@mosaic:example.test' })); + const matrix = transport(fetchImpl); + + await expect(matrix.verifySession('!attacker-room:example.test', scope)).rejects.toMatchObject({ + code: 'not_found', + }); + }); + + it('maps replay-cursor events from only the configured remote identity', async (): Promise => { + const fetchImpl = vi + .fn() + .mockResolvedValueOnce(response(200, { user_id: '@mosaic:example.test' })) + .mockResolvedValueOnce( + response(200, { + next_batch: 'next-cursor', + rooms: { + join: { + '!room:example.test': { + timeline: { + events: [ + { + type: 'mosaic.runtime.event', + sender: '@intruder:example.test', + content: { 'mosaic.runtime.v1': { type: 'message.delta', content: 'nope' } }, + }, + { + type: 'mosaic.runtime.event', + sender: '@native-worker:example.test', + origin_server_ts: 1_784_246_400_000, + content: { + 'mosaic.runtime.v1': { + session_id: 'native-1', + type: 'message.delta', + content: 'accepted', + }, + }, + }, + ], + }, + }, + }, + }, + }), + ); + const matrix = transport(fetchImpl); + + const events = []; + for await (const event of matrix.stream('native-1', 'prior-cursor', scope)) events.push(event); + + expect(events).toEqual([ + { + type: 'message.delta', + sessionId: 'native-1', + cursor: 'next-cursor', + occurredAt: '2026-07-17T00:00:00.000Z', + content: 'accepted', + }, + ]); + expect(fetchImpl.mock.calls[1]?.[0]).toContain('since=prior-cursor'); + }); +}); diff --git a/packages/mosaic/src/fleet/matrix-native-runtime-transport.ts b/packages/mosaic/src/fleet/matrix-native-runtime-transport.ts new file mode 100644 index 00000000..c20b69b4 --- /dev/null +++ b/packages/mosaic/src/fleet/matrix-native-runtime-transport.ts @@ -0,0 +1,367 @@ +import { createHash } from 'node:crypto'; +import type { + RuntimeHealth, + RuntimeMessage, + RuntimeScope, + RuntimeSessionState, + RuntimeStreamEvent, +} from '@mosaicstack/types'; + +export type MatrixRuntimeTransportErrorCode = + | 'forbidden' + | 'invalid_request' + | 'not_found' + | 'unavailable'; + +export class MatrixRuntimeTransportError extends Error { + constructor( + readonly code: MatrixRuntimeTransportErrorCode, + message: string, + ) { + super(message); + this.name = MatrixRuntimeTransportError.name; + } +} + +/** Minimal injectable Matrix fetch surface; it avoids coupling this transport to an SDK. */ +export interface MatrixFetchLike { + ( + url: string, + init?: { method?: string; headers?: Record; body?: string }, + ): Promise<{ + ok: boolean; + status: number; + json(): Promise; + text(): Promise; + }>; +} + +/** A server-configured runtime session binding. Callers never select a Matrix room. */ +export interface MatrixNativeRuntimeSessionBinding { + id: string; + runtimeId: string; + roomId: string; + remoteUserId: string; + parentSessionId?: string; + state: RuntimeSessionState; + createdAt: string; + updatedAt: string; +} + +export interface MatrixNativeRuntimeTransportOptions { + homeserverUrl: string; + accessToken: string; + /** Matrix user authenticated by the service token. */ + userId: string; + sessions: readonly MatrixNativeRuntimeSessionBinding[]; + fetchImpl?: MatrixFetchLike; + now?: () => Date; +} + +interface MatrixSyncEvent { + type?: string; + sender?: string; + origin_server_ts?: number; + content?: Record; +} + +interface MatrixSyncResponse { + next_batch?: string; + rooms?: { join?: Record }; +} + +/** + * Concrete Matrix CS-API transport for the native provider. It authenticates + * the configured sender before each operation and resolves rooms exclusively + * from configured bindings, preventing client-selected room/identity routing. + */ +export class MatrixNativeRuntimeTransport { + private readonly baseUrl: string; + private readonly fetchImpl: MatrixFetchLike; + private readonly now: () => Date; + private readonly bindings: ReadonlyMap; + + constructor(private readonly options: MatrixNativeRuntimeTransportOptions) { + this.baseUrl = validatedHomeserverUrl(options.homeserverUrl); + if (!options.accessToken.trim()) { + throw new MatrixRuntimeTransportError('invalid_request', 'Matrix access token is required'); + } + if (!options.userId.trim()) { + throw new MatrixRuntimeTransportError('invalid_request', 'Matrix user identity is required'); + } + this.bindings = new Map( + options.sessions.map((binding) => [binding.id, Object.freeze({ ...binding })]), + ); + if (this.bindings.size !== options.sessions.length) { + throw new MatrixRuntimeTransportError( + 'invalid_request', + 'Matrix runtime session IDs must be unique', + ); + } + this.fetchImpl = options.fetchImpl ?? (globalThis.fetch as unknown as MatrixFetchLike); + this.now = options.now ?? (() => new Date()); + } + + async health(_scope: RuntimeScope): Promise { + try { + const versions = await this.request('/_matrix/client/versions', { method: 'GET' }); + if (!versions.ok) { + return this.healthResult('down', `versions HTTP ${versions.status}`); + } + await this.assertIdentity(); + return this.healthResult('healthy'); + } catch (error: unknown) { + return this.healthResult('down', message(error)); + } + } + + async listSessions(scope: RuntimeScope): Promise { + await this.assertIdentity(); + void scope; + return [...this.bindings.values()].map((binding) => ({ ...binding })); + } + + async verifySession( + sessionId: string, + scope: RuntimeScope, + ): Promise { + await this.assertIdentity(); + void scope; + const binding = this.bindings.get(sessionId); + if (!binding) { + throw new MatrixRuntimeTransportError( + 'not_found', + 'Matrix runtime session is not configured', + ); + } + return { ...binding }; + } + + async *stream( + sessionId: string, + cursor: string | undefined, + scope: RuntimeScope, + ): AsyncIterable { + const binding = await this.verifySession(sessionId, scope); + const query = new URLSearchParams({ timeout: '0' }); + if (cursor?.trim()) query.set('since', cursor); + const response = await this.request(`/_matrix/client/v3/sync?${query.toString()}`, { + method: 'GET', + headers: this.authHeaders(), + }); + if (!response.ok) { + throw new MatrixRuntimeTransportError( + 'unavailable', + `Matrix sync failed: HTTP ${response.status}`, + ); + } + const payload = (await response.json()) as MatrixSyncResponse; + const nextCursor = payload.next_batch?.trim() || cursor?.trim() || 'initial'; + const events = payload.rooms?.join?.[binding.roomId]?.timeline?.events ?? []; + for (const event of events) { + const normalized = normalizeEvent(event, binding, nextCursor); + if (normalized) yield normalized; + } + } + + async send(sessionId: string, message: RuntimeMessage, scope: RuntimeScope): Promise { + const binding = await this.verifySession(sessionId, scope); + const txnId = transactionId('send', binding.id, message.idempotencyKey); + const response = await this.request( + `/_matrix/client/v3/rooms/${encodeURIComponent(binding.roomId)}/send/m.room.message/${encodeURIComponent(txnId)}`, + { + method: 'PUT', + headers: this.authHeaders(), + body: JSON.stringify({ + msgtype: 'm.text', + body: message.content, + 'mosaic.runtime.v1': metadata(binding, scope, message.idempotencyKey), + }), + }, + ); + if (!response.ok) { + throw new MatrixRuntimeTransportError( + 'unavailable', + `Matrix message delivery failed: HTTP ${response.status}`, + ); + } + } + + async terminate(sessionId: string, approvalRef: string, scope: RuntimeScope): Promise { + const binding = await this.verifySession(sessionId, scope); + const txnId = transactionId('terminate', binding.id, `${approvalRef}:${scope.correlationId}`); + const response = await this.request( + `/_matrix/client/v3/rooms/${encodeURIComponent(binding.roomId)}/send/mosaic.runtime.terminate/${encodeURIComponent(txnId)}`, + { + method: 'PUT', + headers: this.authHeaders(), + body: JSON.stringify({ + ...metadata(binding, scope), + approval_ref: approvalRef, + }), + }, + ); + if (!response.ok) { + throw new MatrixRuntimeTransportError( + 'unavailable', + `Matrix termination delivery failed: HTTP ${response.status}`, + ); + } + } + + private async assertIdentity(): Promise { + let response: Awaited>; + try { + response = await this.request('/_matrix/client/v3/account/whoami', { + method: 'GET', + headers: this.authHeaders(), + }); + } catch (error: unknown) { + throw new MatrixRuntimeTransportError('unavailable', message(error)); + } + if (!response.ok) { + throw new MatrixRuntimeTransportError( + 'forbidden', + `Matrix whoami failed: HTTP ${response.status}`, + ); + } + const body = (await response.json()) as { user_id?: unknown }; + if (body.user_id !== this.options.userId) { + throw new MatrixRuntimeTransportError( + 'forbidden', + 'Matrix whoami identity does not match configuration', + ); + } + } + + private request( + path: string, + init: { method?: string; headers?: Record; body?: string }, + ) { + return this.fetchImpl(`${this.baseUrl}${path}`, init); + } + + private authHeaders(): Record { + return { + Authorization: `Bearer ${this.options.accessToken}`, + 'Content-Type': 'application/json', + }; + } + + private healthResult(status: RuntimeHealth['status'], detail?: string): RuntimeHealth { + return { status, checkedAt: this.now().toISOString(), ...(detail ? { detail } : {}) }; + } +} + +function metadata( + binding: MatrixNativeRuntimeSessionBinding, + scope: RuntimeScope, + idempotencyKey?: string, +): Record { + return { + session_id: binding.id, + runtime_id: binding.runtimeId, + actor_id: scope.actorId, + tenant_id: scope.tenantId, + channel_id: scope.channelId, + correlation_id: scope.correlationId, + ...(idempotencyKey ? { idempotency_key: idempotencyKey } : {}), + }; +} + +function transactionId(kind: 'send' | 'terminate', sessionId: string, identity: string): string { + return `mosaic-${kind}-${createHash('sha256').update(`${sessionId}\u0000${identity}`).digest('base64url')}`; +} + +function normalizeEvent( + event: MatrixSyncEvent, + binding: MatrixNativeRuntimeSessionBinding, + cursor: string, +): RuntimeStreamEvent | undefined { + if (event.type !== 'mosaic.runtime.event' || event.sender !== binding.remoteUserId) + return undefined; + const payload = event.content?.['mosaic.runtime.v1']; + if ( + !isRecord(payload) || + payload['session_id'] !== binding.id || + typeof payload['type'] !== 'string' + ) { + return undefined; + } + const occurredAt = + typeof payload['occurred_at'] === 'string' + ? payload['occurred_at'] + : new Date(event.origin_server_ts ?? 0).toISOString(); + const eventCursor = typeof payload['cursor'] === 'string' ? payload['cursor'] : cursor; + switch (payload['type']) { + case 'session.state': + return isState(payload['state']) + ? { + type: 'session.state', + sessionId: binding.id, + cursor: eventCursor, + occurredAt, + state: payload['state'], + } + : undefined; + case 'message.delta': + return typeof payload['content'] === 'string' + ? { + type: 'message.delta', + sessionId: binding.id, + cursor: eventCursor, + occurredAt, + content: payload['content'], + } + : undefined; + case 'message.complete': + return typeof payload['message_id'] === 'string' + ? { + type: 'message.complete', + sessionId: binding.id, + cursor: eventCursor, + occurredAt, + messageId: payload['message_id'], + } + : undefined; + default: + return undefined; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function isState(value: unknown): value is RuntimeSessionState { + return ( + value === 'starting' || + value === 'active' || + value === 'idle' || + value === 'stopped' || + value === 'failed' + ); +} + +function validatedHomeserverUrl(value: string): string { + let url: URL; + try { + url = new URL(value); + } catch { + throw new MatrixRuntimeTransportError( + 'invalid_request', + 'Matrix homeserver URL must be absolute', + ); + } + if (url.protocol !== 'https:') { + throw new MatrixRuntimeTransportError( + 'invalid_request', + 'Matrix homeserver URL must use HTTPS', + ); + } + return url.toString().replace(/\/$/, ''); +} + +function message(error: unknown): string { + return error instanceof Error ? error.message : 'Matrix transport request failed'; +} diff --git a/packages/mosaic/src/index.ts b/packages/mosaic/src/index.ts index 4ff41622..99cdbf3a 100644 --- a/packages/mosaic/src/index.ts +++ b/packages/mosaic/src/index.ts @@ -2,6 +2,7 @@ export const VERSION = '0.0.0'; export * from './fleet/interaction-service-profile.js'; export * from './fleet/tmux-runtime-transport.js'; +export * from './fleet/matrix-native-runtime-transport.js'; export { backgroundUpdateCheck, From e72388b2cbfe400842fe940fa6cabf984ed43711 Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Mon, 13 Jul 2026 16:14:23 +0000 Subject: [PATCH 037/152] =?UTF-8?q?docs(tess):=20ledger=20sync=20m3=20?= =?UTF-8?q?=E2=80=94=20M5-001=20+=20M5-002=20done=20(#745)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/tess/TASKS.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/tess/TASKS.md b/docs/tess/TASKS.md index ea8c32b3..8297d447 100644 --- a/docs/tess/TASKS.md +++ b/docs/tess/TASKS.md @@ -35,11 +35,11 @@ | TESS-M4-001 | done | Implement Mos coordination handoff/observe/result contract with authority-boundary tests | #710 | coder0 | packages/coord, apps/gateway | feat/tess-mos-coordination | TESS-M3-V | 25K | **MERGED by Mos** → main squash **76325ca3** ("feat(tess): add Mos coordination boundary (#735)"), 2026-07-13 — merge = native-in-process transport ACCEPTED (contract transport-neutral). TESS-MOS-001. Mos-DISPATCHED 2026-07-13 to coder0 DESIGN-FIRST. UPDATE 2026-07-13: coder0 wrote docs/tess/MOS-COORDINATION.md; design checkpoint surfaced to Mos with the transport-adapter question (existing fleet/tmux Mos-authority channel vs dedicated native queue/HTTP). coder0 PROCEEDED (ahead of the Mos transport ruling) choosing a **native in-process adapter** and opened **PR #735** (base=main), head 7936e15d3ae137c91c88efdab4bb09b863a2195d. Impl: transport-NEUTRAL handoff/observe/result contract (MosCoordinationPort); deterministic native in-process InMemoryMosCoordinationPort; gateway derives actor/tenant/requester from trusted context/config; fail-closed for unconfigured-requester, self-delegation, target-drift, cross-tenant observe/result; NO public orchestrator verbs; **NO fleet/tmux transport, NO Mos-side consumer**; command-authorization byte-identical hash a9f829e7; no live creds; no hardcoded Tess identity. Local forced cold-cache typecheck/lint/format/test green (42 tasks); Codex security no findings. CI pipeline 1752 (pull_request, refs/pull/735/head, commit 7936e15d) = **SUCCESS**; head UNMOVED, mergeable=true. Independent non-author ROR **COMPLETE**: reviewer [W-jarvis:reviewer] VERIFIED APPROVE at exact head 7936e15d (Gitea comment 17032) — verified MosCoordinationPort=handoff/observe/result only, gateway-derived authority, fail-closed denial coverage, native in-process port (no tmux/Mos consumer), command-authz byte-identical a9f829e7, no live creds, no Tess literal. ⚠️ HEAD MOVED 2026-07-13 (ROR 17032 INVALIDATED): coder0 pushed one post-ROR commit → new head **5022911f84dd7ac30f40df31a53f6cd31a51728f** (commit "docs(tess): record M4 verification", parent 7936e15d). Orchestrator-verified sole delta = a single scratchpad doc docs/scratchpads/tess-m4-001-mos-coordination.md, ZERO code/test diff. New CI pipeline 1754 (pull_request, refs/pull/735/head, commit 5022911f) = **SUCCESS**; mergeable=true, head now 5022911f. Comment 17032 @ 7936e15d no longer at exact head → re-serialize + re-ROR REQUIRED. Fast delta RE-ROR **COMPLETE**: reviewer [W-jarvis:reviewer] VERIFIED APPROVE at exact head 5022911f (Gitea comment 17036) — confirmed 5022911f is direct child of prior-reviewed 7936e15d, sole two-dot delta = the 3-line scratchpad doc, no code/test diff, command-authz byte-identical a9f829e7, CI 1754 success. Head UNMOVED (5022911f), mergeable=true. **MERGEABLE at 5022911f — HARD STOP for Mos merge** (2026-07-13). MERGE = Mos ACCEPTING the native-in-process transport choice (contract stays transport-neutral; a fleet/tmux or native-queue/HTTP consumer can be added later without contract churn); if Mos wants a different FIRST adapter, hold merge + route rework to coder0. | | TESS-M4-002 | done | Implement transitional Hermes runtime/capability adapter | #710 | coder3 | packages/agent, apps/gateway | feat/tess-hermes-adapter | TESS-M3-V | 40K | **MERGED by Mos** → main squash **9e5b9188** ("feat(agent): add transitional Hermes runtime adapter (#734)"), 2026-07-13 — Mos merge = **option (a) ACCEPTED**; post-merge main CI 1753. TESS-HRM-001; no legacy schema in core contracts. Mos-DISPATCHED 2026-07-13 to coder3 DESIGN-FIRST (contract sketch + questions to Mos before build). Goes in-progress as PR opens; PR-open-STOP → serialize CI + independent non-author ROR at exact head → Mos merges. UPDATE 2026-07-13: coder3 ACTIVE — fresh worktree/branch feat/tess-hermes-adapter off origin/main; boundary sketch at docs/tess/hermes-runtime-adapter-design.md. DESIGN QUESTION surfaced to Mos (coder3 HELD at design-only until ruling): AC-TESS-05 wants approved capability across Kanban/skills/memory/tools/cron, but AgentRuntimeProvider models only SESSION capabilities. (a) adapter-local Hermes inventory/health marks those as explicit UNSUPPORTED, real ops deferred to their Mosaic-owned plugin contracts (coder3 default, preserves hard no-legacy-core-contract rule); vs (b) an existing Mosaic-owned non-runtime capability contract this adapter must implement. Orchestrator recommends (a) to Mos as the conservative boundary-preserving path. UPDATE 2026-07-13: coder3 PROCEEDED WITH (a) and opened **PR #734** (base=main), head 47b8a145ac43688499d275a54b434a52551c1abd — ahead of the Mos (a/b) ruling (design-hold was placed; coder3's original msg said it would proceed with (a) unless directed). Hermes adapter normalized behind packages/agent boundary; core types unchanged, unsupported ops fail-closed, tests prove no legacy field leak; focused tests/typecheck/lint pass; cold-cache turbo typecheck+build 46/46 0-cached. mergeable=true. CI pipeline 1751 (pull_request, refs/pull/734/head, commit 47b8a145) = **SUCCESS**; head UNMOVED, mergeable=true. Independent non-author ROR **COMPLETE**: reviewer [W-jarvis:reviewer] VERIFIED APPROVE at exact head 47b8a145 (Gitea comment 17027) — verified core AgentRuntimeProvider/runtime types UNCHANGED, adapter normalizes Hermes legacy shapes behind packages/agent boundary, unsupported runtime ops FAIL CLOSED via capability_unsupported BEFORE transport side effects (Kanban/skills/memory/tools/cron deferred under option (a)), no live creds, no hardcoded Tess agent identifier. Head UNMOVED, mergeable=true. **MERGEABLE — HARD STOP for Mos merge** (2026-07-13). ⚠️ MERGE GATED on Mos confirming option (a) is accepted (implementation == (a)); if Mos rules (b), #734 needs rework. HARD STOP for Mos merge. | | TESS-M4-003 | done | Implement memory/retrieval, state/inbox, runtime bootstrap, fleet diagnostics and GitOps plugin foundations | #710 | coder0 | packages/memory, packages/agent, packages/mosaic | feat/tess-operator-plugins | TESS-M3-V | 40K | **MERGED by Mos** → main squash **2363f155** ("feat(memory): add operator retrieval plugin (#736)"), 2026-07-13. ⚠️ SCOPE GAP surfaced by Mos: #736 delivered ONLY the leaf @mosaicstack/memory operator-retrieval slice; **TESS-PLG-001 (packages/mosaic catalog/registration) was silently DEFERRED by the author and never surfaced in MISSION-MANIFEST/VERIFICATION-MATRIX** → now tracked explicitly as its own row (see TESS-PLG-001 below) and folded into TESS-M4-W-001. State/inbox/runtime-bootstrap/fleet-diagnostics/GitOps foundations remain follow-on (not in #736). TESS-MEM-001, TESS-PLG-001. Mos HELD 1 beat (2026-07-13) for a well-conditioned lane. UPDATE 2026-07-13: coder0 TOOK OVER M4-003 (preserved coder4 WIP first, then rebased on latest main) and opened **PR #736** (base=main), head a1d63ca8ed07610828e9c51a213fffe9123b3de4. ⚠️ AUTHORIZATION FLAG to Mos: M4-003 was on Mos 1-beat HOLD; confirm this takeover/dispatch was Mos-authorized before merge. Scope delivered: LEAF @mosaicstack/memory operator retrieval plugin — config-injected adapter/namespace, runtime-validated server-derived tenant/owner/session scope, redaction-before-persist, provenance, bounded startup prioritization, wildcard adapter contract, namespace/different-instance tests. NO gateway/catalog/durable-inbox or command-authorization changes. Forced cold-cache typecheck/lint/format/test green (42 tasks); Woodpecker 1756 green; Codex code+security clean. CI pipeline 1756 (pull_request, refs/pull/736/head, commit a1d63ca8) = **SUCCESS**; mergeable=true. Independent non-author ROR COMPLETE: reviewer VERIFIED APPROVE at exact head a1d63ca8 (Gitea comment 17044) — leaf packages/memory/doc only (no gateway/catalog/durable-inbox), command-authz byte-identical a9f829e7, runtime scope validation before storage keying, config-injected adapter/namespace/instance metadata, redaction-before-persist + provenance, scoped wildcard adapter contract, namespace/different-instance tests, no live creds, no Tess literal. Head verified UNMOVED at a1d63ca8, base main, mergeable=true. **MERGEABLE — reported to Mos, HARD STOP for Mos merge.** NOTE: M4-003 scope here is the memory-plugin slice; state/inbox/runtime-bootstrap/fleet-diagnostics/GitOps foundations may be follow-on slices — confirm with Mos whether #736 fully closes M4-003 or is slice 1. | -| TESS-M4-W-001 | in-progress | M4-V remediation — gateway reachability SPINE: register runtime provider into AGENT_RUNTIME_PROVIDER_REGISTRY + wire Mos-coordination consumer + wire operator-memory-plugin consumer (make merged M4 deliverables reachable end-to-end); FOLDS IN minimal TESS-PLG-001 catalog/registration | #710 | coder0 | apps/gateway, packages/mosaic, packages/agent | feat/tess-m4w-reachability-spine | TESS-M4-003 | 30K | **Mos-DISPATCHED 2026-07-13** (remediation). Root cause: M4-V holistic review @ origin/main **2363f155** found the three merged M4 deliverables unit-green but NOT reachable end-to-end (no gateway wiring/consumers; providers never registered into the registry). **SPLIT into 3 sub-parts by coder0 (integrity-honest):** **(#2 Mos-coordination consumer) = DELIVERED as PR #737** (head f7b95f60, base main, "feat(gateway): expose Mos coordination boundary") — real AuthGuard Mos handoff/observe/result consumer, authenticated actor/tenant + required correlation derivation, service authority unchanged, gateway target test/typecheck/lint pass; **CI 1758 SUCCESS** (repo 47, commit==head); head verified UNMOVED at f7b95f60666a4abbbad9a08669637b19fa87c430, base main, mergeable=true. **Independent non-author ROR COMPLETE**: reviewer VERIFIED APPROVE at exact head f7b95f60 (Gitea comment 17054) — clean partial scope confirmed (AuthGuard Mos handoff/observe/result controller + module registration only; no runtime-provider registration / operator-memory consumer; actor/tenant from CurrentUser/scopeFromUser + required X-Correlation-Id before service invocation; MosCoordinationService unchanged; command-authz byte-identical a9f829e7; no live creds/no Tess literal). **#737 MERGEABLE — reported to Mos, HARD STOP for Mos merge (partial slice; land-vs-hold-for-full-spine is Mos's disposition call).** **(#1 runtime-provider registration) + (operator-memory consumer) = BLOCKED, NOT in #737.** coder0 could not truthfully complete them in this slice and REFUSED to fake with deny/unavailable stubs: gateway has **no concrete Hermes transport** and **no gateway-side tmux transport/authority wiring** to register a real provider; OperatorMemory consumer needs **session tenant/owner/session propagation currently ABSENT from AgentService's memory-tools boundary**. ⚠️ **DESIGN RULING ESCALATED TO MOS** (architecture, not resolvable from repo): how to wire provider-registration + memory-scope propagation when no concrete transport exists yet — new remediation slice / re-scope / accept #737 as incremental. TESS-PLG-001 (folded here) is part of the blocked #1 registration path. Command-authz byte-identical a9f829e7. **UPDATE 2026-07-13: #737 MERGED by Mos → main e2376190 ("feat(gateway): expose Mos coordination boundary (#737)").** **Operator-memory consumer sub-part UNBLOCKED + DELIVERED as PR #739** ("feat(memory): bind operator plugin to agent sessions", base main off e2376190, live head 31a59738089f0784428833fc5a0192c6c7c43261, mergeable=true) — coder0 resolved the session-scope-propagation blocker WITHOUT stubbing: gateway bootstrap configures plugin only with MOSAIC_OPERATOR_MEMORY_INSTANCE_ID + MOSAIC_OPERATOR_MEMORY_NAMESPACE, AgentService derives {tenantId,ownerId,sessionId} server-side and binds search/capture tools. Cold-cache root typecheck/lint/format/test (42 tasks) green; security review clean (Codex Optional-import finding = false positive, pre-existing, typecheck passed). **CI 1764 SUCCESS** (repo 47, commit==head); head verified UNMOVED at 31a59738089f0784428833fc5a0192c6c7c43261, base main, mergeable=true. **Independent non-author ROR ROUTED to reviewer at exact head 31a59738089f** (coder0 authored → reviewer is non-author) — asked reviewer to confirm scope is server-derived/non-client-controllable + no cross-tenant leak, and to independently verify the Codex Optional-import finding is a false positive. (Head reconcile CLOSED: coder0 confirmed 31a597380c55… was a transcription typo; live+frozen head is 31a59738089f0784428833fc5a0192c6c7c43261, working tree clean.) **UPDATE 2026-07-13: reviewer REQUEST CHANGES at head 31a59738089f (Gitea comment 17069) — NOT mergeable.** Production code CONFIRMED correct (plugin route wired, config env namespace/instance only, no live creds/no Tess literal, command-authz byte-identical a9f829e7; Codex Optional-import finding = false positive, import present). **Two TEST-COVERAGE blockers:** (1) tests BYPASS production scope derivation — they call createMemoryTools with a PREBUILT scope, never exercising the real createSession→buildToolsForSandbox server-side {tenantId,ownerId,sessionId} derivation; (2) NO divergent cross-tenant/cross-owner ISOLATION/DENIAL test proving a foreign actor cannot reuse a session / reach another operator-memory scope before the plugin call. Routed back to coder0 (integrity: harden real coverage, do NOT weaken assertion). Any new commit MOVES head → invalidates ROR → re-serialize CI + re-ROR at new head. **UPDATE 2026-07-13 (remediated): coder0 pushed hardened tests, NEW frozen head c26b3b775279575276c6ebe8955a9146bfc61413** — added createSession→buildToolsForSandbox PRODUCTION-PATH assertion of derived {tenantId,ownerId,sessionId}; added foreign-actor reuse DENIAL test asserting rejection occurs BEFORE scope/tool construction and before any plugin call. Cold-cache root typecheck/lint/format/test green (42 tasks). Old ROR at 31a59738089f + CI 1764 SUPERSEDED. Re-serialized: **CI 1765 SUCCESS** at c26b3b775279 (ref refs/pull/739/head, commit==head); head verified UNMOVED at c26b3b775279575276c6ebe8955a9146bfc61413, base main, mergeable=true. **Fresh independent non-author ROR RE-ROUTED to reviewer at exact head c26b3b775279** — asked reviewer to confirm both 17069 blockers genuinely closed (prod-path derivation exercised + cross-tenant denial before plugin call, assertion not weakened). **Independent non-author re-ROR COMPLETE**: reviewer VERIFIED APPROVE at exact head c26b3b775279 (Gitea comment 17074) — both 17069 blockers CONFIRMED closed: production createSession→buildToolsForSandbox scope-derivation test asserts {tenantId,ownerId,sessionId}; foreign-scope reuse rejects BEFORE tool construction and BEFORE plugin search/capture; production wiring reachable via MemoryModule env-configured plugin → AgentService injection → memory_search/memory_save_insight plugin path; command-authz byte-identical a9f829e7; Optional import present; no live creds/no Tess literal. Head verified UNMOVED at c26b3b775279, base main, mergeable=true. **#739 MERGEABLE — reported to Mos, HARD STOP for Mos merge.** This lands the operator-memory-consumer sub-part of W-001; REMAINING W-001 gap = only (#1) runtime-provider registration. **REMAINING blocked: (#1) runtime-provider registration into AGENT_RUNTIME_PROVIDER_REGISTRY** — still needs Mos A/B/C design ruling (no concrete Hermes transport yet). So after #739 lands, W-001 = Mos-consumer (#737 merged) + memory-consumer (#739) DONE; only the provider-registration linchpin remains. **UPDATE 2026-07-13: #739 MERGED by Mos → main squash 3378b857eb ("feat(memory): bind operator plugin to agent sessions (#739)"); post-merge main push pipeline 1766 running. W-001 spine now 2-of-3 sub-parts MERGED (Mos-consumer #737 e2376190 + operator-memory-consumer #739 3378b857); ONLY remaining W-001 gap = (#1) runtime-provider registration into AGENT_RUNTIME_PROVIDER_REGISTRY — still BLOCKED on Mos A/B/C design ruling (no concrete Hermes transport; TESS-PLG-001 folded here). coder0 idle/ready to build #1 on ruling.** **UPDATE 2026-07-13: (#1) DELIVERED as PR #740 "feat(gateway): register Hermes runtime provider"** (base main 3378b857, exact live head 127a69ea11ccc36516c78c2007cbe52fbf63ad30 verified unmoved, mergeable=true). coder0 resolved the A/B/C escalation by BUILDING a concrete transport (⚠️ design-direction flagged to Mos for confirm-before-merge): agent.module.ts explicit registry.register(new HermesRuntimeProvider(new GatewayHermesRuntimeTransport())); GatewayHermesRuntimeTransport = server-configured URL+service token, HTTPS-except-loopback, prefixed-URL preserving, forwards full scope incl channel; AuthGuard interaction transitional-capabilities route through RuntimeProviderService + live controller→service→registered-provider reachability test. Cold-cache typecheck/lint/format/test green (42 tasks); Codex path-prefix+channel-header findings remediated, security clean. **CI pipeline 1767 (repo 47, refs/pull/740/head, commit==head) = SUCCESS**; head verified UNMOVED at 127a69ea11ccc36516c78c2007cbe52fbf63ad30 post-CI, base main, mergeable=true. **Independent non-author ROR ROUTED to reviewer at exact head 127a69ea11cc** (coder0 authored → reviewer non-author) — asked reviewer to verify REAL E2E reachability (provider actually in registry + reachability test exercises registered provider, not mock), transport security (HTTPS-except-loopback, no token leak), command-authz byte-identical a9f829e7, no live creds/no Tess literal, Codex findings genuinely remediated. Awaiting reviewer disposition; any new commit moves head → re-serialize + re-ROR. **UPDATE 2026-07-13: reviewer REQUEST CHANGES at head 127a69ea11cc (Gitea comment 17088) — NOT mergeable.** CI 1767 green; command-authz byte-identical a9f829e7 CONFIRMED; production positives CONFIRMED (module factory registers Hermes provider; concrete transport HTTPS/prefix/channel headers; no live creds/no Tess literal). **Blocker (reachability-integrity):** the required live-guarded reachability proof is MISSING — test directly calls controller.transitionalCapabilities + manually constructs RuntimeProviderService/createGatewayRuntimeProviderRegistry; it does NOT exercise live GET /api/interaction/:agentName/transitional-capabilities, Nest DI through AgentModule, or the AuthGuard request path, so it can pass even if injected gateway registry/route wiring is broken (defeats the M4-V E2E-reachability point). Routed back to coder0 (integrity: add genuine Nest-e2e live-guarded reachability test through real DI+route+AuthGuard asserting reach of the registered Hermes provider; do NOT weaken/stub/mock around it; keep command-authz a9f829e7). Old ROR 17088 + CI 1767 will be SUPERSEDED by the remediation head → re-serialize CI + re-ROR at new head. **UPDATE 2026-07-13 (remediated): coder0 pushed the live-guarded reachability test, NEW frozen head a7e5d377e38b40275884a7df6ee35c55c5859e43** (live Gitea head independently verified, base main 3378b857, mergeable=true) — added apps/gateway/src/agent/hermes-runtime-reachability.e2e.test.ts: imports REAL AgentModule (preserves actual AGENT_RUNTIME_PROVIDER_REGISTRY factory + RuntimeProviderService), boots Fastify/Nest, unauth HTTP GET /api/interaction/Nova/transitional-capabilities?provider=runtime.hermes asserts 401 via ACTUAL AuthGuard, authed GET asserts 200 + all five Hermes entries, asserts DI registry resolves HermesRuntimeProvider; only unrelated peripheral modules harness-replaced to avoid DB/queue startup — NO route/guard/DI-registry/runtime-service/provider mock; existing controller unit test retained; command-authz untouched (byte-identical a9f829e7 remains). Cold-cache root typecheck/lint/format/test green 42/42 (gateway 53 files/606 tests). Old ROR 17088 + CI 1767 SUPERSEDED. Re-serializing: **CI 1768 (repo 47, refs/pull/740/head, commit==head a7e5d377) running** — poll in flight; on green → re-route non-author ROR at exact head a7e5d377. **UPDATE 2026-07-13: CI 1768 SETTLED SUCCESS** (repo 47, refs/pull/740/head, commit==head a7e5d377e38b40275884a7df6ee35c55c5859e43); head independently verified UNMOVED at a7e5d377 (live Gitea, NOT worker-reported), base main 3378b857, mergeable=true. **Independent non-author re-ROR COMPLETE: reviewer VERIFIED APPROVE at exact head a7e5d377e38b40275884a7df6ee35c55c5859e43 (Gitea comment 17094).** Prior 17088 blocker CONFIRMED closed — the new hermes-runtime-reachability.e2e.test.ts boots real Nest/Fastify AgentModule and exercises unauth 401 via the ACTUAL AuthGuard + authed HTTP GET /api/interaction/:agentName/transitional-capabilities through the live route→controller→RuntimeProviderService→registered Hermes provider, and asserts DI registry resolves HermesRuntimeProvider (no route/guard/DI/service/provider mock); transport concrete, HTTPS-except-loopback, path-prefix + channel header covered; command-authz byte-identical a9f829e7 CONFIRMED; no live creds/no Tess literal. Head verified UNMOVED at a7e5d377, base main, mergeable=true. **#740 MERGEABLE — the (#1) runtime-provider-registration linchpin — reported to Mos, HARD STOP for Mos merge.** ⚠️ Design-direction (concrete GatewayHermesRuntimeTransport built to resolve the A/B/C escalation) flagged to Mos for confirm-before-merge. On #740 merge, W-001 spine = 3-of-3 sub-parts landed (Mos-consumer #737 + memory-consumer #739 + provider-registration #740) → M4-V re-fire eligible. **UPDATE 2026-07-13: #740 MERGED by Mos → main b7b0f508 ("feat(gateway): register Hermes runtime provider (#740)"). W-001 SPINE NOW 3-OF-3 MERGED (Mos-consumer #737 e2376190 + operator-memory-consumer #739 3378b857 + provider-registration linchpin #740 b7b0f508) — the M4-V reachability remediation code work is COMPLETE. GATE: TESS-M4-V re-fire is now eligible and Mos-owned — this row stays in-progress until M4-V re-fires green (unit-green was never the bar; end-to-end reachability is). ⚠️ main push pipeline 1772 (for the #740 merge to main) FAILED at the `build` step — quality gates (typecheck/lint/format/test) all GREEN, failure is downstream at build/publish (recurring infra/ENOSPC pattern); flagged to Mos as Mos-owned, does not block docs-only PRs. Prior doc-sync ledger PR #741 MERGED → main f40e6ba3 ("docs(tess): sync M4 tracking to merged reality (M4 in-progress / gate-pending)"); ledger writes resumed on fresh branch docs/tess-ledger-sync-m2 off f40e6ba3.** | +| TESS-M4-W-001 | in-progress | M4-V remediation — gateway reachability SPINE: register runtime provider into AGENT_RUNTIME_PROVIDER_REGISTRY + wire Mos-coordination consumer + wire operator-memory-plugin consumer (make merged M4 deliverables reachable end-to-end); FOLDS IN minimal TESS-PLG-001 catalog/registration | #710 | coder0 | apps/gateway, packages/mosaic, packages/agent | feat/tess-m4w-reachability-spine | TESS-M4-003 | 30K | **Mos-DISPATCHED 2026-07-13** (remediation). Root cause: M4-V holistic review @ origin/main **2363f155** found the three merged M4 deliverables unit-green but NOT reachable end-to-end (no gateway wiring/consumers; providers never registered into the registry). **SPLIT into 3 sub-parts by coder0 (integrity-honest):** **(#2 Mos-coordination consumer) = DELIVERED as PR #737** (head f7b95f60, base main, "feat(gateway): expose Mos coordination boundary") — real AuthGuard Mos handoff/observe/result consumer, authenticated actor/tenant + required correlation derivation, service authority unchanged, gateway target test/typecheck/lint pass; **CI 1758 SUCCESS** (repo 47, commit==head); head verified UNMOVED at f7b95f60666a4abbbad9a08669637b19fa87c430, base main, mergeable=true. **Independent non-author ROR COMPLETE**: reviewer VERIFIED APPROVE at exact head f7b95f60 (Gitea comment 17054) — clean partial scope confirmed (AuthGuard Mos handoff/observe/result controller + module registration only; no runtime-provider registration / operator-memory consumer; actor/tenant from CurrentUser/scopeFromUser + required X-Correlation-Id before service invocation; MosCoordinationService unchanged; command-authz byte-identical a9f829e7; no live creds/no Tess literal). **#737 MERGEABLE — reported to Mos, HARD STOP for Mos merge (partial slice; land-vs-hold-for-full-spine is Mos's disposition call).** **(#1 runtime-provider registration) + (operator-memory consumer) = BLOCKED, NOT in #737.** coder0 could not truthfully complete them in this slice and REFUSED to fake with deny/unavailable stubs: gateway has **no concrete Hermes transport** and **no gateway-side tmux transport/authority wiring** to register a real provider; OperatorMemory consumer needs **session tenant/owner/session propagation currently ABSENT from AgentService's memory-tools boundary**. ⚠️ **DESIGN RULING ESCALATED TO MOS** (architecture, not resolvable from repo): how to wire provider-registration + memory-scope propagation when no concrete transport exists yet — new remediation slice / re-scope / accept #737 as incremental. TESS-PLG-001 (folded here) is part of the blocked #1 registration path. Command-authz byte-identical a9f829e7. **UPDATE 2026-07-13: #737 MERGED by Mos → main e2376190 ("feat(gateway): expose Mos coordination boundary (#737)").** **Operator-memory consumer sub-part UNBLOCKED + DELIVERED as PR #739** ("feat(memory): bind operator plugin to agent sessions", base main off e2376190, live head 31a59738089f0784428833fc5a0192c6c7c43261, mergeable=true) — coder0 resolved the session-scope-propagation blocker WITHOUT stubbing: gateway bootstrap configures plugin only with MOSAIC_OPERATOR_MEMORY_INSTANCE_ID + MOSAIC_OPERATOR_MEMORY_NAMESPACE, AgentService derives {tenantId,ownerId,sessionId} server-side and binds search/capture tools. Cold-cache root typecheck/lint/format/test (42 tasks) green; security review clean (Codex Optional-import finding = false positive, pre-existing, typecheck passed). **CI 1764 SUCCESS** (repo 47, commit==head); head verified UNMOVED at 31a59738089f0784428833fc5a0192c6c7c43261, base main, mergeable=true. **Independent non-author ROR ROUTED to reviewer at exact head 31a59738089f** (coder0 authored → reviewer is non-author) — asked reviewer to confirm scope is server-derived/non-client-controllable + no cross-tenant leak, and to independently verify the Codex Optional-import finding is a false positive. (Head reconcile CLOSED: coder0 confirmed 31a597380c55… was a transcription typo; live+frozen head is 31a59738089f0784428833fc5a0192c6c7c43261, working tree clean.) **UPDATE 2026-07-13: reviewer REQUEST CHANGES at head 31a59738089f (Gitea comment 17069) — NOT mergeable.** Production code CONFIRMED correct (plugin route wired, config env namespace/instance only, no live creds/no Tess literal, command-authz byte-identical a9f829e7; Codex Optional-import finding = false positive, import present). **Two TEST-COVERAGE blockers:** (1) tests BYPASS production scope derivation — they call createMemoryTools with a PREBUILT scope, never exercising the real createSession→buildToolsForSandbox server-side {tenantId,ownerId,sessionId} derivation; (2) NO divergent cross-tenant/cross-owner ISOLATION/DENIAL test proving a foreign actor cannot reuse a session / reach another operator-memory scope before the plugin call. Routed back to coder0 (integrity: harden real coverage, do NOT weaken assertion). Any new commit MOVES head → invalidates ROR → re-serialize CI + re-ROR at new head. **UPDATE 2026-07-13 (remediated): coder0 pushed hardened tests, NEW frozen head c26b3b775279575276c6ebe8955a9146bfc61413** — added createSession→buildToolsForSandbox PRODUCTION-PATH assertion of derived {tenantId,ownerId,sessionId}; added foreign-actor reuse DENIAL test asserting rejection occurs BEFORE scope/tool construction and before any plugin call. Cold-cache root typecheck/lint/format/test green (42 tasks). Old ROR at 31a59738089f + CI 1764 SUPERSEDED. Re-serialized: **CI 1765 SUCCESS** at c26b3b775279 (ref refs/pull/739/head, commit==head); head verified UNMOVED at c26b3b775279575276c6ebe8955a9146bfc61413, base main, mergeable=true. **Fresh independent non-author ROR RE-ROUTED to reviewer at exact head c26b3b775279** — asked reviewer to confirm both 17069 blockers genuinely closed (prod-path derivation exercised + cross-tenant denial before plugin call, assertion not weakened). **Independent non-author re-ROR COMPLETE**: reviewer VERIFIED APPROVE at exact head c26b3b775279 (Gitea comment 17074) — both 17069 blockers CONFIRMED closed: production createSession→buildToolsForSandbox scope-derivation test asserts {tenantId,ownerId,sessionId}; foreign-scope reuse rejects BEFORE tool construction and BEFORE plugin search/capture; production wiring reachable via MemoryModule env-configured plugin → AgentService injection → memory_search/memory_save_insight plugin path; command-authz byte-identical a9f829e7; Optional import present; no live creds/no Tess literal. Head verified UNMOVED at c26b3b775279, base main, mergeable=true. **#739 MERGEABLE — reported to Mos, HARD STOP for Mos merge.** This lands the operator-memory-consumer sub-part of W-001; REMAINING W-001 gap = only (#1) runtime-provider registration. **REMAINING blocked: (#1) runtime-provider registration into AGENT_RUNTIME_PROVIDER_REGISTRY** — still needs Mos A/B/C design ruling (no concrete Hermes transport yet). So after #739 lands, W-001 = Mos-consumer (#737 merged) + memory-consumer (#739) DONE; only the provider-registration linchpin remains. **UPDATE 2026-07-13: #739 MERGED by Mos → main squash 3378b857eb ("feat(memory): bind operator plugin to agent sessions (#739)"); post-merge main push pipeline 1766 running. W-001 spine now 2-of-3 sub-parts MERGED (Mos-consumer #737 e2376190 + operator-memory-consumer #739 3378b857); ONLY remaining W-001 gap = (#1) runtime-provider registration into AGENT_RUNTIME_PROVIDER_REGISTRY — still BLOCKED on Mos A/B/C design ruling (no concrete Hermes transport; TESS-PLG-001 folded here). coder0 idle/ready to build #1 on ruling.** **UPDATE 2026-07-13: (#1) DELIVERED as PR #740 "feat(gateway): register Hermes runtime provider"** (base main 3378b857, exact live head 127a69ea11ccc36516c78c2007cbe52fbf63ad30 verified unmoved, mergeable=true). coder0 resolved the A/B/C escalation by BUILDING a concrete transport (⚠️ design-direction flagged to Mos for confirm-before-merge): agent.module.ts explicit registry.register(new HermesRuntimeProvider(new GatewayHermesRuntimeTransport())); GatewayHermesRuntimeTransport = server-configured URL+service token, HTTPS-except-loopback, prefixed-URL preserving, forwards full scope incl channel; AuthGuard interaction transitional-capabilities route through RuntimeProviderService + live controller→service→registered-provider reachability test. Cold-cache typecheck/lint/format/test green (42 tasks); Codex path-prefix+channel-header findings remediated, security clean. **CI pipeline 1767 (repo 47, refs/pull/740/head, commit==head) = SUCCESS**; head verified UNMOVED at 127a69ea11ccc36516c78c2007cbe52fbf63ad30 post-CI, base main, mergeable=true. **Independent non-author ROR ROUTED to reviewer at exact head 127a69ea11cc** (coder0 authored → reviewer non-author) — asked reviewer to verify REAL E2E reachability (provider actually in registry + reachability test exercises registered provider, not mock), transport security (HTTPS-except-loopback, no token leak), command-authz byte-identical a9f829e7, no live creds/no Tess literal, Codex findings genuinely remediated. Awaiting reviewer disposition; any new commit moves head → re-serialize + re-ROR. **UPDATE 2026-07-13: reviewer REQUEST CHANGES at head 127a69ea11cc (Gitea comment 17088) — NOT mergeable.** CI 1767 green; command-authz byte-identical a9f829e7 CONFIRMED; production positives CONFIRMED (module factory registers Hermes provider; concrete transport HTTPS/prefix/channel headers; no live creds/no Tess literal). **Blocker (reachability-integrity):** the required live-guarded reachability proof is MISSING — test directly calls controller.transitionalCapabilities + manually constructs RuntimeProviderService/createGatewayRuntimeProviderRegistry; it does NOT exercise live GET /api/interaction/:agentName/transitional-capabilities, Nest DI through AgentModule, or the AuthGuard request path, so it can pass even if injected gateway registry/route wiring is broken (defeats the M4-V E2E-reachability point). Routed back to coder0 (integrity: add genuine Nest-e2e live-guarded reachability test through real DI+route+AuthGuard asserting reach of the registered Hermes provider; do NOT weaken/stub/mock around it; keep command-authz a9f829e7). Old ROR 17088 + CI 1767 will be SUPERSEDED by the remediation head → re-serialize CI + re-ROR at new head. **UPDATE 2026-07-13 (remediated): coder0 pushed the live-guarded reachability test, NEW frozen head a7e5d377e38b40275884a7df6ee35c55c5859e43** (live Gitea head independently verified, base main 3378b857, mergeable=true) — added apps/gateway/src/agent/hermes-runtime-reachability.e2e.test.ts: imports REAL AgentModule (preserves actual AGENT_RUNTIME_PROVIDER_REGISTRY factory + RuntimeProviderService), boots Fastify/Nest, unauth HTTP GET /api/interaction/Nova/transitional-capabilities?provider=runtime.hermes asserts 401 via ACTUAL AuthGuard, authed GET asserts 200 + all five Hermes entries, asserts DI registry resolves HermesRuntimeProvider; only unrelated peripheral modules harness-replaced to avoid DB/queue startup — NO route/guard/DI-registry/runtime-service/provider mock; existing controller unit test retained; command-authz untouched (byte-identical a9f829e7 remains). Cold-cache root typecheck/lint/format/test green 42/42 (gateway 53 files/606 tests). Old ROR 17088 + CI 1767 SUPERSEDED. Re-serializing: **CI 1768 (repo 47, refs/pull/740/head, commit==head a7e5d377) running** — poll in flight; on green → re-route non-author ROR at exact head a7e5d377. **UPDATE 2026-07-13: CI 1768 SETTLED SUCCESS** (repo 47, refs/pull/740/head, commit==head a7e5d377e38b40275884a7df6ee35c55c5859e43); head independently verified UNMOVED at a7e5d377 (live Gitea, NOT worker-reported), base main 3378b857, mergeable=true. **Independent non-author re-ROR COMPLETE: reviewer VERIFIED APPROVE at exact head a7e5d377e38b40275884a7df6ee35c55c5859e43 (Gitea comment 17094).** Prior 17088 blocker CONFIRMED closed — the new hermes-runtime-reachability.e2e.test.ts boots real Nest/Fastify AgentModule and exercises unauth 401 via the ACTUAL AuthGuard + authed HTTP GET /api/interaction/:agentName/transitional-capabilities through the live route→controller→RuntimeProviderService→registered Hermes provider, and asserts DI registry resolves HermesRuntimeProvider (no route/guard/DI/service/provider mock); transport concrete, HTTPS-except-loopback, path-prefix + channel header covered; command-authz byte-identical a9f829e7 CONFIRMED; no live creds/no Tess literal. Head verified UNMOVED at a7e5d377, base main, mergeable=true. **#740 MERGEABLE — the (#1) runtime-provider-registration linchpin — reported to Mos, HARD STOP for Mos merge.** ⚠️ Design-direction (concrete GatewayHermesRuntimeTransport built to resolve the A/B/C escalation) flagged to Mos for confirm-before-merge. On #740 merge, W-001 spine = 3-of-3 sub-parts landed (Mos-consumer #737 + memory-consumer #739 + provider-registration #740) → M4-V re-fire eligible. **UPDATE 2026-07-13: #740 MERGED by Mos → main b7b0f508 ("feat(gateway): register Hermes runtime provider (#740)"). W-001 SPINE NOW 3-OF-3 MERGED (Mos-consumer #737 e2376190 + operator-memory-consumer #739 3378b857 + provider-registration linchpin #740 b7b0f508) — the M4-V reachability remediation code work is COMPLETE. GATE: TESS-M4-V re-fire is now eligible and Mos-owned — this row stays in-progress until M4-V re-fires green (unit-green was never the bar; end-to-end reachability is). ⚠️ main push pipeline 1772 (for the #740 merge to main) FAILED at the `build` step — quality gates (typecheck/lint/format/test) all GREEN, failure is downstream at build/publish (recurring infra/ENOSPC pattern); flagged to Mos as Mos-owned, does not block docs-only PRs. Prior doc-sync ledger PR #741 MERGED → main f40e6ba3 ("docs(tess): sync M4 tracking to merged reality (M4 in-progress / gate-pending)"); ledger writes resumed on fresh branch docs/tess-ledger-sync-m2 off f40e6ba3. **UPDATE 2026-07-13: consolidated ledger-sync PR #743 (branch docs/tess-ledger-sync-m2, head aa3925510d06, reviewer VERIFIED APPROVE 17123, CI 1775 SUCCESS) MERGED by Mos → main c6e3cfbdf... ; ledger writes resumed on fresh branch docs/tess-ledger-sync-m3 off main 6345dbfc (post-#744 merge).** | | TESS-M4-W-002 | done | M4-V remediation — Hermes capability MATRIX (AC-TESS-05): approved-capability coverage across Kanban/skills/memory/tools/cron for the Hermes adapter | #710 | coder3 | packages/agent, apps/gateway | feat/tess-m4w-hermes-matrix | TESS-M4-002 | 22K | **Mos-DISPATCHED 2026-07-13** (remediation, in flight). Extends the M4-002 option-(a) adapter (merged 9e5b9188) with the AC-TESS-05 capability matrix. UPDATE 2026-07-13: coder3 STARTED — fresh worktree off origin/main 2363f155, TDD failing-matrix-tests-first. Orchestrator TRACKS; on PR-open → serialize CI + independent non-author ROR at EXACT head → HARD STOP for Mos merge. No legacy schema into core contracts; command-authz byte-identical a9f829e7. UPDATE 2026-07-13: **PR #738 OPENED** (base main, head 582c6db2088223fd8dd2105005391b5034c992ac, "feat(agent): add Hermes transitional capability matrix") — normalized exhaustive five-entry matrix (kanban/skills/memory/tools/cron), all explicit unsupported, fails CLOSED before transport; tests 4/4, security review clean, cold-cache 46 successful/0 cached, normalized optional TransitionalCapabilityInventoryProvider (no legacy schema). **CI 1759 SUCCESS** (repo 47, commit==head); head verified UNMOVED at 582c6db2, base main, mergeable=true. **Independent non-author ROR COMPLETE**: reviewer VERIFIED APPROVE at exact head 582c6db2 (Gitea comment 17057) — normalized exhaustive five-entry transitional matrix (kanban/skills/memory/tools/cron) all unsupported; assertTransitionalCapability fails CLOSED with capability_unsupported before Hermes transport; only normalized optional TransitionalCapabilityInventoryProvider added to core (no legacy schema leak); command-authz byte-identical a9f829e7; no live creds/no Tess literal. Head verified UNMOVED at 582c6db2, base main, mergeable=true. **#738 MERGEABLE — reported to Mos, HARD STOP for Mos merge.** This is the COMPLETE matrix deliverable (unlike #737's partial spine). **UPDATE 2026-07-13: #738 MERGED by Mos → merge_commit cca6aaf9. TESS-M4-W-002 DONE.** | | TESS-PLG-001 | in-progress | packages/mosaic plugin catalog / registration (operator plugins registered + discoverable) — was silently deferred by M4-003 author; now VISIBLE | #710 | coder0 | packages/mosaic | feat/tess-m4w-reachability-spine | TESS-M4-003 | (folded) | ⚠️ Surfaced by Mos 2026-07-13 as an invisible gap: M4-003/#736 delivered the memory plugin but NOT its catalog/registration in packages/mosaic; never appeared in MISSION-MANIFEST/VERIFICATION-MATRIX. PLACEMENT DECISION (orchestrator, per Mos "your call"): **FOLD minimal registration into TESS-M4-W-001** (coder0's reachability spine already does registry wiring — same author closes their own gap, keeps it in one lane). This row exists for LEDGER VISIBILITY so the gap is tracked, not re-hidden. If M4-W-001 scope grows too large, split back out as a standalone lane. Manifest/matrix update to follow. | | TESS-M4-V | failed | Cross-provider capability, privacy, authority and failure-path qualification | #710 | sonnet | apps/gateway/src/__tests__/integration, packages/agent | review/tess-m4 | TESS-M4-001,TESS-M4-002,TESS-M4-003,TESS-M4-W-001,TESS-M4-W-002 | 22K | **FAILED 2026-07-13** — independent holistic review @ origin/main **2363f155**: all three M4 deliverables (#734/#735/#736) unit-green but **NOT reachable end-to-end** (providers never registered into AGENT_RUNTIME_PROVIDER_REGISTRY; Mos-coordination + operator-memory consumers unwired; TESS-PLG-001 catalog/registration silently deferred). Remediation TESS-M4-W (W-001 spine coder0 + W-002 Hermes matrix coder3) now in flight. **Mos re-fires M4-V ONLY after the spine + matrix land.** Gate M5 (M5 stays behind M4-V; live-deploy = Jason-reserved). | -| TESS-M5-001 | in-progress | Implement Matrix/native runtime provider behind common contracts and parity suite | #711 | coder0 | packages/mosaic, packages/agent | feat/tess-matrix-provider | TESS-M4-V | 30K | TESS-TRN-001. **Mos-DISPATCHED to coder0 2026-07-13** (advancing to M5, same M4-V dependency-reconciliation caveat as M5-002). Design sketch (branch feat/tess-matrix-provider off origin/main b7b0f508): MatrixNativeRuntimeProvider in packages/agent over a narrow MatrixRuntimeTransport contract + MatrixNativeRuntimeTransport in packages/mosaic (Mosaic adapter owns Matrix HTTP/auth/identity/room mechanics; agent provider owns common provider behavior only). Parity suite runs the SAME provider-contract scenarios against factory fixtures for existing tmux/fleet AND Matrix/native; Matrix native declares only operations concretely wired (no fake reachability, no Matrix default promotion); no gateway/Discord changes; command-authz to remain byte-identical a9f829e7. **In TDD — no PR yet.** On PR-open: freeze head → serialize CI (one-at-a-time on repo 47) → independent non-author ROR at exact head → HARD STOP for Mos merge. | -| TESS-M5-002 | in-progress | Complete migration inventory, cutover, rollback, retention and deprecation evidence | #711 | coder3 | docs/tess | feat/tess-migration-docs | TESS-M4-V | 18K | TESS-MIG-001. **Mos-DISPATCHED to coder3 2026-07-13** ("M4 complete; advancing to M5") — dispatched AHEAD of TESS-M4-V passing; the M4-V-status-vs-#710-CLOSED dependency reconciliation is pending Mos ruling (tracked, not orchestrator-decided). **DELIVERED as PR #742** — 4 new files docs/tess/M5-MIGRATION-{INVENTORY,CUTOVER,ROLLBACK,RETENTION-DEPRECATION}.md, base main b7b0f508, frozen head b5e9d0e528a50aae2e916cc7202bacc2e8db67dd. Docs-only; tracking-control trio (MISSION-MANIFEST/TASKS/VERIFICATION-MATRIX) UNTOUCHED; command-authz byte-identical a9f829e7; no live creds. **CI pipeline 1773 SUCCESS** (repo 47, refs/pull/742/head, commit==head). **Independent non-author ROR COMPLETE: reviewer VERIFIED APPROVE at exact head b5e9d0e528a50aae2e916cc7202bacc2e8db67dd (Gitea comment 17108)** — evidence claims verified to trace to landed Hermes adapter / capability matrix, gateway registry/reachability, operator-memory scope path, Mos coordination boundary; docs do NOT over-claim transcript/profile import, schema migration, unsupported-capability enablement, production cutover, or deprecation completion. Head independently verified UNMOVED at b5e9d0e528a5 post-ROR (live Gitea), base main, mergeable=true. **#742 MERGEABLE — reported to Mos, HARD STOP for Mos merge.** | +| TESS-M5-001 | done | Implement Matrix/native runtime provider behind common contracts and parity suite | #711 | coder0 | packages/mosaic, packages/agent | feat/tess-matrix-provider | TESS-M4-V | 30K | TESS-TRN-001. **Mos-DISPATCHED to coder0 2026-07-13** (advancing to M5, same M4-V dependency-reconciliation caveat as M5-002). Design sketch (branch feat/tess-matrix-provider off origin/main b7b0f508): MatrixNativeRuntimeProvider in packages/agent over a narrow MatrixRuntimeTransport contract + MatrixNativeRuntimeTransport in packages/mosaic (Mosaic adapter owns Matrix HTTP/auth/identity/room mechanics; agent provider owns common provider behavior only). Parity suite runs the SAME provider-contract scenarios against factory fixtures for existing tmux/fleet AND Matrix/native; Matrix native declares only operations concretely wired (no fake reachability, no Matrix default promotion); no gateway/Discord changes; command-authz to remain byte-identical a9f829e7. **In TDD — no PR yet.** On PR-open: freeze head → serialize CI (one-at-a-time on repo 47) → independent non-author ROR at exact head → HARD STOP for Mos merge. **UPDATE 2026-07-13: DELIVERED as PR #744 (7 files packages/agent + packages/mosaic only) frozen head b4fcf139a73678e9e59c8f6b63c108c095a87b3a; CI pipeline 1776 SUCCESS (repo 47, refs/pull/744/head, commit==head); independent non-author ROR COMPLETE — reviewer VERIFIED APPROVE at exact head b4fcf139a736 (Gitea comment 17126): Matrix stays NON-DEFAULT (no gateway/Discord/registry wiring), default-deny read/write authority, immutable read handles, control-attach rejection, parity suite runs same scenarios against tmux/fleet AND Matrix/native, concrete Matrix CS-API HTTPS transport with whoami/remote-identity-filter/deterministic txns, no live creds, command-authz byte-identical a9f829e7. #744 MERGED by Mos → main 6345dbfcf262. Deliverable code LANDED. GATE: TESS-M4-V/M5-V verification-gate reconciliation remains Mos-owned/pending (this row was dispatched ahead of M4-V passing).** | +| TESS-M5-002 | done | Complete migration inventory, cutover, rollback, retention and deprecation evidence | #711 | coder3 | docs/tess | feat/tess-migration-docs | TESS-M4-V | 18K | TESS-MIG-001. **Mos-DISPATCHED to coder3 2026-07-13** ("M4 complete; advancing to M5") — dispatched AHEAD of TESS-M4-V passing; the M4-V-status-vs-#710-CLOSED dependency reconciliation is pending Mos ruling (tracked, not orchestrator-decided). **DELIVERED as PR #742** — 4 new files docs/tess/M5-MIGRATION-{INVENTORY,CUTOVER,ROLLBACK,RETENTION-DEPRECATION}.md, base main b7b0f508, frozen head b5e9d0e528a50aae2e916cc7202bacc2e8db67dd. Docs-only; tracking-control trio (MISSION-MANIFEST/TASKS/VERIFICATION-MATRIX) UNTOUCHED; command-authz byte-identical a9f829e7; no live creds. **CI pipeline 1773 SUCCESS** (repo 47, refs/pull/742/head, commit==head). **Independent non-author ROR COMPLETE: reviewer VERIFIED APPROVE at exact head b5e9d0e528a50aae2e916cc7202bacc2e8db67dd (Gitea comment 17108)** — evidence claims verified to trace to landed Hermes adapter / capability matrix, gateway registry/reachability, operator-memory scope path, Mos coordination boundary; docs do NOT over-claim transcript/profile import, schema migration, unsupported-capability enablement, production cutover, or deprecation completion. Head independently verified UNMOVED at b5e9d0e528a5 post-ROR (live Gitea), base main, mergeable=true. **#742 MERGED by Mos → main 5789711e. Deliverable docs LANDED. GATE: TESS-M4-V/M5-V verification-gate reconciliation remains Mos-owned/pending (this row was dispatched ahead of M4-V passing).** | | TESS-M5-003 | not-started | Complete OpenAPI, user/admin/developer/plugin/operations docs and checklist | #711 | codex | docs | feat/tess-docs | TESS-M5-001,TESS-M5-002 | 22K | Documentation hard gate | | TESS-M5-V | not-started | Full baseline, contract, integration, Discord/CLI E2E, security review, recovery drill and rollback qualification | #711 | sonnet | apps/gateway, packages/agent, plugins/discord, packages/mosaic | review/tess-final | TESS-M5-003 | 35K | Maps AC-TESS-01..11 to evidence | From bc8016c8314ec3a4b6ebc2fec5d9f276fca3327a Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Mon, 13 Jul 2026 17:44:17 +0000 Subject: [PATCH 038/152] docs(#744): complete Tess documentation gate (#746) --- docs/openapi-tess.yaml | 389 ++++++++++++++++++++ docs/tess/ADMIN-GUIDE.md | 5 + docs/tess/DEVELOPER-GUIDE.md | 3 + docs/tess/M5-003-DOCUMENTATION-CHECKLIST.md | 8 + docs/tess/OPERATIONS-GUIDE.md | 3 + docs/tess/PLUGIN-GUIDE.md | 3 + docs/tess/USER-GUIDE.md | 5 + 7 files changed, 416 insertions(+) create mode 100644 docs/openapi-tess.yaml create mode 100644 docs/tess/ADMIN-GUIDE.md create mode 100644 docs/tess/DEVELOPER-GUIDE.md create mode 100644 docs/tess/M5-003-DOCUMENTATION-CHECKLIST.md create mode 100644 docs/tess/OPERATIONS-GUIDE.md create mode 100644 docs/tess/PLUGIN-GUIDE.md create mode 100644 docs/tess/USER-GUIDE.md diff --git a/docs/openapi-tess.yaml b/docs/openapi-tess.yaml new file mode 100644 index 00000000..c348a690 --- /dev/null +++ b/docs/openapi-tess.yaml @@ -0,0 +1,389 @@ +openapi: 3.1.0 +info: { title: Mosaic Tess Gateway, version: 1.0.0 } +security: [{ sessionAuth: [] }] +paths: + /api/interaction/{agentName}/sessions: + { + get: + { + summary: List authorized runtime sessions, + parameters: + [ + { $ref: '#/components/parameters/agentName' }, + { $ref: '#/components/parameters/provider' }, + { $ref: '#/components/parameters/correlation' }, + ], + responses: { '200': { description: Sessions } }, + }, + } + /api/interaction/{agentName}/transitional-capabilities: + { + get: + { + summary: Get transitional capability matrix, + parameters: + [ + { $ref: '#/components/parameters/agentName' }, + { $ref: '#/components/parameters/provider' }, + { $ref: '#/components/parameters/correlation' }, + ], + responses: { '200': { description: Matrix } }, + }, + } + /api/interaction/{agentName}/tree: + { + get: + { + summary: Get authorized session tree, + parameters: + [ + { $ref: '#/components/parameters/agentName' }, + { $ref: '#/components/parameters/provider' }, + { $ref: '#/components/parameters/correlation' }, + ], + responses: { '200': { description: Tree } }, + }, + } + /api/interaction/{agentName}/sessions/{sessionId}/enroll: + { + post: + { + summary: Enroll a durable session, + parameters: + [ + { $ref: '#/components/parameters/agentName' }, + { $ref: '#/components/parameters/sessionId' }, + { $ref: '#/components/parameters/correlation' }, + ], + requestBody: { $ref: '#/components/requestBodies/Enroll' }, + responses: { '200': { description: Enrolled } }, + }, + } + /api/interaction/{agentName}/sessions/{sessionId}/attach: + { + post: + { + summary: Attach to a runtime session, + parameters: + [ + { $ref: '#/components/parameters/agentName' }, + { $ref: '#/components/parameters/sessionId' }, + { $ref: '#/components/parameters/correlation' }, + ], + requestBody: { $ref: '#/components/requestBodies/Attach' }, + responses: { '200': { description: Attachment } }, + }, + } + /api/interaction/{agentName}/sessions/{sessionId}/send: + { + post: + { + summary: Queue a durable provider send, + parameters: + [ + { $ref: '#/components/parameters/agentName' }, + { $ref: '#/components/parameters/sessionId' }, + { $ref: '#/components/parameters/correlation' }, + ], + requestBody: { $ref: '#/components/requestBodies/Send' }, + responses: { '200': { description: Queued } }, + }, + } + /api/interaction/{agentName}/sessions/{sessionId}/stop: + { + post: + { + summary: Stop a session with approval, + parameters: + [ + { $ref: '#/components/parameters/agentName' }, + { $ref: '#/components/parameters/sessionId' }, + { $ref: '#/components/parameters/correlation' }, + ], + requestBody: { $ref: '#/components/requestBodies/Stop' }, + responses: { '200': { description: Stopped }, '403': { description: Approval denied } }, + }, + } + /api/interaction/{agentName}/sessions/{sessionId}/recover: + { + post: + { + summary: Recover interrupted durable work, + parameters: + [ + { $ref: '#/components/parameters/agentName' }, + { $ref: '#/components/parameters/sessionId' }, + { $ref: '#/components/parameters/correlation' }, + ], + responses: { '200': { description: Recovered } }, + }, + } + /api/coord/mos/handoff: + { + post: + { + summary: Submit Mos handoff, + parameters: [{ $ref: '#/components/parameters/correlation' }], + requestBody: { $ref: '#/components/requestBodies/MosHandoff' }, + responses: { '200': { description: Receipt } }, + }, + } + /api/coord/mos/{handoffId}/observe: + { + get: + { + summary: Observe Mos handoff, + parameters: + [ + { $ref: '#/components/parameters/handoffId' }, + { $ref: '#/components/parameters/correlation' }, + ], + responses: { '200': { description: Observation } }, + }, + } + /api/coord/mos/{handoffId}/result: + { + get: + { + summary: Get Mos handoff result, + parameters: + [ + { $ref: '#/components/parameters/handoffId' }, + { $ref: '#/components/parameters/correlation' }, + ], + responses: { '200': { description: Result } }, + }, + } + /api/interaction/{agentName}/sessions/{sessionId}/stream: + { + get: + { + summary: Stream runtime events, + parameters: + [ + { $ref: '#/components/parameters/agentName' }, + { $ref: '#/components/parameters/sessionId' }, + { $ref: '#/components/parameters/correlation' }, + ], + responses: + { + '200': + { + description: Event stream, + content: { text/event-stream: { schema: { type: string } } }, + }, + }, + }, + } + /api/memory/preferences: + { + get: { summary: List preferences, responses: { '200': { description: Preferences } } }, + post: + { + summary: Upsert preference, + requestBody: { $ref: '#/components/requestBodies/Preference' }, + responses: { '200': { description: Preference } }, + }, + } + /api/memory/preferences/{key}: + { + get: + { + summary: Get preference, + parameters: [{ $ref: '#/components/parameters/key' }], + responses: { '200': { description: Preference } }, + }, + delete: + { + summary: Delete preference, + parameters: [{ $ref: '#/components/parameters/key' }], + responses: { '204': { description: Deleted } }, + }, + } + /api/memory/insights: + { + get: { summary: List insights, responses: { '200': { description: Insights } } }, + post: + { + summary: Create insight, + requestBody: { $ref: '#/components/requestBodies/Insight' }, + responses: { '200': { description: Insight } }, + }, + } + /api/memory/insights/{id}: + { + get: + { + summary: Get insight, + parameters: [{ $ref: '#/components/parameters/id' }], + responses: { '200': { description: Insight } }, + }, + delete: + { + summary: Delete insight, + parameters: [{ $ref: '#/components/parameters/id' }], + responses: { '204': { description: Deleted } }, + }, + } + /api/memory/search: + { + post: + { + summary: Search memory, + requestBody: { $ref: '#/components/requestBodies/Search' }, + responses: { '200': { description: Search results } }, + }, + } +components: + securitySchemes: { sessionAuth: { type: http, scheme: bearer } } + parameters: + agentName: { name: agentName, in: path, required: true, schema: { type: string } } + sessionId: { name: sessionId, in: path, required: true, schema: { type: string } } + provider: { name: provider, in: query, required: true, schema: { type: string } } + correlation: { name: X-Correlation-Id, in: header, required: true, schema: { type: string } } + key: { name: key, in: path, required: true, schema: { type: string } } + id: { name: id, in: path, required: true, schema: { type: string } } + requestBodies: + Enroll: + { + required: true, + content: + { + application/json: + { + schema: + { + type: object, + required: [providerId, runtimeSessionId], + properties: + { providerId: { type: string }, runtimeSessionId: { type: string } }, + }, + }, + }, + } + MosHandoff: + { + required: true, + content: + { + application/json: + { + schema: + { + type: object, + required: [idempotencyKey, summary], + properties: + { + idempotencyKey: { type: string }, + summary: { type: string }, + context: { type: string }, + missionId: { type: string }, + }, + }, + }, + }, + } + Attach: + { + content: + { + application/json: { schema: { type: object, properties: { mode: { enum: [read] } } } }, + }, + } + Send: + { + required: true, + content: + { + application/json: + { + schema: + { + type: object, + required: [content, idempotencyKey], + properties: { content: { type: string }, idempotencyKey: { type: string } }, + }, + }, + }, + } + Stop: + { + required: true, + content: + { + application/json: + { + schema: + { + type: object, + required: [approvalRef], + properties: { approvalRef: { type: string } }, + }, + }, + }, + } + Preference: + { + required: true, + content: + { + application/json: + { + schema: + { + type: object, + required: [key, value], + properties: + { + key: { type: string }, + value: {}, + category: { type: string }, + source: { type: string }, + }, + }, + }, + }, + } + Insight: + { + required: true, + content: + { + application/json: + { + schema: + { + type: object, + required: [content], + properties: + { + content: { type: string }, + source: { type: string }, + category: { type: string }, + metadata: { type: object }, + }, + }, + }, + }, + } + Search: + { + required: true, + content: + { + application/json: + { + schema: + { + type: object, + required: [query], + properties: + { + query: { type: string }, + limit: { type: integer }, + maxDistance: { type: number }, + }, + }, + }, + }, + } diff --git a/docs/tess/ADMIN-GUIDE.md b/docs/tess/ADMIN-GUIDE.md new file mode 100644 index 00000000..03b78c3a --- /dev/null +++ b/docs/tess/ADMIN-GUIDE.md @@ -0,0 +1,5 @@ +# Tess Administration + +Configure agent/provider identities outside client input. Verify `/health/ready` and provider health before enabling interaction clients. Every interaction request requires an authenticated actor and correlation header; tenant and owner scope are server-derived. Do not log or return service credentials. + +For an incident, preserve correlation IDs, inspect provider status and durable checkpoint/inbox/outbox state, then use the recovery endpoint. Do not retry an ambiguous external effect automatically. Stop operations require an exact one-time approval reference; provisioning or granting a broad admin capability does not replace that check. diff --git a/docs/tess/DEVELOPER-GUIDE.md b/docs/tess/DEVELOPER-GUIDE.md new file mode 100644 index 00000000..dfd534af --- /dev/null +++ b/docs/tess/DEVELOPER-GUIDE.md @@ -0,0 +1,3 @@ +# Tess Developer Guide + +Interaction adapters pass only server-derived actor/tenant scope, channel, and correlation to runtime providers. Durable session state owns inbox/outbox/checkpoint recovery. Use the OpenAPI contract rather than inventing routes; unsupported provider capabilities fail closed. diff --git a/docs/tess/M5-003-DOCUMENTATION-CHECKLIST.md b/docs/tess/M5-003-DOCUMENTATION-CHECKLIST.md new file mode 100644 index 00000000..5622d0a3 --- /dev/null +++ b/docs/tess/M5-003-DOCUMENTATION-CHECKLIST.md @@ -0,0 +1,8 @@ +# TESS-M5-003 Documentation Checklist + +- [x] `openapi-tess.yaml`: authenticated interaction endpoints including SSE stream, Mos handoff/observe/result, and memory preferences, insights, and search. +- [x] User guide: authorized session and handoff workflows. +- [x] Admin guide: provisioning, policy, health, and approval boundary. +- [x] Developer guide: scope, durable state, and provider adapter contract. +- [x] Plugin guide: replaceable-adapter, redaction, and identity-as-data rules. +- [x] Operations guide: readiness, recovery, ambiguous-effect safety, and tracing. diff --git a/docs/tess/OPERATIONS-GUIDE.md b/docs/tess/OPERATIONS-GUIDE.md new file mode 100644 index 00000000..e0a262e8 --- /dev/null +++ b/docs/tess/OPERATIONS-GUIDE.md @@ -0,0 +1,3 @@ +# Tess Operations and Recovery + +Check `/health/ready`, provider health, and effective policy before recovery. Recover durable sessions through the interaction recovery operation; it requeues only interrupted work and does not replay ambiguous external effects. Preserve correlation IDs for incident tracing and use Mos handoff observation/result endpoints for orchestration visibility. diff --git a/docs/tess/PLUGIN-GUIDE.md b/docs/tess/PLUGIN-GUIDE.md new file mode 100644 index 00000000..4d40348c --- /dev/null +++ b/docs/tess/PLUGIN-GUIDE.md @@ -0,0 +1,3 @@ +# Tess Plugin Authoring + +Plugins are replaceable adapters. Declare capabilities, derive scope from trusted context, preserve correlation IDs, redact before persistence/egress, and return unsupported operations as fail-closed results. Names and identities are configuration data, not literals in keys or defaults. diff --git a/docs/tess/USER-GUIDE.md b/docs/tess/USER-GUIDE.md new file mode 100644 index 00000000..90a85f15 --- /dev/null +++ b/docs/tess/USER-GUIDE.md @@ -0,0 +1,5 @@ +# Tess User Guide + +All HTTP interaction calls require authenticated session credentials and `X-Correlation-Id`. Use `GET /api/interaction/{agentName}/sessions?provider=...` to list only visible runtime sessions, then enroll with `POST .../sessions/{sessionId}/enroll` body `{providerId,runtimeSessionId}`. Attach uses `{mode:"read"}`; send uses `{content,idempotencyKey}`. Stop requires `{approvalRef}` and fails with 403 without the exact durable approval. Recovery only requeues interrupted durable work. + +Memory is user-scoped: preferences support list/get/upsert/delete; insights support list/get/create/delete; search body is `{query,limit?,maxDistance?}`. Mos work is handed off with `POST /api/coord/mos/handoff`; observe and result use the returned handoff ID. From 8dd4e9d541419075b4a8313ccd2c34058b826307 Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Mon, 13 Jul 2026 18:14:24 +0000 Subject: [PATCH 039/152] =?UTF-8?q?docs(tess):=20ledger=20sync=20m4=20?= =?UTF-8?q?=E2=80=94=20M5-003=20done,=20#745/#746=20merged=20(#749)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/tess/TASKS.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/tess/TASKS.md b/docs/tess/TASKS.md index 8297d447..dd729ef9 100644 --- a/docs/tess/TASKS.md +++ b/docs/tess/TASKS.md @@ -35,11 +35,11 @@ | TESS-M4-001 | done | Implement Mos coordination handoff/observe/result contract with authority-boundary tests | #710 | coder0 | packages/coord, apps/gateway | feat/tess-mos-coordination | TESS-M3-V | 25K | **MERGED by Mos** → main squash **76325ca3** ("feat(tess): add Mos coordination boundary (#735)"), 2026-07-13 — merge = native-in-process transport ACCEPTED (contract transport-neutral). TESS-MOS-001. Mos-DISPATCHED 2026-07-13 to coder0 DESIGN-FIRST. UPDATE 2026-07-13: coder0 wrote docs/tess/MOS-COORDINATION.md; design checkpoint surfaced to Mos with the transport-adapter question (existing fleet/tmux Mos-authority channel vs dedicated native queue/HTTP). coder0 PROCEEDED (ahead of the Mos transport ruling) choosing a **native in-process adapter** and opened **PR #735** (base=main), head 7936e15d3ae137c91c88efdab4bb09b863a2195d. Impl: transport-NEUTRAL handoff/observe/result contract (MosCoordinationPort); deterministic native in-process InMemoryMosCoordinationPort; gateway derives actor/tenant/requester from trusted context/config; fail-closed for unconfigured-requester, self-delegation, target-drift, cross-tenant observe/result; NO public orchestrator verbs; **NO fleet/tmux transport, NO Mos-side consumer**; command-authorization byte-identical hash a9f829e7; no live creds; no hardcoded Tess identity. Local forced cold-cache typecheck/lint/format/test green (42 tasks); Codex security no findings. CI pipeline 1752 (pull_request, refs/pull/735/head, commit 7936e15d) = **SUCCESS**; head UNMOVED, mergeable=true. Independent non-author ROR **COMPLETE**: reviewer [W-jarvis:reviewer] VERIFIED APPROVE at exact head 7936e15d (Gitea comment 17032) — verified MosCoordinationPort=handoff/observe/result only, gateway-derived authority, fail-closed denial coverage, native in-process port (no tmux/Mos consumer), command-authz byte-identical a9f829e7, no live creds, no Tess literal. ⚠️ HEAD MOVED 2026-07-13 (ROR 17032 INVALIDATED): coder0 pushed one post-ROR commit → new head **5022911f84dd7ac30f40df31a53f6cd31a51728f** (commit "docs(tess): record M4 verification", parent 7936e15d). Orchestrator-verified sole delta = a single scratchpad doc docs/scratchpads/tess-m4-001-mos-coordination.md, ZERO code/test diff. New CI pipeline 1754 (pull_request, refs/pull/735/head, commit 5022911f) = **SUCCESS**; mergeable=true, head now 5022911f. Comment 17032 @ 7936e15d no longer at exact head → re-serialize + re-ROR REQUIRED. Fast delta RE-ROR **COMPLETE**: reviewer [W-jarvis:reviewer] VERIFIED APPROVE at exact head 5022911f (Gitea comment 17036) — confirmed 5022911f is direct child of prior-reviewed 7936e15d, sole two-dot delta = the 3-line scratchpad doc, no code/test diff, command-authz byte-identical a9f829e7, CI 1754 success. Head UNMOVED (5022911f), mergeable=true. **MERGEABLE at 5022911f — HARD STOP for Mos merge** (2026-07-13). MERGE = Mos ACCEPTING the native-in-process transport choice (contract stays transport-neutral; a fleet/tmux or native-queue/HTTP consumer can be added later without contract churn); if Mos wants a different FIRST adapter, hold merge + route rework to coder0. | | TESS-M4-002 | done | Implement transitional Hermes runtime/capability adapter | #710 | coder3 | packages/agent, apps/gateway | feat/tess-hermes-adapter | TESS-M3-V | 40K | **MERGED by Mos** → main squash **9e5b9188** ("feat(agent): add transitional Hermes runtime adapter (#734)"), 2026-07-13 — Mos merge = **option (a) ACCEPTED**; post-merge main CI 1753. TESS-HRM-001; no legacy schema in core contracts. Mos-DISPATCHED 2026-07-13 to coder3 DESIGN-FIRST (contract sketch + questions to Mos before build). Goes in-progress as PR opens; PR-open-STOP → serialize CI + independent non-author ROR at exact head → Mos merges. UPDATE 2026-07-13: coder3 ACTIVE — fresh worktree/branch feat/tess-hermes-adapter off origin/main; boundary sketch at docs/tess/hermes-runtime-adapter-design.md. DESIGN QUESTION surfaced to Mos (coder3 HELD at design-only until ruling): AC-TESS-05 wants approved capability across Kanban/skills/memory/tools/cron, but AgentRuntimeProvider models only SESSION capabilities. (a) adapter-local Hermes inventory/health marks those as explicit UNSUPPORTED, real ops deferred to their Mosaic-owned plugin contracts (coder3 default, preserves hard no-legacy-core-contract rule); vs (b) an existing Mosaic-owned non-runtime capability contract this adapter must implement. Orchestrator recommends (a) to Mos as the conservative boundary-preserving path. UPDATE 2026-07-13: coder3 PROCEEDED WITH (a) and opened **PR #734** (base=main), head 47b8a145ac43688499d275a54b434a52551c1abd — ahead of the Mos (a/b) ruling (design-hold was placed; coder3's original msg said it would proceed with (a) unless directed). Hermes adapter normalized behind packages/agent boundary; core types unchanged, unsupported ops fail-closed, tests prove no legacy field leak; focused tests/typecheck/lint pass; cold-cache turbo typecheck+build 46/46 0-cached. mergeable=true. CI pipeline 1751 (pull_request, refs/pull/734/head, commit 47b8a145) = **SUCCESS**; head UNMOVED, mergeable=true. Independent non-author ROR **COMPLETE**: reviewer [W-jarvis:reviewer] VERIFIED APPROVE at exact head 47b8a145 (Gitea comment 17027) — verified core AgentRuntimeProvider/runtime types UNCHANGED, adapter normalizes Hermes legacy shapes behind packages/agent boundary, unsupported runtime ops FAIL CLOSED via capability_unsupported BEFORE transport side effects (Kanban/skills/memory/tools/cron deferred under option (a)), no live creds, no hardcoded Tess agent identifier. Head UNMOVED, mergeable=true. **MERGEABLE — HARD STOP for Mos merge** (2026-07-13). ⚠️ MERGE GATED on Mos confirming option (a) is accepted (implementation == (a)); if Mos rules (b), #734 needs rework. HARD STOP for Mos merge. | | TESS-M4-003 | done | Implement memory/retrieval, state/inbox, runtime bootstrap, fleet diagnostics and GitOps plugin foundations | #710 | coder0 | packages/memory, packages/agent, packages/mosaic | feat/tess-operator-plugins | TESS-M3-V | 40K | **MERGED by Mos** → main squash **2363f155** ("feat(memory): add operator retrieval plugin (#736)"), 2026-07-13. ⚠️ SCOPE GAP surfaced by Mos: #736 delivered ONLY the leaf @mosaicstack/memory operator-retrieval slice; **TESS-PLG-001 (packages/mosaic catalog/registration) was silently DEFERRED by the author and never surfaced in MISSION-MANIFEST/VERIFICATION-MATRIX** → now tracked explicitly as its own row (see TESS-PLG-001 below) and folded into TESS-M4-W-001. State/inbox/runtime-bootstrap/fleet-diagnostics/GitOps foundations remain follow-on (not in #736). TESS-MEM-001, TESS-PLG-001. Mos HELD 1 beat (2026-07-13) for a well-conditioned lane. UPDATE 2026-07-13: coder0 TOOK OVER M4-003 (preserved coder4 WIP first, then rebased on latest main) and opened **PR #736** (base=main), head a1d63ca8ed07610828e9c51a213fffe9123b3de4. ⚠️ AUTHORIZATION FLAG to Mos: M4-003 was on Mos 1-beat HOLD; confirm this takeover/dispatch was Mos-authorized before merge. Scope delivered: LEAF @mosaicstack/memory operator retrieval plugin — config-injected adapter/namespace, runtime-validated server-derived tenant/owner/session scope, redaction-before-persist, provenance, bounded startup prioritization, wildcard adapter contract, namespace/different-instance tests. NO gateway/catalog/durable-inbox or command-authorization changes. Forced cold-cache typecheck/lint/format/test green (42 tasks); Woodpecker 1756 green; Codex code+security clean. CI pipeline 1756 (pull_request, refs/pull/736/head, commit a1d63ca8) = **SUCCESS**; mergeable=true. Independent non-author ROR COMPLETE: reviewer VERIFIED APPROVE at exact head a1d63ca8 (Gitea comment 17044) — leaf packages/memory/doc only (no gateway/catalog/durable-inbox), command-authz byte-identical a9f829e7, runtime scope validation before storage keying, config-injected adapter/namespace/instance metadata, redaction-before-persist + provenance, scoped wildcard adapter contract, namespace/different-instance tests, no live creds, no Tess literal. Head verified UNMOVED at a1d63ca8, base main, mergeable=true. **MERGEABLE — reported to Mos, HARD STOP for Mos merge.** NOTE: M4-003 scope here is the memory-plugin slice; state/inbox/runtime-bootstrap/fleet-diagnostics/GitOps foundations may be follow-on slices — confirm with Mos whether #736 fully closes M4-003 or is slice 1. | -| TESS-M4-W-001 | in-progress | M4-V remediation — gateway reachability SPINE: register runtime provider into AGENT_RUNTIME_PROVIDER_REGISTRY + wire Mos-coordination consumer + wire operator-memory-plugin consumer (make merged M4 deliverables reachable end-to-end); FOLDS IN minimal TESS-PLG-001 catalog/registration | #710 | coder0 | apps/gateway, packages/mosaic, packages/agent | feat/tess-m4w-reachability-spine | TESS-M4-003 | 30K | **Mos-DISPATCHED 2026-07-13** (remediation). Root cause: M4-V holistic review @ origin/main **2363f155** found the three merged M4 deliverables unit-green but NOT reachable end-to-end (no gateway wiring/consumers; providers never registered into the registry). **SPLIT into 3 sub-parts by coder0 (integrity-honest):** **(#2 Mos-coordination consumer) = DELIVERED as PR #737** (head f7b95f60, base main, "feat(gateway): expose Mos coordination boundary") — real AuthGuard Mos handoff/observe/result consumer, authenticated actor/tenant + required correlation derivation, service authority unchanged, gateway target test/typecheck/lint pass; **CI 1758 SUCCESS** (repo 47, commit==head); head verified UNMOVED at f7b95f60666a4abbbad9a08669637b19fa87c430, base main, mergeable=true. **Independent non-author ROR COMPLETE**: reviewer VERIFIED APPROVE at exact head f7b95f60 (Gitea comment 17054) — clean partial scope confirmed (AuthGuard Mos handoff/observe/result controller + module registration only; no runtime-provider registration / operator-memory consumer; actor/tenant from CurrentUser/scopeFromUser + required X-Correlation-Id before service invocation; MosCoordinationService unchanged; command-authz byte-identical a9f829e7; no live creds/no Tess literal). **#737 MERGEABLE — reported to Mos, HARD STOP for Mos merge (partial slice; land-vs-hold-for-full-spine is Mos's disposition call).** **(#1 runtime-provider registration) + (operator-memory consumer) = BLOCKED, NOT in #737.** coder0 could not truthfully complete them in this slice and REFUSED to fake with deny/unavailable stubs: gateway has **no concrete Hermes transport** and **no gateway-side tmux transport/authority wiring** to register a real provider; OperatorMemory consumer needs **session tenant/owner/session propagation currently ABSENT from AgentService's memory-tools boundary**. ⚠️ **DESIGN RULING ESCALATED TO MOS** (architecture, not resolvable from repo): how to wire provider-registration + memory-scope propagation when no concrete transport exists yet — new remediation slice / re-scope / accept #737 as incremental. TESS-PLG-001 (folded here) is part of the blocked #1 registration path. Command-authz byte-identical a9f829e7. **UPDATE 2026-07-13: #737 MERGED by Mos → main e2376190 ("feat(gateway): expose Mos coordination boundary (#737)").** **Operator-memory consumer sub-part UNBLOCKED + DELIVERED as PR #739** ("feat(memory): bind operator plugin to agent sessions", base main off e2376190, live head 31a59738089f0784428833fc5a0192c6c7c43261, mergeable=true) — coder0 resolved the session-scope-propagation blocker WITHOUT stubbing: gateway bootstrap configures plugin only with MOSAIC_OPERATOR_MEMORY_INSTANCE_ID + MOSAIC_OPERATOR_MEMORY_NAMESPACE, AgentService derives {tenantId,ownerId,sessionId} server-side and binds search/capture tools. Cold-cache root typecheck/lint/format/test (42 tasks) green; security review clean (Codex Optional-import finding = false positive, pre-existing, typecheck passed). **CI 1764 SUCCESS** (repo 47, commit==head); head verified UNMOVED at 31a59738089f0784428833fc5a0192c6c7c43261, base main, mergeable=true. **Independent non-author ROR ROUTED to reviewer at exact head 31a59738089f** (coder0 authored → reviewer is non-author) — asked reviewer to confirm scope is server-derived/non-client-controllable + no cross-tenant leak, and to independently verify the Codex Optional-import finding is a false positive. (Head reconcile CLOSED: coder0 confirmed 31a597380c55… was a transcription typo; live+frozen head is 31a59738089f0784428833fc5a0192c6c7c43261, working tree clean.) **UPDATE 2026-07-13: reviewer REQUEST CHANGES at head 31a59738089f (Gitea comment 17069) — NOT mergeable.** Production code CONFIRMED correct (plugin route wired, config env namespace/instance only, no live creds/no Tess literal, command-authz byte-identical a9f829e7; Codex Optional-import finding = false positive, import present). **Two TEST-COVERAGE blockers:** (1) tests BYPASS production scope derivation — they call createMemoryTools with a PREBUILT scope, never exercising the real createSession→buildToolsForSandbox server-side {tenantId,ownerId,sessionId} derivation; (2) NO divergent cross-tenant/cross-owner ISOLATION/DENIAL test proving a foreign actor cannot reuse a session / reach another operator-memory scope before the plugin call. Routed back to coder0 (integrity: harden real coverage, do NOT weaken assertion). Any new commit MOVES head → invalidates ROR → re-serialize CI + re-ROR at new head. **UPDATE 2026-07-13 (remediated): coder0 pushed hardened tests, NEW frozen head c26b3b775279575276c6ebe8955a9146bfc61413** — added createSession→buildToolsForSandbox PRODUCTION-PATH assertion of derived {tenantId,ownerId,sessionId}; added foreign-actor reuse DENIAL test asserting rejection occurs BEFORE scope/tool construction and before any plugin call. Cold-cache root typecheck/lint/format/test green (42 tasks). Old ROR at 31a59738089f + CI 1764 SUPERSEDED. Re-serialized: **CI 1765 SUCCESS** at c26b3b775279 (ref refs/pull/739/head, commit==head); head verified UNMOVED at c26b3b775279575276c6ebe8955a9146bfc61413, base main, mergeable=true. **Fresh independent non-author ROR RE-ROUTED to reviewer at exact head c26b3b775279** — asked reviewer to confirm both 17069 blockers genuinely closed (prod-path derivation exercised + cross-tenant denial before plugin call, assertion not weakened). **Independent non-author re-ROR COMPLETE**: reviewer VERIFIED APPROVE at exact head c26b3b775279 (Gitea comment 17074) — both 17069 blockers CONFIRMED closed: production createSession→buildToolsForSandbox scope-derivation test asserts {tenantId,ownerId,sessionId}; foreign-scope reuse rejects BEFORE tool construction and BEFORE plugin search/capture; production wiring reachable via MemoryModule env-configured plugin → AgentService injection → memory_search/memory_save_insight plugin path; command-authz byte-identical a9f829e7; Optional import present; no live creds/no Tess literal. Head verified UNMOVED at c26b3b775279, base main, mergeable=true. **#739 MERGEABLE — reported to Mos, HARD STOP for Mos merge.** This lands the operator-memory-consumer sub-part of W-001; REMAINING W-001 gap = only (#1) runtime-provider registration. **REMAINING blocked: (#1) runtime-provider registration into AGENT_RUNTIME_PROVIDER_REGISTRY** — still needs Mos A/B/C design ruling (no concrete Hermes transport yet). So after #739 lands, W-001 = Mos-consumer (#737 merged) + memory-consumer (#739) DONE; only the provider-registration linchpin remains. **UPDATE 2026-07-13: #739 MERGED by Mos → main squash 3378b857eb ("feat(memory): bind operator plugin to agent sessions (#739)"); post-merge main push pipeline 1766 running. W-001 spine now 2-of-3 sub-parts MERGED (Mos-consumer #737 e2376190 + operator-memory-consumer #739 3378b857); ONLY remaining W-001 gap = (#1) runtime-provider registration into AGENT_RUNTIME_PROVIDER_REGISTRY — still BLOCKED on Mos A/B/C design ruling (no concrete Hermes transport; TESS-PLG-001 folded here). coder0 idle/ready to build #1 on ruling.** **UPDATE 2026-07-13: (#1) DELIVERED as PR #740 "feat(gateway): register Hermes runtime provider"** (base main 3378b857, exact live head 127a69ea11ccc36516c78c2007cbe52fbf63ad30 verified unmoved, mergeable=true). coder0 resolved the A/B/C escalation by BUILDING a concrete transport (⚠️ design-direction flagged to Mos for confirm-before-merge): agent.module.ts explicit registry.register(new HermesRuntimeProvider(new GatewayHermesRuntimeTransport())); GatewayHermesRuntimeTransport = server-configured URL+service token, HTTPS-except-loopback, prefixed-URL preserving, forwards full scope incl channel; AuthGuard interaction transitional-capabilities route through RuntimeProviderService + live controller→service→registered-provider reachability test. Cold-cache typecheck/lint/format/test green (42 tasks); Codex path-prefix+channel-header findings remediated, security clean. **CI pipeline 1767 (repo 47, refs/pull/740/head, commit==head) = SUCCESS**; head verified UNMOVED at 127a69ea11ccc36516c78c2007cbe52fbf63ad30 post-CI, base main, mergeable=true. **Independent non-author ROR ROUTED to reviewer at exact head 127a69ea11cc** (coder0 authored → reviewer non-author) — asked reviewer to verify REAL E2E reachability (provider actually in registry + reachability test exercises registered provider, not mock), transport security (HTTPS-except-loopback, no token leak), command-authz byte-identical a9f829e7, no live creds/no Tess literal, Codex findings genuinely remediated. Awaiting reviewer disposition; any new commit moves head → re-serialize + re-ROR. **UPDATE 2026-07-13: reviewer REQUEST CHANGES at head 127a69ea11cc (Gitea comment 17088) — NOT mergeable.** CI 1767 green; command-authz byte-identical a9f829e7 CONFIRMED; production positives CONFIRMED (module factory registers Hermes provider; concrete transport HTTPS/prefix/channel headers; no live creds/no Tess literal). **Blocker (reachability-integrity):** the required live-guarded reachability proof is MISSING — test directly calls controller.transitionalCapabilities + manually constructs RuntimeProviderService/createGatewayRuntimeProviderRegistry; it does NOT exercise live GET /api/interaction/:agentName/transitional-capabilities, Nest DI through AgentModule, or the AuthGuard request path, so it can pass even if injected gateway registry/route wiring is broken (defeats the M4-V E2E-reachability point). Routed back to coder0 (integrity: add genuine Nest-e2e live-guarded reachability test through real DI+route+AuthGuard asserting reach of the registered Hermes provider; do NOT weaken/stub/mock around it; keep command-authz a9f829e7). Old ROR 17088 + CI 1767 will be SUPERSEDED by the remediation head → re-serialize CI + re-ROR at new head. **UPDATE 2026-07-13 (remediated): coder0 pushed the live-guarded reachability test, NEW frozen head a7e5d377e38b40275884a7df6ee35c55c5859e43** (live Gitea head independently verified, base main 3378b857, mergeable=true) — added apps/gateway/src/agent/hermes-runtime-reachability.e2e.test.ts: imports REAL AgentModule (preserves actual AGENT_RUNTIME_PROVIDER_REGISTRY factory + RuntimeProviderService), boots Fastify/Nest, unauth HTTP GET /api/interaction/Nova/transitional-capabilities?provider=runtime.hermes asserts 401 via ACTUAL AuthGuard, authed GET asserts 200 + all five Hermes entries, asserts DI registry resolves HermesRuntimeProvider; only unrelated peripheral modules harness-replaced to avoid DB/queue startup — NO route/guard/DI-registry/runtime-service/provider mock; existing controller unit test retained; command-authz untouched (byte-identical a9f829e7 remains). Cold-cache root typecheck/lint/format/test green 42/42 (gateway 53 files/606 tests). Old ROR 17088 + CI 1767 SUPERSEDED. Re-serializing: **CI 1768 (repo 47, refs/pull/740/head, commit==head a7e5d377) running** — poll in flight; on green → re-route non-author ROR at exact head a7e5d377. **UPDATE 2026-07-13: CI 1768 SETTLED SUCCESS** (repo 47, refs/pull/740/head, commit==head a7e5d377e38b40275884a7df6ee35c55c5859e43); head independently verified UNMOVED at a7e5d377 (live Gitea, NOT worker-reported), base main 3378b857, mergeable=true. **Independent non-author re-ROR COMPLETE: reviewer VERIFIED APPROVE at exact head a7e5d377e38b40275884a7df6ee35c55c5859e43 (Gitea comment 17094).** Prior 17088 blocker CONFIRMED closed — the new hermes-runtime-reachability.e2e.test.ts boots real Nest/Fastify AgentModule and exercises unauth 401 via the ACTUAL AuthGuard + authed HTTP GET /api/interaction/:agentName/transitional-capabilities through the live route→controller→RuntimeProviderService→registered Hermes provider, and asserts DI registry resolves HermesRuntimeProvider (no route/guard/DI/service/provider mock); transport concrete, HTTPS-except-loopback, path-prefix + channel header covered; command-authz byte-identical a9f829e7 CONFIRMED; no live creds/no Tess literal. Head verified UNMOVED at a7e5d377, base main, mergeable=true. **#740 MERGEABLE — the (#1) runtime-provider-registration linchpin — reported to Mos, HARD STOP for Mos merge.** ⚠️ Design-direction (concrete GatewayHermesRuntimeTransport built to resolve the A/B/C escalation) flagged to Mos for confirm-before-merge. On #740 merge, W-001 spine = 3-of-3 sub-parts landed (Mos-consumer #737 + memory-consumer #739 + provider-registration #740) → M4-V re-fire eligible. **UPDATE 2026-07-13: #740 MERGED by Mos → main b7b0f508 ("feat(gateway): register Hermes runtime provider (#740)"). W-001 SPINE NOW 3-OF-3 MERGED (Mos-consumer #737 e2376190 + operator-memory-consumer #739 3378b857 + provider-registration linchpin #740 b7b0f508) — the M4-V reachability remediation code work is COMPLETE. GATE: TESS-M4-V re-fire is now eligible and Mos-owned — this row stays in-progress until M4-V re-fires green (unit-green was never the bar; end-to-end reachability is). ⚠️ main push pipeline 1772 (for the #740 merge to main) FAILED at the `build` step — quality gates (typecheck/lint/format/test) all GREEN, failure is downstream at build/publish (recurring infra/ENOSPC pattern); flagged to Mos as Mos-owned, does not block docs-only PRs. Prior doc-sync ledger PR #741 MERGED → main f40e6ba3 ("docs(tess): sync M4 tracking to merged reality (M4 in-progress / gate-pending)"); ledger writes resumed on fresh branch docs/tess-ledger-sync-m2 off f40e6ba3. **UPDATE 2026-07-13: consolidated ledger-sync PR #743 (branch docs/tess-ledger-sync-m2, head aa3925510d06, reviewer VERIFIED APPROVE 17123, CI 1775 SUCCESS) MERGED by Mos → main c6e3cfbdf... ; ledger writes resumed on fresh branch docs/tess-ledger-sync-m3 off main 6345dbfc (post-#744 merge).** | +| TESS-M4-W-001 | in-progress | M4-V remediation — gateway reachability SPINE: register runtime provider into AGENT_RUNTIME_PROVIDER_REGISTRY + wire Mos-coordination consumer + wire operator-memory-plugin consumer (make merged M4 deliverables reachable end-to-end); FOLDS IN minimal TESS-PLG-001 catalog/registration | #710 | coder0 | apps/gateway, packages/mosaic, packages/agent | feat/tess-m4w-reachability-spine | TESS-M4-003 | 30K | **Mos-DISPATCHED 2026-07-13** (remediation). Root cause: M4-V holistic review @ origin/main **2363f155** found the three merged M4 deliverables unit-green but NOT reachable end-to-end (no gateway wiring/consumers; providers never registered into the registry). **SPLIT into 3 sub-parts by coder0 (integrity-honest):** **(#2 Mos-coordination consumer) = DELIVERED as PR #737** (head f7b95f60, base main, "feat(gateway): expose Mos coordination boundary") — real AuthGuard Mos handoff/observe/result consumer, authenticated actor/tenant + required correlation derivation, service authority unchanged, gateway target test/typecheck/lint pass; **CI 1758 SUCCESS** (repo 47, commit==head); head verified UNMOVED at f7b95f60666a4abbbad9a08669637b19fa87c430, base main, mergeable=true. **Independent non-author ROR COMPLETE**: reviewer VERIFIED APPROVE at exact head f7b95f60 (Gitea comment 17054) — clean partial scope confirmed (AuthGuard Mos handoff/observe/result controller + module registration only; no runtime-provider registration / operator-memory consumer; actor/tenant from CurrentUser/scopeFromUser + required X-Correlation-Id before service invocation; MosCoordinationService unchanged; command-authz byte-identical a9f829e7; no live creds/no Tess literal). **#737 MERGEABLE — reported to Mos, HARD STOP for Mos merge (partial slice; land-vs-hold-for-full-spine is Mos's disposition call).** **(#1 runtime-provider registration) + (operator-memory consumer) = BLOCKED, NOT in #737.** coder0 could not truthfully complete them in this slice and REFUSED to fake with deny/unavailable stubs: gateway has **no concrete Hermes transport** and **no gateway-side tmux transport/authority wiring** to register a real provider; OperatorMemory consumer needs **session tenant/owner/session propagation currently ABSENT from AgentService's memory-tools boundary**. ⚠️ **DESIGN RULING ESCALATED TO MOS** (architecture, not resolvable from repo): how to wire provider-registration + memory-scope propagation when no concrete transport exists yet — new remediation slice / re-scope / accept #737 as incremental. TESS-PLG-001 (folded here) is part of the blocked #1 registration path. Command-authz byte-identical a9f829e7. **UPDATE 2026-07-13: #737 MERGED by Mos → main e2376190 ("feat(gateway): expose Mos coordination boundary (#737)").** **Operator-memory consumer sub-part UNBLOCKED + DELIVERED as PR #739** ("feat(memory): bind operator plugin to agent sessions", base main off e2376190, live head 31a59738089f0784428833fc5a0192c6c7c43261, mergeable=true) — coder0 resolved the session-scope-propagation blocker WITHOUT stubbing: gateway bootstrap configures plugin only with MOSAIC_OPERATOR_MEMORY_INSTANCE_ID + MOSAIC_OPERATOR_MEMORY_NAMESPACE, AgentService derives {tenantId,ownerId,sessionId} server-side and binds search/capture tools. Cold-cache root typecheck/lint/format/test (42 tasks) green; security review clean (Codex Optional-import finding = false positive, pre-existing, typecheck passed). **CI 1764 SUCCESS** (repo 47, commit==head); head verified UNMOVED at 31a59738089f0784428833fc5a0192c6c7c43261, base main, mergeable=true. **Independent non-author ROR ROUTED to reviewer at exact head 31a59738089f** (coder0 authored → reviewer is non-author) — asked reviewer to confirm scope is server-derived/non-client-controllable + no cross-tenant leak, and to independently verify the Codex Optional-import finding is a false positive. (Head reconcile CLOSED: coder0 confirmed 31a597380c55… was a transcription typo; live+frozen head is 31a59738089f0784428833fc5a0192c6c7c43261, working tree clean.) **UPDATE 2026-07-13: reviewer REQUEST CHANGES at head 31a59738089f (Gitea comment 17069) — NOT mergeable.** Production code CONFIRMED correct (plugin route wired, config env namespace/instance only, no live creds/no Tess literal, command-authz byte-identical a9f829e7; Codex Optional-import finding = false positive, import present). **Two TEST-COVERAGE blockers:** (1) tests BYPASS production scope derivation — they call createMemoryTools with a PREBUILT scope, never exercising the real createSession→buildToolsForSandbox server-side {tenantId,ownerId,sessionId} derivation; (2) NO divergent cross-tenant/cross-owner ISOLATION/DENIAL test proving a foreign actor cannot reuse a session / reach another operator-memory scope before the plugin call. Routed back to coder0 (integrity: harden real coverage, do NOT weaken assertion). Any new commit MOVES head → invalidates ROR → re-serialize CI + re-ROR at new head. **UPDATE 2026-07-13 (remediated): coder0 pushed hardened tests, NEW frozen head c26b3b775279575276c6ebe8955a9146bfc61413** — added createSession→buildToolsForSandbox PRODUCTION-PATH assertion of derived {tenantId,ownerId,sessionId}; added foreign-actor reuse DENIAL test asserting rejection occurs BEFORE scope/tool construction and before any plugin call. Cold-cache root typecheck/lint/format/test green (42 tasks). Old ROR at 31a59738089f + CI 1764 SUPERSEDED. Re-serialized: **CI 1765 SUCCESS** at c26b3b775279 (ref refs/pull/739/head, commit==head); head verified UNMOVED at c26b3b775279575276c6ebe8955a9146bfc61413, base main, mergeable=true. **Fresh independent non-author ROR RE-ROUTED to reviewer at exact head c26b3b775279** — asked reviewer to confirm both 17069 blockers genuinely closed (prod-path derivation exercised + cross-tenant denial before plugin call, assertion not weakened). **Independent non-author re-ROR COMPLETE**: reviewer VERIFIED APPROVE at exact head c26b3b775279 (Gitea comment 17074) — both 17069 blockers CONFIRMED closed: production createSession→buildToolsForSandbox scope-derivation test asserts {tenantId,ownerId,sessionId}; foreign-scope reuse rejects BEFORE tool construction and BEFORE plugin search/capture; production wiring reachable via MemoryModule env-configured plugin → AgentService injection → memory_search/memory_save_insight plugin path; command-authz byte-identical a9f829e7; Optional import present; no live creds/no Tess literal. Head verified UNMOVED at c26b3b775279, base main, mergeable=true. **#739 MERGEABLE — reported to Mos, HARD STOP for Mos merge.** This lands the operator-memory-consumer sub-part of W-001; REMAINING W-001 gap = only (#1) runtime-provider registration. **REMAINING blocked: (#1) runtime-provider registration into AGENT_RUNTIME_PROVIDER_REGISTRY** — still needs Mos A/B/C design ruling (no concrete Hermes transport yet). So after #739 lands, W-001 = Mos-consumer (#737 merged) + memory-consumer (#739) DONE; only the provider-registration linchpin remains. **UPDATE 2026-07-13: #739 MERGED by Mos → main squash 3378b857eb ("feat(memory): bind operator plugin to agent sessions (#739)"); post-merge main push pipeline 1766 running. W-001 spine now 2-of-3 sub-parts MERGED (Mos-consumer #737 e2376190 + operator-memory-consumer #739 3378b857); ONLY remaining W-001 gap = (#1) runtime-provider registration into AGENT_RUNTIME_PROVIDER_REGISTRY — still BLOCKED on Mos A/B/C design ruling (no concrete Hermes transport; TESS-PLG-001 folded here). coder0 idle/ready to build #1 on ruling.** **UPDATE 2026-07-13: (#1) DELIVERED as PR #740 "feat(gateway): register Hermes runtime provider"** (base main 3378b857, exact live head 127a69ea11ccc36516c78c2007cbe52fbf63ad30 verified unmoved, mergeable=true). coder0 resolved the A/B/C escalation by BUILDING a concrete transport (⚠️ design-direction flagged to Mos for confirm-before-merge): agent.module.ts explicit registry.register(new HermesRuntimeProvider(new GatewayHermesRuntimeTransport())); GatewayHermesRuntimeTransport = server-configured URL+service token, HTTPS-except-loopback, prefixed-URL preserving, forwards full scope incl channel; AuthGuard interaction transitional-capabilities route through RuntimeProviderService + live controller→service→registered-provider reachability test. Cold-cache typecheck/lint/format/test green (42 tasks); Codex path-prefix+channel-header findings remediated, security clean. **CI pipeline 1767 (repo 47, refs/pull/740/head, commit==head) = SUCCESS**; head verified UNMOVED at 127a69ea11ccc36516c78c2007cbe52fbf63ad30 post-CI, base main, mergeable=true. **Independent non-author ROR ROUTED to reviewer at exact head 127a69ea11cc** (coder0 authored → reviewer non-author) — asked reviewer to verify REAL E2E reachability (provider actually in registry + reachability test exercises registered provider, not mock), transport security (HTTPS-except-loopback, no token leak), command-authz byte-identical a9f829e7, no live creds/no Tess literal, Codex findings genuinely remediated. Awaiting reviewer disposition; any new commit moves head → re-serialize + re-ROR. **UPDATE 2026-07-13: reviewer REQUEST CHANGES at head 127a69ea11cc (Gitea comment 17088) — NOT mergeable.** CI 1767 green; command-authz byte-identical a9f829e7 CONFIRMED; production positives CONFIRMED (module factory registers Hermes provider; concrete transport HTTPS/prefix/channel headers; no live creds/no Tess literal). **Blocker (reachability-integrity):** the required live-guarded reachability proof is MISSING — test directly calls controller.transitionalCapabilities + manually constructs RuntimeProviderService/createGatewayRuntimeProviderRegistry; it does NOT exercise live GET /api/interaction/:agentName/transitional-capabilities, Nest DI through AgentModule, or the AuthGuard request path, so it can pass even if injected gateway registry/route wiring is broken (defeats the M4-V E2E-reachability point). Routed back to coder0 (integrity: add genuine Nest-e2e live-guarded reachability test through real DI+route+AuthGuard asserting reach of the registered Hermes provider; do NOT weaken/stub/mock around it; keep command-authz a9f829e7). Old ROR 17088 + CI 1767 will be SUPERSEDED by the remediation head → re-serialize CI + re-ROR at new head. **UPDATE 2026-07-13 (remediated): coder0 pushed the live-guarded reachability test, NEW frozen head a7e5d377e38b40275884a7df6ee35c55c5859e43** (live Gitea head independently verified, base main 3378b857, mergeable=true) — added apps/gateway/src/agent/hermes-runtime-reachability.e2e.test.ts: imports REAL AgentModule (preserves actual AGENT_RUNTIME_PROVIDER_REGISTRY factory + RuntimeProviderService), boots Fastify/Nest, unauth HTTP GET /api/interaction/Nova/transitional-capabilities?provider=runtime.hermes asserts 401 via ACTUAL AuthGuard, authed GET asserts 200 + all five Hermes entries, asserts DI registry resolves HermesRuntimeProvider; only unrelated peripheral modules harness-replaced to avoid DB/queue startup — NO route/guard/DI-registry/runtime-service/provider mock; existing controller unit test retained; command-authz untouched (byte-identical a9f829e7 remains). Cold-cache root typecheck/lint/format/test green 42/42 (gateway 53 files/606 tests). Old ROR 17088 + CI 1767 SUPERSEDED. Re-serializing: **CI 1768 (repo 47, refs/pull/740/head, commit==head a7e5d377) running** — poll in flight; on green → re-route non-author ROR at exact head a7e5d377. **UPDATE 2026-07-13: CI 1768 SETTLED SUCCESS** (repo 47, refs/pull/740/head, commit==head a7e5d377e38b40275884a7df6ee35c55c5859e43); head independently verified UNMOVED at a7e5d377 (live Gitea, NOT worker-reported), base main 3378b857, mergeable=true. **Independent non-author re-ROR COMPLETE: reviewer VERIFIED APPROVE at exact head a7e5d377e38b40275884a7df6ee35c55c5859e43 (Gitea comment 17094).** Prior 17088 blocker CONFIRMED closed — the new hermes-runtime-reachability.e2e.test.ts boots real Nest/Fastify AgentModule and exercises unauth 401 via the ACTUAL AuthGuard + authed HTTP GET /api/interaction/:agentName/transitional-capabilities through the live route→controller→RuntimeProviderService→registered Hermes provider, and asserts DI registry resolves HermesRuntimeProvider (no route/guard/DI/service/provider mock); transport concrete, HTTPS-except-loopback, path-prefix + channel header covered; command-authz byte-identical a9f829e7 CONFIRMED; no live creds/no Tess literal. Head verified UNMOVED at a7e5d377, base main, mergeable=true. **#740 MERGEABLE — the (#1) runtime-provider-registration linchpin — reported to Mos, HARD STOP for Mos merge.** ⚠️ Design-direction (concrete GatewayHermesRuntimeTransport built to resolve the A/B/C escalation) flagged to Mos for confirm-before-merge. On #740 merge, W-001 spine = 3-of-3 sub-parts landed (Mos-consumer #737 + memory-consumer #739 + provider-registration #740) → M4-V re-fire eligible. **UPDATE 2026-07-13: #740 MERGED by Mos → main b7b0f508 ("feat(gateway): register Hermes runtime provider (#740)"). W-001 SPINE NOW 3-OF-3 MERGED (Mos-consumer #737 e2376190 + operator-memory-consumer #739 3378b857 + provider-registration linchpin #740 b7b0f508) — the M4-V reachability remediation code work is COMPLETE. GATE: TESS-M4-V re-fire is now eligible and Mos-owned — this row stays in-progress until M4-V re-fires green (unit-green was never the bar; end-to-end reachability is). ⚠️ main push pipeline 1772 (for the #740 merge to main) FAILED at the `build` step — quality gates (typecheck/lint/format/test) all GREEN, failure is downstream at build/publish (recurring infra/ENOSPC pattern); flagged to Mos as Mos-owned, does not block docs-only PRs. Prior doc-sync ledger PR #741 MERGED → main f40e6ba3 ("docs(tess): sync M4 tracking to merged reality (M4 in-progress / gate-pending)"); ledger writes resumed on fresh branch docs/tess-ledger-sync-m2 off f40e6ba3. **UPDATE 2026-07-13: consolidated ledger-sync PR #743 (branch docs/tess-ledger-sync-m2, head aa3925510d06, reviewer VERIFIED APPROVE 17123, CI 1775 SUCCESS) MERGED by Mos → main c6e3cfbdf... ; ledger writes resumed on fresh branch docs/tess-ledger-sync-m3 off main 6345dbfc (post-#744 merge). **UPDATE 2026-07-13: ledger-sync m3 PR #745 MERGED by Mos → main e72388b2 ("docs(tess): ledger sync m3 — M5-001 + M5-002 done (#745)"); mission issue #706 preserved OPEN (Refs #706 non-closing). Ledger writes resumed on fresh branch docs/tess-ledger-sync-m4 off main bc8016c8 (post-#746 merge) recording M5-003 done.** | | TESS-M4-W-002 | done | M4-V remediation — Hermes capability MATRIX (AC-TESS-05): approved-capability coverage across Kanban/skills/memory/tools/cron for the Hermes adapter | #710 | coder3 | packages/agent, apps/gateway | feat/tess-m4w-hermes-matrix | TESS-M4-002 | 22K | **Mos-DISPATCHED 2026-07-13** (remediation, in flight). Extends the M4-002 option-(a) adapter (merged 9e5b9188) with the AC-TESS-05 capability matrix. UPDATE 2026-07-13: coder3 STARTED — fresh worktree off origin/main 2363f155, TDD failing-matrix-tests-first. Orchestrator TRACKS; on PR-open → serialize CI + independent non-author ROR at EXACT head → HARD STOP for Mos merge. No legacy schema into core contracts; command-authz byte-identical a9f829e7. UPDATE 2026-07-13: **PR #738 OPENED** (base main, head 582c6db2088223fd8dd2105005391b5034c992ac, "feat(agent): add Hermes transitional capability matrix") — normalized exhaustive five-entry matrix (kanban/skills/memory/tools/cron), all explicit unsupported, fails CLOSED before transport; tests 4/4, security review clean, cold-cache 46 successful/0 cached, normalized optional TransitionalCapabilityInventoryProvider (no legacy schema). **CI 1759 SUCCESS** (repo 47, commit==head); head verified UNMOVED at 582c6db2, base main, mergeable=true. **Independent non-author ROR COMPLETE**: reviewer VERIFIED APPROVE at exact head 582c6db2 (Gitea comment 17057) — normalized exhaustive five-entry transitional matrix (kanban/skills/memory/tools/cron) all unsupported; assertTransitionalCapability fails CLOSED with capability_unsupported before Hermes transport; only normalized optional TransitionalCapabilityInventoryProvider added to core (no legacy schema leak); command-authz byte-identical a9f829e7; no live creds/no Tess literal. Head verified UNMOVED at 582c6db2, base main, mergeable=true. **#738 MERGEABLE — reported to Mos, HARD STOP for Mos merge.** This is the COMPLETE matrix deliverable (unlike #737's partial spine). **UPDATE 2026-07-13: #738 MERGED by Mos → merge_commit cca6aaf9. TESS-M4-W-002 DONE.** | | TESS-PLG-001 | in-progress | packages/mosaic plugin catalog / registration (operator plugins registered + discoverable) — was silently deferred by M4-003 author; now VISIBLE | #710 | coder0 | packages/mosaic | feat/tess-m4w-reachability-spine | TESS-M4-003 | (folded) | ⚠️ Surfaced by Mos 2026-07-13 as an invisible gap: M4-003/#736 delivered the memory plugin but NOT its catalog/registration in packages/mosaic; never appeared in MISSION-MANIFEST/VERIFICATION-MATRIX. PLACEMENT DECISION (orchestrator, per Mos "your call"): **FOLD minimal registration into TESS-M4-W-001** (coder0's reachability spine already does registry wiring — same author closes their own gap, keeps it in one lane). This row exists for LEDGER VISIBILITY so the gap is tracked, not re-hidden. If M4-W-001 scope grows too large, split back out as a standalone lane. Manifest/matrix update to follow. | | TESS-M4-V | failed | Cross-provider capability, privacy, authority and failure-path qualification | #710 | sonnet | apps/gateway/src/__tests__/integration, packages/agent | review/tess-m4 | TESS-M4-001,TESS-M4-002,TESS-M4-003,TESS-M4-W-001,TESS-M4-W-002 | 22K | **FAILED 2026-07-13** — independent holistic review @ origin/main **2363f155**: all three M4 deliverables (#734/#735/#736) unit-green but **NOT reachable end-to-end** (providers never registered into AGENT_RUNTIME_PROVIDER_REGISTRY; Mos-coordination + operator-memory consumers unwired; TESS-PLG-001 catalog/registration silently deferred). Remediation TESS-M4-W (W-001 spine coder0 + W-002 Hermes matrix coder3) now in flight. **Mos re-fires M4-V ONLY after the spine + matrix land.** Gate M5 (M5 stays behind M4-V; live-deploy = Jason-reserved). | | TESS-M5-001 | done | Implement Matrix/native runtime provider behind common contracts and parity suite | #711 | coder0 | packages/mosaic, packages/agent | feat/tess-matrix-provider | TESS-M4-V | 30K | TESS-TRN-001. **Mos-DISPATCHED to coder0 2026-07-13** (advancing to M5, same M4-V dependency-reconciliation caveat as M5-002). Design sketch (branch feat/tess-matrix-provider off origin/main b7b0f508): MatrixNativeRuntimeProvider in packages/agent over a narrow MatrixRuntimeTransport contract + MatrixNativeRuntimeTransport in packages/mosaic (Mosaic adapter owns Matrix HTTP/auth/identity/room mechanics; agent provider owns common provider behavior only). Parity suite runs the SAME provider-contract scenarios against factory fixtures for existing tmux/fleet AND Matrix/native; Matrix native declares only operations concretely wired (no fake reachability, no Matrix default promotion); no gateway/Discord changes; command-authz to remain byte-identical a9f829e7. **In TDD — no PR yet.** On PR-open: freeze head → serialize CI (one-at-a-time on repo 47) → independent non-author ROR at exact head → HARD STOP for Mos merge. **UPDATE 2026-07-13: DELIVERED as PR #744 (7 files packages/agent + packages/mosaic only) frozen head b4fcf139a73678e9e59c8f6b63c108c095a87b3a; CI pipeline 1776 SUCCESS (repo 47, refs/pull/744/head, commit==head); independent non-author ROR COMPLETE — reviewer VERIFIED APPROVE at exact head b4fcf139a736 (Gitea comment 17126): Matrix stays NON-DEFAULT (no gateway/Discord/registry wiring), default-deny read/write authority, immutable read handles, control-attach rejection, parity suite runs same scenarios against tmux/fleet AND Matrix/native, concrete Matrix CS-API HTTPS transport with whoami/remote-identity-filter/deterministic txns, no live creds, command-authz byte-identical a9f829e7. #744 MERGED by Mos → main 6345dbfcf262. Deliverable code LANDED. GATE: TESS-M4-V/M5-V verification-gate reconciliation remains Mos-owned/pending (this row was dispatched ahead of M4-V passing).** | | TESS-M5-002 | done | Complete migration inventory, cutover, rollback, retention and deprecation evidence | #711 | coder3 | docs/tess | feat/tess-migration-docs | TESS-M4-V | 18K | TESS-MIG-001. **Mos-DISPATCHED to coder3 2026-07-13** ("M4 complete; advancing to M5") — dispatched AHEAD of TESS-M4-V passing; the M4-V-status-vs-#710-CLOSED dependency reconciliation is pending Mos ruling (tracked, not orchestrator-decided). **DELIVERED as PR #742** — 4 new files docs/tess/M5-MIGRATION-{INVENTORY,CUTOVER,ROLLBACK,RETENTION-DEPRECATION}.md, base main b7b0f508, frozen head b5e9d0e528a50aae2e916cc7202bacc2e8db67dd. Docs-only; tracking-control trio (MISSION-MANIFEST/TASKS/VERIFICATION-MATRIX) UNTOUCHED; command-authz byte-identical a9f829e7; no live creds. **CI pipeline 1773 SUCCESS** (repo 47, refs/pull/742/head, commit==head). **Independent non-author ROR COMPLETE: reviewer VERIFIED APPROVE at exact head b5e9d0e528a50aae2e916cc7202bacc2e8db67dd (Gitea comment 17108)** — evidence claims verified to trace to landed Hermes adapter / capability matrix, gateway registry/reachability, operator-memory scope path, Mos coordination boundary; docs do NOT over-claim transcript/profile import, schema migration, unsupported-capability enablement, production cutover, or deprecation completion. Head independently verified UNMOVED at b5e9d0e528a5 post-ROR (live Gitea), base main, mergeable=true. **#742 MERGED by Mos → main 5789711e. Deliverable docs LANDED. GATE: TESS-M4-V/M5-V verification-gate reconciliation remains Mos-owned/pending (this row was dispatched ahead of M4-V passing).** | -| TESS-M5-003 | not-started | Complete OpenAPI, user/admin/developer/plugin/operations docs and checklist | #711 | codex | docs | feat/tess-docs | TESS-M5-001,TESS-M5-002 | 22K | Documentation hard gate | +| TESS-M5-003 | done | Complete OpenAPI, user/admin/developer/plugin/operations docs and checklist | #711 | codex | docs | feat/tess-docs | TESS-M5-001,TESS-M5-002 | 22K | Documentation hard gate. **DELIVERED as PR #746** (branch feat/tess-docs, base main, 7 docs-only files: docs/openapi-tess.yaml + docs/tess/{ADMIN,DEVELOPER,OPERATIONS,PLUGIN,USER}-GUIDE.md + M5-003-DOCUMENTATION-CHECKLIST.md). 4-round revise-loop (heads 470eb911→c9f69300→7aea94e2→25b9d642) converged: OpenAPI covers interaction routes + SSE /sessions/{sessionId}/stream + Mos coord (/api/coord/mos/handoff,/observe,/result) + memory preferences/insights/search; request-body schemas aligned to real DTOs (Send requires content+idempotencyKey, Stop requires approvalRef, Insight requires only content, MosHandoff body requires idempotencyKey+summary); checklist accurate. **CI pipeline 1786 SUCCESS** (repo 47, refs/pull/746/head, commit==head 25b9d642). **Independent non-author ROR COMPLETE: reviewer VERIFIED APPROVE at exact head 25b9d642b939014b3efd61826e7524cafc6ffc2e (Gitea comment 17170)** — docs-only, tracking-trio untouched, command-authz byte-identical a9f829e7, no false coverage claims. Head verified UNMOVED at 25b9d642 (live Gitea, not worker-reported), base main, mergeable=true. **#746 MERGED by Mos → main bc8016c8314ec3a4b6ebc2fec5d9f276fca3327a. Documentation gate LANDED.** GATE: TESS-M4-V/M5-V verification-gate reconciliation remains Mos-owned/pending. | | TESS-M5-V | not-started | Full baseline, contract, integration, Discord/CLI E2E, security review, recovery drill and rollback qualification | #711 | sonnet | apps/gateway, packages/agent, plugins/discord, packages/mosaic | review/tess-final | TESS-M5-003 | 35K | Maps AC-TESS-01..11 to evidence | From 405984af5ac19bdd51e89e4415e6b1b88a1a08ce Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Mon, 13 Jul 2026 18:59:27 +0000 Subject: [PATCH 040/152] De-hardcode orchestrator and interaction agent names (#748) --- .../tess-cross-surface.integration.test.ts | 6 +- apps/gateway/src/agent/agent.module.ts | 10 +- ...-session.dto.ts => durable-session.dto.ts} | 2 +- ....ts => durable-session.repository.test.ts} | 34 ++++--- ...itory.ts => durable-session.repository.ts} | 19 ++-- ....service.ts => durable-session.service.ts} | 28 +++--- .../hermes-runtime-reachability.e2e.test.ts | 8 +- .../src/agent/interaction.controller.ts | 4 +- apps/gateway/src/chat/chat.gateway.ts | 6 +- apps/gateway/src/coord/coord.module.ts | 25 ++--- ...teraction-coordination.controller.test.ts} | 19 +++- ...=> interaction-coordination.controller.ts} | 30 +++--- ...dto.ts => interaction-coordination.dto.ts} | 12 +-- ...teraction-coordination.routing.e2e.test.ts | 88 ++++++++++++++++++ ... interaction-coordination.service.test.ts} | 62 ++++++------- ...ts => interaction-coordination.service.ts} | 91 ++++++++++--------- docs/scratchpads/747-wsa-dehardcode.md | 43 +++++++++ docs/tess/MOS-COORDINATION.md | 4 + ...ession.test.ts => durable-session.test.ts} | 2 +- ...-durable-session.ts => durable-session.ts} | 20 ++-- packages/agent/src/index.ts | 2 +- .../src/matrix-native-runtime-provider.ts | 2 +- .../src/tmux-fleet-runtime-provider.test.ts | 21 ++++- .../agent/src/tmux-fleet-runtime-provider.ts | 12 +-- ...st.ts => interaction-coordination.test.ts} | 50 +++++----- ...n-memory-interaction-coordination-port.ts} | 58 ++++++------ packages/coord/src/index.ts | 27 +++--- ...ination.ts => interaction-coordination.ts} | 80 ++++++++-------- .../fleet/roles/operator-interaction.md | 4 +- .../mosaic/framework/fleet/roster.schema.json | 8 ++ packages/mosaic/src/commands/fleet.spec.ts | 86 +++++++++++++++++- packages/mosaic/src/commands/fleet.ts | 18 +++- packages/mosaic/src/commands/launch.ts | 2 +- 33 files changed, 579 insertions(+), 304 deletions(-) rename apps/gateway/src/agent/{tess-durable-session.dto.ts => durable-session.dto.ts} (87%) rename apps/gateway/src/agent/{tess-durable-session.repository.test.ts => durable-session.repository.test.ts} (91%) rename apps/gateway/src/agent/{tess-durable-session.repository.ts => durable-session.repository.ts} (95%) rename apps/gateway/src/agent/{tess-durable-session.service.ts => durable-session.service.ts} (78%) rename apps/gateway/src/coord/{mos-coordination.controller.test.ts => interaction-coordination.controller.test.ts} (57%) rename apps/gateway/src/coord/{mos-coordination.controller.ts => interaction-coordination.controller.ts} (68%) rename apps/gateway/src/coord/{mos-coordination.dto.ts => interaction-coordination.dto.ts} (59%) create mode 100644 apps/gateway/src/coord/interaction-coordination.routing.e2e.test.ts rename apps/gateway/src/coord/{mos-coordination.service.test.ts => interaction-coordination.service.test.ts} (79%) rename apps/gateway/src/coord/{mos-coordination.service.ts => interaction-coordination.service.ts} (75%) create mode 100644 docs/scratchpads/747-wsa-dehardcode.md rename packages/agent/src/{tess-durable-session.test.ts => durable-session.test.ts} (99%) rename packages/agent/src/{tess-durable-session.ts => durable-session.ts} (95%) rename packages/coord/src/__tests__/{mos-coordination.test.ts => interaction-coordination.test.ts} (77%) rename packages/coord/src/{in-memory-mos-coordination-port.ts => in-memory-interaction-coordination-port.ts} (77%) rename packages/coord/src/{mos-coordination.ts => interaction-coordination.ts} (66%) diff --git a/apps/gateway/src/__tests__/integration/tess-cross-surface.integration.test.ts b/apps/gateway/src/__tests__/integration/tess-cross-surface.integration.test.ts index 7bb11188..fc8b6668 100644 --- a/apps/gateway/src/__tests__/integration/tess-cross-surface.integration.test.ts +++ b/apps/gateway/src/__tests__/integration/tess-cross-surface.integration.test.ts @@ -7,7 +7,7 @@ import { } from '@mosaicstack/discord-plugin'; import { InteractionController } from '../../agent/interaction.controller.js'; import { RuntimeProviderService } from '../../agent/runtime-provider-registry.service.js'; -import { TessDurableSessionService } from '../../agent/tess-durable-session.service.js'; +import { DurableSessionService } from '../../agent/durable-session.service.js'; import { ChatGateway } from '../../chat/chat.gateway.js'; import { CommandAuthorizationService } from '../../commands/command-authorization.service.js'; @@ -51,7 +51,7 @@ function authorization(): CommandAuthorizationService { ); } -describe('Tess Discord/CLI durable-session integration', () => { +describe('interaction Discord/CLI durable-session integration', () => { afterEach(() => { for (const key of envKeys) { const value = priorEnv.get(key); @@ -80,7 +80,7 @@ describe('Tess Discord/CLI durable-session integration', () => { }, ]); - const durable = new TessDurableSessionService( + const durable = new DurableSessionService( new InMemoryDurableSessionStore() as never, {} as never, ); diff --git a/apps/gateway/src/agent/agent.module.ts b/apps/gateway/src/agent/agent.module.ts index 0c8b1b6a..0ec2cefc 100644 --- a/apps/gateway/src/agent/agent.module.ts +++ b/apps/gateway/src/agent/agent.module.ts @@ -11,8 +11,8 @@ import { SessionsController } from './sessions.controller.js'; import { AgentConfigsController } from './agent-configs.controller.js'; import { InteractionController } from './interaction.controller.js'; import { RoutingController } from './routing/routing.controller.js'; -import { TessDurableSessionRepository } from './tess-durable-session.repository.js'; -import { TessDurableSessionService } from './tess-durable-session.service.js'; +import { DurableSessionRepository } from './durable-session.repository.js'; +import { DurableSessionService } from './durable-session.service.js'; import { CoordModule } from '../coord/coord.module.js'; import { McpClientModule } from '../mcp-client/mcp-client.module.js'; import { SkillsModule } from '../skills/skills.module.js'; @@ -44,8 +44,8 @@ export function createGatewayRuntimeProviderRegistry(): AgentRuntimeProviderRegi RoutingService, RoutingEngineService, SkillLoaderService, - TessDurableSessionRepository, - TessDurableSessionService, + DurableSessionRepository, + DurableSessionService, { provide: AGENT_RUNTIME_PROVIDER_REGISTRY, useFactory: createGatewayRuntimeProviderRegistry, @@ -76,7 +76,7 @@ export function createGatewayRuntimeProviderRegistry(): AgentRuntimeProviderRegi RoutingService, RoutingEngineService, SkillLoaderService, - TessDurableSessionService, + DurableSessionService, RuntimeProviderService, AGENT_RUNTIME_PROVIDER_REGISTRY, ], diff --git a/apps/gateway/src/agent/tess-durable-session.dto.ts b/apps/gateway/src/agent/durable-session.dto.ts similarity index 87% rename from apps/gateway/src/agent/tess-durable-session.dto.ts rename to apps/gateway/src/agent/durable-session.dto.ts index 92487979..5280505e 100644 --- a/apps/gateway/src/agent/tess-durable-session.dto.ts +++ b/apps/gateway/src/agent/durable-session.dto.ts @@ -1,7 +1,7 @@ import type { RuntimeProviderRequestContext } from './runtime-provider-registry.service.js'; /** Server-side request for a replay-safe provider message. */ -export interface TessProviderOutboxDto { +export interface ProviderOutboxDto { sessionId: string; idempotencyKey: string; correlationId: string; diff --git a/apps/gateway/src/agent/tess-durable-session.repository.test.ts b/apps/gateway/src/agent/durable-session.repository.test.ts similarity index 91% rename from apps/gateway/src/agent/tess-durable-session.repository.test.ts rename to apps/gateway/src/agent/durable-session.repository.test.ts index c1944999..b69dcae6 100644 --- a/apps/gateway/src/agent/tess-durable-session.repository.test.ts +++ b/apps/gateway/src/agent/durable-session.repository.test.ts @@ -6,8 +6,8 @@ import { eq, sql, interactionCheckpoints, interactionInbox } from '@mosaicstack/ import { DurableSessionCoordinator, type DurableSessionIdentity } from '@mosaicstack/agent'; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { createPgliteDb, runPgliteMigrations, type DbHandle } from '@mosaicstack/db'; -import { TessDurableSessionRepository } from './tess-durable-session.repository.js'; -import { TessDurableSessionService } from './tess-durable-session.service.js'; +import { DurableSessionRepository } from './durable-session.repository.js'; +import { DurableSessionService } from './durable-session.service.js'; const IDENTITY: DurableSessionIdentity = { agentName: 'Nova', @@ -18,7 +18,7 @@ const IDENTITY: DurableSessionIdentity = { runtimeSessionId: 'nova', }; -describe('TessDurableSessionRepository', () => { +describe('DurableSessionRepository', () => { let dataDir: string | undefined; let handle: DbHandle; let previousAuthSecret: string | undefined; @@ -48,9 +48,7 @@ describe('TessDurableSessionRepository', () => { }); it('survives a full PGlite close/reopen mid-session without duplicate inbox or outbox side effects', async () => { - const beforeRestart = new DurableSessionCoordinator( - new TessDurableSessionRepository(handle.db), - ); + const beforeRestart = new DurableSessionCoordinator(new DurableSessionRepository(handle.db)); await beforeRestart.create(IDENTITY); await beforeRestart.receive({ sessionId: IDENTITY.sessionId, @@ -92,7 +90,7 @@ describe('TessDurableSessionRepository', () => { await handle.close(); handle = createPgliteDb(dataDir!); - const afterRestart = new DurableSessionCoordinator(new TessDurableSessionRepository(handle.db)); + const afterRestart = new DurableSessionCoordinator(new DurableSessionRepository(handle.db)); const recovered = await afterRestart.recover(IDENTITY.sessionId); const resumedHandoff = await afterRestart.resumeHandoff('handoff-before-kill'); const handled: string[] = []; @@ -120,7 +118,7 @@ describe('TessDurableSessionRepository', () => { }, 30_000); it('redacts sensitive durable payloads before persistence', async () => { - const coordinator = new DurableSessionCoordinator(new TessDurableSessionRepository(handle.db)); + const coordinator = new DurableSessionCoordinator(new DurableSessionRepository(handle.db)); await coordinator.create(IDENTITY); await coordinator.receive({ sessionId: IDENTITY.sessionId, @@ -157,7 +155,7 @@ describe('TessDurableSessionRepository', () => { }, 30_000); it('fails closed when the configured idempotency secret is unavailable', async () => { - const coordinator = new DurableSessionCoordinator(new TessDurableSessionRepository(handle.db)); + const coordinator = new DurableSessionCoordinator(new DurableSessionRepository(handle.db)); await coordinator.create(IDENTITY); const secret = process.env['BETTER_AUTH_SECRET']; delete process.env['BETTER_AUTH_SECRET']; @@ -177,7 +175,7 @@ describe('TessDurableSessionRepository', () => { }, 30_000); it('uses keyed pre-redaction digests to reject distinct sensitive checkpoint payloads', async () => { - const coordinator = new DurableSessionCoordinator(new TessDurableSessionRepository(handle.db)); + const coordinator = new DurableSessionCoordinator(new DurableSessionRepository(handle.db)); await coordinator.create(IDENTITY); const input = { sessionId: IDENTITY.sessionId, @@ -227,7 +225,7 @@ describe('TessDurableSessionRepository', () => { }, 30_000); it('rejects distinct sensitive inbox and outbox payloads under reused idempotency keys', async () => { - const coordinator = new DurableSessionCoordinator(new TessDurableSessionRepository(handle.db)); + const coordinator = new DurableSessionCoordinator(new DurableSessionRepository(handle.db)); await coordinator.create(IDENTITY); const inbox = { sessionId: IDENTITY.sessionId, @@ -255,7 +253,7 @@ describe('TessDurableSessionRepository', () => { }, 30_000); it('rejects database inbox and outbox idempotency-key conflicts', async () => { - const coordinator = new DurableSessionCoordinator(new TessDurableSessionRepository(handle.db)); + const coordinator = new DurableSessionCoordinator(new DurableSessionRepository(handle.db)); await coordinator.create(IDENTITY); await coordinator.receive({ sessionId: IDENTITY.sessionId, @@ -293,10 +291,10 @@ describe('TessDurableSessionRepository', () => { }, 30_000); it('does not requeue a live outbox claim during a normal scoped dispatch', async () => { - const repository = new TessDurableSessionRepository(handle.db); + const repository = new DurableSessionRepository(handle.db); const coordinator = new DurableSessionCoordinator(repository); const runtimeProviders = { sendMessage: vi.fn().mockResolvedValue(undefined) }; - const service = new TessDurableSessionService(repository, runtimeProviders as never); + const service = new DurableSessionService(repository, runtimeProviders as never); const input = { sessionId: IDENTITY.sessionId, idempotencyKey: 'live-effect', @@ -333,10 +331,10 @@ describe('TessDurableSessionRepository', () => { }, 30_000); it('rejects an outbox correlation mismatch before claiming the pending effect', async () => { - const repository = new TessDurableSessionRepository(handle.db); + const repository = new DurableSessionRepository(handle.db); const coordinator = new DurableSessionCoordinator(repository); const runtimeProviders = { sendMessage: vi.fn().mockResolvedValue(undefined) }; - const service = new TessDurableSessionService(repository, runtimeProviders as never); + const service = new DurableSessionService(repository, runtimeProviders as never); const input = { sessionId: IDENTITY.sessionId, idempotencyKey: 'mismatch-effect', @@ -366,10 +364,10 @@ describe('TessDurableSessionRepository', () => { }, 30_000); it('dispatches only the outbox record bound to the supplied correlation and channel', async () => { - const repository = new TessDurableSessionRepository(handle.db); + const repository = new DurableSessionRepository(handle.db); const coordinator = new DurableSessionCoordinator(repository); const runtimeProviders = { sendMessage: vi.fn().mockResolvedValue(undefined) }; - const service = new TessDurableSessionService(repository, runtimeProviders as never); + const service = new DurableSessionService(repository, runtimeProviders as never); const first = { sessionId: IDENTITY.sessionId, idempotencyKey: 'scoped-effect-one', diff --git a/apps/gateway/src/agent/tess-durable-session.repository.ts b/apps/gateway/src/agent/durable-session.repository.ts similarity index 95% rename from apps/gateway/src/agent/tess-durable-session.repository.ts rename to apps/gateway/src/agent/durable-session.repository.ts index 52d853bb..bc000a85 100644 --- a/apps/gateway/src/agent/tess-durable-session.repository.ts +++ b/apps/gateway/src/agent/durable-session.repository.ts @@ -33,7 +33,7 @@ import type { import { DB } from '../database/database.module.js'; @Injectable() -export class TessDurableSessionRepository implements DurableSessionStore { +export class DurableSessionRepository implements DurableSessionStore { constructor(@Inject(DB) private readonly db: Db) {} async create(identity: DurableSessionIdentity): Promise { @@ -51,7 +51,7 @@ export class TessDurableSessionRepository implements DurableSessionStore { const existing = await this.session(identity.sessionId); if (!existing || !sameEnrollmentScope(existing, identity)) { - throw new Error(`Durable Tess session identity conflict: ${identity.sessionId}`); + throw new Error(`Durable session identity conflict: ${identity.sessionId}`); } // A recovered/re-enrolled runtime can receive a new provider session ID; // the conversation handle and owner scope remain immutable. @@ -135,13 +135,13 @@ export class TessDurableSessionRepository implements DurableSessionStore { ), ) .limit(1); - if (!existing[0]) throw new Error(`Durable Tess inbox enqueue failed: ${input.idempotencyKey}`); + if (!existing[0]) throw new Error(`Durable inbox enqueue failed: ${input.idempotencyKey}`); const entry = toInbox(existing[0]); if ( !sameInbox(entry, record) || !matchesContentDigest(existing[0].contentDigest, input.content) ) { - throw new Error(`Durable Tess inbox idempotency conflict: ${input.idempotencyKey}`); + throw new Error(`Durable inbox idempotency conflict: ${input.idempotencyKey}`); } return { accepted: false, status: entry.status }; } @@ -224,14 +224,13 @@ export class TessDurableSessionRepository implements DurableSessionStore { ), ) .limit(1); - if (!existing[0]) - throw new Error(`Durable Tess outbox enqueue failed: ${input.idempotencyKey}`); + if (!existing[0]) throw new Error(`Durable outbox enqueue failed: ${input.idempotencyKey}`); const entry = toOutbox(existing[0]); if ( !sameOutbox(entry, record) || !matchesContentDigest(existing[0].contentDigest, input.content) ) { - throw new Error(`Durable Tess outbox idempotency conflict: ${input.idempotencyKey}`); + throw new Error(`Durable outbox idempotency conflict: ${input.idempotencyKey}`); } return { accepted: false, status: entry.status }; } @@ -338,7 +337,7 @@ export class TessDurableSessionRepository implements DurableSessionStore { !sameCheckpoint(toCheckpoint(existing[0]), checkpoint) || !matchesCheckpointDigest(existing[0].contentDigest, digest) ) { - throw new Error(`Durable Tess checkpoint identity conflict: ${input.checkpointId}`); + throw new Error(`Durable checkpoint identity conflict: ${input.checkpointId}`); } } @@ -360,7 +359,7 @@ export class TessDurableSessionRepository implements DurableSessionStore { async handoff(input: DurableHandoffInput): Promise { const checkpoint = await this.findCheckpoint(input.sessionId, input.checkpointId); if (!checkpoint) { - throw new Error(`Durable Tess handoff checkpoint is unavailable: ${input.checkpointId}`); + throw new Error(`Durable handoff checkpoint is unavailable: ${input.checkpointId}`); } const inserted = await this.db .insert(interactionHandoffs) @@ -371,7 +370,7 @@ export class TessDurableSessionRepository implements DurableSessionStore { const existing = await this.findHandoff(input.handoffId); if (!existing || !sameHandoff(existing, input)) { - throw new Error(`Durable Tess handoff identity conflict: ${input.handoffId}`); + throw new Error(`Durable handoff identity conflict: ${input.handoffId}`); } } diff --git a/apps/gateway/src/agent/tess-durable-session.service.ts b/apps/gateway/src/agent/durable-session.service.ts similarity index 78% rename from apps/gateway/src/agent/tess-durable-session.service.ts rename to apps/gateway/src/agent/durable-session.service.ts index 340694fa..a2384fd3 100644 --- a/apps/gateway/src/agent/tess-durable-session.service.ts +++ b/apps/gateway/src/agent/durable-session.service.ts @@ -1,23 +1,23 @@ import { ForbiddenException, Inject, Injectable } from '@nestjs/common'; import { DurableSessionCoordinator, type DurableSessionIdentity } from '@mosaicstack/agent'; -import type { TessProviderOutboxDto } from './tess-durable-session.dto.js'; -import { TessDurableSessionRepository } from './tess-durable-session.repository.js'; +import type { ProviderOutboxDto } from './durable-session.dto.js'; +import { DurableSessionRepository } from './durable-session.repository.js'; import { RuntimeProviderService, type RuntimeProviderRequestContext, } from './runtime-provider-registry.service.js'; /** - * Scoped gateway boundary for the canonical Tess state machine. It deliberately + * Scoped gateway boundary for the canonical durable session state machine. It deliberately * uses composition: raw state methods cannot be injected into channel, CLI, or * MCP adapters without a server-derived actor/tenant/correlation context. */ @Injectable() -export class TessDurableSessionService { +export class DurableSessionService { private readonly coordinator: DurableSessionCoordinator; constructor( - @Inject(TessDurableSessionRepository) repository: TessDurableSessionRepository, + @Inject(DurableSessionRepository) repository: DurableSessionRepository, @Inject(RuntimeProviderService) private readonly runtimeProviders: RuntimeProviderService, ) { this.coordinator = new DurableSessionCoordinator(repository); @@ -32,12 +32,12 @@ export class TessDurableSessionService { identity.ownerId !== context.actorScope.userId || identity.tenantId !== context.actorScope.tenantId ) { - throw new ForbiddenException('Durable Tess enrollment scope mismatch'); + throw new ForbiddenException('Durable session enrollment scope mismatch'); } await this.coordinator.create(identity); } - async queueProviderSend(input: TessProviderOutboxDto): Promise { + async queueProviderSend(input: ProviderOutboxDto): Promise { const snapshot = await this.coordinator.snapshot(input.sessionId); this.assertScope(snapshot.identity.ownerId, snapshot.identity.tenantId, input); await this.coordinator.enqueueOutbox({ @@ -50,9 +50,9 @@ export class TessDurableSessionService { }); } - async dispatchProviderOutbox(sessionId: string, input: TessProviderOutboxDto): Promise { + async dispatchProviderOutbox(sessionId: string, input: ProviderOutboxDto): Promise { if (sessionId !== input.sessionId) { - throw new ForbiddenException('Durable Tess outbox session mismatch'); + throw new ForbiddenException('Durable outbox session mismatch'); } const snapshot = await this.coordinator.snapshot(sessionId); this.assertScope(snapshot.identity.ownerId, snapshot.identity.tenantId, input); @@ -92,7 +92,7 @@ export class TessDurableSessionService { } /** Startup/recovery-only path; normal queue/dispatch methods never requeue live work. */ - async recoverProviderSession(sessionId: string, input: TessProviderOutboxDto): Promise { + async recoverProviderSession(sessionId: string, input: ProviderOutboxDto): Promise { const snapshot = await this.coordinator.snapshot(sessionId); this.assertScope(snapshot.identity.ownerId, snapshot.identity.tenantId, input); await this.coordinator.recover(sessionId); @@ -100,24 +100,24 @@ export class TessDurableSessionService { private assertOutboxScope( entry: { kind: string; correlationId: string; channelId: string }, - input: TessProviderOutboxDto, + input: ProviderOutboxDto, ): void { if ( entry.kind !== 'provider.send' || entry.correlationId !== input.correlationId || entry.channelId !== input.context.channelId ) { - throw new ForbiddenException('Durable Tess outbox scope or correlation mismatch'); + throw new ForbiddenException('Durable outbox scope or correlation mismatch'); } } - private assertScope(ownerId: string, tenantId: string, input: TessProviderOutboxDto): void { + private assertScope(ownerId: string, tenantId: string, input: ProviderOutboxDto): void { if ( input.context.actorScope.userId !== ownerId || input.context.actorScope.tenantId !== tenantId || input.context.correlationId !== input.correlationId ) { - throw new ForbiddenException('Durable Tess session scope or correlation mismatch'); + throw new ForbiddenException('Durable session scope or correlation mismatch'); } } } diff --git a/apps/gateway/src/agent/hermes-runtime-reachability.e2e.test.ts b/apps/gateway/src/agent/hermes-runtime-reachability.e2e.test.ts index 625bd97e..546e64ef 100644 --- a/apps/gateway/src/agent/hermes-runtime-reachability.e2e.test.ts +++ b/apps/gateway/src/agent/hermes-runtime-reachability.e2e.test.ts @@ -21,8 +21,8 @@ import { RUNTIME_PROVIDER_AUDIT_SINK, RuntimeProviderAuditService, } from './runtime-provider-registry.service.js'; -import { TessDurableSessionService } from './tess-durable-session.service.js'; -import { TessDurableSessionRepository } from './tess-durable-session.repository.js'; +import { DurableSessionService } from './durable-session.service.js'; +import { DurableSessionRepository } from './durable-session.repository.js'; import { AgentService } from './agent.service.js'; import { ProviderService } from './provider.service.js'; import { ProviderCredentialsService } from './provider-credentials.service.js'; @@ -88,9 +88,9 @@ describe('Hermes runtime provider reachability', (): void => { .useValue({ record: vi.fn().mockResolvedValue(undefined) }) .overrideProvider(RUNTIME_APPROVAL_VERIFIER) .useValue({ consume: vi.fn().mockResolvedValue(false) }) - .overrideProvider(TessDurableSessionService) + .overrideProvider(DurableSessionService) .useValue({}) - .overrideProvider(TessDurableSessionRepository) + .overrideProvider(DurableSessionRepository) .useValue({}) .overrideProvider(AgentService) .useValue({}) diff --git a/apps/gateway/src/agent/interaction.controller.ts b/apps/gateway/src/agent/interaction.controller.ts index 04b77d56..54a4ec76 100644 --- a/apps/gateway/src/agent/interaction.controller.ts +++ b/apps/gateway/src/agent/interaction.controller.ts @@ -17,7 +17,7 @@ import { Observable } from 'rxjs'; import { AuthGuard } from '../auth/auth.guard.js'; import { CurrentUser } from '../auth/current-user.decorator.js'; import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js'; -import { TessDurableSessionService } from './tess-durable-session.service.js'; +import { DurableSessionService } from './durable-session.service.js'; import { RuntimeApprovalDeniedFilter } from './runtime-approval-denied.filter.js'; import { RuntimeProviderService, @@ -34,7 +34,7 @@ import { export class InteractionController { constructor( @Inject(RuntimeProviderService) private readonly runtime: RuntimeProviderService, - @Inject(TessDurableSessionService) private readonly durable: TessDurableSessionService, + @Inject(DurableSessionService) private readonly durable: DurableSessionService, ) {} @Get('sessions') diff --git a/apps/gateway/src/chat/chat.gateway.ts b/apps/gateway/src/chat/chat.gateway.ts index a7549e4c..9f2541dd 100644 --- a/apps/gateway/src/chat/chat.gateway.ts +++ b/apps/gateway/src/chat/chat.gateway.ts @@ -37,7 +37,7 @@ import { RuntimeProviderService, type RuntimeAuditSink, } from '../agent/runtime-provider-registry.service.js'; -import { TessDurableSessionService } from '../agent/tess-durable-session.service.js'; +import { DurableSessionService } from '../agent/durable-session.service.js'; import { AUTH } from '../auth/auth.tokens.js'; import { scopeFromUser, @@ -140,8 +140,8 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa @Inject(RuntimeProviderService) private readonly runtimeRegistry: RuntimeProviderService | null = null, @Optional() - @Inject(TessDurableSessionService) - private readonly durableSessions: TessDurableSessionService | null = null, + @Inject(DurableSessionService) + private readonly durableSessions: DurableSessionService | null = null, @Optional() @Inject(RUNTIME_PROVIDER_AUDIT_SINK) private readonly runtimeAudit: RuntimeAuditSink | null = null, diff --git a/apps/gateway/src/coord/coord.module.ts b/apps/gateway/src/coord/coord.module.ts index 170280db..8c279a28 100644 --- a/apps/gateway/src/coord/coord.module.ts +++ b/apps/gateway/src/coord/coord.module.ts @@ -1,31 +1,32 @@ import { Module } from '@nestjs/common'; -import { InMemoryMosCoordinationPort } from '@mosaicstack/coord'; +import { InMemoryInteractionCoordinationPort } from '@mosaicstack/coord'; import { CoordService } from './coord.service.js'; import { CoordController } from './coord.controller.js'; -import { MosCoordinationController } from './mos-coordination.controller.js'; +import { InteractionCoordinationController } from './interaction-coordination.controller.js'; import { - MOS_COORDINATION_CONFIG, - MOS_COORDINATION_PORT, - MosCoordinationService, -} from './mos-coordination.service.js'; + COORDINATION_CONFIG, + COORDINATION_PORT, + InteractionCoordinationService, +} from './interaction-coordination.service.js'; @Module({ providers: [ CoordService, { - provide: MOS_COORDINATION_PORT, - useFactory: (): InMemoryMosCoordinationPort => new InMemoryMosCoordinationPort(), + provide: COORDINATION_PORT, + useFactory: (): InMemoryInteractionCoordinationPort => + new InMemoryInteractionCoordinationPort(), }, { - provide: MOS_COORDINATION_CONFIG, + provide: COORDINATION_CONFIG, useFactory: () => ({ interactionAgentId: process.env['MOSAIC_AGENT_NAME'], orchestrationAgentId: process.env['MOSAIC_ORCHESTRATOR_AGENT_NAME'], }), }, - MosCoordinationService, + InteractionCoordinationService, ], - controllers: [CoordController, MosCoordinationController], - exports: [CoordService, MosCoordinationService], + controllers: [CoordController, InteractionCoordinationController], + exports: [CoordService, InteractionCoordinationService], }) export class CoordModule {} diff --git a/apps/gateway/src/coord/mos-coordination.controller.test.ts b/apps/gateway/src/coord/interaction-coordination.controller.test.ts similarity index 57% rename from apps/gateway/src/coord/mos-coordination.controller.test.ts rename to apps/gateway/src/coord/interaction-coordination.controller.test.ts index 0bec4b75..d3b2b091 100644 --- a/apps/gateway/src/coord/mos-coordination.controller.test.ts +++ b/apps/gateway/src/coord/interaction-coordination.controller.test.ts @@ -1,16 +1,27 @@ +const PATH_METADATA = 'path'; import { describe, expect, it, vi } from 'vitest'; -import { MosCoordinationController } from './mos-coordination.controller.js'; +import { InteractionCoordinationController } from './interaction-coordination.controller.js'; const user = { id: 'operator-1', tenantId: 'tenant-a' }; -describe('MosCoordinationController', () => { +describe('InteractionCoordinationController', () => { + it('exposes the neutral canonical route and Mos compatibility alias over identical handlers', () => { + expect(Reflect.getMetadata(PATH_METADATA, InteractionCoordinationController)).toEqual([ + 'api/coord/interaction', + 'api/coord/mos', + ]); + expect(InteractionCoordinationController.prototype.handoff).toBeTypeOf('function'); + expect(InteractionCoordinationController.prototype.observe).toBeTypeOf('function'); + expect(InteractionCoordinationController.prototype.result).toBeTypeOf('function'); + }); + it('derives actor and tenant from the authenticated user rather than handoff input', async () => { const coordination = { handoff: vi.fn(async () => ({ handoffId: 'handoff-1' })), observe: vi.fn(), result: vi.fn(), }; - const controller = new MosCoordinationController(coordination as never); + const controller = new InteractionCoordinationController(coordination as never); await controller.handoff({ idempotencyKey: 'request-1', summary: 'Implement' }, user, 'corr-1'); @@ -26,7 +37,7 @@ describe('MosCoordinationController', () => { it('requires a correlation header before invoking the coordination service', async () => { const coordination = { handoff: vi.fn(), observe: vi.fn(), result: vi.fn() }; - const controller = new MosCoordinationController(coordination as never); + const controller = new InteractionCoordinationController(coordination as never); await expect( controller.handoff({ idempotencyKey: 'request-1', summary: 'Implement' }, user, undefined), diff --git a/apps/gateway/src/coord/mos-coordination.controller.ts b/apps/gateway/src/coord/interaction-coordination.controller.ts similarity index 68% rename from apps/gateway/src/coord/mos-coordination.controller.ts rename to apps/gateway/src/coord/interaction-coordination.controller.ts index cacfe7ba..092f204f 100644 --- a/apps/gateway/src/coord/mos-coordination.controller.ts +++ b/apps/gateway/src/coord/interaction-coordination.controller.ts @@ -14,27 +14,29 @@ import { CurrentUser } from '../auth/current-user.decorator.js'; import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js'; import type { RuntimeProviderRequestContext } from '../agent/runtime-provider-registry.service.js'; import type { - MosCoordinationObservationDto, - MosCoordinationResponseDto, - MosCoordinationResultDto, - CreateMosHandoffDto, -} from './mos-coordination.dto.js'; -import { MosCoordinationService } from './mos-coordination.service.js'; + InteractionCoordinationObservationDto, + InteractionCoordinationResponseDto, + InteractionCoordinationResultDto, + CreateHandoffDto, +} from './interaction-coordination.dto.js'; +import { InteractionCoordinationService } from './interaction-coordination.service.js'; -/** Authenticated interaction-plane boundary for the handoff/observe/result-only Mos contract. */ -@Controller('api/coord/mos') +/** Authenticated interaction-plane boundary for the handoff/observe/result-only interaction coordination contract. */ +/** `api/coord/interaction` is canonical; the Mos path remains a compatibility alias. */ +@Controller(['api/coord/interaction', 'api/coord/mos']) @UseGuards(AuthGuard) -export class MosCoordinationController { +export class InteractionCoordinationController { constructor( - @Inject(MosCoordinationService) private readonly coordination: MosCoordinationService, + @Inject(InteractionCoordinationService) + private readonly coordination: InteractionCoordinationService, ) {} @Post('handoff') async handoff( - @Body() request: CreateMosHandoffDto, + @Body() request: CreateHandoffDto, @CurrentUser() user: AuthenticatedUserLike, @Headers('x-correlation-id') correlationId?: string, - ): Promise { + ): Promise { return { receipt: await this.coordination.handoff(request, this.context(user, correlationId)) }; } @@ -43,7 +45,7 @@ export class MosCoordinationController { @Param('handoffId') handoffId: string, @CurrentUser() user: AuthenticatedUserLike, @Headers('x-correlation-id') correlationId?: string, - ): Promise { + ): Promise { return { observation: await this.coordination.observe(handoffId, this.context(user, correlationId)), }; @@ -54,7 +56,7 @@ export class MosCoordinationController { @Param('handoffId') handoffId: string, @CurrentUser() user: AuthenticatedUserLike, @Headers('x-correlation-id') correlationId?: string, - ): Promise { + ): Promise { return { result: await this.coordination.result(handoffId, this.context(user, correlationId)) }; } diff --git a/apps/gateway/src/coord/mos-coordination.dto.ts b/apps/gateway/src/coord/interaction-coordination.dto.ts similarity index 59% rename from apps/gateway/src/coord/mos-coordination.dto.ts rename to apps/gateway/src/coord/interaction-coordination.dto.ts index 7967753a..be8d5e96 100644 --- a/apps/gateway/src/coord/mos-coordination.dto.ts +++ b/apps/gateway/src/coord/interaction-coordination.dto.ts @@ -1,25 +1,25 @@ import type { CoordinationObservation, CoordinationResult, - MosHandoffReceipt, + HandoffReceipt, } from '@mosaicstack/coord'; /** Input accepted at the gateway coordination boundary. Agent identity is not caller-controlled. */ -export interface CreateMosHandoffDto { +export interface CreateHandoffDto { idempotencyKey: string; summary: string; context?: string; missionId?: string; } -export interface MosCoordinationResponseDto { - receipt: MosHandoffReceipt; +export interface InteractionCoordinationResponseDto { + receipt: HandoffReceipt; } -export interface MosCoordinationObservationDto { +export interface InteractionCoordinationObservationDto { observation: CoordinationObservation; } -export interface MosCoordinationResultDto { +export interface InteractionCoordinationResultDto { result: CoordinationResult; } diff --git a/apps/gateway/src/coord/interaction-coordination.routing.e2e.test.ts b/apps/gateway/src/coord/interaction-coordination.routing.e2e.test.ts new file mode 100644 index 00000000..3a9da8d5 --- /dev/null +++ b/apps/gateway/src/coord/interaction-coordination.routing.e2e.test.ts @@ -0,0 +1,88 @@ +import 'reflect-metadata'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { Global, Module } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify'; +import { AUTH } from '../auth/auth.tokens.js'; +import { AuthGuard } from '../auth/auth.guard.js'; +import { InteractionCoordinationController } from './interaction-coordination.controller.js'; +import { InteractionCoordinationService } from './interaction-coordination.service.js'; + +@Global() +@Module({ + providers: [ + { + provide: AUTH, + useValue: { + api: { + getSession: vi.fn(async ({ headers }: { headers: Headers }) => + headers.get('cookie') === 'session=trusted' + ? { user: { id: 'operator-1', tenantId: 'tenant-1' }, session: { id: 'session-1' } } + : null, + ), + }, + }, + }, + AuthGuard, + ], + exports: [AUTH, AuthGuard], +}) +class AuthenticatedRequestModule {} + +describe('InteractionCoordinationController route aliases', (): void => { + let app: NestFastifyApplication | undefined; + const coordination = { + handoff: vi.fn(async () => ({ handoffId: 'handoff-1' })), + observe: vi.fn(async () => ({ status: 'running' })), + result: vi.fn(async () => ({ status: 'completed' })), + }; + + beforeAll(async (): Promise => { + const moduleRef = await Test.createTestingModule({ + imports: [AuthenticatedRequestModule], + controllers: [InteractionCoordinationController], + providers: [{ provide: InteractionCoordinationService, useValue: coordination }], + }).compile(); + app = moduleRef.createNestApplication(new FastifyAdapter()); + await app.init(); + await app.getHttpAdapter().getInstance().ready(); + }); + afterAll(async (): Promise => app?.close()); + + it('routes handoff, observe, and result through the same AuthGuard-protected service for both prefixes', async (): Promise => { + if (!app) throw new Error('test app was not initialized'); + for (const prefix of ['/api/coord/interaction', '/api/coord/mos']) { + const headers = { cookie: 'session=trusted', 'x-correlation-id': `corr-${prefix}` }; + expect( + ( + await app.inject({ + method: 'POST', + url: `${prefix}/handoff`, + headers, + payload: { idempotencyKey: `key-${prefix}`, summary: 'handoff' }, + }) + ).statusCode, + ).toBe(201); + expect( + (await app.inject({ method: 'GET', url: `${prefix}/handoff-1/observe`, headers })) + .statusCode, + ).toBe(200); + expect( + (await app.inject({ method: 'GET', url: `${prefix}/handoff-1/result`, headers })) + .statusCode, + ).toBe(200); + } + expect(coordination.handoff).toHaveBeenCalledTimes(2); + expect(coordination.observe).toHaveBeenCalledTimes(2); + expect(coordination.result).toHaveBeenCalledTimes(2); + expect( + ( + await app.inject({ + method: 'POST', + url: '/api/coord/interaction/handoff', + payload: { idempotencyKey: 'denied', summary: 'x' }, + }) + ).statusCode, + ).toBe(401); + }); +}); diff --git a/apps/gateway/src/coord/mos-coordination.service.test.ts b/apps/gateway/src/coord/interaction-coordination.service.test.ts similarity index 79% rename from apps/gateway/src/coord/mos-coordination.service.test.ts rename to apps/gateway/src/coord/interaction-coordination.service.test.ts index 51b2a26b..f9eadbb8 100644 --- a/apps/gateway/src/coord/mos-coordination.service.test.ts +++ b/apps/gateway/src/coord/interaction-coordination.service.test.ts @@ -1,15 +1,15 @@ import { describe, expect, it, vi } from 'vitest'; import { - InMemoryMosCoordinationPort, - type MosCoordinationPort, - type MosHandoff, + InMemoryInteractionCoordinationPort, + type InteractionCoordinationPort, + type Handoff, } from '@mosaicstack/coord'; import type { RuntimeProviderRequestContext } from '../agent/runtime-provider-registry.service.js'; import { - MosCoordinationService, - type MosCoordinationConfig, - type MosCoordinationGatewayError, -} from './mos-coordination.service.js'; + InteractionCoordinationService, + type InteractionCoordinationConfig, + type InteractionCoordinationGatewayError, +} from './interaction-coordination.service.js'; const context: RuntimeProviderRequestContext = { actorScope: { userId: 'operator-1', tenantId: 'tenant-a' }, @@ -17,28 +17,28 @@ const context: RuntimeProviderRequestContext = { correlationId: 'corr-1', }; -const config: MosCoordinationConfig = { +const config: InteractionCoordinationConfig = { interactionAgentId: 'Nova', orchestrationAgentId: 'Conductor', }; function service( - port: MosCoordinationPort = new InMemoryMosCoordinationPort(), + port: InteractionCoordinationPort = new InMemoryInteractionCoordinationPort(), options: { - config?: MosCoordinationConfig; + config?: InteractionCoordinationConfig; handoffIdFactory?: () => string; } = {}, -): MosCoordinationService { - return new MosCoordinationService( +): InteractionCoordinationService { + return new InteractionCoordinationService( port, options.config ?? config, options.handoffIdFactory ?? (() => 'handoff-1'), ); } -describe('MosCoordinationService authority boundary', (): void => { +describe('InteractionCoordinationService authority boundary', (): void => { it('derives identity and actor/tenant scope server-side, then round-trips the native adapter', async (): Promise => { - const adapter = new InMemoryMosCoordinationPort(); + const adapter = new InMemoryInteractionCoordinationPort(); const coordination = service(adapter); await expect( @@ -53,8 +53,8 @@ describe('MosCoordinationService authority boundary', (): void => { correlationId: 'corr-1', }); - adapter.recordActivity('handoff-1', 'running', 'Mos accepted the request'); - adapter.recordResult('handoff-1', 'completed', 'Merged by Mos'); + adapter.recordActivity('handoff-1', 'running', 'Orchestrator accepted the request'); + adapter.recordResult('handoff-1', 'completed', 'Merged by orchestrator'); const followUpContext = { ...context, correlationId: 'corr-2' }; await expect(coordination.observe('handoff-1', followUpContext)).resolves.toMatchObject({ @@ -64,12 +64,12 @@ describe('MosCoordinationService authority boundary', (): void => { await expect(coordination.result('handoff-1', followUpContext)).resolves.toMatchObject({ targetAgentId: 'Conductor', status: 'completed', - summary: 'Merged by Mos', + summary: 'Merged by orchestrator', }); }); it('fails closed without calling a port when the interaction requester is unconfigured', async (): Promise => { - const adapter = new InMemoryMosCoordinationPort(); + const adapter = new InMemoryInteractionCoordinationPort(); const handoff = vi.spyOn(adapter, 'handoff'); const coordination = service(adapter, { config: { interactionAgentId: '', orchestrationAgentId: 'Conductor' }, @@ -82,12 +82,12 @@ describe('MosCoordinationService authority boundary', (): void => { ), ).rejects.toMatchObject({ code: 'unconfigured_requester', - } satisfies Partial); + } satisfies Partial); expect(handoff).not.toHaveBeenCalled(); }); it('rejects self-delegation configuration before delivering work', async (): Promise => { - const adapter = new InMemoryMosCoordinationPort(); + const adapter = new InMemoryInteractionCoordinationPort(); const handoff = vi.spyOn(adapter, 'handoff'); const coordination = service(adapter, { config: { interactionAgentId: 'Nova', orchestrationAgentId: 'Nova' }, @@ -103,7 +103,7 @@ describe('MosCoordinationService authority boundary', (): void => { }); it('denies cross-tenant observe and result before calling the adapter', async (): Promise => { - const adapter = new InMemoryMosCoordinationPort(); + const adapter = new InMemoryInteractionCoordinationPort(); const observe = vi.spyOn(adapter, 'observe'); const result = vi.spyOn(adapter, 'result'); const coordination = service(adapter); @@ -118,10 +118,10 @@ describe('MosCoordinationService authority boundary', (): void => { }; await expect(coordination.observe('handoff-1', otherTenant)).rejects.toMatchObject({ code: 'cross_tenant_forbidden', - } satisfies Partial); + } satisfies Partial); await expect(coordination.result('handoff-1', otherTenant)).rejects.toMatchObject({ code: 'cross_tenant_forbidden', - } satisfies Partial); + } satisfies Partial); expect(observe).not.toHaveBeenCalled(); expect(result).not.toHaveBeenCalled(); }); @@ -132,8 +132,8 @@ describe('MosCoordinationService authority boundary', (): void => { const delivered = new Promise((resolve: () => void): void => { release = resolve; }); - const adapter: MosCoordinationPort = { - handoff: vi.fn(async (handoff: MosHandoff) => { + const adapter: InteractionCoordinationPort = { + handoff: vi.fn(async (handoff: Handoff) => { await delivered; return { handoffId: handoff.handoffId, @@ -169,7 +169,7 @@ describe('MosCoordinationService authority boundary', (): void => { }); it('rejects idempotency-key payload drift and malformed handoff input before delivery', async (): Promise => { - const adapter = new InMemoryMosCoordinationPort(); + const adapter = new InMemoryInteractionCoordinationPort(); const handoff = vi.spyOn(adapter, 'handoff'); const coordination = service(adapter); await coordination.handoff( @@ -181,23 +181,23 @@ describe('MosCoordinationService authority boundary', (): void => { coordination.handoff({ idempotencyKey: 'request-1', summary: 'Different work' }, context), ).rejects.toMatchObject({ code: 'handoff_conflict', - } satisfies Partial); + } satisfies Partial); await expect( coordination.handoff({ idempotencyKey: 'request-2', summary: '' }, context), ).rejects.toMatchObject({ code: 'invalid_request', - } satisfies Partial); + } satisfies Partial); await expect( coordination.handoff({ idempotencyKey: 'request-3', summary: 'x'.repeat(2_049) }, context), ).rejects.toMatchObject({ code: 'invalid_request', - } satisfies Partial); + } satisfies Partial); expect(handoff).toHaveBeenCalledTimes(1); }); it('fails closed when the port reports a target that drifts from configuration', async (): Promise => { - const adapter: MosCoordinationPort = { - handoff: vi.fn(async (handoff: MosHandoff) => ({ + const adapter: InteractionCoordinationPort = { + handoff: vi.fn(async (handoff: Handoff) => ({ handoffId: handoff.handoffId, targetAgentId: 'Unexpected', status: 'accepted' as const, diff --git a/apps/gateway/src/coord/mos-coordination.service.ts b/apps/gateway/src/coord/interaction-coordination.service.ts similarity index 75% rename from apps/gateway/src/coord/mos-coordination.service.ts rename to apps/gateway/src/coord/interaction-coordination.service.ts index bc4bb98f..cf16e64a 100644 --- a/apps/gateway/src/coord/mos-coordination.service.ts +++ b/apps/gateway/src/coord/interaction-coordination.service.ts @@ -1,18 +1,18 @@ import { Inject, Injectable } from '@nestjs/common'; import { - MosCoordinationClient, + InteractionCoordinationClient, type CoordinationObservation, type CoordinationResult, type CoordinationScope, - type MosCoordinationIdentity, - type MosCoordinationPort, - type MosHandoffReceipt, + type InteractionCoordinationIdentity, + type InteractionCoordinationPort, + type HandoffReceipt, } from '@mosaicstack/coord'; import type { RuntimeProviderRequestContext } from '../agent/runtime-provider-registry.service.js'; -import type { CreateMosHandoffDto } from './mos-coordination.dto.js'; +import type { CreateHandoffDto } from './interaction-coordination.dto.js'; -export const MOS_COORDINATION_PORT = Symbol('MOS_COORDINATION_PORT'); -export const MOS_COORDINATION_CONFIG = Symbol('MOS_COORDINATION_CONFIG'); +export const COORDINATION_PORT = Symbol('COORDINATION_PORT'); +export const COORDINATION_CONFIG = Symbol('COORDINATION_CONFIG'); const HANDOFF_TRACKING_TTL_MS = 60 * 60 * 1_000; const MAX_TRACKED_HANDOFFS = 1_000; @@ -21,7 +21,7 @@ const MAX_SUMMARY_LENGTH = 2_048; const MAX_CONTEXT_LENGTH = 8_192; const MAX_MISSION_ID_LENGTH = 128; -export interface MosCoordinationConfig { +export interface InteractionCoordinationConfig { interactionAgentId?: string; orchestrationAgentId?: string; } @@ -34,7 +34,7 @@ interface HandoffOwner { expiresAt: number; } -interface NormalizedMosHandoffRequest { +interface NormalizedHandoffRequest { idempotencyKey: string; summary: string; context?: string; @@ -42,31 +42,31 @@ interface NormalizedMosHandoffRequest { } interface TrackedHandoff { - request: NormalizedMosHandoffRequest; - receipt: Promise; + request: NormalizedHandoffRequest; + receipt: Promise; expiresAt: number; } /** * Gateway authority boundary for the interaction agent. It derives requester, * actor, and tenant from trusted server configuration and authentication; no - * channel request can name a target or gain Mos-owned orchestration verbs. + * channel request can name a target or gain orchestrator-owned orchestration verbs. */ @Injectable() -export class MosCoordinationService { +export class InteractionCoordinationService { private readonly owners = new Map(); private readonly handoffsByIdempotencyKey = new Map(); constructor( - @Inject(MOS_COORDINATION_PORT) private readonly port: MosCoordinationPort, - @Inject(MOS_COORDINATION_CONFIG) private readonly config: MosCoordinationConfig, + @Inject(COORDINATION_PORT) private readonly port: InteractionCoordinationPort, + @Inject(COORDINATION_CONFIG) private readonly config: InteractionCoordinationConfig, private readonly handoffIdFactory: () => string = (): string => crypto.randomUUID(), ) {} async handoff( - request: CreateMosHandoffDto, + request: CreateHandoffDto, context: RuntimeProviderRequestContext, - ): Promise { + ): Promise { this.pruneExpiredTracking(); const normalized = this.normalizeRequest(request); const scope = this.scope(context); @@ -74,9 +74,9 @@ export class MosCoordinationService { const existing = this.handoffsByIdempotencyKey.get(idempotencyKey); if (existing !== undefined) { if (!sameRequest(existing.request, normalized)) { - throw new MosCoordinationGatewayError( + throw new InteractionCoordinationGatewayError( 'handoff_conflict', - 'Mos handoff idempotency key is already bound to different immutable input', + 'Handoff idempotency key is already bound to different immutable input', ); } return existing.receipt; @@ -122,9 +122,9 @@ export class MosCoordinationService { private async deliverHandoff( handoffId: string, - request: NormalizedMosHandoffRequest, + request: NormalizedHandoffRequest, scope: CoordinationScope, - ): Promise { + ): Promise { const receipt = await this.client((): string => handoffId).handoff(request, scope); const owner: HandoffOwner = { actorId: scope.actorId, @@ -135,9 +135,9 @@ export class MosCoordinationService { }; const existing = this.owners.get(receipt.handoffId); if (existing !== undefined && !sameOwner(existing, owner)) { - throw new MosCoordinationGatewayError( + throw new InteractionCoordinationGatewayError( 'handoff_conflict', - 'Mos handoff ID is already bound to a different authenticated scope', + 'Handoff ID is already bound to a different authenticated scope', ); } this.owners.set(receipt.handoffId, owner); @@ -145,21 +145,21 @@ export class MosCoordinationService { return receipt; } - private client(handoffIdFactory?: () => string): MosCoordinationClient { - return new MosCoordinationClient(this.identity(), this.port, handoffIdFactory); + private client(handoffIdFactory?: () => string): InteractionCoordinationClient { + return new InteractionCoordinationClient(this.identity(), this.port, handoffIdFactory); } - private identity(): MosCoordinationIdentity { + private identity(): InteractionCoordinationIdentity { const interactionAgentId = this.config.interactionAgentId?.trim(); const orchestrationAgentId = this.config.orchestrationAgentId?.trim(); if (!interactionAgentId) { - throw new MosCoordinationGatewayError( + throw new InteractionCoordinationGatewayError( 'unconfigured_requester', 'Interaction agent identity is not configured', ); } if (!orchestrationAgentId) { - throw new MosCoordinationGatewayError( + throw new InteractionCoordinationGatewayError( 'unconfigured_target', 'Orchestration agent identity is not configured', ); @@ -177,9 +177,12 @@ export class MosCoordinationService { }); } - private normalizeRequest(request: CreateMosHandoffDto): NormalizedMosHandoffRequest { + private normalizeRequest(request: CreateHandoffDto): NormalizedHandoffRequest { if (typeof request !== 'object' || request === null) { - throw new MosCoordinationGatewayError('invalid_request', 'Mos handoff request is invalid'); + throw new InteractionCoordinationGatewayError( + 'invalid_request', + 'Handoff request is invalid', + ); } const idempotencyKey = this.requiredString( request.idempotencyKey, @@ -203,14 +206,17 @@ export class MosCoordinationService { private requiredString(value: unknown, field: string, maximumLength: number): string { if (typeof value !== 'string') { - throw new MosCoordinationGatewayError( + throw new InteractionCoordinationGatewayError( 'invalid_request', - `Mos handoff ${field} must be a string`, + `Handoff ${field} must be a string`, ); } const normalized = value.trim(); if (normalized.length === 0 || normalized.length > maximumLength) { - throw new MosCoordinationGatewayError('invalid_request', `Mos handoff ${field} is invalid`); + throw new InteractionCoordinationGatewayError( + 'invalid_request', + `Handoff ${field} is invalid`, + ); } return normalized; } @@ -245,23 +251,23 @@ export class MosCoordinationService { private ownerFor(handoffId: string, scope: CoordinationScope): HandoffOwner { const owner = this.owners.get(handoffId); if (owner === undefined) { - throw new MosCoordinationGatewayError('not_found', 'Mos handoff was not found'); + throw new InteractionCoordinationGatewayError('not_found', 'Handoff was not found'); } if ( owner.tenantId !== scope.tenantId || owner.actorId !== scope.actorId || owner.requesterAgentId !== scope.requesterAgentId ) { - throw new MosCoordinationGatewayError( + throw new InteractionCoordinationGatewayError( 'cross_tenant_forbidden', - 'Mos handoff is outside the authenticated scope', + 'Handoff is outside the authenticated scope', ); } return owner; } } -export type MosCoordinationGatewayErrorCode = +export type InteractionCoordinationGatewayErrorCode = | 'cross_tenant_forbidden' | 'handoff_conflict' | 'invalid_request' @@ -277,10 +283,7 @@ function sameOwner(left: HandoffOwner, right: HandoffOwner): boolean { ); } -function sameRequest( - left: NormalizedMosHandoffRequest, - right: NormalizedMosHandoffRequest, -): boolean { +function sameRequest(left: NormalizedHandoffRequest, right: NormalizedHandoffRequest): boolean { return ( left.idempotencyKey === right.idempotencyKey && left.summary === right.summary && @@ -289,12 +292,12 @@ function sameRequest( ); } -export class MosCoordinationGatewayError extends Error { +export class InteractionCoordinationGatewayError extends Error { constructor( - readonly code: MosCoordinationGatewayErrorCode, + readonly code: InteractionCoordinationGatewayErrorCode, message: string, ) { super(message); - this.name = MosCoordinationGatewayError.name; + this.name = InteractionCoordinationGatewayError.name; } } diff --git a/docs/scratchpads/747-wsa-dehardcode.md b/docs/scratchpads/747-wsa-dehardcode.md new file mode 100644 index 00000000..655200bb --- /dev/null +++ b/docs/scratchpads/747-wsa-dehardcode.md @@ -0,0 +1,43 @@ +# #747 — De-hardcode orchestrator and interaction agent names + +## Objective + +Replace branded Mos/Tess symbols, filenames, DI tokens, and error prose with role-neutral orchestrator/interaction vocabulary without changing env-driven runtime identity behavior. Add optional roster `alias` and `provider` fields and show aliases in `mosaic fleet ps` with name fallback. + +## Scope and constraints + +- Requirements: `/home/hermes/agent-work/reviews/747-wsa-dehardcode-brief.md`. +- Branch: `feat/747-dehardcode-orchestrator-interaction-names` from `main` at `e72388b2`. +- Keep `MOSAIC_AGENT_NAME` and `MOSAIC_ORCHESTRATOR_AGENT_NAME` unchanged. +- Sample/test data may retain operator display names. +- No behavior change beyond optional roster metadata and alias display. +- Budget: no explicit cap; conservative mechanical-rename scope only. +- TDD: optional and skipped because this is a mechanical rename with existing focused coverage; add focused alias/schema regression coverage before completion. + +## Plan + +1. Rename coordination and durable-session files and symbols using canonical vocabulary. +2. Scrub branded symbol names and error prose in the assigned source trees while preserving allowed sample data. +3. Extend roster schema with optional `alias` and `provider`; update fleet roster typing/rendering and focused tests. +4. Run grep-clean verification, build, typecheck, lint/format, focused coord/durable-session/fleet tests, and roster validation. +5. Commit, queue-guard, push, open a Gitea PR closing #747, and report to the coordinator. + +## Progress + +- 2026-07-13: Task resumed from coordinator brief; repository clean at `e72388b2`. +- Renamed coordination and durable-session files, exports, gateway DI symbols, DTOs, services, repositories, and tests. +- Replaced branded authority/error prose while preserving the existing `/api/coord/mos` compatibility route and env-variable identity inputs. +- Added optional roster `alias`/`provider` support and alias-first `fleet ps` display with canonical-name fallback. + +## Verification + +- `pnpm typecheck`: passed (42 tasks). +- `pnpm build`: passed (23 tasks). +- `pnpm lint`: passed (23 tasks). +- `pnpm format:check`: passed. +- Coordination tests: 7 passed. +- Agent durable-session/runtime tests: 23 passed. +- Gateway coordination/durable-session/integration tests: 20 passed. +- Full `fleet.spec.ts`: 192 passed, including alias/provider parsing and alias display. +- JSON Schema 2020 validation: legacy minimal roster and extended alias/provider roster passed; `alias` and `provider` remain absent from `required`. +- Grep verification: no branded symbol/type/file/DI names or error prose remain in assigned source trees; one allowed `Tess Owner` test-data display name remains. diff --git a/docs/tess/MOS-COORDINATION.md b/docs/tess/MOS-COORDINATION.md index 2eb3c811..07092a84 100644 --- a/docs/tess/MOS-COORDINATION.md +++ b/docs/tess/MOS-COORDINATION.md @@ -52,6 +52,10 @@ The port deliberately omits generic orchestrator verbs. It is tenant- and correlation-scoped; its gateway implementation obtains `actorId`, `tenantId`, and the requester agent from trusted authentication/configuration only. +## HTTP routes + +`/api/coord/interaction` is the canonical HTTP coordination prefix for handoff, observe, and result. `/api/coord/mos` remains a backward-compatible alias with the same handlers and DTOs; new integrations use the neutral canonical prefix. + ## Enforcement point `apps/gateway` owns a `MosCoordinationService` boundary that compares the diff --git a/packages/agent/src/tess-durable-session.test.ts b/packages/agent/src/durable-session.test.ts similarity index 99% rename from packages/agent/src/tess-durable-session.test.ts rename to packages/agent/src/durable-session.test.ts index 3cadaf43..109702ee 100644 --- a/packages/agent/src/tess-durable-session.test.ts +++ b/packages/agent/src/durable-session.test.ts @@ -3,7 +3,7 @@ import { DurableSessionCoordinator, InMemoryDurableSessionStore, type DurableSessionIdentity, -} from './tess-durable-session.js'; +} from './durable-session.js'; const IDENTITY: DurableSessionIdentity = { agentName: 'Nova', diff --git a/packages/agent/src/tess-durable-session.ts b/packages/agent/src/durable-session.ts similarity index 95% rename from packages/agent/src/tess-durable-session.ts rename to packages/agent/src/durable-session.ts index adc84c6a..a66e3a51 100644 --- a/packages/agent/src/tess-durable-session.ts +++ b/packages/agent/src/durable-session.ts @@ -102,7 +102,7 @@ export interface DurableSessionStore { export class DurableSessionNotFoundError extends Error { constructor(sessionId: string) { - super(`Durable Tess session not found: ${sessionId}`); + super(`Durable session not found: ${sessionId}`); this.name = 'DurableSessionNotFoundError'; } } @@ -212,11 +212,11 @@ export class DurableSessionCoordinator { async resumeHandoff(handoffId: string): Promise { const handoff = await this.store.findHandoff(handoffId); - if (!handoff) throw new Error(`Durable Tess handoff not found: ${handoffId}`); + if (!handoff) throw new Error(`Durable handoff not found: ${handoffId}`); const snapshot = await this.snapshot(handoff.sessionId); const checkpoint = await this.store.findCheckpoint(handoff.sessionId, handoff.checkpointId); if (!checkpoint) { - throw new Error(`Durable Tess handoff checkpoint is unavailable: ${handoff.checkpointId}`); + throw new Error(`Durable handoff checkpoint is unavailable: ${handoff.checkpointId}`); } return { identity: snapshot.identity, checkpoint, handoff }; } @@ -257,7 +257,7 @@ export class InMemoryDurableSessionStore implements DurableSessionStore { const existing = this.sessions.get(identity.sessionId); if (existing) { if (!sameEnrollmentScope(existing.identity, identity)) { - throw new Error(`Durable Tess session identity conflict: ${identity.sessionId}`); + throw new Error(`Durable session identity conflict: ${identity.sessionId}`); } existing.identity.providerId = identity.providerId; existing.identity.runtimeSessionId = identity.runtimeSessionId; @@ -290,7 +290,7 @@ export class InMemoryDurableSessionStore implements DurableSessionStore { const existing = state.inbox.get(input.idempotencyKey); if (existing) { if (!sameInbox(existing, input)) { - throw new Error(`Durable Tess inbox idempotency conflict: ${input.idempotencyKey}`); + throw new Error(`Durable inbox idempotency conflict: ${input.idempotencyKey}`); } return { accepted: false, status: existing.status }; } @@ -323,7 +323,7 @@ export class InMemoryDurableSessionStore implements DurableSessionStore { const existing = state.outbox.get(input.idempotencyKey); if (existing) { if (!sameOutbox(existing, input)) { - throw new Error(`Durable Tess outbox idempotency conflict: ${input.idempotencyKey}`); + throw new Error(`Durable outbox idempotency conflict: ${input.idempotencyKey}`); } return { accepted: false, status: existing.status }; } @@ -364,7 +364,7 @@ export class InMemoryDurableSessionStore implements DurableSessionStore { const checkpoints = this.require(input.sessionId).checkpoints; const existing = checkpoints.get(input.checkpointId); if (existing && !sameCheckpoint(existing, input)) { - throw new Error(`Durable Tess checkpoint identity conflict: ${input.checkpointId}`); + throw new Error(`Durable checkpoint identity conflict: ${input.checkpointId}`); } if (!existing) checkpoints.set(input.checkpointId, { ...input }); } @@ -377,11 +377,11 @@ export class InMemoryDurableSessionStore implements DurableSessionStore { async handoff(input: DurableHandoffInput): Promise { const state = this.require(input.sessionId); if (!state.checkpoints.has(input.checkpointId)) { - throw new Error(`Durable Tess handoff checkpoint is unavailable: ${input.checkpointId}`); + throw new Error(`Durable handoff checkpoint is unavailable: ${input.checkpointId}`); } const existing = state.handoffs.get(input.handoffId); if (existing && !sameHandoff(existing, input)) { - throw new Error(`Durable Tess handoff identity conflict: ${input.handoffId}`); + throw new Error(`Durable handoff identity conflict: ${input.handoffId}`); } if (!existing) state.handoffs.set(input.handoffId, { ...input }); } @@ -413,7 +413,7 @@ export class InMemoryDurableSessionStore implements DurableSessionStore { kind: string, ): T { const entry = records.get(idempotencyKey); - if (!entry) throw new Error(`Durable Tess ${kind} entry not found: ${idempotencyKey}`); + if (!entry) throw new Error(`Durable ${kind} entry not found: ${idempotencyKey}`); return entry; } } diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 702d9d9a..01237936 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -4,4 +4,4 @@ export * from './runtime-provider-registry.js'; export * from './tmux-fleet-runtime-provider.js'; export * from './hermes-runtime-provider.js'; export * from './matrix-native-runtime-provider.js'; -export * from './tess-durable-session.js'; +export * from './durable-session.js'; diff --git a/packages/agent/src/matrix-native-runtime-provider.ts b/packages/agent/src/matrix-native-runtime-provider.ts index 33985142..c32a1366 100644 --- a/packages/agent/src/matrix-native-runtime-provider.ts +++ b/packages/agent/src/matrix-native-runtime-provider.ts @@ -117,7 +117,7 @@ class DenyMatrixWriteAuthority implements MatrixWriteAuthority { async assertAuthorized(): Promise { throw new MatrixRuntimeProviderError( 'forbidden', - 'Matrix runtime writes require Mos authority', + 'Matrix runtime writes require orchestrator authority', ); } } diff --git a/packages/agent/src/tmux-fleet-runtime-provider.test.ts b/packages/agent/src/tmux-fleet-runtime-provider.test.ts index 84f01466..2031572c 100644 --- a/packages/agent/src/tmux-fleet-runtime-provider.test.ts +++ b/packages/agent/src/tmux-fleet-runtime-provider.test.ts @@ -202,7 +202,7 @@ describe('TmuxFleetRuntimeProvider security policy', (): void => { expect(fleet.terminate).not.toHaveBeenCalled(); }); - it('rejects an unverified target before consulting Mos write authority', async (): Promise => { + it('rejects an unverified target before consulting orchestrator write authority', async (): Promise => { const fleet = transport(); fleet.verifySession = vi.fn(async (): Promise => { throw new Error('target identity mismatch'); @@ -220,7 +220,20 @@ describe('TmuxFleetRuntimeProvider security policy', (): void => { expect(fleet.sendMessage).not.toHaveBeenCalled(); }); - it('passes an exact session ID to the fleet transport only through authorized Mos writes', async (): Promise => { + it('uses the role-neutral interaction source label by default', async (): Promise => { + const fleet = transport(); + const writeAuthority: FleetWriteAuthority = { + canWrite: vi.fn(async (): Promise => true), + assertAuthorized: vi.fn(async (): Promise => undefined), + }; + const provider = new TmuxFleetRuntimeProvider({ transport: fleet, writeAuthority }); + + await provider.sendMessage('coder0', { content: 'hello', idempotencyKey: 'message-1' }, scope); + + expect(fleet.sendMessage).toHaveBeenCalledWith('coder0', 'hello', 'interaction'); + }); + + it('passes an exact session ID to the fleet transport only through orchestrator-authorized writes', async (): Promise => { const fleet = transport(); const writeAuthority: FleetWriteAuthority = { canWrite: vi.fn(async (): Promise => true), @@ -228,7 +241,7 @@ describe('TmuxFleetRuntimeProvider security policy', (): void => { }; const provider = new TmuxFleetRuntimeProvider({ transport: fleet, - sourceLabel: 'tess', + sourceLabel: 'operator', writeAuthority, }); @@ -246,7 +259,7 @@ describe('TmuxFleetRuntimeProvider security policy', (): void => { scope, approvalRef: 'approval-1', }); - expect(fleet.sendMessage).toHaveBeenCalledWith('coder0', 'hello', 'tess'); + expect(fleet.sendMessage).toHaveBeenCalledWith('coder0', 'hello', 'operator'); expect(fleet.terminate).toHaveBeenCalledWith('coder0'); }); diff --git a/packages/agent/src/tmux-fleet-runtime-provider.ts b/packages/agent/src/tmux-fleet-runtime-provider.ts index ed36795b..0f0c67ba 100644 --- a/packages/agent/src/tmux-fleet-runtime-provider.ts +++ b/packages/agent/src/tmux-fleet-runtime-provider.ts @@ -64,14 +64,14 @@ export interface FleetWriteAuthorization { } /** - * Mos is the only authority that may permit Tess write/control requests to a + * The orchestrator is the only authority that may permit interaction-plane write/control requests to a * fleet peer. Gateway records the request and denial/success around provider - * invocation; the default authority prevents direct Tess writes by design. + * invocation; the default authority prevents direct interaction-plane writes by design. */ export interface FleetWriteAuthority { /** Non-consuming preflight used before probing the fleet transport. */ canWrite(authorization: FleetWriteAuthorization): Promise; - /** Final exact-target authorization; may consume a Mos grant. */ + /** Final exact-target authorization; may consume an orchestrator grant. */ assertAuthorized(authorization: FleetWriteAuthorization): Promise; } @@ -116,7 +116,7 @@ class DenyFleetWriteAuthority implements FleetWriteAuthority { async assertAuthorized(_authorization: FleetWriteAuthorization): Promise { throw new FleetRuntimeProviderError( 'forbidden', - 'Fleet writes require an explicit Mos authority decision', + 'Fleet writes require an explicit orchestrator authority decision', ); } } @@ -124,7 +124,7 @@ class DenyFleetWriteAuthority implements FleetWriteAuthority { /** * A capability-limited provider for rostered local fleet peers. It never * permits raw tmux socket/target selection, interactive control attach, or - * direct Tess writes; all side effects pass through exact transport checks. + * direct interaction-plane writes; all side effects pass through exact transport checks. */ export class TmuxFleetRuntimeProvider implements AgentRuntimeProvider { readonly id = FLEET_PROVIDER_ID; @@ -139,7 +139,7 @@ export class TmuxFleetRuntimeProvider implements AgentRuntimeProvider { constructor(private readonly options: TmuxFleetRuntimeProviderOptions) { this.readAuthority = options.readAuthority ?? new DenyFleetReadAuthority(); this.writeAuthority = options.writeAuthority ?? new DenyFleetWriteAuthority(); - this.sourceLabel = options.sourceLabel ?? 'tess'; + this.sourceLabel = options.sourceLabel ?? 'interaction'; this.attachmentIdFactory = options.attachmentIdFactory ?? randomUUID; this.now = options.now ?? (() => new Date()); this.attachmentTtlMs = options.attachmentTtlMs ?? ATTACHMENT_TTL_MS; diff --git a/packages/coord/src/__tests__/mos-coordination.test.ts b/packages/coord/src/__tests__/interaction-coordination.test.ts similarity index 77% rename from packages/coord/src/__tests__/mos-coordination.test.ts rename to packages/coord/src/__tests__/interaction-coordination.test.ts index 912bdc69..cb64ee39 100644 --- a/packages/coord/src/__tests__/mos-coordination.test.ts +++ b/packages/coord/src/__tests__/interaction-coordination.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it, vi } from 'vitest'; import { - InMemoryMosCoordinationPort, - MosCoordinationClient, + InMemoryInteractionCoordinationPort, + InteractionCoordinationClient, type CoordinationScope, - type MosCoordinationAuthorityError, - type MosCoordinationPort, + type InteractionCoordinationAuthorityError, + type InteractionCoordinationPort, } from '../index.js'; const scope: CoordinationScope = { @@ -15,19 +15,19 @@ const scope: CoordinationScope = { }; function client( - port: MosCoordinationPort, + port: InteractionCoordinationPort, handoffIdFactory: () => string = (): string => 'handoff-1', -): MosCoordinationClient { - return new MosCoordinationClient( +): InteractionCoordinationClient { + return new InteractionCoordinationClient( { interactionAgentId: 'Nova', orchestrationAgentId: 'Conductor' }, port, handoffIdFactory, ); } -describe('MosCoordinationClient', (): void => { +describe('InteractionCoordinationClient', (): void => { it('round-trips handoff, observation, and result through the native port with identities as data', async (): Promise => { - const adapter = new InMemoryMosCoordinationPort(); + const adapter = new InMemoryInteractionCoordinationPort(); const coordination = client(adapter); await expect( @@ -42,8 +42,8 @@ describe('MosCoordinationClient', (): void => { correlationId: 'corr-1', }); - adapter.recordActivity('handoff-1', 'running', 'Mos accepted the request'); - adapter.recordResult('handoff-1', 'completed', 'Merged by Mos'); + adapter.recordActivity('handoff-1', 'running', 'Orchestrator accepted the request'); + adapter.recordResult('handoff-1', 'completed', 'Merged by orchestrator'); await expect(coordination.observe('handoff-1', scope)).resolves.toMatchObject({ status: 'completed', @@ -59,7 +59,7 @@ describe('MosCoordinationClient', (): void => { targetAgentId: 'Conductor', status: 'completed', correlationId: 'corr-1', - summary: 'Merged by Mos', + summary: 'Merged by orchestrator', }); expect(coordination).not.toHaveProperty('dispatch'); @@ -69,8 +69,8 @@ describe('MosCoordinationClient', (): void => { expect(coordination).not.toHaveProperty('cancel'); }); - it('fails closed before delivery when an unconfigured agent requests Mos work', async (): Promise => { - const adapter = new InMemoryMosCoordinationPort(); + it('fails closed before delivery when an unconfigured agent requests orchestrator work', async (): Promise => { + const adapter = new InMemoryInteractionCoordinationPort(); await expect( client(adapter).handoff( @@ -79,31 +79,31 @@ describe('MosCoordinationClient', (): void => { ), ).rejects.toMatchObject({ code: 'requester_forbidden', - } satisfies Partial); + } satisfies Partial); }); it('rejects self-delegation configuration before constructing a client', (): void => { expect( - (): MosCoordinationClient => - new MosCoordinationClient( + (): InteractionCoordinationClient => + new InteractionCoordinationClient( { interactionAgentId: 'Nova', orchestrationAgentId: 'Nova' }, - new InMemoryMosCoordinationPort(), + new InMemoryInteractionCoordinationPort(), ), ).toThrow('Interaction and orchestration identities must differ'); }); it('rejects whitespace-equivalent self-delegation identities', (): void => { expect( - (): MosCoordinationClient => - new MosCoordinationClient( + (): InteractionCoordinationClient => + new InteractionCoordinationClient( { interactionAgentId: 'Nova ', orchestrationAgentId: 'Nova' }, - new InMemoryMosCoordinationPort(), + new InMemoryInteractionCoordinationPort(), ), ).toThrow('Interaction and orchestration identities must differ'); }); it('does not expose another tenant handoff to observe or result', async (): Promise => { - const adapter = new InMemoryMosCoordinationPort(); + const adapter = new InMemoryInteractionCoordinationPort(); const coordination = client(adapter); await coordination.handoff( { idempotencyKey: 'handoff-request-1', summary: 'Implement the requested feature' }, @@ -120,7 +120,7 @@ describe('MosCoordinationClient', (): void => { }); it('bounds native handoff retention by evicting the oldest handoff', async (): Promise => { - const adapter = new InMemoryMosCoordinationPort({ maxHandoffs: 1 }); + const adapter = new InMemoryInteractionCoordinationPort({ maxHandoffs: 1 }); const first = client(adapter, (): string => 'handoff-1'); const second = client(adapter, (): string => 'handoff-2'); await first.handoff({ idempotencyKey: 'handoff-request-1', summary: 'First request' }, scope); @@ -131,7 +131,7 @@ describe('MosCoordinationClient', (): void => { }); it('fails closed when a transport reports target drift', async (): Promise => { - const adapter: MosCoordinationPort = { + const adapter: InteractionCoordinationPort = { handoff: vi.fn(async () => ({ handoffId: 'handoff-1', targetAgentId: 'Unexpected', @@ -149,6 +149,6 @@ describe('MosCoordinationClient', (): void => { ), ).rejects.toMatchObject({ code: 'target_drift', - } satisfies Partial); + } satisfies Partial); }); }); diff --git a/packages/coord/src/in-memory-mos-coordination-port.ts b/packages/coord/src/in-memory-interaction-coordination-port.ts similarity index 77% rename from packages/coord/src/in-memory-mos-coordination-port.ts rename to packages/coord/src/in-memory-interaction-coordination-port.ts index ddebbadd..517e2e12 100644 --- a/packages/coord/src/in-memory-mos-coordination-port.ts +++ b/packages/coord/src/in-memory-interaction-coordination-port.ts @@ -2,25 +2,25 @@ import { type CoordinationObservation, type CoordinationResult, type CoordinationScope, - type MosCoordinationActivity, - type MosCoordinationPort, - type MosHandoff, - type MosHandoffReceipt, - type MosHandoffStatus, -} from './mos-coordination.js'; + type InteractionCoordinationActivity, + type InteractionCoordinationPort, + type Handoff, + type HandoffReceipt, + type HandoffStatus, +} from './interaction-coordination.js'; const DEFAULT_HANDOFF_TTL_MS = 60 * 60 * 1_000; const DEFAULT_MAX_HANDOFFS = 1_000; interface StoredHandoff { - readonly handoff: MosHandoff; - status: MosHandoffStatus; - readonly activity: MosCoordinationActivity[]; + readonly handoff: Handoff; + status: HandoffStatus; + readonly activity: InteractionCoordinationActivity[]; readonly expiresAt: number; result?: CoordinationResult; } -export interface InMemoryMosCoordinationPortOptions { +export interface InMemoryInteractionCoordinationPortOptions { now?: () => Date; handoffTtlMs?: number; maxHandoffs?: number; @@ -29,21 +29,21 @@ export interface InMemoryMosCoordinationPortOptions { /** * Native deterministic queue/port adapter for the coordination boundary. * It intentionally has no fleet/tmux dependency. A future deployment adapter - * implements MosCoordinationPort without changing interaction-plane callers. + * implements InteractionCoordinationPort without changing interaction-plane callers. */ -export class InMemoryMosCoordinationPort implements MosCoordinationPort { +export class InMemoryInteractionCoordinationPort implements InteractionCoordinationPort { private readonly handoffs = new Map(); private readonly now: () => Date; private readonly handoffTtlMs: number; private readonly maxHandoffs: number; - constructor(options: InMemoryMosCoordinationPortOptions = {}) { + constructor(options: InMemoryInteractionCoordinationPortOptions = {}) { this.now = options.now ?? (() => new Date()); this.handoffTtlMs = options.handoffTtlMs ?? DEFAULT_HANDOFF_TTL_MS; this.maxHandoffs = options.maxHandoffs ?? DEFAULT_MAX_HANDOFFS; } - async handoff(handoff: MosHandoff): Promise { + async handoff(handoff: Handoff): Promise { this.pruneExpiredHandoffs(); const existing = this.handoffs.get(handoff.handoffId); if (existing !== undefined) { @@ -88,14 +88,14 @@ export class InMemoryMosCoordinationPort implements MosCoordinationPort { } /** Host-side progression seam; interaction clients never receive this capability. */ - recordActivity(handoffId: string, status: MosHandoffStatus, summary: string): void { + recordActivity(handoffId: string, status: HandoffStatus, summary: string): void { this.pruneExpiredHandoffs(); const stored = this.requireHandoff(handoffId); stored.status = status; stored.activity.push(activity(status, summary, this.now)); } - /** Host-side result seam for deterministic qualification; not a Mos consumer. */ + /** Host-side result seam for deterministic qualification; not an orchestrator consumer. */ recordResult(handoffId: string, status: 'completed' | 'failed', summary: string): void { this.pruneExpiredHandoffs(); const stored = this.requireHandoff(handoffId); @@ -110,7 +110,7 @@ export class InMemoryMosCoordinationPort implements MosCoordinationPort { }; } - private receipt(handoff: MosHandoff, status: MosHandoffStatus): MosHandoffReceipt { + private receipt(handoff: Handoff, status: HandoffStatus): HandoffReceipt { return { handoffId: handoff.handoffId, targetAgentId: handoff.targetAgentId, @@ -141,7 +141,7 @@ export class InMemoryMosCoordinationPort implements MosCoordinationPort { stored.handoff.scope.actorId !== scope.actorId || stored.handoff.scope.requesterAgentId !== scope.requesterAgentId ) { - throw new InMemoryMosCoordinationError('forbidden', 'Handoff scope does not match'); + throw new InMemoryInteractionCoordinationError('forbidden', 'Handoff scope does not match'); } return stored; } @@ -149,12 +149,12 @@ export class InMemoryMosCoordinationPort implements MosCoordinationPort { private requireHandoff(handoffId: string): StoredHandoff { const stored = this.handoffs.get(handoffId); if (stored === undefined) { - throw new InMemoryMosCoordinationError('not_found', 'Handoff was not found'); + throw new InMemoryInteractionCoordinationError('not_found', 'Handoff was not found'); } return stored; } - private assertSameHandoff(existing: MosHandoff, incoming: MosHandoff): void { + private assertSameHandoff(existing: Handoff, incoming: Handoff): void { if ( existing.targetAgentId !== incoming.targetAgentId || existing.request.idempotencyKey !== incoming.request.idempotencyKey || @@ -166,7 +166,7 @@ export class InMemoryMosCoordinationPort implements MosCoordinationPort { existing.scope.correlationId !== incoming.scope.correlationId || existing.scope.requesterAgentId !== incoming.scope.requesterAgentId ) { - throw new InMemoryMosCoordinationError( + throw new InMemoryInteractionCoordinationError( 'conflict', 'Handoff ID is already bound to different immutable input', ); @@ -174,31 +174,31 @@ export class InMemoryMosCoordinationPort implements MosCoordinationPort { } } -export type InMemoryMosCoordinationErrorCode = 'conflict' | 'forbidden' | 'not_found'; +export type InMemoryInteractionCoordinationErrorCode = 'conflict' | 'forbidden' | 'not_found'; -export class InMemoryMosCoordinationError extends Error { +export class InMemoryInteractionCoordinationError extends Error { constructor( - readonly code: InMemoryMosCoordinationErrorCode, + readonly code: InMemoryInteractionCoordinationErrorCode, message: string, ) { super(message); - this.name = InMemoryMosCoordinationError.name; + this.name = InMemoryInteractionCoordinationError.name; } } function activity( - status: MosHandoffStatus, + status: HandoffStatus, summary: string, now: () => Date, -): MosCoordinationActivity { +): InteractionCoordinationActivity { return { occurredAt: now().toISOString(), status, summary }; } -function copyActivity(entry: MosCoordinationActivity): MosCoordinationActivity { +function copyActivity(entry: InteractionCoordinationActivity): InteractionCoordinationActivity { return { ...entry }; } -function snapshotHandoff(handoff: MosHandoff): MosHandoff { +function snapshotHandoff(handoff: Handoff): Handoff { return Object.freeze({ handoffId: handoff.handoffId, targetAgentId: handoff.targetAgentId, diff --git a/packages/coord/src/index.ts b/packages/coord/src/index.ts index cf59bac4..db708740 100644 --- a/packages/coord/src/index.ts +++ b/packages/coord/src/index.ts @@ -3,22 +3,25 @@ export { parseTasksFile, updateTaskStatus, writeTasksFile } from './tasks-file.j export { runTask, resumeTask } from './runner.js'; export { getMissionStatus, getTaskStatus } from './status.js'; export { - InMemoryMosCoordinationError, - InMemoryMosCoordinationPort, -} from './in-memory-mos-coordination-port.js'; -export { MosCoordinationAuthorityError, MosCoordinationClient } from './mos-coordination.js'; + InMemoryInteractionCoordinationError, + InMemoryInteractionCoordinationPort, +} from './in-memory-interaction-coordination-port.js'; +export { + InteractionCoordinationAuthorityError, + InteractionCoordinationClient, +} from './interaction-coordination.js'; export type { CoordinationObservation, CoordinationResult, CoordinationScope, - MosCoordinationActivity, - MosCoordinationIdentity, - MosCoordinationPort, - MosHandoff, - MosHandoffReceipt, - MosHandoffRequest, - MosHandoffStatus, -} from './mos-coordination.js'; + InteractionCoordinationActivity, + InteractionCoordinationIdentity, + InteractionCoordinationPort, + Handoff, + HandoffReceipt, + HandoffRequest, + HandoffStatus, +} from './interaction-coordination.js'; export type { CreateMissionOptions, Mission, diff --git a/packages/coord/src/mos-coordination.ts b/packages/coord/src/interaction-coordination.ts similarity index 66% rename from packages/coord/src/mos-coordination.ts rename to packages/coord/src/interaction-coordination.ts index f71d2ea3..a07a3ff0 100644 --- a/packages/coord/src/mos-coordination.ts +++ b/packages/coord/src/interaction-coordination.ts @@ -1,4 +1,4 @@ -export type MosHandoffStatus = 'queued' | 'accepted' | 'running' | 'completed' | 'failed'; +export type HandoffStatus = 'queued' | 'accepted' | 'running' | 'completed' | 'failed'; export interface CoordinationScope { readonly actorId: string; @@ -8,44 +8,44 @@ export interface CoordinationScope { readonly requesterAgentId: string; } -export interface MosCoordinationIdentity { +export interface InteractionCoordinationIdentity { readonly interactionAgentId: string; readonly orchestrationAgentId: string; } -export interface MosHandoffRequest { +export interface HandoffRequest { readonly idempotencyKey: string; readonly summary: string; readonly context?: string; readonly missionId?: string; } -export interface MosHandoff { +export interface Handoff { readonly handoffId: string; readonly targetAgentId: string; - readonly request: MosHandoffRequest; + readonly request: HandoffRequest; readonly scope: CoordinationScope; } -export interface MosHandoffReceipt { +export interface HandoffReceipt { readonly handoffId: string; readonly targetAgentId: string; readonly status: 'queued' | 'accepted'; readonly correlationId: string; } -export interface MosCoordinationActivity { +export interface InteractionCoordinationActivity { readonly occurredAt: string; - readonly status: MosHandoffStatus; + readonly status: HandoffStatus; readonly summary: string; } export interface CoordinationObservation { readonly handoffId: string; readonly targetAgentId: string; - readonly status: MosHandoffStatus; + readonly status: HandoffStatus; readonly correlationId: string; - readonly activity: readonly MosCoordinationActivity[]; + readonly activity: readonly InteractionCoordinationActivity[]; } export interface CoordinationResult { @@ -61,25 +61,25 @@ export interface CoordinationResult { * its progress/result, but it cannot issue worker, review, merge, or other * general orchestration commands. */ -export interface MosCoordinationPort { - handoff(handoff: MosHandoff): Promise; +export interface InteractionCoordinationPort { + handoff(handoff: Handoff): Promise; observe(handoffId: string, scope: CoordinationScope): Promise; result(handoffId: string, scope: CoordinationScope): Promise; } -export type MosCoordinationAuthorityErrorCode = +export type InteractionCoordinationAuthorityErrorCode = | 'invalid_identity' | 'requester_forbidden' | 'target_drift' | 'correlation_drift'; -export class MosCoordinationAuthorityError extends Error { +export class InteractionCoordinationAuthorityError extends Error { constructor( - readonly code: MosCoordinationAuthorityErrorCode, + readonly code: InteractionCoordinationAuthorityErrorCode, message: string, ) { super(message); - this.name = MosCoordinationAuthorityError.name; + this.name = InteractionCoordinationAuthorityError.name; } } @@ -87,20 +87,20 @@ export class MosCoordinationAuthorityError extends Error { * Enforces the interaction-to-orchestration authority boundary before a * transport is reached. Identity names remain configuration data. */ -export class MosCoordinationClient { - private readonly identity: MosCoordinationIdentity; +export class InteractionCoordinationClient { + private readonly identity: InteractionCoordinationIdentity; constructor( - identity: MosCoordinationIdentity, - private readonly port: MosCoordinationPort, + identity: InteractionCoordinationIdentity, + private readonly port: InteractionCoordinationPort, private readonly handoffIdFactory: () => string = (): string => crypto.randomUUID(), ) { this.identity = normalizeIdentity(identity); } - async handoff(request: MosHandoffRequest, scope: CoordinationScope): Promise { + async handoff(request: HandoffRequest, scope: CoordinationScope): Promise { this.assertRequester(scope); - const handoff: MosHandoff = { + const handoff: Handoff = { handoffId: this.handoffIdFactory(), targetAgentId: this.identity.orchestrationAgentId, request: snapshotRequest(request), @@ -111,15 +111,15 @@ export class MosCoordinationClient { receipt.handoffId !== handoff.handoffId || receipt.targetAgentId !== handoff.targetAgentId ) { - throw new MosCoordinationAuthorityError( + throw new InteractionCoordinationAuthorityError( 'target_drift', - 'Mos coordination transport returned a mismatched handoff target', + 'Interaction coordination transport returned a mismatched handoff target', ); } if (receipt.correlationId !== handoff.scope.correlationId) { - throw new MosCoordinationAuthorityError( + throw new InteractionCoordinationAuthorityError( 'correlation_drift', - 'Mos coordination transport returned a mismatched correlation ID', + 'Interaction coordination transport returned a mismatched correlation ID', ); } return receipt; @@ -137,7 +137,7 @@ export class MosCoordinationClient { private assertRequester(scope: CoordinationScope): void { if (scope.requesterAgentId !== this.identity.interactionAgentId) { - throw new MosCoordinationAuthorityError( + throw new InteractionCoordinationAuthorityError( 'requester_forbidden', 'Requester is not the configured interaction agent', ); @@ -149,15 +149,15 @@ export class MosCoordinationClient { scope: CoordinationScope, ): CoordinationObservation { if (observation.targetAgentId !== this.identity.orchestrationAgentId) { - throw new MosCoordinationAuthorityError( + throw new InteractionCoordinationAuthorityError( 'target_drift', - 'Mos coordination transport returned an unexpected observation target', + 'Interaction coordination transport returned an unexpected observation target', ); } if (observation.correlationId !== scope.correlationId) { - throw new MosCoordinationAuthorityError( + throw new InteractionCoordinationAuthorityError( 'correlation_drift', - 'Mos coordination transport returned a mismatched observation correlation ID', + 'Interaction coordination transport returned a mismatched observation correlation ID', ); } return observation; @@ -165,32 +165,34 @@ export class MosCoordinationClient { private assertResult(result: CoordinationResult, scope: CoordinationScope): CoordinationResult { if (result.targetAgentId !== this.identity.orchestrationAgentId) { - throw new MosCoordinationAuthorityError( + throw new InteractionCoordinationAuthorityError( 'target_drift', - 'Mos coordination transport returned an unexpected result target', + 'Interaction coordination transport returned an unexpected result target', ); } if (result.correlationId !== scope.correlationId) { - throw new MosCoordinationAuthorityError( + throw new InteractionCoordinationAuthorityError( 'correlation_drift', - 'Mos coordination transport returned a mismatched result correlation ID', + 'Interaction coordination transport returned a mismatched result correlation ID', ); } return result; } } -function normalizeIdentity(identity: MosCoordinationIdentity): MosCoordinationIdentity { +function normalizeIdentity( + identity: InteractionCoordinationIdentity, +): InteractionCoordinationIdentity { const interactionAgentId = identity.interactionAgentId.trim(); const orchestrationAgentId = identity.orchestrationAgentId.trim(); if (interactionAgentId.length === 0 || orchestrationAgentId.length === 0) { - throw new MosCoordinationAuthorityError( + throw new InteractionCoordinationAuthorityError( 'invalid_identity', 'Interaction and orchestration identities are required', ); } if (interactionAgentId === orchestrationAgentId) { - throw new MosCoordinationAuthorityError( + throw new InteractionCoordinationAuthorityError( 'invalid_identity', 'Interaction and orchestration identities must differ', ); @@ -198,7 +200,7 @@ function normalizeIdentity(identity: MosCoordinationIdentity): MosCoordinationId return Object.freeze({ interactionAgentId, orchestrationAgentId }); } -function snapshotRequest(request: MosHandoffRequest): MosHandoffRequest { +function snapshotRequest(request: HandoffRequest): HandoffRequest { return Object.freeze({ ...request }); } diff --git a/packages/mosaic/framework/fleet/roles/operator-interaction.md b/packages/mosaic/framework/fleet/roles/operator-interaction.md index 48f2490e..81f40bb9 100644 --- a/packages/mosaic/framework/fleet/roles/operator-interaction.md +++ b/packages/mosaic/framework/fleet/roles/operator-interaction.md @@ -2,10 +2,10 @@ The **operator-interaction** role is the authorized human interaction plane for Mosaic. It presents runtime and fleet state, mediates approved actions, and -hands coding or general orchestration work to Mos. +hands coding or general orchestration work to the orchestrator. ## Boundaries -- It does not claim Mos-owned coding or general orchestration work. +- It does not claim orchestrator-owned coding or general orchestration work. - It exposes only the configured, observable tool policy. - It does not receive or surface credentials in its effective policy. diff --git a/packages/mosaic/framework/fleet/roster.schema.json b/packages/mosaic/framework/fleet/roster.schema.json index 85b862c5..386ac9e7 100644 --- a/packages/mosaic/framework/fleet/roster.schema.json +++ b/packages/mosaic/framework/fleet/roster.schema.json @@ -75,6 +75,14 @@ "type": "string", "pattern": "^[A-Za-z0-9_.-]+$" }, + "alias": { + "description": "Optional operator-defined display name for the agent.", + "type": "string" + }, + "provider": { + "description": "Optional agent runtime provider identifier such as openai-codex.", + "type": "string" + }, "runtime": { "type": "string" }, diff --git a/packages/mosaic/src/commands/fleet.spec.ts b/packages/mosaic/src/commands/fleet.spec.ts index 641c0605..0c2fcbe2 100644 --- a/packages/mosaic/src/commands/fleet.spec.ts +++ b/packages/mosaic/src/commands/fleet.spec.ts @@ -189,6 +189,36 @@ describe('fleet roster parsing', () => { expect(getRosterAgent(roster, 'canary-pi').runtime).toBe('pi'); }); + it('accepts optional agent alias and provider metadata without requiring them', async () => { + cleanup = await tempDir(); + const rosterPath = join(cleanup, 'roster.yaml'); + await writeFile( + rosterPath, + [ + 'version: 1', + 'transport: tmux', + 'agents:', + ' - name: interaction', + ' alias: Friday', + ' provider: openai-codex', + ' runtime: pi', + ' - name: worker0', + ' runtime: codex', + ].join('\n'), + ); + + const roster = await loadFleetRoster(rosterPath); + + expect(getRosterAgent(roster, 'interaction')).toMatchObject({ + name: 'interaction', + alias: 'Friday', + provider: 'openai-codex', + }); + expect(getRosterAgent(roster, 'worker0')).toMatchObject({ name: 'worker0' }); + expect(getRosterAgent(roster, 'worker0').alias).toBeUndefined(); + expect(getRosterAgent(roster, 'worker0').provider).toBeUndefined(); + }); + it('socketArgs: named socket → -L ; empty → no -L (default socket)', () => { expect(socketArgs('mosaic-fleet')).toEqual(['-L', 'mosaic-fleet']); expect(socketArgs('')).toEqual([]); @@ -1872,7 +1902,61 @@ describe('fleet ps — tenant and host', () => { }); }); -describe('fleet ps — JSON output shape (FR-6)', () => { +describe('fleet ps — aliases and JSON output shape (FR-6)', () => { + it('shows an agent alias in table output and falls back to name when absent', async () => { + const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-')); + const rosterPath = join(home, 'fleet', 'roster.yaml'); + await mkdir(join(home, 'fleet'), { recursive: true }); + await writeFile( + rosterPath, + [ + 'version: 1', + 'transport: tmux', + 'agents:', + ' - name: interaction', + ' alias: Friday', + ' runtime: pi', + ' - name: worker0', + ' runtime: codex', + ].join('\n'), + ); + + const runner: CommandRunner = async (command, args) => { + const fullArgs = [command, ...args].join(' '); + if (fullArgs.includes('list-sessions')) { + return { stdout: 'interaction\nworker0\n', stderr: '', exitCode: 0 }; + } + return { + stdout: fullArgs.includes('systemctl') + ? 'ActiveState=active\nSubState=running\nUnitFileState=enabled\n' + : '12345 pi 0 0 0 0\n', + stderr: '', + exitCode: 0, + }; + }; + + const lines: string[] = []; + const origLog = console.log; + console.log = (msg: string) => { + lines.push(msg); + }; + + const program = new Command(); + program.exitOverride(); + registerFleetCommand(program, { runner, mosaicHome: home }); + + try { + await program.parseAsync(['node', 'mosaic', 'fleet', 'ps']); + } finally { + console.log = origLog; + await rm(home, { recursive: true, force: true }); + } + + const output = lines.join('\n'); + expect(output).toContain('Friday'); + expect(output).toContain('worker0'); + }); + it('produces --json records including tenant_id and host for each agent', async () => { const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-')); const rosterPath = join(home, 'fleet', 'roster.yaml'); diff --git a/packages/mosaic/src/commands/fleet.ts b/packages/mosaic/src/commands/fleet.ts index 293fc9bc..87fdc9db 100644 --- a/packages/mosaic/src/commands/fleet.ts +++ b/packages/mosaic/src/commands/fleet.ts @@ -81,6 +81,8 @@ interface RawFleetRoster { runtimes?: Record; agents?: Array<{ name?: unknown; + alias?: unknown; + provider?: unknown; runtime?: unknown; class?: unknown; working_directory?: unknown; @@ -102,6 +104,8 @@ interface RawFleetRoster { export interface FleetAgent { name: string; + alias?: string; + provider?: string; runtime: string; className: string; workingDirectory?: string; @@ -1074,6 +1078,7 @@ export interface HeartbeatInfo { export interface AgentPsRow { name: string; + alias?: string; tenant_id: string; host: string; runtime: string; @@ -1755,6 +1760,7 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps = rows.push({ name: agent.name, + alias: agent.alias, tenant_id, host, runtime: agent.runtime, @@ -1888,7 +1894,7 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps = console.log( [ - row.name.padEnd(18), + (row.alias ?? row.name).padEnd(18), row.tenant_id.padEnd(12), row.host.padEnd(12), row.runtime.padEnd(10), @@ -2494,6 +2500,8 @@ function normalizeAgent(raw: NonNullable[number]): Fle assertObject(raw, 'Fleet roster agent'); assertKnownKeys(raw, 'Fleet roster agent', [ 'name', + 'alias', + 'provider', 'runtime', 'class', 'working_directory', @@ -2525,6 +2533,8 @@ function normalizeAgent(raw: NonNullable[number]): Fle } return { name, + alias: optionalString(raw.alias, `Fleet roster agent "${name}" alias`), + provider: optionalString(raw.provider, `Fleet roster agent "${name}" provider`), runtime, className: stringValue(raw.class, 'worker', `Fleet roster agent "${name}" class`), workingDirectory: optionalString( @@ -2827,6 +2837,12 @@ export function serializeRosterToYaml(roster: FleetRoster): string { runtime: agent.runtime, class: agent.className, }; + if (agent.alias !== undefined) { + raw['alias'] = agent.alias; + } + if (agent.provider !== undefined) { + raw['provider'] = agent.provider; + } if (agent.workingDirectory !== undefined) { raw['working_directory'] = agent.workingDirectory; } diff --git a/packages/mosaic/src/commands/launch.ts b/packages/mosaic/src/commands/launch.ts index 4d96f9af..03a0d840 100644 --- a/packages/mosaic/src/commands/launch.ts +++ b/packages/mosaic/src/commands/launch.ts @@ -414,7 +414,7 @@ function readFleetToolPolicyBlock(policy: string | undefined): string { '', 'Permitted: authorized conversation, status, retrieval, and safe diagnostics.', 'Denied by default: coding/general orchestration claims, direct fleet control, destructive actions, and credential access.', - 'Delegate Mos-owned work through the authorized handoff boundary.', + 'Delegate orchestrator-owned work through the authorized handoff boundary.', ].join('\n'); } From d0771835542deab048ad8e79f271e3abdb6151f7 Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Mon, 13 Jul 2026 19:59:38 +0000 Subject: [PATCH 041/152] docs(tess): remediate M5 qualification findings (#750) --- .../hermes-runtime-reachability.e2e.test.ts | 32 ++++++++++ docs/SITEMAP.md | 32 ++++++++++ docs/openapi-tess.yaml | 4 +- .../tess-m4-001-mos-coordination.md | 6 +- docs/tess/ARCHITECTURE.md | 2 +- docs/tess/M5-MIGRATION-CUTOVER.md | 2 +- docs/tess/M5-MIGRATION-INVENTORY.md | 2 +- docs/tess/M5-MIGRATION-ROLLBACK.md | 2 +- docs/tess/MOS-COORDINATION.md | 18 +++--- docs/tess/THREAT-MODEL.md | 22 ++++--- .../gateway-api.interaction-errors.test.ts | 61 +++++++++++++++++++ 11 files changed, 156 insertions(+), 27 deletions(-) create mode 100644 docs/SITEMAP.md create mode 100644 packages/mosaic/src/tui/gateway-api.interaction-errors.test.ts diff --git a/apps/gateway/src/agent/hermes-runtime-reachability.e2e.test.ts b/apps/gateway/src/agent/hermes-runtime-reachability.e2e.test.ts index 546e64ef..1b20084f 100644 --- a/apps/gateway/src/agent/hermes-runtime-reachability.e2e.test.ts +++ b/apps/gateway/src/agent/hermes-runtime-reachability.e2e.test.ts @@ -115,6 +115,38 @@ describe('Hermes runtime provider reachability', (): void => { await app?.close(); }); + it('returns gateway denial responses from the actual guarded interaction routes', async (): Promise => { + if (!app) throw new Error('Nest application did not initialize'); + + const attachDenied = await app.inject({ + method: 'POST', + url: '/api/interaction/Nova/sessions/session-1/attach', + headers: { 'x-correlation-id': 'correlation-1' }, + payload: { mode: 'read' }, + }); + expect(attachDenied.statusCode).toBe(401); + + const sendDenied = await app.inject({ + method: 'POST', + url: '/api/interaction/Nova/sessions/session-1/send', + headers: { cookie: 'session=trusted', 'x-correlation-id': 'correlation-1' }, + payload: {}, + }); + expect(sendDenied.statusCode).toBe(403); + expect(sendDenied.json()).toMatchObject({ + message: 'Content and idempotency key are required', + }); + + const stopDenied = await app.inject({ + method: 'POST', + url: '/api/interaction/Nova/sessions/session-1/stop', + headers: { cookie: 'session=trusted', 'x-correlation-id': 'correlation-1' }, + payload: {}, + }); + expect(stopDenied.statusCode).toBe(403); + expect(stopDenied.json()).toMatchObject({ message: 'Exact-action approval is required' }); + }); + it('requires authentication and reaches the Hermes provider registered by AgentModule', async (): Promise => { if (!app) throw new Error('Nest application did not initialize'); const registry = app.get(AGENT_RUNTIME_PROVIDER_REGISTRY); diff --git a/docs/SITEMAP.md b/docs/SITEMAP.md new file mode 100644 index 00000000..cab3d2c6 --- /dev/null +++ b/docs/SITEMAP.md @@ -0,0 +1,32 @@ +# Documentation Sitemap + +## Tess interaction agent + +### Operator guides + +- [User guide](tess/USER-GUIDE.md) — authorized session, attach, send, stop, and handoff workflows. +- [Admin guide](tess/ADMIN-GUIDE.md) — deployment configuration, policy, and approval controls. +- [Developer guide](tess/DEVELOPER-GUIDE.md) — provider contracts, scope boundaries, and test workflow. +- [Plugin guide](tess/PLUGIN-GUIDE.md) — adapter, redaction, and identity-as-data requirements. +- [Operations guide](tess/OPERATIONS-GUIDE.md) — readiness, recovery, and incident-safe procedures. + +### Architecture and security + +- [Architecture](tess/ARCHITECTURE.md) +- [Threat model](tess/THREAT-MODEL.md) +- [Mos coordination boundary](tess/MOS-COORDINATION.md) +- [Hermes runtime adapter design](tess/hermes-runtime-adapter-design.md) +- [Operator plugin sketch](tess/M4-003-OPERATOR-PLUGIN-SKETCH.md) + +### API contract + +- [Tess OpenAPI contract](openapi-tess.yaml) + +### Migration and qualification + +- [Migration inventory](tess/M5-MIGRATION-INVENTORY.md) +- [Cutover procedure](tess/M5-MIGRATION-CUTOVER.md) +- [Rollback procedure](tess/M5-MIGRATION-ROLLBACK.md) +- [Retention and deprecation evidence](tess/M5-MIGRATION-RETENTION-DEPRECATION.md) +- [Verification matrix](tess/VERIFICATION-MATRIX.md) +- [Documentation checklist](tess/M5-003-DOCUMENTATION-CHECKLIST.md) diff --git a/docs/openapi-tess.yaml b/docs/openapi-tess.yaml index c348a690..10ddeb0c 100644 --- a/docs/openapi-tess.yaml +++ b/docs/openapi-tess.yaml @@ -124,7 +124,7 @@ paths: { summary: Submit Mos handoff, parameters: [{ $ref: '#/components/parameters/correlation' }], - requestBody: { $ref: '#/components/requestBodies/MosHandoff' }, + requestBody: { $ref: '#/components/requestBodies/Handoff' }, responses: { '200': { description: Receipt } }, }, } @@ -261,7 +261,7 @@ components: }, }, } - MosHandoff: + Handoff: { required: true, content: diff --git a/docs/scratchpads/tess-m4-001-mos-coordination.md b/docs/scratchpads/tess-m4-001-mos-coordination.md index f2246058..c75eb76b 100644 --- a/docs/scratchpads/tess-m4-001-mos-coordination.md +++ b/docs/scratchpads/tess-m4-001-mos-coordination.md @@ -18,12 +18,12 @@ Implement a transport-neutral coordination contract allowing a configured intera ## Design checkpoint — 2026-07-12 -Created `docs/tess/MOS-COORDINATION.md`. Mos approved the design and selected the native in-process `InMemoryMosCoordinationPort` for M4. Fleet/tmux remains a documented M5 adapter seam; no Mos-side consumer is built in this task. +Created `docs/tess/MOS-COORDINATION.md`. Mos approved the design and selected the native in-process `InMemoryInteractionCoordinationPort` for M4. Fleet/tmux remains a documented M5 adapter seam; no Mos-side consumer is built in this task. ## Progress checkpoint — 2026-07-13 -- Implemented `MosCoordinationPort` with handoff/observe/result only, an authority-checking client, and deterministic native adapter in `@mosaicstack/coord`. -- Implemented the gateway `MosCoordinationService`, deriving requester identity from trusted configuration and actor/tenant/correlation from authenticated context. +- Implemented `InteractionCoordinationPort` with handoff/observe/result only, an authority-checking client, and deterministic native adapter in `@mosaicstack/coord`. +- Implemented the gateway `InteractionCoordinationService`, deriving requester identity from trusted configuration and actor/tenant/correlation from authenticated context. - Added contract and gateway boundary tests for configurable identities, native round-trip, unconfigured requester, self-delegation, target drift, and cross-tenant observe/result denial before adapter invocation. - Did not modify `apps/gateway/src/commands/command-authorization.service.ts`. diff --git a/docs/tess/ARCHITECTURE.md b/docs/tess/ARCHITECTURE.md index d268f520..88121829 100644 --- a/docs/tess/ARCHITECTURE.md +++ b/docs/tess/ARCHITECTURE.md @@ -54,7 +54,7 @@ Termination is fail-closed: a runtime approval verifier consumes a one-time, exa ### Mos Coordination Boundary -`@mosaicstack/coord` exposes only the transport-neutral `MosCoordinationPort` +`@mosaicstack/coord` exposes only the transport-neutral `InteractionCoordinationPort` verbs `handoff`, `observe`, and `result`. Gateway derives the actor, tenant, correlation, and interaction-agent identity from authenticated context plus trusted configuration; callers never provide an orchestration target. It diff --git a/docs/tess/M5-MIGRATION-CUTOVER.md b/docs/tess/M5-MIGRATION-CUTOVER.md index cd5a7150..973eb96f 100644 --- a/docs/tess/M5-MIGRATION-CUTOVER.md +++ b/docs/tess/M5-MIGRATION-CUTOVER.md @@ -6,7 +6,7 @@ This procedure is evidence-bound. It does not authorize a production cutover unt 2. Query the normalized runtime capability surface, not a Hermes API directly. Confirm the session capabilities required for the operation are advertised. 3. Query the transitional matrix through `RuntimeProviderService.transitionalCapabilityMatrix` (`apps/gateway/src/agent/runtime-provider-registry.service.ts`). Kanban, skills, memory, tools, and cron must remain `unsupported`; stop rather than route those operations through Hermes. 4. Route new memory activity through the Mosaic operator-memory plugin path; there is no landed Hermes memory import. -5. Use `MosCoordinationService` for orchestration handoff. Tess does not take Mos authority. +5. Use `InteractionCoordinationService` (`apps/gateway/src/coord/interaction-coordination.service.ts`) for orchestration handoff. The interaction agent does not take configured orchestrator authority. 6. Record the qualification evidence and only then update an external deployment/channel binding through its separately authorized operational process. No claim here authorizes bulk transcript copying, data-schema migration, or enabling an unsupported transitional capability. diff --git a/docs/tess/M5-MIGRATION-INVENTORY.md b/docs/tess/M5-MIGRATION-INVENTORY.md index 05358aa6..adaeebbf 100644 --- a/docs/tess/M5-MIGRATION-INVENTORY.md +++ b/docs/tess/M5-MIGRATION-INVENTORY.md @@ -7,5 +7,5 @@ Hermes is a reference adapter, not a Mosaic core dependency. `packages/agent/src | sessions, hierarchy, streaming, send/attach/terminate | `HermesRuntimeProvider` plus `hermes-runtime-provider.test.ts` | adapted | | Kanban, skills, memory, tools, cron | normalized matrix in `HermesRuntimeProvider.transitionalCapabilityMatrix`; each is `unsupported` and `assertTransitionalCapability` denies before a transport call | deferred / fail-closed | | operator memory | `packages/memory/src/operator-memory-plugin.ts`, constructed by `apps/gateway/src/memory/memory.module.ts` and session-scoped by `apps/gateway/src/agent/agent.service.ts` | native Mosaic path | -| orchestration handoff | `apps/gateway/src/coord/mos-coordination.service.ts` retains authenticated handoff/observe/result ownership checks | native Mosaic path | +| orchestration handoff | `InteractionCoordinationService` in `apps/gateway/src/coord/interaction-coordination.service.ts` retains authenticated handoff/observe/result ownership checks | native Mosaic path | | transcripts, profiles, preferences | no Hermes importer/schema mapping landed | no automatic migration | diff --git a/docs/tess/M5-MIGRATION-ROLLBACK.md b/docs/tess/M5-MIGRATION-ROLLBACK.md index 34ec7008..6f659d3e 100644 --- a/docs/tess/M5-MIGRATION-ROLLBACK.md +++ b/docs/tess/M5-MIGRATION-ROLLBACK.md @@ -6,5 +6,5 @@ Rollback is configuration/binding reversal, not a database rollback: no Hermes s 2. Keep the gateway registration and core contracts unchanged unless a reviewed code rollback is required; `AgentRuntimeProviderRegistry` registration is explicit and non-replacing (`packages/agent/src/runtime-provider-registry.ts`). 3. Do not replay an unsupported Kanban, skills, memory, tools, or cron operation. The transitional matrix is intentionally fail-closed. 4. Preserve Mosaic audit, session, and operator-memory records under their normal scoped retention rules; do not copy them into Hermes as a rollback shortcut. -5. For an in-flight coordination request, use the owned handoff observation/result flow in `MosCoordinationService`; do not create a second orchestrator path. +5. For an in-flight coordination request, use the owned handoff observation/result flow in `InteractionCoordinationService` (`apps/gateway/src/coord/interaction-coordination.service.ts`); do not create a second orchestrator path. 6. Capture the binding reversal, affected scope, correlation IDs, and reason in the approved operational record before retrying a cutover. diff --git a/docs/tess/MOS-COORDINATION.md b/docs/tess/MOS-COORDINATION.md index 07092a84..6ee7d099 100644 --- a/docs/tess/MOS-COORDINATION.md +++ b/docs/tess/MOS-COORDINATION.md @@ -20,29 +20,29 @@ interface CoordinationScope { readonly requesterAgentId: string; // trusted gateway/configuration data } -interface MosHandoffRequest { +interface HandoffRequest { readonly idempotencyKey: string; readonly summary: string; readonly context?: string; readonly missionId?: string; } -interface MosHandoffReceipt { +interface HandoffReceipt { readonly handoffId: string; readonly targetAgentId: string; readonly status: 'accepted' | 'queued'; readonly correlationId: string; } -interface MosHandoff { +interface Handoff { readonly handoffId: string; readonly targetAgentId: string; - readonly request: MosHandoffRequest; + readonly request: HandoffRequest; readonly scope: CoordinationScope; } -interface MosCoordinationPort { - handoff(handoff: MosHandoff): Promise; +interface InteractionCoordinationPort { + handoff(handoff: Handoff): Promise; observe(handoffId: string, scope: CoordinationScope): Promise; result(handoffId: string, scope: CoordinationScope): Promise; } @@ -58,20 +58,20 @@ and the requester agent from trusted authentication/configuration only. ## Enforcement point -`apps/gateway` owns a `MosCoordinationService` boundary that compares the +`apps/gateway` owns an `InteractionCoordinationService` (`apps/gateway/src/coord/interaction-coordination.service.ts`) boundary that compares the trusted configured requester/target identities and rejects all of the following before calling a transport: unconfigured requester, self-delegation, target identity drift, cross-tenant observe/result lookup, and attempts to observe or receive a result for a handoff outside the originating tenant. The service exposes handoff, observe, and result only, and delegates delivery to an injected adapter. -M4 ships a native in-process `InMemoryMosCoordinationPort` as the concrete, +M4 ships a native in-process `InMemoryInteractionCoordinationPort` as the concrete, deterministic adapter. It preserves the immutable handoff ID, tenant, requester identity, and correlation ID while demonstrating the handoff → observe → result round trip. It is a queue/port adapter, not a Mos-side consumer. A future fleet/tmux adapter is a documented M5 deployment seam and must -implement the same `MosCoordinationPort`; no channel client or interaction +implement the same `InteractionCoordinationPort`; no channel client or interaction runtime calls a transport directly. ## Required tests diff --git a/docs/tess/THREAT-MODEL.md b/docs/tess/THREAT-MODEL.md index 8e6a79c9..fef2a4cb 100644 --- a/docs/tess/THREAT-MODEL.md +++ b/docs/tess/THREAT-MODEL.md @@ -33,14 +33,18 @@ Trust boundaries: Discord→plugin, CLI→gateway, plugin→gateway service iden 6. Every externally caused operation is replay-safe and correlated. 7. Provider capability absence is a denial, not an invitation to shell around it. -## Existing Findings That Block Tess +## Closed Prerequisite Findings -- Command executor lacks server-side enforcement for declared scopes. -- Session list/reuse/destroy surfaces are not owner-filtered consistently. -- MCP schemas accept caller-supplied user identity. -- Discord plugin lacks a complete authenticated service ingress and user/channel allowlists. -- Chat/tool persistence lacks mandatory redaction. -- Sessions/pending Discord output are in-memory and not restart-safe. -- Session GC currently performs globally scoped promotion. +The original M1 findings below are closed by landed controls and retained for audit traceability. -These are tracked as M1 security prerequisites and must pass independent security review before Tess ingress is enabled. +| Former finding | Closed evidence | +| ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Command scope/role enforcement | `apps/gateway/src/commands/command-authorization.service.ts` and its authorization tests enforce the server-side approval boundary. | +| Cross-owner session access | Gateway session ownership tests cover server-derived owner and tenant scope. | +| Caller-controlled MCP identity | MCP tools derive actor and tenant from authenticated gateway context. | +| Missing Discord ingress allowlists | `apps/gateway/src/plugin/plugin.module.ts` requires the guild, channel, and user allowlist environment values; `apps/gateway/src/plugin/discord-ingress.security.spec.ts` exercises denial and configured ingress. | +| Missing redaction before persistence/egress | Gateway and log redaction coverage verifies sensitive content is classified before durable storage or channel delivery. | +| In-memory-only restart safety | `packages/agent/src/durable-session.test.ts` reconstructs durable identity, inbox/outbox, checkpoints, and handoffs after simulated restart. | +| Globally scoped session GC | `apps/gateway/src/gc/session-gc.service.spec.ts` verifies session-only collection and the absence of automatic global collection entry points. | + +These controls remain subject to the runtime's independent review and release qualification gates. diff --git a/packages/mosaic/src/tui/gateway-api.interaction-errors.test.ts b/packages/mosaic/src/tui/gateway-api.interaction-errors.test.ts new file mode 100644 index 00000000..6ce68808 --- /dev/null +++ b/packages/mosaic/src/tui/gateway-api.interaction-errors.test.ts @@ -0,0 +1,61 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + attachInteractionSession, + sendInteractionMessage, + stopInteractionSession, +} from './gateway-api.js'; + +const gateway = 'https://gateway.example.test'; +const cookie = 'session=test'; +const base = { agentName: 'Nova', correlationId: 'corr-1', sessionId: 'session-1' }; + +afterEach(() => vi.unstubAllGlobals()); + +/** Unit coverage: the TUI preserves a non-success gateway response in its CLI error. */ +describe('interaction gateway error mapping', (): void => { + it.each([ + { + name: 'attach unauthorized', + response: { status: 401, message: 'Invalid or expired session' }, + invoke: (): Promise => attachInteractionSession(gateway, cookie, base), + expected: + 'Failed to attach interaction session (401): {"message":"Invalid or expired session"}', + }, + { + name: 'send invalid request', + response: { status: 403, message: 'Content and idempotency key are required' }, + invoke: (): Promise => + sendInteractionMessage(gateway, cookie, { + ...base, + content: 'hello', + idempotencyKey: 'message-1', + }), + expected: + 'Failed to send interaction message (403): {"message":"Content and idempotency key are required"}', + }, + { + name: 'stop forbidden', + response: { status: 403, message: 'Runtime termination approval denied' }, + invoke: (): Promise => + stopInteractionSession(gateway, cookie, { ...base, approvalRef: 'approval-1' }), + expected: + 'Failed to stop interaction session (403): {"message":"Runtime termination approval denied"}', + }, + ])( + '$name preserves the typed gateway denial in the CLI error', + async ({ response, invoke, expected }) => { + vi.stubGlobal( + 'fetch', + vi.fn( + async () => + new Response(JSON.stringify({ message: response.message }), { + status: response.status, + headers: { 'Content-Type': 'application/json' }, + }), + ), + ); + + await expect(invoke()).rejects.toThrow(expected); + }, + ); +}); From 49e8a54105eddf41e8e0e44603ded616ee76044f Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Tue, 14 Jul 2026 17:08:09 +0000 Subject: [PATCH 042/152] docs(#751): Publish native Kanban/SOT canon (#752) --- docs/MISSION-MANIFEST.md | 15 +- docs/SITEMAP.md | 11 + docs/TASKS.md | 9 +- .../DOCUMENTATION-CHECKLIST.md | 36 + docs/native-kanban-sot/INDEX.md | 64 + docs/native-kanban-sot/MISSION-MANIFEST.md | 195 +++ docs/native-kanban-sot/SHARED-CONTRACT.md | 219 +++ docs/native-kanban-sot/TASKS.md | 261 ++++ .../contracts/health-state.v1.ts | 206 +++ .../contracts/kanban-schema.v1.ts | 1294 +++++++++++++++++ .../contracts/mechanical-coordinator.v1.ts | 419 ++++++ .../contracts/recovery-posture.v1.ts | 369 +++++ docs/native-kanban-sot/tsconfig.json | 16 + .../canon-final-rereview-go.md | 59 + .../canon-initial-review-no-go.md | 357 +++++ .../native-kanban-sot/ultron-final-go.md | 38 + docs/requirements/native-kanban-sot.md | 368 +++++ docs/scratchpads/751-native-kanban-canon.md | 152 ++ 18 files changed, 4077 insertions(+), 11 deletions(-) create mode 100644 docs/native-kanban-sot/DOCUMENTATION-CHECKLIST.md create mode 100644 docs/native-kanban-sot/INDEX.md create mode 100644 docs/native-kanban-sot/MISSION-MANIFEST.md create mode 100644 docs/native-kanban-sot/SHARED-CONTRACT.md create mode 100644 docs/native-kanban-sot/TASKS.md create mode 100644 docs/native-kanban-sot/contracts/health-state.v1.ts create mode 100644 docs/native-kanban-sot/contracts/kanban-schema.v1.ts create mode 100644 docs/native-kanban-sot/contracts/mechanical-coordinator.v1.ts create mode 100644 docs/native-kanban-sot/contracts/recovery-posture.v1.ts create mode 100644 docs/native-kanban-sot/tsconfig.json create mode 100644 docs/reports/native-kanban-sot/canon-final-rereview-go.md create mode 100644 docs/reports/native-kanban-sot/canon-initial-review-no-go.md create mode 100644 docs/reports/native-kanban-sot/ultron-final-go.md create mode 100644 docs/requirements/native-kanban-sot.md create mode 100644 docs/scratchpads/751-native-kanban-canon.md diff --git a/docs/MISSION-MANIFEST.md b/docs/MISSION-MANIFEST.md index ce80c48a..8baa41e8 100644 --- a/docs/MISSION-MANIFEST.md +++ b/docs/MISSION-MANIFEST.md @@ -10,9 +10,9 @@ **Statement:** Ship a self-hosted, multi-user AI agent platform that consolidates the user's disparate jarvis-brain usage across home and USC workstations into a single coherent system reachable via three first-class surfaces — webUI, TUI, and CLI — with federation as the data-layer mechanism that makes cross-host agent sessions work in real time without copying user data across the boundary. **Phase:** Execution (workstream W1 in planning-complete state) **Current Workstream:** W1 — Federation v1 -**Progress:** 0 / 1 declared workstreams complete (more workstreams will be declared as scope is refined) +**Progress:** 0 / 3 declared workstreams complete (more workstreams will be declared as scope is refined) **Status:** active (continuous since 2026-03-13) -**Last Updated:** 2026-04-19 (manifest authored at the rollup level; install-ux-v2 archived; W1 federation planning landed via PR #468) +**Last Updated:** 2026-07-14 (W3 Native Kanban/SOT canon independently approved under issue #751) **Source PRD:** [docs/PRD.md](./PRD.md) — Mosaic Stack v0.1.0 **Scratchpad:** [docs/scratchpads/mvp-20260312.md](./scratchpads/mvp-20260312.md) (active since 2026-03-13; 14 prior sessions of phase-based execution) @@ -67,11 +67,12 @@ The MVP is complete when ALL declared workstreams are complete AND every cross-c ## Workstreams -| # | ID | Name | Status | Manifest | Notes | -| --- | ---- | ------------------------------------------- | ----------------- | ----------------------------------------------------------------------- | --------------------------------------------------- | -| W1 | FED | Federation v1 | planning-complete | [docs/federation/MISSION-MANIFEST.md](./federation/MISSION-MANIFEST.md) | 7 milestones, ~175K tokens, issues #460–#466 filed | -| W2 | TESS | Tess interaction agent | planning-complete | [docs/tess/MISSION-MANIFEST.md](./tess/MISSION-MANIFEST.md) | 5 milestones; issue #706; M1 issue #707 ready | -| W3+ | TBD | (additional workstreams declared as scoped) | — | — | Scope creep is expected and explicitly accommodated | +| # | ID | Name | Status | Manifest | Notes | +| --- | ---- | ------------------------------------------- | ----------------- | ------------------------------------------------------------------------------------- | -------------------------------------------------------- | +| W1 | FED | Federation v1 | planning-complete | [docs/federation/MISSION-MANIFEST.md](./federation/MISSION-MANIFEST.md) | 7 milestones, ~175K tokens, issues #460–#466 filed | +| W2 | TESS | Tess interaction agent | planning-complete | [docs/tess/MISSION-MANIFEST.md](./tess/MISSION-MANIFEST.md) | 5 milestones; issue #706; M1 issue #707 ready | +| W3 | KBN | Native Kanban and canonical task SOT | planning-complete | [docs/native-kanban-sot/MISSION-MANIFEST.md](./native-kanban-sot/MISSION-MANIFEST.md) | P0–P3; issue #751; implementation held until canon merge | +| W4+ | TBD | (additional workstreams declared as scoped) | — | — | Scope creep is expected and explicitly accommodated | ### Likely Additional Workstreams (Not Yet Declared) diff --git a/docs/SITEMAP.md b/docs/SITEMAP.md index cab3d2c6..d228cf1d 100644 --- a/docs/SITEMAP.md +++ b/docs/SITEMAP.md @@ -1,5 +1,16 @@ # Documentation Sitemap +## Native Kanban and canonical task SOT + +- [Canonical requirements](requirements/native-kanban-sot.md) — ratified P0–P3 requirements and acceptance criteria. +- [Workstream index](native-kanban-sot/INDEX.md) — artifact map, lane partition, and delivery order. +- [Mission manifest](native-kanban-sot/MISSION-MANIFEST.md) — scope, authority, invariants, and gate model. +- [Task decomposition](native-kanban-sot/TASKS.md) — dependency-ordered implementation slices and ownership boundaries. +- [Frozen shared contract](native-kanban-sot/SHARED-CONTRACT.md) — schema, API, Coordinator, health, recovery, and migration contracts. +- [Initial independent review](reports/native-kanban-sot/canon-initial-review-no-go.md) — KCR-001–016 findings that blocked the first draft. +- [Final independent re-review](reports/native-kanban-sot/canon-final-rereview-go.md) — closure evidence and GO verdict. +- [Ultron final gate](reports/native-kanban-sot/ultron-final-go.md) — final requirements, authority, schema, migration, recovery, and evidence review. + ## Tess interaction agent ### Operator guides diff --git a/docs/TASKS.md b/docs/TASKS.md index abe7fe0d..4a4e7b8a 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -14,10 +14,11 @@ ## Workstream Rollup -| id | status | workstream | progress | tasks file | notes | -| --- | ----------------- | ---------------------- | ---------------- | ------------------------------------------------- | --------------------------------------------------------------- | -| W1 | planning-complete | Federation v1 (FED) | 0 / 7 milestones | [docs/federation/TASKS.md](./federation/TASKS.md) | M1 task breakdown populated; M2–M7 deferred to mission planning | -| W2 | planning-complete | Tess interaction agent | 0 / 5 milestones | [docs/tess/TASKS.md](./tess/TASKS.md) | Issue #706; independent planning gate PASS; M1 issue #707 ready | +| id | status | workstream | progress | tasks file | notes | +| --- | ----------------- | ---------------------- | ---------------- | --------------------------------------------------------------- | -------------------------------------------------------------------------------- | +| W1 | planning-complete | Federation v1 (FED) | 0 / 7 milestones | [docs/federation/TASKS.md](./federation/TASKS.md) | M1 task breakdown populated; M2–M7 deferred to mission planning | +| W2 | planning-complete | Tess interaction agent | 0 / 5 milestones | [docs/tess/TASKS.md](./tess/TASKS.md) | Issue #706; independent planning gate PASS; M1 issue #707 ready | +| W3 | planning-complete | Native Kanban/SOT | 0 / 4 phases | [docs/native-kanban-sot/TASKS.md](./native-kanban-sot/TASKS.md) | Issue #751; canon independently approved; implementation held until canon merges | ## Cross-Cutting Tracking diff --git a/docs/native-kanban-sot/DOCUMENTATION-CHECKLIST.md b/docs/native-kanban-sot/DOCUMENTATION-CHECKLIST.md new file mode 100644 index 00000000..017237f4 --- /dev/null +++ b/docs/native-kanban-sot/DOCUMENTATION-CHECKLIST.md @@ -0,0 +1,36 @@ +# Documentation Completion Checklist — Native Kanban/SOT Canon + +**Tracking:** Mosaic Stack issue #751 +**Scope:** Requirements and contract publication only; runtime implementation follows in separate slices. + +## Required artifacts + +- [x] Project `docs/PRD.md` exists; the workstream requirements refine its task/project-management scope. +- [x] Canonical workstream requirements published at `docs/requirements/native-kanban-sot.md`. +- [x] Mission manifest, task decomposition, frozen shared contract, and typed contract declarations included. +- [x] `docs/SITEMAP.md` updated. +- [x] Independent initial review and final GO report stored under `docs/reports/native-kanban-sot/`. +- [x] Task scratchpad stored under `docs/scratchpads/`. +- [ ] User/Admin/Developer guides — N/A for canon-only publication; required in implementation slices that change behavior or operations. +- [ ] OpenAPI and endpoint index — N/A until KBN-105 freezes implementation-ready endpoint contracts. + +## Structural and root hygiene + +- [x] Canonical requirements are under `docs/requirements/`. +- [x] Workstream artifacts are under `docs/native-kanban-sot/`. +- [x] Review reports are under `docs/reports/native-kanban-sot/`. +- [x] No new unscoped document was added to the `docs/` root. +- [x] Root mission/task rollups link to the workstream. + +## Review gate + +- [x] Author and independent reviewer are different agents. +- [x] KCR-001–016 closure was independently verified. +- [x] Ultron final gate returned GO with zero BLOCKER/HIGH findings. +- [x] Formatter, lint, typecheck, strict contract TypeScript, link, scope, and invariant publication validation passed in the current Stack toolchain. +- [ ] PR review, CI, squash merge, and issue closure remain required before publication completion. + +## Publishing + +- [x] Canonical source remains in-repository. +- [x] No external publishing platform is required for this internal architecture contract. diff --git a/docs/native-kanban-sot/INDEX.md b/docs/native-kanban-sot/INDEX.md new file mode 100644 index 00000000..2f125996 --- /dev/null +++ b/docs/native-kanban-sot/INDEX.md @@ -0,0 +1,64 @@ +# Native Kanban/SOT Canon + +**Status:** KCR-001–016 independently cleared; canonical publication is in progress under issue [#751](https://git.mosaicstack.dev/mosaicstack/stack/issues/751) +**Date:** 2026-07-14 +**Implementation hold:** no feature implementation starts until this canon is squash-merged to `main` with terminal-green CI; after merge, every slice remains held until its KBN prerequisite graph is satisfied. + +## Artifacts + +| Artifact | Purpose | +| ---------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [Canonical requirements](../requirements/native-kanban-sot.md) | Canonical P0–P3 requirements, all seven ratified decisions, fixed invariants, thin MVP, recovery tiers, non-goals, and per-requirement acceptance criteria | +| [`MISSION-MANIFEST.md`](./MISSION-MANIFEST.md) | Mission/authority boundaries, exact role chain, gate model, mandatory SecReview triggers, Certifier final/no-merge rule, and collision-free slice ownership | +| [`TASKS.md`](./TASKS.md) | Dependency-ordered, bounded P0–P3 slices with IN/OUT scope, dependencies, shared contracts, file ownership, evidence, and USC coder2/3/4/5 parallelization | +| [`SHARED-CONTRACT.md`](./SHARED-CONTRACT.md) | Remediated v1 integration contract: proof authority, exact failures/routes/DTOs/MCP ownership, concrete current-main field migration map, relational invariants, Coordinator split, recovery delivery | +| [`contracts/kanban-schema.v1.ts`](./contracts/kanban-schema.v1.ts) | Drizzle target declarations including exact owner/principal membership, project congruence, tags/archive, proposals, persisted assignments, monotonic fences, durable retry, immutable evidence/audit | +| [`contracts/mechanical-coordinator.v1.ts`](./contracts/mechanical-coordinator.v1.ts) | Pure snapshot decision engine separated from persistence/service adapter; ID-bound approvals, bigint-safe fences, durable retry/quarantine, artifact-backed checkpoints, exact failures | +| [`contracts/health-state.v1.ts`](./contracts/health-state.v1.ts) | Discriminated public health, separate branded transaction-local write proof, and non-overlapping denial/transport/version-conflict mappings | +| [`contracts/recovery-posture.v1.ts`](./contracts/recovery-posture.v1.ts) | Provider-neutral shape schema plus normative runtime refinement, cross-field constraints, and Lite/Standard/High-assurance defaults | +| [`tsconfig.json`](./tsconfig.json) | Strict no-emit project scope for linting and compiling the four frozen TypeScript contracts against the current Stack Drizzle declarations | +| [`DOCUMENTATION-CHECKLIST.md`](./DOCUMENTATION-CHECKLIST.md) | Publication documentation gate and implementation-slice deferrals | +| [Initial independent review](../reports/native-kanban-sot/canon-initial-review-no-go.md) | KCR-001–016 findings that blocked the first draft | +| [Final independent re-review](../reports/native-kanban-sot/canon-final-rereview-go.md) | Closure matrix, reproducible validation evidence, and GO verdict | +| [Ultron final gate](../reports/native-kanban-sot/ultron-final-go.md) | Final requirements, authority, schema, migration, recovery, decomposition, and evidence review GO | + +## Recommended USC lane partition + +| Lane | Natural seam | Exclusive ownership | +| ---------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **coder2** | Schema + migrations + recovery slice | Unified Drizzle schema, migration SQL/meta/journal/tests, then recovery parser/mechanism/runbook files | +| **coder3** | Domain + Gateway + MCP server | Workspace-safe repositories, DTOs/controllers/services, exact `apps/gateway/src/mcp/**` files, health proof, proposals, Coordinator persistence adapter | +| **coder4** | Pure Coordinator + tooling | `packages/coord` mechanical engine, CLI/MCP consumers, generated projection, one-way importer and cutover tooling; lane-serialized internally | +| **coder5** | Web | Tasks/Projects Kanban/List/detail and later Coordinator/migration-review UI | +| **Mos** | Serialized integration | Canon publication, frozen-contract changes, shared-root/exports, integration gates, merge authority | + +The safe order is KBN-010 → KBN-100 → KBN-105, then coder3 Gateway/MCP server, coder4 CLI/projection, coder5 web, and coder2 recovery can proceed on disjoint files. coder4 then runs pure Coordinator → importer → cutover tooling serially. No two active slices edit the same files. + +## Recovery defaults + +| Tier | RPO / RTO | WAL / PITR | Base backup | Restore / break-glass | Off-cluster | +| -------------- | ------------ | ------------------- | ----------- | ----------------------- | ----------------------------------------------- | +| Lite | 24h / 24h | disabled / disabled | daily | quarterly / annual | encrypted separate target | +| Standard | 1h / 8h | q15m / 14d | daily | quarterly / semiannual | encrypted separate object storage | +| High-assurance | **15m / 4h** | **q5m / 35d** | **daily** | **monthly / quarterly** | **encrypted base+WAL, separate failure domain** | + +These knobs affect recovery posture only. PostgreSQL remains the sole writable SOT in every tier. Fail-closed writes, generated-file non-authority, attributable post-recovery proposals, non-LLM Coordinator limits, and Certifier final-gate/no-merge authority are fixed for every tier. + +## Non-blocking implementation sub-decisions for Mos + +The source plan and ratified seven decisions resolve all build-blocking product choices. The following implementation-local selections remain for the owning slices/Mos and must not weaken v1: + +1. Exact PostgreSQL write-health probe SQL and bounded proof lifetime; authority and failures are frozen. +2. Dependency-cycle serialization mechanism (recursive CTE plus transaction/advisory lock or equivalent); required behavior is frozen. +3. Whether RLS lands in the first migration or immediately after the tested session-context pattern; workspace constraints/repository authorization are required from migration one. +4. Concrete off-cluster backup provider/bucket and selected production recovery tier; High-assurance minima are frozen if selected. +5. Cutover reconciliation thresholds and stabilization duration, to be owner-approved before P3 execution. + +None authorizes a second writer, dual sync, LLM scheduling, Coordinator gate waiver/merge, or Certifier merge authority. + +## Publication validation evidence + +- Concrete TypeScript contracts are formatted with repository Prettier. +- All four contracts pass strict TypeScript no-emit checking against the current Stack Drizzle toolchain. +- Contract remediation and KCR-001–016 traceability are recorded in the issue scratchpad and linked review reports. +- Independent re-review returned GO with KCR-001–016 closed; implementation remains held until canon merge and the dependency-ordered KBN prerequisites complete. diff --git a/docs/native-kanban-sot/MISSION-MANIFEST.md b/docs/native-kanban-sot/MISSION-MANIFEST.md new file mode 100644 index 00000000..d103643b --- /dev/null +++ b/docs/native-kanban-sot/MISSION-MANIFEST.md @@ -0,0 +1,195 @@ +# Mission Manifest — Mosaic Native Kanban and Canonical Task SOT P0–P3 + +**Mission status:** CANON INDEPENDENTLY APPROVED; publication in progress under issue [#751](https://git.mosaicstack.dev/mosaicstack/stack/issues/751) +**Date:** 2026-07-14 +**Human decision owner:** Jason +**Orchestrator/publication owner:** web1 control plane (`mos-claude`; `mosaic-100` acting during Claude quota outage) +**Execution topology:** USC web1, partitioned across collision-free GPT coder2/3/4/5 lanes +**Canonical requirements:** [`../requirements/native-kanban-sot.md`](../requirements/native-kanban-sot.md) +**Frozen integration contract:** `SHARED-CONTRACT.md` and `contracts/*.v1.ts` + +## 1. Mission statement + +Extend current `mosaicstack/stack` main into the sole native control plane for workspace-scoped project, mission, milestone, task, dependency, assignment, lease, approval, evidence, and audit state. First deliver a thin writable Kanban/List vertical slice; then add deterministic mechanical coordination and execute a one-way migration/cutover from jarvis-brain/Vikunja project/task stores. + +Success means every user, agent, orchestrator, specialist, and UI sees and mutates the same PostgreSQL aggregate revisions through typed Gateway commands, with no writable fallback and no hidden second authority. + +## 2. Scope boundaries + +### In scope + +- Current Drizzle/PostgreSQL schema extension and migrations. +- Workspace tenancy and authorization from the first migration. +- Projects, missions, milestones, tasks, normalized tags, dependencies, assignments, durable execution/quarantine state, links, immutable artifacts/evidence joins, outage change proposals, events, approvals, leases, checkpoints, and transactional outbox. +- NestJS Gateway queries and explicit lifecycle commands. +- MCP/CLI agent surfaces and generated read-only projections. +- Thin writable Next.js Tasks Kanban/List, task detail, minimal Projects CRUD, filters, dependency readiness, ownership/lease separation, and audit timeline. +- Non-LLM Mechanical Coordinator eligibility, proposal, approval-policy, lease/fence, heartbeat, retry, expiry, quarantine, and restart recovery. +- Planning, Enhance, Coder, Review, SecReview, PR-Monitor, and Certifier role/gate representation. +- One-way shadow importer, reconciliation, write freeze, final delta, cutover, rollback package, and legacy read-only stabilization. +- Recovery-posture configuration and health-state/fail-closed contract. + +### Out of scope + +- Greenfield services, Prisma runtime revival, or jarvis-brain flat files as runtime storage. +- Writable Markdown/JSON/Valkey/browser/provider fallback. +- Gitea issue/PR replacement or generic bidirectional provider sync. +- Calendar, email, GLPI cache, CRM, billing, time tracking, personal-brain migration. +- LLM scheduling or scope interpretation by the Coordinator. +- Autonomous gate waiver, certification, merge, release, deployment, or issue closure by Coordinator. +- Merge authority for Certifier. +- P4 full portfolio/mission designer and P5 fleet-scale policy unless separately released. + +## 3. Fixed invariants + +Every deployment MUST preserve all of the following: + +1. PostgreSQL is the sole writable SOT. +2. Drizzle on current stack main is the only persistence foundation. +3. Mutations fail closed when DB write-health cannot be proven `healthy`. +4. No file, Valkey, browser, queue, provider, or human note becomes a fallback writer. +5. `TASKS.md`, `mission.json`, and every file export are generated, read-only, non-authoritative, and never import sources. +6. Human outage notes become attributable post-recovery proposals only. +7. Workspace is the hard tenant; Team is intra-workspace authorization. +8. Valkey is expendable; PostgreSQL owns state, leases, fencing, audit, and outbox. +9. Mechanical Coordinator is deterministic/non-LLM and cannot invent scope, waive gates, certify, or merge. +10. Certifier is the final independent quality gate and has no merge authority. +11. Mutations use idempotency and optimistic aggregate versions; worker commands also require a current fencing token. +12. Recovery tier changes only backup/recovery posture, never authority or gate semantics. + +## 4. Configurable recovery posture + +Deployments select Lite, Standard, or High-assurance defaults from [`../requirements/native-kanban-sot.md`](../requirements/native-kanban-sot.md) and `contracts/recovery-posture.v1.ts`. Configurable fields are limited to: + +- backup/base-backup cadence; +- RPO and RTO targets; +- PITR retention; +- WAL archive cadence; +- restore-test frequency; +- break-glass drill frequency; +- encrypted off-cluster storage. + +High-assurance defaults are fixed reference values: RPO 15 minutes, RTO 4 hours, encrypted off-cluster WAL every 5 minutes with 35-day PITR, daily base backup, monthly restore test, and quarterly break-glass drill. + +## 5. Canonical role map + +```text +User + ↓ objectives, constraints, ratified decisions +Interaction Layer + ↓ workspace/project context; no scheduling authority +Portfolio Orchestrator + ↓ approved mission, cross-project priority/capacity +Project Sub-Orchestrator + ↓ decomposition, DAG, acceptance, release, routing policy, overrides +Gateway + ↓ authenticated/authorized typed commands +Project/Task Domain Services + ↓ transactional state + semantic event + outbox +Mechanical Coordinator + ↓ deterministic eligibility/proposal/lease/fence/retry/quarantine +Specialists + Planning → Enhance → Coder → Review → conditional SecReview → remediation + ↓ complete evidence bundle +Certifier + ↓ final pass/reject/escalate; NO merge authority +Project Sub-Orchestrator / control plane + ↓ merge authority after all gates +Post-merge validation +``` + +### Authority table + +| Role/layer | Owns | Explicitly cannot do | +| ------------------------ | ----------------------------------------------------------------------------------------------------------- | -------------------------------------------------- | +| User | Objectives, constraints, Jason-owned decisions | Direct DB/file authority bypass | +| Interaction | Conversation and context resolution | Schedule, approve, lease, certify | +| Portfolio Orchestrator | Mission approval, cross-project priority/capacity/global holds | Implement or self-certify specialist work | +| Project Sub-Orchestrator | Task decomposition/DAG/acceptance, release to ready, routing policy, overrides, remediation, merge go-ahead | Bypass required independent gates | +| Gateway | Identity, tenancy, DTO validation, commands, state-machine enforcement | Accept file edits or client SQL as mutations | +| Domain services | Transactional business invariants, semantic events/outbox | Depend on Valkey/files for committed truth | +| Mechanical Coordinator | Eligibility, dependencies, proposal, approved routing, lease/fence, heartbeat, retry/quarantine | Invent/alter scope, waive gates, certify, merge | +| Specialists | Bounded planning/implementation/review artifacts under a task lease | Modify another lane's owned files or self-approve | +| Certifier | Final independent evidence/traceability/gate decision | Merge, close provider issue, release, waive policy | + +## 6. Gate model + +### Mandatory gates + +1. Requirements/contract freeze before parallel implementation. +2. P0 schema/authority threat model and tenant isolation review. +3. Author and reviewer MUST be different principals/sessions. +4. Functional review validates requirements, endpoint registry, concurrency, and negative paths. +5. **Mandatory SecReview (`secrev`)** for any auth, authorization, tenant, service-token, secret, database schema/migration, data-integrity, import/cutover, audit, lease/fencing, recovery, or destructive-retirement surface. +6. Review findings enter bounded remediation owned by the implementation lane. +7. Raising reviewer re-verifies remediation. +8. Certifier performs the final independent evidence and traceability gate. +9. Merge authority remains with `mos-claude`/Project Sub-Orchestrator control plane after gates pass. +10. Post-merge CI and situational validation must be terminal green before closure. + +### Gate outcomes + +- **PASS:** evidence complete; next authority may proceed. +- **REJECT:** findings are explicit and route to remediation. +- **ESCALATE:** policy/owner decision required; no implicit waiver. + +No role can transform a missing gate into a warning by changing status, editing a projection, or writing Valkey. + +## 7. Slice ownership rules + +1. USC web1 is the sole execution environment; coder2/3/4/5 are independent bounded lanes under Mos. +2. Every slice has one named file-tree owner and an explicit IN/OUT boundary in `TASKS.md`. +3. Two active slices MUST NOT edit the same source file, migration file, generated snapshot, lockfile, or API contract. +4. coder2 exclusively owns `packages/db/src/schema.ts`, `packages/db/drizzle/**`, migration journal/meta/tests, then its disjoint recovery-parser/runbook slice. All schema requests serialize through coder2. +5. Frozen `contracts/*.v1.ts` are read-only inputs during implementation. Contract changes require Mos approval, a version bump/amendment, and coordinated rebase before work resumes. +6. coder3 exclusively owns Gateway DTO/controllers/services and the enumerated `apps/gateway/src/mcp/**` server files. coder4 owns CLI/projection clients and never edits MCP server files. Web consumers use the exact KBN-105 endpoint/DTO freeze. +7. coder4 executes one lane order: CLI/projection → pure Coordinator → importer → cutover. The pure Coordinator under `packages/coord` does not load IDs or access DB, Gateway, Valkey, recovery I/O, or web files; coder3 owns the persistence/service adapter. +8. Migration/import tooling calls Gateway/migration-only approved ports and does not add a second database model. +9. Each lane commits only its owned files and reports any needed cross-slice change as a contract-change request instead of editing another lane's tree. +10. Cross-review is mandatory: no lane reviews its own changes. Recommended ring is coder2 ← coder5, coder3 ← coder2, coder4 ← coder3, coder5 ← coder4, followed by independent SecReview where triggered and Certifier final. +11. Integration-only edits are a separate serialized slice after component lanes are green; no opportunistic merge-conflict resolution may alter semantics. + +## 8. Delivery phases and exit gates + +### P0 — Canon and authority foundation + +- Publish this canon, frozen schema/ports/health/recovery contracts, threat model, authorization matrix, exact endpoint/DTO registry, concrete current-main field-by-field migration map, and standards amendment. +- Build hold remains active until independent author≠reviewer re-review returns GO on health proof/failures, approval binding, fencing, tenant relationships, proposals, migration map, slice ordering/API freeze, recovery validation, and vocabulary alignment. +- Exit: no unresolved second writer or contract blocker, tenant boundary frozen, all seven decisions traceable, and independent re-review GO recorded. + +### P1 — Thin native MVP + +- Schema/migration, tenant-safe Gateway, CLI/MCP/projection, writable Kanban/List/Projects, dependencies/readiness/audit. +- Exit: same revision across web/CLI/MCP/projection; cross-workspace tests fail closed; generated files cannot mutate state. + +### P2 — Mechanical coordination + +- Agent/session registry, deterministic engine, approval queue, PostgreSQL leases/fencing/checkpoints/outbox, retry/quarantine, operations UI. +- Exit: one lease winner, stale tokens rejected, dependencies/approvals enforced, DB/Valkey fault semantics proven, Certifier gate has no merge authority. + +### P3 — Shadow migration and cutover + +- Importer, lineage, reconciliation, reviewer UI, write freeze, final delta, Gateway switch, legacy read-only, stabilization and rollback package. +- Exit: signed reconciliation, zero active legacy writers, scoped Gateway identities, imported backlog cannot dispatch accidentally. + +## 9. Evidence required for mission closure + +- Requirement-to-test/evidence matrix. +- Schema/migration and N-1 rolling-deploy proof. +- Cross-workspace API/repository/import/Coordinator negative tests. +- Health-state and fail-closed fault injection. +- Valkey-loss/outbox replay and Coordinator restart tests. +- Concurrent lease and stale fencing tests. +- Endpoint-registry alignment across web/CLI/MCP/Gateway. +- Accessible real-Gateway Kanban journeys. +- Generated projection tamper/no-import proof. +- One-way migration dry-run/apply/verify and field reconciliation. +- Author-independent functional review and required SecReview. +- Certifier final decision and evidence bundle. +- Merged main SHA, terminal green CI, closed linked task/issue, and post-merge situational validation under orchestrator ownership. + +## 10. Change control + +This manifest is derived from the ratified source plan. Any change to SOT authority, workspace tenancy, fixed statuses, Coordinator/Certifier authority, health-state semantics, schema v1, migration direction, or recovery-tier field set is a contract change. Contract changes require Jason/Mos authorization and cannot be inferred by an implementation lane. + +No coder lane may start while the build hold is active. KBN-010 must complete before KBN-100; KBN-105 exact endpoint/DTO freeze must complete before any API consumer implementation. diff --git a/docs/native-kanban-sot/SHARED-CONTRACT.md b/docs/native-kanban-sot/SHARED-CONTRACT.md new file mode 100644 index 00000000..43e44c32 --- /dev/null +++ b/docs/native-kanban-sot/SHARED-CONTRACT.md @@ -0,0 +1,219 @@ +# Native Kanban/SOT — Remediated Shared Contract v1 + +**Status:** INDEPENDENT REVIEW GO; freezes as v1 when issue #751 canon merges to `main` +**Version:** 1.0.0-rc.3 +**Date:** 2026-07-14 +**Change authority:** Mosaic control plane/Jason only + +## 1. Authority + +Concrete contracts are the four `contracts/*.v1.ts` files. PostgreSQL/current-main Drizzle is the sole writable SOT. Public health, Valkey, files, exports, providers, browser state, and outage notes cannot authorize/reconstruct writes. Mechanical Coordinator is non-LLM with no scope/gate/certification/merge authority. Certifier is final independent gate with no merge authority. No feature lane starts until this canon merges and the KBN-010/KBN-105 prerequisites are satisfied. + +## 2. Health proof and exact failures + +`KanbanHealthResponseV1` is a discriminated union: + +| State | read | write | Capability | +| -------------------- | ----: | ----: | --------------------------------------------------- | +| `healthy` | true | true | reads; public state still cannot authorize mutation | +| `read-only-degraded` | true | false | reads only | +| `write-unavailable` | false | false | diagnostics only | + +Every response has `checkedAt`, `validUntil`, `policyRevision`; contradictory booleans fail validation. + +For a mutation, Gateway opens the PostgreSQL transaction, executes the live write probe on that transaction/connection, mints the internal branded `PostgresWriteHealthProofV1`, and revalidates time/policy/transaction identity immediately before mutation. Public REST/MCP/CLI DTOs never accept health/proof fields. Valkey/caller assertions cannot mint proof. Pure Coordinator takes `KanbanEvaluationContextV1`; persistence takes `InternalKanbanMutationContextV1` or probes internally. + +| Case | HTTP | Frozen result | Retry | +| ---------------------------- | --------------------------: | ------------------------------------------------------------------- | -------------------- | +| degraded write | 503 | `KANBAN_WRITE_HEALTH_UNPROVEN`, `read-only-degraded`, `not_applied` | false | +| write unavailable | 503 | `KANBAN_WRITE_UNAVAILABLE`, `write-unavailable`, `not_applied` | false | +| version conflict | 409 | `AGGREGATE_VERSION_CONFLICT`, actual version, `not_applied` | false | +| timeout/unreachable | timeout/502/504 | `retryable_transport_error`, `unknown` | same idempotency key | +| stale fence/session/approval | coordinator rejection union | `not_applied` | false | + +Required negatives: contradictory state, expired/policy-mismatched/wrong-transaction proof, Valkey-only health, forged healthy, and exhaustive non-cross-mapping of 503 vs 502/504/timeout vs 409. + +## 3. Canonical schema invariants + +Complete declaration: `contracts/kanban-schema.v1.ts`. + +- Tables: tenant/identity (`workspaces`, members, teams/members, agents/sessions); planning (`projects`, `milestones`, current-milestone join, `missions`, mission-milestones, `tasks`, normalized tags, dependencies); orchestration (`task_assignments`, durable execution state, leases, checkpoints/evidence); governance (`change_proposals`, immutable artifacts/evidence, events, approvals, outbox, external links). +- Task statuses: `backlog | ready | in_progress | blocked | in_review | done | cancelled`. +- Assignment states everywhere: `awaiting_approval | policy_pre_authorized | approved | rejected | leased | released | expired | superseded`. +- Specialist roles everywhere: `planning | enhance | coder | review | security-review | pr-monitor | certifier`. +- Owner uses exactly-one user/team; assignment principal exactly-one user/team/agent; users require active membership; agent/session and all evidence are workspace-bound. +- Task→mission/milestone/parent, mission→milestone, and project→current-milestone are project-congruent composite relations. +- Dependency identity is workspace+predecessor+successor independent of type. +- Approval evidence and checkpoint evidence are workspace-scoped joins to immutable artifacts, never JSON ID arrays. +- Proposal audit links are composite relations: `(workspace_id, submitted_audit_event_id)` and `(workspace_id, accepted_command_audit_event_id)` reference `task_events(workspace_id, id)` with RESTRICT deletion. +- Assignment is persisted with task/version, exact target/session, expiry/state/policy/proposer/reason. Approval relates to assignment. Lease acquisition accepts IDs, then reloads/locks and validates every relation. +- `tasks.fencing_counter` is bigint; locked atomic increment/RETURNING creates a decimal-string lease token. Lease/checkpoint composites bind exact workspace+task+assignment/session+fence. +- `task_execution_states` durably records retry/quarantine/exhaustion. +- Tags are normalized; legacy `tasks.tags` remains through N-1. Archive is explicit actor/reason/time and does not change lifecycle. +- Canonical parents use RESTRICT. Events/checkpoints/artifacts/evidence are INSERT/SELECT-only for application roles. Normal flow archives/cancels; purge is audited break-glass retention work. + +## 4. Outage proposal contract + +`change_proposals` stores workspace, active-member proposer, source-note digest, target/version, typed command/payload, idempotency, lifecycle, decision actor/reason/time, proposal version, and submit/accepted event IDs. Both event IDs are workspace-aware composite foreign keys to `task_events(workspace_id, id)`; a bare UUID is never sufficient. + +Submission preallocates the proposal ID. One transaction inserts `change_proposal.submitted` with the proposal workspace, `aggregate_type='change_proposal'`, `aggregate_id=`, `previous_version=NULL`, and `new_version=1`, then inserts the proposal referencing that event. Missing, foreign-workspace, wrong-type, or unrelated-proposal events abort the transaction. + +Submit/list/get/accept/reject are explicit Gateway commands. Pending/rejected proposals are inert: no scheduling, dependency/gate satisfaction, or direct target mutation. Acceptance locks proposal+target, obtains fresh transaction-local proof, verifies pending/expected version, invokes the normal command handler, and atomically stores the emitted normal-command event ID. That event must share the proposal workspace, match `target_aggregate_type` and `target_aggregate_id`, use `causation_id=submitted_audit_event_id`, and carry `payload.changeProposalId=`. Missing, foreign-workspace, unrelated-target, unrelated-proposal, or unrelated-command events abort acceptance. + +## 5. Concrete current-main N-1 migration delta + +**Inspected:** `origin/main:packages/db/src/schema.ts` at `e72388b2cbfe400842fe940fa6cabf984ed43711` (2026-07-13). It has global teams/no workspace keys, legacy project/mission/task statuses, nullable task project/mission, `tasks.assignee/tags/due_date`, mission JSON/config, duplicated `mission_tasks.status`, legacy agent fields, and separate fleet `backlog` claims. + +Legacy columns remain declared in unified `schema.ts` for expand + full N-1/rollback window. Generation must not infer early drops. + +### 5.1 Ordered phases + +1. **Pre-expand:** N-1 patch stops `mission_tasks.status` as write source; inventory writers; backup/checksum. +2. **Expand:** add enums/tables and nullable-first columns; retain legacy declarations/uniques; emit no v1-only status. +3. **Backfill:** bootstrap workspace; bounded idempotent cursor/checksum batches; quarantine ambiguous rows. +4. **Validate:** no null tenant, cross-project link, ambiguous owner; status/tag/date/config retention; then constraints/NOT NULL. +5. **Compatibility:** N-1 reads legacy; same-DB transaction mirrors only unavoidable fields; never file/Valkey dual write. +6. **Switch:** stop N-1 writers; Gateway sole command boundary; enable canonical statuses. +7. **Contract release:** later release after rollback/N-1; remove compatibility/global uniques/legacy fields. + +### 5.2 New audit/proposal DDL order + +KBN-100 migration DDL must execute in this order: + +1. create `task_events` and its unique `(workspace_id, id)` key; +2. create `change_proposals` with nullable acceptance-event ID and required submission-event ID; +3. add `change_proposals_workspace_submitted_event_fk` from `(workspace_id, submitted_audit_event_id)` to `task_events(workspace_id, id)` with `ON DELETE RESTRICT`; +4. add `change_proposals_workspace_accepted_command_event_fk` from `(workspace_id, accepted_command_audit_event_id)` to the same composite key with `ON DELETE RESTRICT`; +5. install application-role immutability privileges and same-transaction semantic validation before enabling proposal commands. + +The submission transaction inserts the event first using a preallocated proposal UUID, then the proposal. Acceptance inserts the normal command event before updating the locked proposal. Neither FK is omitted or replaced by a bare UUID/index check. + +### 5.3 Field map + +| Current | Expand/backfill | N-1 compatibility | Switch/contract | +| ---------------------------------------------- | -------------------------------------------------------------------------------------- | ----------------------------------------------- | ------------------------------------------------------------- | +| global `teams`, `team_members` | add workspace nullable; bootstrap; validate active owners | retain global slug/FKs | workspace composites; global unique contracts later | +| `projects.status` | add `canonical_status`; map active/paused/completed/archived | mirror representable values; no `planning` | canonical authority; legacy contracts later | +| project `owner_id/team_id/owner_type` | add exact accountable user/team; deterministic map or quarantine | preserve old reads and compare drift | canonical exact-one; remove legacy after parity | +| current milestone | create milestones then join table (no circular DDL) | absent to N-1 | join is authority | +| nullable `missions.project_id` | derive workspace/project; null/orphan exception, never guess | keep nullable legacy read | canonical required; validate/set NOT NULL later | +| mission `description` | add objective; preserve description; reviewed nonblank mapping | N-1 description | objective authority; retain until signed review | +| `missions.status` | add canonical; planning→draft, active/paused/completed/failed same | no new-only statuses emitted | canonical authority | +| mission `milestones` JSON | normalize with source digest; preserve malformed/original | N-1 reads JSON; no reverse sync | normalized authority; JSON removed after checksum sign-off | +| mission config/metadata/phase/user | retain all; map known typed policy only | all remain declared | remove only by signed consumer inventory | +| nullable `tasks.project_id` | derive explicit/mission project; orphan quarantine | retain nullable read/write during compatibility | canonical required; NOT NULL later | +| `tasks.mission_id` | add project-congruent composite | old relation readable | composite authority | +| `tasks.status` | canonical: not-started→backlog, in-progress→in_progress, others same | no ready/in_review emission | canonical authority | +| `tasks.assignee` | deterministic active user/team/agent assignment; raw value preserved if ambiguous | mirror text only if unambiguous | canonical owner/assignment; remove after no-loss sign-off | +| `tasks.tags` JSON | normalize trim/case/dedupe with original digest | transactionally mirror normalized rows | normalized authority; JSON later removed | +| `tasks.due_date` | copy exactly to `due_at` | mirror | due_at authority; legacy later | +| task common fields | preserve metadata byte-for-byte; add criteria/rank/retry/archive/version/fence | old reads valid | new fields canonical | +| `mission_tasks.status` | keep; prohibit as write source; linked status ignored; unlinked becomes task or reject | read-only compatibility value | membership uses task mission; status dropped after no readers | +| mission-task notes/PR/user | map to metadata/artifact/event/link/attribution; preserve | read-only | remove after parity | +| `agents.status` | add workspace/lifecycle/runtime/roles; status remains presence | retain all legacy fields | lifecycle/roles authority; status may remain telemetry | +| agent project/owner/prompt/tools/skills/config | preserve; validate tenant; derive typed capabilities without loss | N-1 reads | removal only by separate inventory | +| fleet `backlog` | map to designated-project tasks; edges; claimed rows quarantine | freeze claims before switch; read-only compare | task/lease authority; retire after stabilization | + +### 5.4 Required migration tests + +Empty DB; exact production-shape snapshot; crash/resume; rollback before switch; N-1 startup/read/write; workspace/member negatives; status-shadow/no premature new status; `mission_tasks.status` write prohibition; tags/assignee/date/mission JSON/config/description/agent checksum; project congruence/current-milestone order; backlog freeze/no dispatch; and proof legacy declarations persist until contract release. + +Proposal-specific negatives must attempt: missing submission event, foreign-workspace submission event, foreign-workspace acceptance event, same-workspace event for another proposal, event for another target aggregate, and unrelated normal-command event. Every attempt must fail atomically with no accepted proposal and no target mutation. + +## 6. Ownership and Coordinator split + +coder2 solely owns `packages/db/src/schema.ts`, `packages/db/drizzle/**`, journal/metadata, and migration tests. No other lane generates migrations. Expand is additive; no drop/rename/narrow; constraints validate before NOT NULL; compatibility is same-DB only; contract is later. + +KBN-200/coder4 owns pure `MechanicalCoordinatorDecisionEngineV1`: complete immutable snapshots in, deterministic eligibility/proposal/retry decisions out; no ID loading, SQL, Gateway, Valkey, proof, persistence, restart I/O, or LLM. + +KBN-210/coder3 owns `MechanicalCoordinatorServicePortV1`: ID loading, locks, fresh proof, assignment/approval persistence, atomic fencing, lease/checkpoint/outbox, Valkey wakes, durable retry/quarantine, and `recoverFromPostgres`. Cycle: load snapshots → pure decision → persist assignment → authoritative approval/policy → acquire by IDs/locks → increment fence → lease → ack/heartbeat/checkpoint → submit to review or durable retry/quarantine. No completion/certification/merge method exists. + +## 7. Exact Gateway/DTO freeze for KBN-105 + +### 7.1 Common wire rules + +Base is `/api/v1/workspaces/:workspaceId`. Mutations require header `Idempotency-Key` (1–128 chars). Existing-aggregate mutations also require `If-Match-Version` (positive integer); create and privileged assignment-cycle requests are the only exceptions, while proposal submission carries `expectedTargetVersion` in its body. Body workspace fields are forbidden. Tenant denial follows one 404/403 policy without foreign existence detail. + +```ts +interface SuccessEnvelopeV1 { + contractVersion: '1.0.0'; + data: T; + aggregateRevision: string; + correlationId: string; +} +interface ListEnvelopeV1 extends SuccessEnvelopeV1 { + page: { cursor: string | null; nextCursor: string | null; limit: number }; +} +``` + +Errors are the exact health/transport/version unions in §2 plus validation/auth/not-found. Public DTOs never expose/accept internal write proof. + +### 7.2 Exact route registry + +| Method/path | Request body/query | Success data | +| ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------- | +| `GET /kanban-health` | none | `KanbanHealthResponseV1` | +| `GET /projects` | `status,ownerUserId,ownerTeamId,cursor,limit` | project list | +| `POST /projects` | `name,key,description,status,priority,ownerUserId XOR ownerTeamId,metadata` | project | +| `GET /projects/:projectId` | none | project | +| `PATCH /projects/:projectId` | editable create fields + expected header | project | +| `POST /projects/:projectId/archive` | `reason` | project | +| `GET /tasks` | `projectId,missionId,milestoneId,status,priority,ownerUserId,ownerTeamId,specialistRole,tag,dueState,archived,cursor,limit` | task summary list | +| `POST /tasks` | `projectId,missionId?,milestoneId?,parentTaskId?,title,description?,acceptanceCriteria[],status,priority,rank,ownerUserId XOR ownerTeamId,specialistRole?,dueAt?,notBeforeAt?,estimateMinutes?,retryPolicy?,tagIds[],metadata` | task detail | +| `GET /tasks/:taskId` | none | task detail including readiness/dependencies/assignment/lease/events | +| `PATCH /tasks/:taskId` | editable non-transition fields | task detail | +| `POST /tasks/:taskId/transition` | `toStatus,reason?` | task detail | +| `POST /tasks/:taskId/move` | `toStatus?,beforeTaskId?,afterTaskId?` | task detail with persisted rank | +| `POST /tasks/:taskId/archive` | `reason` | task detail | +| `PUT /tasks/:taskId/tags` | `tagIds[]` | task detail | +| `POST /tasks/:taskId/dependencies` | `predecessorTaskId,type` | dependency | +| `DELETE /tasks/:taskId/dependencies/:predecessorTaskId` | no body | deleted dependency ID | +| `GET /tasks/:taskId/events` | `cursor,limit` | event list | +| `GET /tags` | `query,cursor,limit` | tag list | +| `POST /tags` | `name,color?` | tag | +| `GET /change-proposals` | `state,targetType,targetId,cursor,limit` | proposal list | +| `POST /change-proposals` | `sourceNoteDigest,targetType,targetId,expectedTargetVersion,commandType,commandPayload` | inert proposal | +| `GET /change-proposals/:proposalId` | none | proposal | +| `POST /change-proposals/:proposalId/accept` | `reason` | proposal + normal command result | +| `POST /change-proposals/:proposalId/reject` | `reason` | proposal | +| `GET /coordinator/eligibility` | `projectId?,missionId?,cursor,limit` | `EligibilityDecisionV1[]` | +| `POST /coordinator/assignment-cycles` | `limit` | assignment proposals; privileged internal | +| `POST /coordinator/assignments/:assignmentId/approve` | `decision,reason,policyRevision,artifactIds[]` | approval decision | +| `POST /coordinator/leases/acquire` | `taskId,assignmentId,approvalDecisionId,targetSessionId,leaseTtlSeconds` | lease with decimal-string fence | +| `POST /coordinator/leases/:leaseId/ack` | `taskId,sessionId,fencingToken` | lease | +| `POST /coordinator/leases/:leaseId/heartbeat` | `taskId,sessionId,fencingToken,extendSeconds` | lease | +| `POST /coordinator/leases/:leaseId/checkpoints` | `taskId,sessionId,fencingToken,sequence,resumableSummary,artifactIds[],contextUsagePercent` | checkpoint | +| `POST /coordinator/leases/:leaseId/submit-review` | `taskId,sessionId,fencingToken,artifactIds[],summary` | task in `in_review` | + +All Coordinator mutations except human approval are service-identity-only. Generic task PATCH cannot perform claim/heartbeat/checkpoint/review/certification/completion shortcuts. Completion after certification uses a separately gated lifecycle command owned by the Portfolio/Sub-Orchestrator flow, not the Coordinator. + +### 7.3 DTO invariants + +Task summary/detail use exact schema vocabularies, owner union, `version: number`, `fencingCounter: string`, explicit `archivedAt/by/reason`, normalized tags, computed readiness, and separate assignment/lease. Assignment DTO includes one persisted ID, task/version, exact principal/agent/session, role, state, expiry, policy, proposer/reason. Lease/checkpoint DTOs serialize every fence as decimal string. Proposal DTO exposes no hidden write authority. + +### 7.4 MCP ownership and mapping + +coder3 exclusively owns: + +- `apps/gateway/src/mcp/mcp.dto.ts` +- `mcp.controller.ts` +- `mcp.service.ts` +- `mcp.module.ts` +- `mcp.tokens.ts` +- `mcp.service.spec.ts` + +MCP tools are thin maps: `mosaic_projects_{list,get,create,update,archive}`, `mosaic_tasks_{list,get,create,update,transition,move,archive,set_tags,add_dependency,remove_dependency}`, and `mosaic_change_proposals_{list,get,submit,accept,reject}` to the exact routes above. coder4 owns CLI/projection clients only and must not edit Gateway MCP files. + +KBN-105 publishes route+DTO fixture digest before KBN-110/120/130. Every web/CLI/MCP call must match this registry and the generated client. + +## 8. Recovery contract and bounded delivery slice + +Runtime must invoke normative `validateRecoveryPostureV1`; JSON Schema alone is insufficient. It rejects unknown fields, PITR/WAL mismatch, RPO better than mechanism, unsafe storage, and weakened High-assurance. High-assurance is RPO 15m/RTO 4h, WAL ≤5m, PITR ≥35d, base ≤24h, restore test ≤30d, break-glass ≤90d, encrypted separate-failure-domain storage. + +KBN-115/coder2 owns `packages/config/src/recovery-posture.ts`, tests, and recovery runbook. It wires parser/refinement, override audit, mechanism assertions, restore test, and break-glass evidence. Any deployment manifest is separately enumerated and Mos-serialized. Recovery config has no SOT/gate/Coordinator authority fields. + +## 9. Integration, security, and hold + +Required release evidence includes empty/prod/partial/rollback/N-1 migration tests; cross-workspace and same-workspace wrong-project negatives; active-membership owners/principals; proposal inertness/normal acceptance; exact failure mapping; concurrent monotonic bigint fences; relational lease/checkpoint/evidence mismatch; immutability privileges/RESTRICT; recovery validation/mechanism evidence; endpoint registry alignment; accessible web journeys; author≠reviewer; mandatory SecReview; final Certifier pass/no merge authority. + +The build hold remains active until independent re-review reports GO for KCR-001–016. Mos alone releases waves and serializes integration roots. diff --git a/docs/native-kanban-sot/TASKS.md b/docs/native-kanban-sot/TASKS.md new file mode 100644 index 00000000..c0e10efa --- /dev/null +++ b/docs/native-kanban-sot/TASKS.md @@ -0,0 +1,261 @@ +# Native Kanban/SOT P0–P3 — Dependency-Ordered Build Slices + +**Status:** CANON INDEPENDENTLY APPROVED; PUBLICATION IN PROGRESS +**Tracking:** [Mosaic Stack issue #751](https://git.mosaicstack.dev/mosaicstack/stack/issues/751) +**Execution:** USC web1 only; collision-free GPT coder2/3/4/5 lanes +**Contract:** `SHARED-CONTRACT.md` + four `contracts/*.v1.ts` files +**Implementation hold:** no feature slice starts until the canon PR is merged to `main` with terminal-green CI; after merge, each slice remains held until every declared KBN prerequisite is complete. + +> This publication file is not a runtime task authority. After cutover, repository `TASKS.md` is generated read-only and never imported. + +## Execution invariants + +- PostgreSQL is the sole writable SOT; current-main Drizzle is the persistence foundation. +- Mutations require fresh internal PostgreSQL transaction-local write proof and fail closed otherwise. +- Public health DTOs, Valkey, files, browser state, providers, and outage notes cannot authorize writes. +- Outage notes return only through attributable `change_proposals`; proposal acceptance executes the normal command. +- Mechanical Coordinator is non-LLM and cannot invent scope, waive gates, certify, or merge. +- Certifier is final independent gate with no merge authority. +- Workspace is the hard tenant. Project hierarchy is project-congruent. Assignment, approval, lease, fence, checkpoint, and evidence are relationally bound. +- Recovery tiers change recovery posture only. + +## 1. Collision-free ownership + +| USC lane | Exclusive ownership | Must not edit | +| ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | +| **coder2 — schema/recovery** | `packages/db/src/schema.ts`; `packages/db/drizzle/**`; DB tests; `packages/config/src/recovery-posture.ts`; `packages/config/src/recovery-posture.spec.ts`; `docs/runbooks/kanban-postgres-recovery.md` | Gateway, Brain repositories, Coordinator, web, CLI/importer | +| **coder3 — domain/Gateway/MCP server** | Kanban repositories under `packages/brain/src/`; Gateway workspace/project/mission/milestone/task/kanban/health/coord modules; **exact MCP files:** `apps/gateway/src/mcp/mcp.dto.ts`, `mcp.controller.ts`, `mcp.service.ts`, `mcp.module.ts`, `mcp.tokens.ts`, `mcp.service.spec.ts`; Gateway root wiring/tests | DB schema/migrations, `packages/coord`, web, CLI/importer | +| **coder4 — CLI → pure Coordinator → migration tooling** | In this one fixed lane order: KBN-120 (`packages/mosaic` CLI/projection) → KBN-200 (`packages/coord/src/mechanical/**`) → KBN-300/320 (`scripts/kanban-migration/**`) | DB, Gateway/MCP server, web | +| **coder5 — web** | `apps/web/src/app/(dashboard)/{tasks,projects}/**`; `apps/web/src/components/{tasks,projects}/**`; Kanban web API/types; later Coordinator/migration-review routes | DB, Gateway, Coordinator, CLI/importer | +| **Mos — publication/integration** | Contract amendments, exact endpoint registry publication, serialized root exports/manifests/lockfiles, integration gates | Active lane feature files | + +Shared roots, package exports/manifests, lockfiles, and generated artifacts are integration-serialized. Contract changes stop affected lanes and require Mos approval. + +## 2. Parallelization legend + +- **SERIAL:** prerequisite must be complete and reviewed. +- **PARALLEL-GROUP:** disjoint files and exact frozen contract permit concurrent work. +- **LANE-SERIAL:** one lane's stated order cannot change. +- **INTEGRATION-SERIAL:** component heads green first; semantic findings return to owner. + +## 3. Corrected dependency graph + +```text +KBN-000 canon remediation + -> KBN-010 threat/auth/constraint-impact gate (MUST COMPLETE) + -> KBN-100 schema + concrete N-1 migration implementation + ├─ KBN-105 exact endpoint/DTO/error/registry freeze (SERIAL) + │ ├─ KBN-110 domain + Gateway + MCP server implementation + │ ├─ KBN-120 CLI/projection implementation [coder4 first] + │ └─ KBN-130 web MVP implementation + └─ KBN-115 recovery parser/mechanism slice [coder2 lane-serial] +KBN-110 + KBN-120 + KBN-130 + KBN-115 + -> KBN-140 P1 integration/SIT + -> KBN-200 pure decision engine [coder4 after KBN-120] + -> KBN-210 persistence/service adapter + approval/lease binding + -> KBN-220 Coordinator operations UI + -> KBN-230 P2 concurrency/fault/gate integration +KBN-230 + -> KBN-300 importer dry-run/apply/verify [coder4 after KBN-200] + ├─ KBN-310 migration reviewer UI + └─ KBN-320 cutover/rollback tooling [coder4 after KBN-300] +KBN-310 + KBN-320 + -> KBN-330 rehearsal/reconciliation + -> KBN-340 owner-gated cutover/stabilization +``` + +No consumer implementation begins before KBN-105. No schema work begins before KBN-010 completes. The coder4 order is always KBN-120 → KBN-200 → KBN-300 → KBN-320. + +## 4. P0 — Canon, threat gate, schema, and exact API freeze + +### KBN-000 — Remediate and publish canon + +- **Owner:** Mos / publication control plane. +- **Mode:** SERIAL; publication gate in progress. +- **IN:** Resolve KCR-001–016 in requirements, schema, health, Coordinator, recovery, migration map, and slices; independent re-review. +- **OUT:** Feature implementation. +- **Depends on:** none. +- **Contract surfaces:** all canon. +- **Evidence:** strict TS; Prettier; per-finding traceability; independent author≠reviewer GO. + +### KBN-010 — Threat, authorization, and constraint-impact gate + +- **Owner:** coder3; independent `secrev`. +- **Mode:** SERIAL prerequisite of KBN-100. +- **Exclusive files:** Mos-selected threat/auth docs only. +- **IN:** Cross-workspace owners/principals/evidence; active membership; stale/forged health; approval forgery; fence monotonicity; audit retention; proposal target/audit-event forgery; service tokens; DB/Valkey outage. +- **OUT:** Runtime/schema edits. +- **Depends on:** KBN-000 independent re-review GO. +- **Contract surfaces:** schema constraints, health proof, exact errors, command-family authorization. +- **Evidence:** signed constraint-impact matrix; no unresolved schema-impact finding; SecReview pass. + +### KBN-100 — Unified Drizzle schema and concrete N-1 migration + +- **Owner:** **coder2**. +- **Mode:** SERIAL. +- **Exclusive files:** `packages/db/src/schema.ts`, `packages/db/drizzle/**`, DB tests. +- **IN:** All frozen tables/joins/enums; workspace/project-congruent constraints; owners/principals; tags/archive; change proposals with both workspace-aware task-event composite FKs and frozen event-before-proposal DDL order; assignment approvals; durable execution/quarantine; monotonic bigint fence; exact checkpoint/evidence joins; RESTRICT/immutability; concrete current-main expand/backfill/switch/contract map. +- **OUT:** Repositories, Gateway, Coordinator behavior, UI, importer. +- **Depends on:** **KBN-010 completed**. +- **Contract surfaces:** `kanban-schema.v1.ts`; SHARED-CONTRACT current-main delta map. +- **Evidence:** reviewed SQL; empty/prod-shape/partial-resume/rollback tests; N-1 app safety; legacy columns remain declared; workspace/project mismatch negatives; proposal event-FK missing/foreign-workspace tests; one active lease; monotonic fence; parent-delete RESTRICT; immutability privileges; SecReview. + +### KBN-105 — Exact Gateway/MCP endpoint, DTO, and error freeze + +- **Owner:** Mos + coder3 contract author; independent endpoint-alignment reviewer. +- **Mode:** SERIAL after KBN-100; prerequisite for KBN-110/120/130. +- **Exclusive files:** canonical endpoint-registry/DTO contract docs; no implementation. +- **IN:** Exact routes and methods from SHARED-CONTRACT §8; request/success/error fields; status codes; pagination/filter/revision envelopes; idempotency/expected-version headers/fields; proposal commands; health proof exclusion from public DTOs; MCP tool-to-route map. +- **OUT:** Controller/service/client implementation. +- **Depends on:** KBN-100. +- **Contract surfaces:** health/error unions; schema IDs/statuses; Gateway DTO freeze. +- **Evidence:** every FE/CLI/MCP call maps 1:1 to a route; 503/502-504/409 non-cross-map fixtures; contract digest published. + +### KBN-115 — Recovery posture parser, mechanisms, and evidence + +- **Owner:** **coder2**, lane-serial after KBN-100. +- **Mode:** PARALLEL with KBN-110/120/130 after KBN-105. +- **Exclusive files:** `packages/config/src/recovery-posture.ts`, `.spec.ts`, `docs/runbooks/kanban-postgres-recovery.md`; deployment-specific backup manifest changes are a separately enumerated Mos integration patch. +- **IN:** Wire normative `validateRecoveryPostureV1`; override audit; backup/WAL/PITR mechanism assertions; off-cluster encryption/failure-domain checks; restore and break-glass evidence procedure. +- **OUT:** SOT/gate/Coordinator policy knobs; DB business schema. +- **Depends on:** KBN-100, KBN-105. +- **Contract surfaces:** `recovery-posture.v1.ts` only. +- **Evidence:** impossible-combination tests; High-assurance weakening tests; selected-tier mechanism verification; restore and break-glass evidence; SecReview. + +## 5. P1 — Thin native MVP + +### KBN-110 — Workspace-safe domain, Gateway, MCP server, and proposal commands + +- **Owner:** **coder3**. +- **Mode:** PARALLEL-GROUP P1-A after KBN-105. +- **Exclusive files:** ownership map, including all exact MCP server files listed there. +- **IN:** Workspace-safe repositories; project/task/dependency/tag/archive CRUD; transitions; exact owners; assignment/approval/link/artifact queries; submit/query/accept/reject change proposals; health endpoint; internal write-proof mint/revalidation; event/outbox atomicity; frozen DTOs/routes. +- **OUT:** Scheduling algorithm, web, CLI, DB schema. +- **Depends on:** KBN-100, KBN-105. +- **Contract surfaces:** all four TypeScript contracts and exact registry. +- **Evidence:** DTO/service/controller/integration tests; active-membership and no-oracle negatives; proposal cannot mutate directly; submission event is the new proposal's exact `change_proposal.submitted` event; acceptance links the executed normal command for the locked proposal and same workspace/target; missing, foreign-workspace, unrelated-proposal/target/command event negatives; exact failure mapping; endpoint registry; SecReview. + +### KBN-120 — CLI, MCP client mapping, and generated projection + +- **Owner:** **coder4**; first coder4 slice. +- **Mode:** PARALLEL-GROUP P1-A after KBN-105. +- **Exclusive files:** `packages/mosaic/src/commands/{kanban,tasks,projects}.ts`; `packages/mosaic/src/projections/**`; tests. **No `apps/gateway/src/mcp/**` edits.\*\* +- **IN:** Frozen query/mutation routes; proposal commands; compact context; generated `TASKS.md`; deliberate denial/transport/conflict handling. +- **OUT:** Gateway/MCP server, file importer, raw SQL/Valkey, Coordinator. +- **Depends on:** KBN-105; runtime integration later requires KBN-110. +- **Evidence:** contract fixtures; same revision; no import parser; same idempotency key on transport retry; 503 never auto-retried. + +### KBN-130 — Writable Kanban/List and minimal Projects UI + +- **Owner:** **coder5**. +- **Mode:** PARALLEL-GROUP P1-A after KBN-105. +- **Exclusive files:** web ownership map. +- **IN:** Workspace context; projects; tasks; tags; explicit archive; detail; accessible move/reorder; filters; dependency/readiness; owner/assignment/lease; audit; proposal visibility; conflict/loading/error/reconnect. +- **OUT:** Gateway/schema, Coordinator operations UI, migration UI. +- **Depends on:** KBN-105; runtime integration later requires KBN-110. +- **Evidence:** frozen contract mocks; real-Gateway journeys; keyboard/non-drag; tags/archive semantics; no-oracle tenant negatives; 503/transport/409 distinct UI. + +### KBN-140 — P1 integration and situational gate + +- **Owner:** Mos integration; independent reviewer/SecReview/Certifier. +- **Mode:** INTEGRATION-SERIAL. +- **IN:** KBN-110/120/130/115; unavoidable root exports only. +- **OUT:** P2 behavior. +- **Depends on:** KBN-110, KBN-120, KBN-130, KBN-115. +- **Evidence:** clean migration; web/CLI/MCP/projection revision parity; forged/expired health negatives; change-proposal event-chain success plus missing/foreign/unrelated-event negatives; tag/archive; tenant negatives; endpoint registry; author-independent review; Certifier pass. + +## 6. P2 — Mechanical Coordinator + +### KBN-200 — Pure deterministic decision engine + +- **Owner:** **coder4**; second coder4 slice, strictly after KBN-120. +- **Mode:** SERIAL in coder4 lane. +- **Exclusive files:** `packages/coord/src/mechanical/**` and pure tests. +- **IN:** `MechanicalCoordinatorDecisionEngineV1`; complete immutable snapshots; eligibility/explanation; fairness/order; capability matching; expiry/retry/quarantine decisions. +- **OUT:** ID loading, PostgreSQL, Drizzle, Gateway, Valkey, health-proof minting, persistence, `recoverFromPostgres`, LLM calls. +- **Depends on:** KBN-140 (or Mos may release after KBN-120 + frozen types if no P1 semantic risk remains). +- **Evidence:** deterministic/property tests; snapshot completeness; no I/O/model imports; no authority methods. + +### KBN-210 — Coordinator persistence/service adapter and approval-bound leases + +- **Owner:** **coder3**. +- **Mode:** SERIAL after KBN-200. +- **Exclusive files:** Gateway `coord` and repositories. +- **IN:** `MechanicalCoordinatorServicePortV1`; snapshot loading; proposal persistence; manual/versioned policy approval; acquire by IDs; reload+lock task/assignment/approval/session; fresh txn-local write proof; atomic task fence increment; lease/ack/heartbeat/checkpoint/submit; durable retry/quarantine; outbox/Valkey wake; restart recovery. +- **OUT:** Pure algorithm, UI, DB schema. +- **Depends on:** KBN-110, KBN-200. +- **Evidence:** forged/stale approval rejection; target/session/version/expiry/policy checks; concurrent monotonic fences; same-workspace mismatch negatives; bigint precision; stale worker rejection; DB/Valkey faults; SecReview. + +### KBN-220 — Coordinator operations UI + +- **Owner:** **coder5**. +- **Mode:** after KBN-210 exact DTO freeze. +- **IN:** Roster; eligibility; persisted assignment state; approvals/overrides; exact lease/fence; durable retry/quarantine; role/gate/Certifier visibility. +- **OUT:** Scheduling decisions, schema, merge control for Certifier. +- **Depends on:** KBN-210. +- **Evidence:** authorized journeys; reason required; stale refresh; no Certifier merge; endpoint alignment/accessibility. + +### KBN-230 — P2 concurrency/fault/gate integration + +- **Owner:** Mos integration; independent reviewer/SecReview/Certifier. +- **Mode:** INTEGRATION-SERIAL. +- **Depends on:** KBN-200, KBN-210, KBN-220. +- **Evidence:** one lease; monotonic fences; exact relational mismatches rejected; expired proof; forged healthy; approval binding; restart; durable quarantine; outbox recovery; author≠reviewer; Certifier final/no merge. + +## 7. P3 — Shadow migration and cutover + +### KBN-300 — One-way importer dry-run/apply/verify + +- **Owner:** **coder4**; third coder4 slice. +- **Mode:** after KBN-230. +- **Exclusive files:** `scripts/kanban-migration/import/**`. +- **IN:** Immutable jarvis-brain/Vikunja snapshots; deterministic mapping; source digest/lineage; Gateway writes; rejects; no dispatch. +- **OUT:** Bidirectional sync, direct DB/file canonical writes, unrelated brain data. +- **Depends on:** KBN-230. +- **Evidence:** idempotency; counts/fields; malformed/foreign rejects; no dispatch; SecReview. + +### KBN-310 — Shadow reviewer UI + +- **Owner:** **coder5**. +- **Mode:** PARALLEL-GROUP P3-A after KBN-300 report freeze. +- **IN:** Read-only counts/diffs/rejects/lineage/sign-off. +- **OUT:** Apply/cutover mutations. +- **Depends on:** KBN-300. +- **Evidence:** read-only and tenant tests; pagination/accessibility. + +### KBN-320 — Cutover/rollback tooling + +- **Owner:** **coder4**; fourth coder4 slice, after KBN-300. +- **Mode:** PARALLEL-GROUP P3-A with KBN-310. +- **Exclusive files:** `scripts/kanban-migration/cutover/**`. +- **IN:** Freeze assertion; backup/checksum; final delta; client switch; legacy writer/credential shutdown; rollback delta; stabilization. +- **OUT:** Destructive deletion, reverse sync, ungated production execution. +- **Depends on:** KBN-300. +- **Evidence:** fail-safe rehearsal; no dual writer; rollback authority; SecReview. + +### KBN-330 — Migration rehearsal/reconciliation + +- **Owner:** Mos + coder4 support + independent data reviewer. +- **Mode:** INTEGRATION-SERIAL. +- **Depends on:** KBN-310, KBN-320. +- **Evidence:** signed exceptions; selected-tier restore; backlog hold; no legacy changes; Certifier readiness. + +### KBN-340 — Final cutover/stabilization + +- **Owner:** Mos/control plane; owner-gated operation. +- **Mode:** SERIAL. +- **Depends on:** KBN-330 PASS and Jason authorization. +- **Evidence:** no legacy writer; scoped Gateway identities; no accidental dispatch; terminal green health/CI; Certifier evidence; owner retirement approval. + +## 8. Consistent USC wave schedule + +| Wave | coder2 | coder3 | coder4 | coder5 | +| ---- | ------------------------- | -------------------------------------- | ------------------------------ | ------------------------------ | +| 0 | Wait | **KBN-010** | Wait | Wait | +| 1 | **KBN-100** | Review constraint implementation | Wait | Wait | +| 2 | **KBN-115** after KBN-100 | **KBN-105** exact freeze, then KBN-110 | **KBN-120** only after KBN-105 | **KBN-130** only after KBN-105 | +| 3 | Review support | Finish KBN-110 | **KBN-200 after KBN-120** | Finish KBN-130 | +| 4 | — | **KBN-210 after KBN-200** | Review/support | **KBN-220 after KBN-210 DTOs** | +| 5 | — | P2 remediation | **KBN-300 then KBN-320** | **KBN-310** | + +Mos alone releases slices and lifts the build hold after independent re-review GO. diff --git a/docs/native-kanban-sot/contracts/health-state.v1.ts b/docs/native-kanban-sot/contracts/health-state.v1.ts new file mode 100644 index 00000000..2deaa0f7 --- /dev/null +++ b/docs/native-kanban-sot/contracts/health-state.v1.ts @@ -0,0 +1,206 @@ +/** + * Mosaic Native Kanban — frozen health/error contract v1. + * Publication contract only; no runtime implementation is included here. + * + * PostgreSQL is the sole writable SOT. Public health DTOs are observations, + * never write authority. Only an internal transaction-local proof produced by + * the PostgreSQL adapter may authorize a mutation. + */ + +export const KANBAN_CONTRACT_VERSION = '1.0.0' as const; + +export const kanbanHealthStates = ['healthy', 'read-only-degraded', 'write-unavailable'] as const; +export type KanbanHealthState = (typeof kanbanHealthStates)[number]; + +interface KanbanHealthBaseV1 { + contractVersion: typeof KANBAN_CONTRACT_VERSION; + checkedAt: string; + /** Observation expires at this RFC 3339 instant; it still never authorizes writes. */ + validUntil: string; + policyRevision: string; + reasons: string[]; +} + +export interface HealthyKanbanHealthResponseV1 extends KanbanHealthBaseV1 { + state: 'healthy'; + readHealthProven: true; + writeHealthProven: true; +} + +export interface ReadOnlyDegradedKanbanHealthResponseV1 extends KanbanHealthBaseV1 { + state: 'read-only-degraded'; + readHealthProven: true; + writeHealthProven: false; +} + +export interface WriteUnavailableKanbanHealthResponseV1 extends KanbanHealthBaseV1 { + state: 'write-unavailable'; + readHealthProven: false; + writeHealthProven: false; +} + +/** Public, discriminated observation. Contradictory combinations are unrepresentable. */ +export type KanbanHealthResponseV1 = + | HealthyKanbanHealthResponseV1 + | ReadOnlyDegradedKanbanHealthResponseV1 + | WriteUnavailableKanbanHealthResponseV1; + +/** Pure evaluation context. It cannot authorize a mutation. */ +export interface KanbanEvaluationContextV1 { + contractVersion: typeof KANBAN_CONTRACT_VERSION; + workspaceId: string; + correlationId: string; + now: string; + policyRevision: string; + observedHealth: KanbanHealthResponseV1; +} + +/** + * Non-exported brand: public DTO deserialization cannot construct this type. + * The PostgreSQL adapter mints it only after a fresh write probe inside the same + * transaction and validates checkedAt <= now < validUntil and policy revision. + */ +declare const postgresWriteHealthProofBrand: unique symbol; +export interface PostgresWriteHealthProofV1 { + readonly [postgresWriteHealthProofBrand]: true; + readonly source: 'postgres-transaction-local-write-probe'; + readonly transactionId: string; + readonly checkedAt: string; + readonly validUntil: string; + readonly policyRevision: string; +} + +/** Internal mutation context; MUST NOT appear in REST/MCP/CLI request DTOs. */ +export interface InternalKanbanMutationContextV1 { + contractVersion: typeof KANBAN_CONTRACT_VERSION; + workspaceId: string; + correlationId: string; + causationId?: string; + idempotencyKey: string; + now: string; + expectedPolicyRevision: string; + writeProof: PostgresWriteHealthProofV1; +} + +interface MutationFailureBaseV1 { + contractVersion: typeof KANBAN_CONTRACT_VERSION; + retryable: false; + requestOutcome: 'not_applied'; + idempotencyKey: string; + correlationId: string; + message: string; +} + +/** KCR-016: code/state pairing is exact and cannot cross-map. */ +export interface ReadOnlyWriteHealthDenialV1 extends MutationFailureBaseV1 { + kind: 'deliberate_fail_closed_denial'; + code: 'KANBAN_WRITE_HEALTH_UNPROVEN'; + healthState: 'read-only-degraded'; + checkedAt: string; +} + +export interface WriteUnavailableDenialV1 extends MutationFailureBaseV1 { + kind: 'deliberate_fail_closed_denial'; + code: 'KANBAN_WRITE_UNAVAILABLE'; + healthState: 'write-unavailable'; + checkedAt: string; +} + +export type DeliberateWriteDenialV1 = ReadOnlyWriteHealthDenialV1 | WriteUnavailableDenialV1; + +export const transportErrorCodes = [ + 'GATEWAY_UNREACHABLE', + 'GATEWAY_TIMEOUT', + 'UPSTREAM_BAD_GATEWAY', +] as const; +export type TransportErrorCode = (typeof transportErrorCodes)[number]; + +/** Client-normalized transport uncertainty; never an authoritative 503 body. */ +export interface RetryableTransportErrorV1 { + contractVersion: typeof KANBAN_CONTRACT_VERSION; + kind: 'retryable_transport_error'; + code: TransportErrorCode; + retryable: true; + requestOutcome: 'unknown'; + /** Retry MUST reuse this exact key. */ + idempotencyKey: string; + correlationId: string; + message: string; +} + +export interface VersionConflictV1 { + contractVersion: typeof KANBAN_CONTRACT_VERSION; + kind: 'version_conflict'; + code: 'AGGREGATE_VERSION_CONFLICT'; + retryable: false; + requestOutcome: 'not_applied'; + aggregateType: 'project' | 'mission' | 'milestone' | 'task' | 'change_proposal'; + aggregateId: string; + expectedVersion: number; + actualVersion: number; + idempotencyKey: string; + correlationId: string; + message: string; +} + +export type KanbanMutationFailureV1 = + | DeliberateWriteDenialV1 + | RetryableTransportErrorV1 + | VersionConflictV1; + +export const kanbanHealthCapabilities: Readonly< + Record +> = { + healthy: { canonicalReads: true, mutations: true }, + 'read-only-degraded': { canonicalReads: true, mutations: false }, + 'write-unavailable': { canonicalReads: false, mutations: false }, +}; + +/** Exact HTTP/error normalization freeze; 503, transport, and 409 cannot cross-map. */ +export const kanbanFailureHttpMapV1 = { + KANBAN_WRITE_HEALTH_UNPROVEN: { + httpStatus: 503, + kind: 'deliberate_fail_closed_denial', + requestOutcome: 'not_applied', + retryable: false, + }, + KANBAN_WRITE_UNAVAILABLE: { + httpStatus: 503, + kind: 'deliberate_fail_closed_denial', + requestOutcome: 'not_applied', + retryable: false, + }, + AGGREGATE_VERSION_CONFLICT: { + httpStatus: 409, + kind: 'version_conflict', + requestOutcome: 'not_applied', + retryable: false, + }, + GATEWAY_UNREACHABLE: { + httpStatus: 502, + kind: 'retryable_transport_error', + requestOutcome: 'unknown', + retryable: true, + }, + GATEWAY_TIMEOUT: { + httpStatus: 504, + kind: 'retryable_transport_error', + requestOutcome: 'unknown', + retryable: true, + }, + UPSTREAM_BAD_GATEWAY: { + httpStatus: 502, + kind: 'retryable_transport_error', + requestOutcome: 'unknown', + retryable: true, + }, +} as const; + +/** + * Required negative contract tests: + * - contradictory state/proof booleans fail type/schema validation; + * - expired internal proof and policy mismatch deny before mutation; + * - Valkey-only liveness cannot mint PostgresWriteHealthProofV1; + * - public/caller-forged `healthy` cannot enter InternalKanbanMutationContextV1; + * - authoritative 503, transport 502/504/timeout, and 409 mappings are exhaustive. + */ diff --git a/docs/native-kanban-sot/contracts/kanban-schema.v1.ts b/docs/native-kanban-sot/contracts/kanban-schema.v1.ts new file mode 100644 index 00000000..594a5567 --- /dev/null +++ b/docs/native-kanban-sot/contracts/kanban-schema.v1.ts @@ -0,0 +1,1294 @@ +/** + * Mosaic Native Kanban — frozen Drizzle schema contract v1. + * + * This is the canonical target/compatibility declaration for integration into + * the ONE current-main packages/db/src/schema.ts. It MUST NOT be imported as a + * competing schema module. KBN-100 applies the field-by-field expand/backfill/ + * switch/contract map in SHARED-CONTRACT.md; legacy fields marked below remain + * declared throughout the expand and N-1 window. + * + * Existing Better Auth users.id is the identity parent. User references require + * active workspace membership checks in the same authoritative transaction. + */ + +import { sql } from 'drizzle-orm'; +import { + bigint, + boolean, + check, + foreignKey, + index, + integer, + jsonb, + numeric, + pgEnum, + pgTable, + primaryKey, + text, + timestamp, + uniqueIndex, + uuid, +} from 'drizzle-orm/pg-core'; + +// ─── Frozen vocabularies ───────────────────────────────────────────────────── + +export const workspaceLifecycleEnum = pgEnum('workspace_lifecycle', [ + 'active', + 'suspended', + 'archived', +]); +export const workspaceMemberRoleEnum = pgEnum('workspace_member_role', [ + 'owner', + 'admin', + 'member', + 'auditor', + 'service', +]); +export const teamMemberRoleEnum = pgEnum('team_member_role', ['manager', 'member']); +export const agentLifecycleEnum = pgEnum('agent_lifecycle', ['enabled', 'disabled']); +export const agentSessionStateEnum = pgEnum('agent_session_state', [ + 'starting', + 'available', + 'busy', + 'degraded', + 'offline', + 'ended', +]); +export const projectStatusEnum = pgEnum('project_status_v1', [ + 'planning', + 'active', + 'paused', + 'completed', + 'archived', +]); +export const missionStatusEnum = pgEnum('mission_status_v1', [ + 'draft', + 'awaiting_approval', + 'active', + 'paused', + 'certifying', + 'completed', + 'failed', + 'cancelled', +]); +export const milestoneStatusEnum = pgEnum('milestone_status_v1', [ + 'planned', + 'active', + 'at_risk', + 'completed', + 'cancelled', +]); +export const taskStatusEnum = pgEnum('task_status_v1', [ + 'backlog', + 'ready', + 'in_progress', + 'blocked', + 'in_review', + 'done', + 'cancelled', +]); +export const priorityEnum = pgEnum('work_priority_v1', ['critical', 'high', 'medium', 'low']); +export const specialistRoleEnum = pgEnum('specialist_role_v1', [ + 'planning', + 'enhance', + 'coder', + 'review', + 'security-review', + 'pr-monitor', + 'certifier', +]); +export const dependencyTypeEnum = pgEnum('task_dependency_type', [ + 'blocks', + 'review_gate', + 'certification_gate', +]); +/** Must match assignmentStates in mechanical-coordinator.v1.ts exactly. */ +export const assignmentStateEnum = pgEnum('task_assignment_state_v1', [ + 'awaiting_approval', + 'policy_pre_authorized', + 'approved', + 'rejected', + 'leased', + 'released', + 'expired', + 'superseded', +]); +export const leaseStateEnum = pgEnum('task_lease_state', [ + 'pending_ack', + 'active', + 'released', + 'expired', + 'revoked', +]); +export const actorKindEnum = pgEnum('actor_kind_v1', [ + 'user', + 'agent', + 'session', + 'service', + 'policy', + 'system', +]); +export const approvalDecisionEnum = pgEnum('approval_decision_v1', [ + 'requested', + 'approved', + 'rejected', + 'escalated', +]); +export const executionDispositionEnum = pgEnum('task_execution_disposition_v1', [ + 'available', + 'retry_delayed', + 'quarantined', + 'exhausted', +]); +export const changeProposalStateEnum = pgEnum('change_proposal_state_v1', [ + 'pending', + 'accepted', + 'rejected', +]); +export const outboxStateEnum = pgEnum('outbox_state_v1', [ + 'pending', + 'publishing', + 'published', + 'failed', +]); + +// ─── Tenant and identity ───────────────────────────────────────────────────── + +export const workspacesV1 = pgTable( + 'workspaces', + { + id: uuid('id').primaryKey().defaultRandom(), + name: text('name').notNull(), + slug: text('slug').notNull(), + settings: jsonb('settings').notNull().$type>().default({}), + lifecycle: workspaceLifecycleEnum('lifecycle').notNull().default('active'), + ownerId: text('owner_id').notNull(), // FK to existing users.id at integration + version: integer('version').notNull().default(1), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [ + uniqueIndex('workspaces_slug_uidx').on(t.slug), + check('workspaces_version_positive_chk', sql`${t.version} > 0`), + ], +); + +export const workspaceMembersV1 = pgTable( + 'workspace_members', + { + id: uuid('id').primaryKey().defaultRandom(), + workspaceId: uuid('workspace_id') + .notNull() + .references(() => workspacesV1.id, { onDelete: 'restrict' }), + userId: text('user_id').notNull(), // FK to existing users.id at integration + role: workspaceMemberRoleEnum('role').notNull().default('member'), + active: boolean('active').notNull().default(true), + joinedAt: timestamp('joined_at', { withTimezone: true }).notNull().defaultNow(), + revokedAt: timestamp('revoked_at', { withTimezone: true }), + }, + (t) => [ + uniqueIndex('workspace_members_workspace_user_uidx').on(t.workspaceId, t.userId), + index('workspace_members_user_active_idx').on(t.userId, t.active), + ], +); + +export const teamsV1 = pgTable( + 'teams', + { + id: uuid('id').primaryKey().defaultRandom(), + workspaceId: uuid('workspace_id') + .notNull() + .references(() => workspacesV1.id, { onDelete: 'restrict' }), + name: text('name').notNull(), + slug: text('slug').notNull(), + ownerId: text('owner_id').notNull(), // legacy/current users.id field retained + managerId: text('manager_id').notNull(), // legacy/current users.id field retained + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [ + uniqueIndex('teams_workspace_slug_uidx').on(t.workspaceId, t.slug), + uniqueIndex('teams_workspace_id_uidx').on(t.workspaceId, t.id), + ], +); + +export const teamMembersV1 = pgTable( + 'team_members', + { + id: uuid('id').primaryKey().defaultRandom(), + workspaceId: uuid('workspace_id') + .notNull() + .references(() => workspacesV1.id, { onDelete: 'restrict' }), + teamId: uuid('team_id').notNull(), + userId: text('user_id').notNull(), + role: teamMemberRoleEnum('role').notNull().default('member'), + invitedBy: text('invited_by'), + joinedAt: timestamp('joined_at', { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [ + foreignKey({ + name: 'team_members_workspace_team_fk', + columns: [t.workspaceId, t.teamId], + foreignColumns: [teamsV1.workspaceId, teamsV1.id], + }).onDelete('restrict'), + uniqueIndex('team_members_workspace_team_user_uidx').on(t.workspaceId, t.teamId, t.userId), + ], +); + +export const agentsV1 = pgTable( + 'agents', + { + id: uuid('id').primaryKey().defaultRandom(), + workspaceId: uuid('workspace_id') + .notNull() + .references(() => workspacesV1.id, { onDelete: 'restrict' }), + name: text('name').notNull(), + provider: text('provider').notNull(), // legacy retained + model: text('model').notNull(), // legacy retained + legacyStatus: text('status', { + enum: ['idle', 'active', 'error', 'offline'], + }) + .notNull() + .default('idle'), + projectId: uuid('project_id'), // legacy retained through N-1 + ownerId: text('owner_id'), // legacy retained; active membership required + systemPrompt: text('system_prompt'), // legacy retained + allowedTools: jsonb('allowed_tools').$type(), // legacy retained + skills: jsonb('skills').$type(), // legacy retained + isSystem: boolean('is_system').notNull().default(false), // legacy retained + config: jsonb('config'), // legacy retained + runtime: text('runtime').notNull(), + roles: jsonb('roles').notNull().$type().default([]), + capabilities: jsonb('capabilities').notNull().$type().default([]), + lifecycle: agentLifecycleEnum('lifecycle').notNull().default('enabled'), + metadata: jsonb('metadata').notNull().$type>().default({}), + version: integer('version').notNull().default(1), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [ + uniqueIndex('agents_workspace_id_uidx').on(t.workspaceId, t.id), + uniqueIndex('agents_workspace_name_uidx').on(t.workspaceId, t.name), + index('agents_workspace_lifecycle_idx').on(t.workspaceId, t.lifecycle), + check('agents_version_positive_chk', sql`${t.version} > 0`), + ], +); + +export const agentSessionsV1 = pgTable( + 'agent_sessions', + { + id: uuid('id').primaryKey().defaultRandom(), + workspaceId: uuid('workspace_id') + .notNull() + .references(() => workspacesV1.id, { onDelete: 'restrict' }), + agentId: uuid('agent_id').notNull(), + harnessSessionKey: text('harness_session_key').notNull(), + host: text('host').notNull(), + state: agentSessionStateEnum('state').notNull().default('starting'), + declaredRoles: specialistRoleEnum('declared_primary_role'), + roleSet: jsonb('role_set').notNull().$type().default([]), + capabilities: jsonb('capabilities').notNull().$type().default([]), + capacity: integer('capacity').notNull().default(1), + contextUsagePercent: integer('context_usage_percent'), + startedAt: timestamp('started_at', { withTimezone: true }).notNull().defaultNow(), + lastHeartbeatAt: timestamp('last_heartbeat_at', { withTimezone: true }), + endedAt: timestamp('ended_at', { withTimezone: true }), + metadata: jsonb('metadata').notNull().$type>().default({}), + }, + (t) => [ + foreignKey({ + name: 'agent_sessions_workspace_agent_fk', + columns: [t.workspaceId, t.agentId], + foreignColumns: [agentsV1.workspaceId, agentsV1.id], + }).onDelete('restrict'), + uniqueIndex('agent_sessions_workspace_harness_key_uidx').on(t.workspaceId, t.harnessSessionKey), + uniqueIndex('agent_sessions_workspace_id_uidx').on(t.workspaceId, t.id), + uniqueIndex('agent_sessions_workspace_agent_id_uidx').on(t.workspaceId, t.agentId, t.id), + index('agent_sessions_workspace_state_heartbeat_idx').on( + t.workspaceId, + t.state, + t.lastHeartbeatAt, + ), + check('agent_sessions_capacity_positive_chk', sql`${t.capacity} > 0`), + check( + 'agent_sessions_context_percent_chk', + sql`${t.contextUsagePercent} is null or (${t.contextUsagePercent} >= 0 and ${t.contextUsagePercent} <= 100)`, + ), + ], +); + +// ─── Planning hierarchy ────────────────────────────────────────────────────── + +export const projectsV1 = pgTable( + 'projects', + { + id: uuid('id').primaryKey().defaultRandom(), + workspaceId: uuid('workspace_id') + .notNull() + .references(() => workspacesV1.id, { onDelete: 'restrict' }), + name: text('name').notNull(), + description: text('description'), + /** Legacy status remains declared/readable through the N-1 window. */ + legacyStatus: text('status', { + enum: ['active', 'paused', 'completed', 'archived'], + }) + .notNull() + .default('active'), + canonicalStatus: projectStatusEnum('canonical_status').notNull().default('planning'), + /** Legacy ownership fields retained until contract release. */ + legacyOwnerId: text('owner_id'), + legacyTeamId: uuid('team_id'), + legacyOwnerType: text('owner_type', { enum: ['user', 'team'] }) + .notNull() + .default('user'), + accountableUserId: text('accountable_user_id'), + accountableTeamId: uuid('accountable_team_id'), + priority: priorityEnum('priority').notNull().default('medium'), + repositoryUrl: text('repository_url'), + repositoryProvider: text('repository_provider'), + defaultBranch: text('default_branch'), + domain: text('domain'), + startDate: timestamp('start_date', { withTimezone: true }), + targetDate: timestamp('target_date', { withTimezone: true }), + blockerSummary: text('blocker_summary'), + progressPolicy: jsonb('progress_policy').notNull().$type>().default({}), + metadata: jsonb('metadata').notNull().$type>().default({}), + version: integer('version').notNull().default(1), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [ + foreignKey({ + name: 'projects_workspace_accountable_user_fk', + columns: [t.workspaceId, t.accountableUserId], + foreignColumns: [workspaceMembersV1.workspaceId, workspaceMembersV1.userId], + }).onDelete('restrict'), + foreignKey({ + name: 'projects_workspace_accountable_team_fk', + columns: [t.workspaceId, t.accountableTeamId], + foreignColumns: [teamsV1.workspaceId, teamsV1.id], + }).onDelete('restrict'), + uniqueIndex('projects_workspace_id_uidx').on(t.workspaceId, t.id), + index('projects_workspace_status_idx').on(t.workspaceId, t.canonicalStatus), + check( + 'projects_exactly_one_accountable_owner_chk', + sql`num_nonnulls(${t.accountableUserId}, ${t.accountableTeamId}) = 1`, + ), + check('projects_version_positive_chk', sql`${t.version} > 0`), + ], +); + +export const milestonesV1 = pgTable( + 'milestones', + { + id: uuid('id').primaryKey().defaultRandom(), + workspaceId: uuid('workspace_id') + .notNull() + .references(() => workspacesV1.id, { onDelete: 'restrict' }), + projectId: uuid('project_id').notNull(), + name: text('name').notNull(), + description: text('description'), + status: milestoneStatusEnum('status').notNull().default('planned'), + sequence: integer('sequence').notNull(), + targetDate: timestamp('target_date', { withTimezone: true }), + completedAt: timestamp('completed_at', { withTimezone: true }), + providerMilestoneRef: text('provider_milestone_ref'), + acceptanceSummary: text('acceptance_summary'), + version: integer('version').notNull().default(1), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [ + foreignKey({ + name: 'milestones_workspace_project_fk', + columns: [t.workspaceId, t.projectId], + foreignColumns: [projectsV1.workspaceId, projectsV1.id], + }).onDelete('restrict'), + uniqueIndex('milestones_workspace_project_id_uidx').on(t.workspaceId, t.projectId, t.id), + uniqueIndex('milestones_project_sequence_uidx').on(t.workspaceId, t.projectId, t.sequence), + index('milestones_workspace_project_status_idx').on(t.workspaceId, t.projectId, t.status), + check('milestones_sequence_positive_chk', sql`${t.sequence} > 0`), + check('milestones_version_positive_chk', sql`${t.version} > 0`), + ], +); + +/** Avoids an unsafe circular projects.current_milestone FK during expand. */ +export const projectCurrentMilestonesV1 = pgTable( + 'project_current_milestones', + { + workspaceId: uuid('workspace_id') + .notNull() + .references(() => workspacesV1.id, { onDelete: 'restrict' }), + projectId: uuid('project_id').notNull(), + milestoneId: uuid('milestone_id').notNull(), + setByUserId: text('set_by_user_id').notNull(), + setAt: timestamp('set_at', { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [ + primaryKey({ name: 'project_current_milestones_pk', columns: [t.workspaceId, t.projectId] }), + foreignKey({ + name: 'project_current_milestones_project_fk', + columns: [t.workspaceId, t.projectId], + foreignColumns: [projectsV1.workspaceId, projectsV1.id], + }).onDelete('restrict'), + foreignKey({ + name: 'project_current_milestones_milestone_fk', + columns: [t.workspaceId, t.projectId, t.milestoneId], + foreignColumns: [milestonesV1.workspaceId, milestonesV1.projectId, milestonesV1.id], + }).onDelete('restrict'), + foreignKey({ + name: 'project_current_milestones_set_by_user_fk', + columns: [t.workspaceId, t.setByUserId], + foreignColumns: [workspaceMembersV1.workspaceId, workspaceMembersV1.userId], + }).onDelete('restrict'), + ], +); + +export const missionsV1 = pgTable( + 'missions', + { + id: uuid('id').primaryKey().defaultRandom(), + workspaceId: uuid('workspace_id') + .notNull() + .references(() => workspacesV1.id, { onDelete: 'restrict' }), + projectId: uuid('project_id').notNull(), + name: text('name').notNull(), + /** Legacy fields remain through N-1 and are mapped, never dropped on expand. */ + legacyDescription: text('description'), + legacyStatus: text('status', { + enum: ['planning', 'active', 'paused', 'completed', 'failed'], + }) + .notNull() + .default('planning'), + legacyUserId: text('user_id'), + legacyMilestones: jsonb('milestones').$type[]>(), + legacyConfig: jsonb('config'), + objective: text('objective').notNull(), + canonicalStatus: missionStatusEnum('canonical_status').notNull().default('draft'), + phase: text('phase'), + prdArtifactUri: text('prd_artifact_uri'), + prdRevision: text('prd_revision'), + portfolioOrchestratorId: text('portfolio_orchestrator_id'), + projectSubOrchestratorId: text('project_sub_orchestrator_id'), + approvalPolicy: jsonb('approval_policy').notNull().$type>().default({}), + startedAt: timestamp('started_at', { withTimezone: true }), + completedAt: timestamp('completed_at', { withTimezone: true }), + metadata: jsonb('metadata').notNull().$type>().default({}), + version: integer('version').notNull().default(1), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [ + foreignKey({ + name: 'missions_workspace_project_fk', + columns: [t.workspaceId, t.projectId], + foreignColumns: [projectsV1.workspaceId, projectsV1.id], + }).onDelete('restrict'), + uniqueIndex('missions_workspace_project_id_uidx').on(t.workspaceId, t.projectId, t.id), + index('missions_workspace_project_status_idx').on( + t.workspaceId, + t.projectId, + t.canonicalStatus, + ), + check('missions_version_positive_chk', sql`${t.version} > 0`), + ], +); + +export const missionMilestonesV1 = pgTable( + 'mission_milestones', + { + workspaceId: uuid('workspace_id') + .notNull() + .references(() => workspacesV1.id, { onDelete: 'restrict' }), + projectId: uuid('project_id').notNull(), + missionId: uuid('mission_id').notNull(), + milestoneId: uuid('milestone_id').notNull(), + ordering: integer('ordering').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [ + primaryKey({ + name: 'mission_milestones_pk', + columns: [t.workspaceId, t.projectId, t.missionId, t.milestoneId], + }), + foreignKey({ + name: 'mission_milestones_project_mission_fk', + columns: [t.workspaceId, t.projectId, t.missionId], + foreignColumns: [missionsV1.workspaceId, missionsV1.projectId, missionsV1.id], + }).onDelete('restrict'), + foreignKey({ + name: 'mission_milestones_project_milestone_fk', + columns: [t.workspaceId, t.projectId, t.milestoneId], + foreignColumns: [milestonesV1.workspaceId, milestonesV1.projectId, milestonesV1.id], + }).onDelete('restrict'), + uniqueIndex('mission_milestones_order_uidx').on( + t.workspaceId, + t.projectId, + t.missionId, + t.ordering, + ), + check('mission_milestones_order_positive_chk', sql`${t.ordering} > 0`), + ], +); + +// ─── Tasks, tags, dependencies ─────────────────────────────────────────────── + +export const tasksV1 = pgTable( + 'tasks', + { + id: uuid('id').primaryKey().defaultRandom(), + workspaceId: uuid('workspace_id') + .notNull() + .references(() => workspacesV1.id, { onDelete: 'restrict' }), + projectId: uuid('project_id').notNull(), + missionId: uuid('mission_id'), + milestoneId: uuid('milestone_id'), + parentTaskId: uuid('parent_task_id'), + title: text('title').notNull(), + description: text('description'), + /** Legacy/current-main fields retained throughout expand/N-1. */ + legacyStatus: text('status', { + enum: ['not-started', 'in-progress', 'blocked', 'done', 'cancelled'], + }) + .notNull() + .default('not-started'), + legacyAssignee: text('assignee'), + legacyTags: jsonb('tags').$type(), + legacyDueDate: timestamp('due_date', { withTimezone: true }), + acceptanceCriteria: jsonb('acceptance_criteria') + .notNull() + .$type | string[]>() + .default([]), + canonicalStatus: taskStatusEnum('canonical_status').notNull().default('backlog'), + priority: priorityEnum('priority').notNull().default('medium'), + boardRank: numeric('board_rank', { precision: 30, scale: 15 }).notNull().default('1000'), + accountableUserId: text('accountable_user_id'), + accountableTeamId: uuid('accountable_team_id'), + assignedSpecialistRole: specialistRoleEnum('assigned_specialist_role'), + dueAt: timestamp('due_at', { withTimezone: true }), + notBeforeAt: timestamp('not_before_at', { withTimezone: true }), + estimateMinutes: integer('estimate_minutes'), + progressPercent: integer('progress_percent').notNull().default(0), + blocker: text('blocker'), + retryPolicy: jsonb('retry_policy').notNull().$type>().default({}), + /** Atomically incremented under this task row lock for every new lease. */ + fencingCounter: bigint('fencing_counter', { mode: 'bigint' }) + .notNull() + .default(sql`0`), + archivedAt: timestamp('archived_at', { withTimezone: true }), + archivedByUserId: text('archived_by_user_id'), + archiveReason: text('archive_reason'), + metadata: jsonb('metadata').notNull().$type>().default({}), + version: integer('version').notNull().default(1), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + completedAt: timestamp('completed_at', { withTimezone: true }), + }, + (t) => [ + foreignKey({ + name: 'tasks_workspace_project_fk', + columns: [t.workspaceId, t.projectId], + foreignColumns: [projectsV1.workspaceId, projectsV1.id], + }).onDelete('restrict'), + foreignKey({ + name: 'tasks_project_mission_fk', + columns: [t.workspaceId, t.projectId, t.missionId], + foreignColumns: [missionsV1.workspaceId, missionsV1.projectId, missionsV1.id], + }).onDelete('restrict'), + foreignKey({ + name: 'tasks_project_milestone_fk', + columns: [t.workspaceId, t.projectId, t.milestoneId], + foreignColumns: [milestonesV1.workspaceId, milestonesV1.projectId, milestonesV1.id], + }).onDelete('restrict'), + foreignKey({ + name: 'tasks_project_parent_fk', + columns: [t.workspaceId, t.projectId, t.parentTaskId], + foreignColumns: [t.workspaceId, t.projectId, t.id], + }).onDelete('restrict'), + foreignKey({ + name: 'tasks_workspace_accountable_user_fk', + columns: [t.workspaceId, t.accountableUserId], + foreignColumns: [workspaceMembersV1.workspaceId, workspaceMembersV1.userId], + }).onDelete('restrict'), + foreignKey({ + name: 'tasks_workspace_archived_by_user_fk', + columns: [t.workspaceId, t.archivedByUserId], + foreignColumns: [workspaceMembersV1.workspaceId, workspaceMembersV1.userId], + }).onDelete('restrict'), + foreignKey({ + name: 'tasks_workspace_accountable_team_fk', + columns: [t.workspaceId, t.accountableTeamId], + foreignColumns: [teamsV1.workspaceId, teamsV1.id], + }).onDelete('restrict'), + uniqueIndex('tasks_workspace_id_uidx').on(t.workspaceId, t.id), + uniqueIndex('tasks_workspace_project_id_uidx').on(t.workspaceId, t.projectId, t.id), + index('tasks_workspace_project_status_rank_idx').on( + t.workspaceId, + t.projectId, + t.canonicalStatus, + t.boardRank, + ), + index('tasks_workspace_due_idx').on(t.workspaceId, t.dueAt), + check( + 'tasks_exactly_one_accountable_owner_chk', + sql`num_nonnulls(${t.accountableUserId}, ${t.accountableTeamId}) = 1`, + ), + check('tasks_version_positive_chk', sql`${t.version} > 0`), + check('tasks_fencing_counter_nonnegative_chk', sql`${t.fencingCounter} >= 0`), + check( + 'tasks_progress_percent_chk', + sql`${t.progressPercent} >= 0 and ${t.progressPercent} <= 100`, + ), + check( + 'tasks_archive_fields_chk', + sql`(${t.archivedAt} is null and ${t.archivedByUserId} is null and ${t.archiveReason} is null) or (${t.archivedAt} is not null and ${t.archivedByUserId} is not null and ${t.archiveReason} is not null)`, + ), + ], +); + +export const tagsV1 = pgTable( + 'tags', + { + id: uuid('id').primaryKey().defaultRandom(), + workspaceId: uuid('workspace_id') + .notNull() + .references(() => workspacesV1.id, { onDelete: 'restrict' }), + name: text('name').notNull(), + normalizedName: text('normalized_name').notNull(), + color: text('color'), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [ + uniqueIndex('tags_workspace_id_uidx').on(t.workspaceId, t.id), + uniqueIndex('tags_workspace_normalized_name_uidx').on(t.workspaceId, t.normalizedName), + ], +); + +export const taskTagsV1 = pgTable( + 'task_tags', + { + workspaceId: uuid('workspace_id') + .notNull() + .references(() => workspacesV1.id, { onDelete: 'restrict' }), + taskId: uuid('task_id').notNull(), + tagId: uuid('tag_id').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [ + primaryKey({ name: 'task_tags_pk', columns: [t.workspaceId, t.taskId, t.tagId] }), + foreignKey({ + name: 'task_tags_workspace_task_fk', + columns: [t.workspaceId, t.taskId], + foreignColumns: [tasksV1.workspaceId, tasksV1.id], + }).onDelete('restrict'), + foreignKey({ + name: 'task_tags_workspace_tag_fk', + columns: [t.workspaceId, t.tagId], + foreignColumns: [tagsV1.workspaceId, tagsV1.id], + }).onDelete('restrict'), + ], +); + +export const taskDependenciesV1 = pgTable( + 'task_dependencies', + { + id: uuid('id').primaryKey().defaultRandom(), + workspaceId: uuid('workspace_id') + .notNull() + .references(() => workspacesV1.id, { onDelete: 'restrict' }), + predecessorTaskId: uuid('predecessor_task_id').notNull(), + successorTaskId: uuid('successor_task_id').notNull(), + dependencyType: dependencyTypeEnum('dependency_type').notNull().default('blocks'), + completionCondition: jsonb('completion_condition').$type>(), + createdByActorKind: actorKindEnum('created_by_actor_kind').notNull(), + createdByActorId: text('created_by_actor_id').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [ + foreignKey({ + name: 'task_dependencies_predecessor_fk', + columns: [t.workspaceId, t.predecessorTaskId], + foreignColumns: [tasksV1.workspaceId, tasksV1.id], + }).onDelete('restrict'), + foreignKey({ + name: 'task_dependencies_successor_fk', + columns: [t.workspaceId, t.successorTaskId], + foreignColumns: [tasksV1.workspaceId, tasksV1.id], + }).onDelete('restrict'), + /** One directed pair only; dependency type is an attribute, not a second edge. */ + uniqueIndex('task_dependencies_directed_edge_uidx').on( + t.workspaceId, + t.predecessorTaskId, + t.successorTaskId, + ), + index('task_dependencies_successor_idx').on(t.workspaceId, t.successorTaskId), + check( + 'task_dependencies_no_self_edge_chk', + sql`${t.predecessorTaskId} <> ${t.successorTaskId}`, + ), + ], +); + +// ─── Links, immutable artifacts, audit events, outage proposals ────────────── + +export const externalLinksV1 = pgTable( + 'external_links', + { + id: uuid('id').primaryKey().defaultRandom(), + workspaceId: uuid('workspace_id') + .notNull() + .references(() => workspacesV1.id, { onDelete: 'restrict' }), + entityType: text('entity_type', { + enum: ['project', 'mission', 'milestone', 'task'], + }).notNull(), + entityId: uuid('entity_id').notNull(), + provider: text('provider').notNull(), + linkType: text('link_type', { + enum: ['issue', 'pr', 'ci', 'document', 'release', 'deployment'], + }).notNull(), + externalId: text('external_id').notNull(), + url: text('url').notNull(), + repository: text('repository'), + syncMetadata: jsonb('sync_metadata').notNull().$type>().default({}), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [ + uniqueIndex('external_links_entity_provider_type_external_uidx').on( + t.workspaceId, + t.entityType, + t.entityId, + t.provider, + t.linkType, + t.externalId, + ), + index('external_links_entity_idx').on(t.workspaceId, t.entityType, t.entityId), + ], +); + +export const artifactsV1 = pgTable( + 'artifacts', + { + id: uuid('id').primaryKey().defaultRandom(), + workspaceId: uuid('workspace_id') + .notNull() + .references(() => workspacesV1.id, { onDelete: 'restrict' }), + taskId: uuid('task_id'), + missionId: uuid('mission_id'), + type: text('type').notNull(), + uri: text('uri').notNull(), + immutableRevision: text('immutable_revision').notNull(), + digest: text('digest').notNull(), + producerActorKind: actorKindEnum('producer_actor_kind').notNull(), + producerActorId: text('producer_actor_id').notNull(), + evidenceClassification: text('evidence_classification').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [ + foreignKey({ + name: 'artifacts_workspace_task_fk', + columns: [t.workspaceId, t.taskId], + foreignColumns: [tasksV1.workspaceId, tasksV1.id], + }).onDelete('restrict'), + foreignKey({ + name: 'artifacts_workspace_mission_fk', + columns: [t.workspaceId, t.missionId], + foreignColumns: [missionsV1.workspaceId, missionsV1.id], + }).onDelete('restrict'), + uniqueIndex('artifacts_workspace_id_uidx').on(t.workspaceId, t.id), + uniqueIndex('artifacts_workspace_digest_uidx').on(t.workspaceId, t.digest), + check('artifacts_exactly_one_owner_chk', sql`num_nonnulls(${t.taskId}, ${t.missionId}) = 1`), + ], +); + +export const taskEventsV1 = pgTable( + 'task_events', + { + id: uuid('id').primaryKey().defaultRandom(), + workspaceId: uuid('workspace_id') + .notNull() + .references(() => workspacesV1.id, { onDelete: 'restrict' }), + aggregateType: text('aggregate_type', { + enum: [ + 'workspace', + 'project', + 'mission', + 'milestone', + 'task', + 'assignment', + 'lease', + 'change_proposal', + ], + }).notNull(), + aggregateId: uuid('aggregate_id').notNull(), + eventType: text('event_type').notNull(), + actorKind: actorKindEnum('actor_kind').notNull(), + actorId: text('actor_id').notNull(), + correlationId: uuid('correlation_id').notNull(), + causationId: uuid('causation_id'), + idempotencyKey: text('idempotency_key').notNull(), + previousVersion: integer('previous_version'), + newVersion: integer('new_version'), + payload: jsonb('payload').notNull().$type>().default({}), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [ + uniqueIndex('task_events_workspace_id_uidx').on(t.workspaceId, t.id), + uniqueIndex('task_events_workspace_idempotency_uidx').on(t.workspaceId, t.idempotencyKey), + index('task_events_aggregate_created_idx').on( + t.workspaceId, + t.aggregateType, + t.aggregateId, + t.createdAt, + ), + ], +); + +export const changeProposalsV1 = pgTable( + 'change_proposals', + { + id: uuid('id').primaryKey().defaultRandom(), + workspaceId: uuid('workspace_id') + .notNull() + .references(() => workspacesV1.id, { onDelete: 'restrict' }), + proposerUserId: text('proposer_user_id').notNull(), + sourceNoteDigest: text('source_note_digest').notNull(), + targetAggregateType: text('target_aggregate_type', { + enum: ['project', 'mission', 'milestone', 'task'], + }).notNull(), + targetAggregateId: uuid('target_aggregate_id').notNull(), + expectedAggregateVersion: integer('expected_aggregate_version').notNull(), + proposedCommand: text('proposed_command').notNull(), + proposedPayload: jsonb('proposed_payload').notNull().$type>(), + state: changeProposalStateEnum('state').notNull().default('pending'), + idempotencyKey: text('idempotency_key').notNull(), + submittedAuditEventId: uuid('submitted_audit_event_id').notNull(), + decisionActorUserId: text('decision_actor_user_id'), + decisionReason: text('decision_reason'), + decidedAt: timestamp('decided_at', { withTimezone: true }), + acceptedCommandAuditEventId: uuid('accepted_command_audit_event_id'), + version: integer('version').notNull().default(1), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [ + foreignKey({ + name: 'change_proposals_workspace_proposer_user_fk', + columns: [t.workspaceId, t.proposerUserId], + foreignColumns: [workspaceMembersV1.workspaceId, workspaceMembersV1.userId], + }).onDelete('restrict'), + foreignKey({ + name: 'change_proposals_workspace_decision_actor_user_fk', + columns: [t.workspaceId, t.decisionActorUserId], + foreignColumns: [workspaceMembersV1.workspaceId, workspaceMembersV1.userId], + }).onDelete('restrict'), + foreignKey({ + name: 'change_proposals_workspace_submitted_event_fk', + columns: [t.workspaceId, t.submittedAuditEventId], + foreignColumns: [taskEventsV1.workspaceId, taskEventsV1.id], + }).onDelete('restrict'), + foreignKey({ + name: 'change_proposals_workspace_accepted_command_event_fk', + columns: [t.workspaceId, t.acceptedCommandAuditEventId], + foreignColumns: [taskEventsV1.workspaceId, taskEventsV1.id], + }).onDelete('restrict'), + uniqueIndex('change_proposals_workspace_id_uidx').on(t.workspaceId, t.id), + uniqueIndex('change_proposals_workspace_idempotency_uidx').on(t.workspaceId, t.idempotencyKey), + index('change_proposals_workspace_target_state_idx').on( + t.workspaceId, + t.targetAggregateType, + t.targetAggregateId, + t.state, + ), + check('change_proposals_expected_version_positive_chk', sql`${t.expectedAggregateVersion} > 0`), + check('change_proposals_version_positive_chk', sql`${t.version} > 0`), + check( + 'change_proposals_decision_fields_chk', + sql`(${t.state} = 'pending' and ${t.decisionActorUserId} is null and ${t.decisionReason} is null and ${t.decidedAt} is null and ${t.acceptedCommandAuditEventId} is null) or (${t.state} = 'rejected' and ${t.decisionActorUserId} is not null and ${t.decisionReason} is not null and ${t.decidedAt} is not null and ${t.acceptedCommandAuditEventId} is null) or (${t.state} = 'accepted' and ${t.decisionActorUserId} is not null and ${t.decisionReason} is not null and ${t.decidedAt} is not null and ${t.acceptedCommandAuditEventId} is not null)`, + ), + ], +); + +// ─── Assignments, execution, fencing, checkpoints ──────────────────────────── + +export const taskAssignmentsV1 = pgTable( + 'task_assignments', + { + id: uuid('id').primaryKey().defaultRandom(), + workspaceId: uuid('workspace_id') + .notNull() + .references(() => workspacesV1.id, { onDelete: 'restrict' }), + taskId: uuid('task_id').notNull(), + taskVersion: integer('task_version').notNull(), + targetUserId: text('target_user_id'), + targetTeamId: uuid('target_team_id'), + targetAgentId: uuid('target_agent_id'), + targetSessionId: uuid('target_session_id'), + specialistRole: specialistRoleEnum('specialist_role').notNull(), + state: assignmentStateEnum('state').notNull().default('awaiting_approval'), + policyRevision: text('policy_revision').notNull(), + proposedByUserId: text('proposed_by_user_id'), + proposedByAgentId: uuid('proposed_by_agent_id'), + reason: text('reason').notNull(), + proposedAt: timestamp('proposed_at', { withTimezone: true }).notNull().defaultNow(), + expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(), + approvedAt: timestamp('approved_at', { withTimezone: true }), + endedAt: timestamp('ended_at', { withTimezone: true }), + }, + (t) => [ + foreignKey({ + name: 'task_assignments_workspace_task_fk', + columns: [t.workspaceId, t.taskId], + foreignColumns: [tasksV1.workspaceId, tasksV1.id], + }).onDelete('restrict'), + foreignKey({ + name: 'task_assignments_workspace_user_fk', + columns: [t.workspaceId, t.targetUserId], + foreignColumns: [workspaceMembersV1.workspaceId, workspaceMembersV1.userId], + }).onDelete('restrict'), + foreignKey({ + name: 'task_assignments_workspace_proposer_user_fk', + columns: [t.workspaceId, t.proposedByUserId], + foreignColumns: [workspaceMembersV1.workspaceId, workspaceMembersV1.userId], + }).onDelete('restrict'), + foreignKey({ + name: 'task_assignments_workspace_proposer_agent_fk', + columns: [t.workspaceId, t.proposedByAgentId], + foreignColumns: [agentsV1.workspaceId, agentsV1.id], + }).onDelete('restrict'), + foreignKey({ + name: 'task_assignments_workspace_team_fk', + columns: [t.workspaceId, t.targetTeamId], + foreignColumns: [teamsV1.workspaceId, teamsV1.id], + }).onDelete('restrict'), + foreignKey({ + name: 'task_assignments_workspace_agent_fk', + columns: [t.workspaceId, t.targetAgentId], + foreignColumns: [agentsV1.workspaceId, agentsV1.id], + }).onDelete('restrict'), + foreignKey({ + name: 'task_assignments_workspace_agent_session_fk', + columns: [t.workspaceId, t.targetAgentId, t.targetSessionId], + foreignColumns: [agentSessionsV1.workspaceId, agentSessionsV1.agentId, agentSessionsV1.id], + }).onDelete('restrict'), + uniqueIndex('task_assignments_workspace_id_uidx').on(t.workspaceId, t.id), + uniqueIndex('task_assignments_exact_target_uidx').on( + t.workspaceId, + t.taskId, + t.id, + t.targetAgentId, + t.targetSessionId, + ), + index('task_assignments_workspace_task_state_idx').on(t.workspaceId, t.taskId, t.state), + check( + 'task_assignments_exactly_one_principal_chk', + sql`num_nonnulls(${t.targetUserId}, ${t.targetTeamId}, ${t.targetAgentId}) = 1`, + ), + check( + 'task_assignments_exactly_one_proposer_chk', + sql`num_nonnulls(${t.proposedByUserId}, ${t.proposedByAgentId}) = 1`, + ), + check( + 'task_assignments_agent_session_pair_chk', + sql`(${t.targetAgentId} is null and ${t.targetSessionId} is null) or (${t.targetAgentId} is not null and ${t.targetSessionId} is not null)`, + ), + check('task_assignments_task_version_positive_chk', sql`${t.taskVersion} > 0`), + ], +); + +export const taskExecutionStatesV1 = pgTable( + 'task_execution_states', + { + workspaceId: uuid('workspace_id') + .notNull() + .references(() => workspacesV1.id, { onDelete: 'restrict' }), + taskId: uuid('task_id').notNull(), + disposition: executionDispositionEnum('disposition').notNull().default('available'), + attemptCount: integer('attempt_count').notNull().default(0), + maxAttempts: integer('max_attempts').notNull(), + nextEligibleAt: timestamp('next_eligible_at', { withTimezone: true }), + terminalReason: text('terminal_reason'), + updatedByActorKind: actorKindEnum('updated_by_actor_kind').notNull(), + updatedByActorId: text('updated_by_actor_id').notNull(), + policyRevision: text('policy_revision').notNull(), + version: integer('version').notNull().default(1), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [ + primaryKey({ name: 'task_execution_states_pk', columns: [t.workspaceId, t.taskId] }), + foreignKey({ + name: 'task_execution_states_workspace_task_fk', + columns: [t.workspaceId, t.taskId], + foreignColumns: [tasksV1.workspaceId, tasksV1.id], + }).onDelete('restrict'), + index('task_execution_states_disposition_next_idx').on( + t.workspaceId, + t.disposition, + t.nextEligibleAt, + ), + check('task_execution_states_attempt_nonnegative_chk', sql`${t.attemptCount} >= 0`), + check('task_execution_states_max_positive_chk', sql`${t.maxAttempts} > 0`), + check('task_execution_states_attempt_bound_chk', sql`${t.attemptCount} <= ${t.maxAttempts}`), + check('task_execution_states_version_positive_chk', sql`${t.version} > 0`), + ], +); + +export const taskLeasesV1 = pgTable( + 'task_leases', + { + id: uuid('id').primaryKey().defaultRandom(), + workspaceId: uuid('workspace_id') + .notNull() + .references(() => workspacesV1.id, { onDelete: 'restrict' }), + taskId: uuid('task_id').notNull(), + assignmentId: uuid('assignment_id').notNull(), + agentId: uuid('agent_id').notNull(), + agentSessionId: uuid('agent_session_id').notNull(), + state: leaseStateEnum('state').notNull().default('pending_ack'), + acquiredAt: timestamp('acquired_at', { withTimezone: true }).notNull().defaultNow(), + acknowledgedAt: timestamp('acknowledged_at', { withTimezone: true }), + acknowledgeBy: timestamp('acknowledge_by', { withTimezone: true }).notNull(), + lastHeartbeatAt: timestamp('last_heartbeat_at', { withTimezone: true }), + expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(), + /** Exact value atomically returned from tasks.fencing_counter. */ + fencingToken: bigint('fencing_token', { mode: 'bigint' }).notNull(), + attemptNumber: integer('attempt_number').notNull(), + releaseReason: text('release_reason'), + releasedAt: timestamp('released_at', { withTimezone: true }), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [ + foreignKey({ + name: 'task_leases_exact_assignment_target_fk', + columns: [t.workspaceId, t.taskId, t.assignmentId, t.agentId, t.agentSessionId], + foreignColumns: [ + taskAssignmentsV1.workspaceId, + taskAssignmentsV1.taskId, + taskAssignmentsV1.id, + taskAssignmentsV1.targetAgentId, + taskAssignmentsV1.targetSessionId, + ], + }).onDelete('restrict'), + uniqueIndex('task_leases_active_task_uidx') + .on(t.workspaceId, t.taskId) + .where(sql`${t.state} in ('pending_ack', 'active')`), + uniqueIndex('task_leases_exact_fence_uidx').on(t.workspaceId, t.taskId, t.id, t.fencingToken), + uniqueIndex('task_leases_task_fence_uidx').on(t.workspaceId, t.taskId, t.fencingToken), + index('task_leases_workspace_state_expiry_idx').on(t.workspaceId, t.state, t.expiresAt), + check('task_leases_fence_positive_chk', sql`${t.fencingToken} > 0`), + check('task_leases_attempt_positive_chk', sql`${t.attemptNumber} > 0`), + ], +); + +export const taskCheckpointsV1 = pgTable( + 'task_checkpoints', + { + id: uuid('id').primaryKey().defaultRandom(), + workspaceId: uuid('workspace_id') + .notNull() + .references(() => workspacesV1.id, { onDelete: 'restrict' }), + taskId: uuid('task_id').notNull(), + leaseId: uuid('lease_id').notNull(), + fencingToken: bigint('fencing_token', { mode: 'bigint' }).notNull(), + sequence: integer('sequence').notNull(), + resumableSummary: text('resumable_summary').notNull(), + contextUsagePercent: integer('context_usage_percent'), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [ + foreignKey({ + name: 'task_checkpoints_exact_lease_fence_fk', + columns: [t.workspaceId, t.taskId, t.leaseId, t.fencingToken], + foreignColumns: [ + taskLeasesV1.workspaceId, + taskLeasesV1.taskId, + taskLeasesV1.id, + taskLeasesV1.fencingToken, + ], + }).onDelete('restrict'), + uniqueIndex('task_checkpoints_workspace_task_id_uidx').on(t.workspaceId, t.taskId, t.id), + uniqueIndex('task_checkpoints_lease_sequence_uidx').on(t.workspaceId, t.leaseId, t.sequence), + index('task_checkpoints_task_created_idx').on(t.workspaceId, t.taskId, t.createdAt), + check('task_checkpoints_sequence_positive_chk', sql`${t.sequence} > 0`), + check('task_checkpoints_fence_positive_chk', sql`${t.fencingToken} > 0`), + check( + 'task_checkpoints_context_percent_chk', + sql`${t.contextUsagePercent} is null or (${t.contextUsagePercent} >= 0 and ${t.contextUsagePercent} <= 100)`, + ), + ], +); + +export const checkpointArtifactsV1 = pgTable( + 'task_checkpoint_artifacts', + { + workspaceId: uuid('workspace_id') + .notNull() + .references(() => workspacesV1.id, { onDelete: 'restrict' }), + taskId: uuid('task_id').notNull(), + checkpointId: uuid('checkpoint_id').notNull(), + artifactId: uuid('artifact_id').notNull(), + }, + (t) => [ + primaryKey({ + name: 'task_checkpoint_artifacts_pk', + columns: [t.workspaceId, t.checkpointId, t.artifactId], + }), + foreignKey({ + name: 'task_checkpoint_artifacts_checkpoint_fk', + columns: [t.workspaceId, t.taskId, t.checkpointId], + foreignColumns: [ + taskCheckpointsV1.workspaceId, + taskCheckpointsV1.taskId, + taskCheckpointsV1.id, + ], + }).onDelete('restrict'), + foreignKey({ + name: 'task_checkpoint_artifacts_artifact_fk', + columns: [t.workspaceId, t.artifactId], + foreignColumns: [artifactsV1.workspaceId, artifactsV1.id], + }).onDelete('restrict'), + ], +); + +// ─── Approvals, evidence, outbox ───────────────────────────────────────────── + +export const approvalDecisionsV1 = pgTable( + 'approval_decisions', + { + id: uuid('id').primaryKey().defaultRandom(), + workspaceId: uuid('workspace_id') + .notNull() + .references(() => workspacesV1.id, { onDelete: 'restrict' }), + taskId: uuid('task_id'), + missionId: uuid('mission_id'), + assignmentId: uuid('assignment_id'), + gateType: text('gate_type').notNull(), + requestedFromRole: specialistRoleEnum('requested_from_role'), + decision: approvalDecisionEnum('decision').notNull().default('requested'), + conditions: jsonb('conditions').notNull().$type>().default({}), + actorKind: actorKindEnum('actor_kind'), + actorId: text('actor_id'), + policyRevision: text('policy_revision').notNull(), + reason: text('reason'), + requestedAt: timestamp('requested_at', { withTimezone: true }).notNull().defaultNow(), + decidedAt: timestamp('decided_at', { withTimezone: true }), + }, + (t) => [ + foreignKey({ + name: 'approval_decisions_workspace_task_fk', + columns: [t.workspaceId, t.taskId], + foreignColumns: [tasksV1.workspaceId, tasksV1.id], + }).onDelete('restrict'), + foreignKey({ + name: 'approval_decisions_workspace_mission_fk', + columns: [t.workspaceId, t.missionId], + foreignColumns: [missionsV1.workspaceId, missionsV1.id], + }).onDelete('restrict'), + foreignKey({ + name: 'approval_decisions_workspace_assignment_fk', + columns: [t.workspaceId, t.assignmentId], + foreignColumns: [taskAssignmentsV1.workspaceId, taskAssignmentsV1.id], + }).onDelete('restrict'), + uniqueIndex('approval_decisions_workspace_id_uidx').on(t.workspaceId, t.id), + index('approval_decisions_assignment_idx').on(t.workspaceId, t.assignmentId, t.decision), + check( + 'approval_decisions_one_target_chk', + sql`num_nonnulls(${t.taskId}, ${t.missionId}, ${t.assignmentId}) = 1`, + ), + ], +); + +export const approvalDecisionArtifactsV1 = pgTable( + 'approval_decision_artifacts', + { + workspaceId: uuid('workspace_id') + .notNull() + .references(() => workspacesV1.id, { onDelete: 'restrict' }), + approvalDecisionId: uuid('approval_decision_id').notNull(), + artifactId: uuid('artifact_id').notNull(), + }, + (t) => [ + primaryKey({ + name: 'approval_decision_artifacts_pk', + columns: [t.workspaceId, t.approvalDecisionId, t.artifactId], + }), + foreignKey({ + name: 'approval_decision_artifacts_decision_fk', + columns: [t.workspaceId, t.approvalDecisionId], + foreignColumns: [approvalDecisionsV1.workspaceId, approvalDecisionsV1.id], + }).onDelete('restrict'), + foreignKey({ + name: 'approval_decision_artifacts_artifact_fk', + columns: [t.workspaceId, t.artifactId], + foreignColumns: [artifactsV1.workspaceId, artifactsV1.id], + }).onDelete('restrict'), + ], +); + +export const outboxEventsV1 = pgTable( + 'outbox_events', + { + id: uuid('id').primaryKey().defaultRandom(), + workspaceId: uuid('workspace_id') + .notNull() + .references(() => workspacesV1.id, { onDelete: 'restrict' }), + aggregateType: text('aggregate_type').notNull(), + aggregateId: uuid('aggregate_id').notNull(), + aggregateRevision: integer('aggregate_revision').notNull(), + eventType: text('event_type').notNull(), + payload: jsonb('payload').notNull().$type>(), + state: outboxStateEnum('state').notNull().default('pending'), + attempts: integer('attempts').notNull().default(0), + nextAttemptAt: timestamp('next_attempt_at', { withTimezone: true }), + publishedAt: timestamp('published_at', { withTimezone: true }), + lastError: text('last_error'), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [ + uniqueIndex('outbox_events_aggregate_revision_type_uidx').on( + t.workspaceId, + t.aggregateType, + t.aggregateId, + t.aggregateRevision, + t.eventType, + ), + index('outbox_events_state_next_attempt_idx').on(t.state, t.nextAttemptAt), + check('outbox_events_revision_positive_chk', sql`${t.aggregateRevision} > 0`), + check('outbox_events_attempts_nonnegative_chk', sql`${t.attempts} >= 0`), + ], +); + +/** + * Mandatory transaction/privilege rules frozen with this schema: + * + * 1. Every user principal/owner/proposer/decision actor must have ACTIVE + * workspace_members membership; denials expose no foreign-ID existence. + * 2. roleSet/roles JSON values are validated against specialist_role_v1 until + * normalized role joins are introduced; the primary/assigned role is enum. + * 3. Dependency cycles are rejected under a serialized recursive check. + * 4. task_events is declared before change_proposals so migration DDL creates + * task_events_workspace_id_uidx before both workspace-aware proposal event + * FKs. Submission preallocates the proposal ID and, in one transaction, + * inserts `change_proposal.submitted` with aggregate_type=change_proposal, + * aggregate_id=proposal.id, previous_version=NULL, new_version=1, then the + * proposal referencing that event. Missing or foreign-workspace events fail. + * 5. change_proposals never mutate targets directly. Acceptance locks proposal + * and target, proves fresh PG write health, checks expected version, invokes + * the NORMAL typed command, and in that same transaction binds its emitted + * event. The event workspace/aggregate type/aggregate ID must equal the + * proposal workspace/target, causation_id must equal submittedAuditEventId, + * and payload.changeProposalId must equal proposal.id; unrelated events fail. + * 6. Lease acquisition locks task+assignment+approval+session, increments + * tasks.fencing_counter atomically, and uses RETURNING bigint as the token. + * 7. task_events, task_checkpoints, checkpoint/artifact evidence, approval + * evidence, and immutable artifacts grant application roles INSERT/SELECT + * only. Canonical parents are archived, not hard-deleted; all parent FKs use + * RESTRICT. Purge requires an audited retention/break-glass procedure. + * 8. Polymorphic external_links and change_proposals targets are validated in a + * workspace-scoped transaction before insert; no existence oracle. + * 9. Legacy fields remain in the unified Drizzle declaration throughout expand + * and N-1. New Gateway commands never accept mission_tasks.status as a write + * source; its concrete retirement is specified in SHARED-CONTRACT.md. + */ diff --git a/docs/native-kanban-sot/contracts/mechanical-coordinator.v1.ts b/docs/native-kanban-sot/contracts/mechanical-coordinator.v1.ts new file mode 100644 index 00000000..b75f4ce8 --- /dev/null +++ b/docs/native-kanban-sot/contracts/mechanical-coordinator.v1.ts @@ -0,0 +1,419 @@ +/** + * Mosaic Native Kanban — frozen Mechanical Coordinator contracts v1. + * + * The pure decision engine and persistence/orchestration service are separate. + * Neither surface can create scope, edit acceptance, waive gates, certify, + * merge, release a deployment, or close a provider issue. + */ + +import type { + DeliberateWriteDenialV1, + InternalKanbanMutationContextV1, + KanbanEvaluationContextV1, + KanbanMutationFailureV1, + RetryableTransportErrorV1, + VersionConflictV1, +} from './health-state.v1.js'; + +export const COORDINATOR_CONTRACT_VERSION = '1.0.0' as const; +export type Uuid = string; +export type IsoTimestamp = string; +/** PostgreSQL bigint-safe decimal string; never a JavaScript number. */ +export type FencingTokenV1 = string; + +export const specialistRoles = [ + 'planning', + 'enhance', + 'coder', + 'review', + 'security-review', + 'pr-monitor', + 'certifier', +] as const; +export type SpecialistRole = (typeof specialistRoles)[number]; + +/** One vocabulary shared with task_assignment_state_v1 in the Drizzle schema. */ +export const assignmentStates = [ + 'awaiting_approval', + 'policy_pre_authorized', + 'approved', + 'rejected', + 'leased', + 'released', + 'expired', + 'superseded', +] as const; +export type AssignmentStateV1 = (typeof assignmentStates)[number]; + +export const readinessStates = [ + 'dependency-gated', + 'schedule-gated', + 'policy-gated', + 'lease-available', + 'leased', + 'retry-delayed', + 'exhausted', + 'quarantined', +] as const; +export type ReadinessState = (typeof readinessStates)[number]; + +export interface RetryStateSnapshotV1 { + disposition: 'available' | 'retry_delayed' | 'quarantined' | 'exhausted'; + attemptCount: number; + maxAttempts: number; + nextEligibleAt: IsoTimestamp | null; + idempotent: boolean; + terminalReason: string | null; + version: number; +} + +export interface TaskEligibilitySnapshotV1 { + workspaceId: Uuid; + taskId: Uuid; + taskVersion: number; + projectId: Uuid; + projectActive: boolean; + missionId: Uuid | null; + missionActive: boolean; + status: 'ready'; + priority: 'critical' | 'high' | 'medium' | 'low'; + boardRank: string; + dueAt: IsoTimestamp | null; + notBeforeAt: IsoTimestamp | null; + createdAt: IsoTimestamp; + requiredRole: SpecialistRole; + requiredCapabilities: readonly string[]; + blockingDependencies: readonly { + taskId: Uuid; + done: boolean; + completionConditionSatisfied: boolean; + }[]; + releaseApproval: { + decisionId: Uuid; + approved: boolean; + policyRevision: string; + } | null; + activeLeaseId: Uuid | null; + retry: RetryStateSnapshotV1; +} + +export interface AgentSessionSnapshotV1 { + workspaceId: Uuid; + agentId: Uuid; + sessionId: Uuid; + state: 'available' | 'busy'; + roles: readonly SpecialistRole[]; + capabilities: readonly string[]; + capacity: number; + activeLeaseCount: number; + heartbeatAt: IsoTimestamp; +} + +export interface EligibilityExplanationV1 { + taskId: Uuid; + eligible: boolean; + readiness: ReadinessState; + reasons: readonly { + gate: + | 'status' + | 'project' + | 'mission' + | 'dependency' + | 'schedule' + | 'retry' + | 'approval' + | 'lease' + | 'capability' + | 'capacity' + | 'health'; + satisfied: boolean; + code: string; + detail: string; + }[]; + policyRevision: string; + evaluatedAt: IsoTimestamp; +} + +export interface AssignmentProposalDecisionV1 { + workspaceId: Uuid; + taskId: Uuid; + taskVersion: number; + targetAgentId: Uuid; + targetSessionId: Uuid; + specialistRole: SpecialistRole; + initialState: 'awaiting_approval' | 'policy_pre_authorized'; + policyRevision: string; + explanation: EligibilityExplanationV1; + expiresAt: IsoTimestamp; +} + +export interface AssignmentCycleSnapshotV1 { + context: KanbanEvaluationContextV1; + tasks: readonly TaskEligibilitySnapshotV1[]; + sessions: readonly AgentSessionSnapshotV1[]; + workspaceFairness: Readonly>; + limit: number; +} + +export interface AssignmentCycleDecisionV1 { + evaluatedTaskCount: number; + proposals: readonly AssignmentProposalDecisionV1[]; + explanations: readonly EligibilityExplanationV1[]; +} + +export interface LeaseExpirySnapshotV1 { + workspaceId: Uuid; + taskId: Uuid; + taskVersion: number; + leaseId: Uuid; + assignmentId: Uuid; + sessionId: Uuid; + fencingToken: FencingTokenV1; + state: 'pending_ack' | 'active'; + acknowledgeBy: IsoTimestamp; + expiresAt: IsoTimestamp; + lastHeartbeatAt: IsoTimestamp | null; + retry: RetryStateSnapshotV1; +} + +export interface LeaseExpiryDecisionV1 { + leaseId: Uuid; + action: 'retain' | 'release' | 'retry' | 'quarantine' | 'exhaust'; + reason: string; + nextEligibleAt: IsoTimestamp | null; +} + +/** Pure package owned by KBN-200. It receives complete immutable snapshots. */ +export interface MechanicalCoordinatorDecisionEngineV1 { + evaluateAssignmentCycle(snapshot: AssignmentCycleSnapshotV1): AssignmentCycleDecisionV1; + explainEligibility( + context: KanbanEvaluationContextV1, + task: TaskEligibilitySnapshotV1, + sessions: readonly AgentSessionSnapshotV1[], + ): EligibilityExplanationV1; + decideLeaseExpiry( + context: KanbanEvaluationContextV1, + lease: LeaseExpirySnapshotV1, + ): LeaseExpiryDecisionV1; +} + +export interface PersistedAssignmentV1 { + assignmentId: Uuid; + workspaceId: Uuid; + taskId: Uuid; + taskVersion: number; + targetAgentId: Uuid; + targetSessionId: Uuid; + specialistRole: SpecialistRole; + state: AssignmentStateV1; + policyRevision: string; + proposedBy: { kind: 'user' | 'agent'; id: Uuid }; + reason: string; + createdAt: IsoTimestamp; + expiresAt: IsoTimestamp; +} + +export interface TaskLeaseV1 { + leaseId: Uuid; + workspaceId: Uuid; + taskId: Uuid; + taskVersion: number; + assignmentId: Uuid; + agentId: Uuid; + sessionId: Uuid; + state: 'pending_ack' | 'active'; + fencingToken: FencingTokenV1; + attempt: number; + acquiredAt: IsoTimestamp; + acknowledgeBy: IsoTimestamp; + lastHeartbeatAt: IsoTimestamp | null; + expiresAt: IsoTimestamp; +} + +interface ServiceCommandBaseV1 { + context: InternalKanbanMutationContextV1; + taskId: Uuid; + expectedTaskVersion: number; +} + +export interface AcquireApprovedLeaseCommandV1 extends ServiceCommandBaseV1 { + assignmentId: Uuid; + approvalDecisionId: Uuid; + targetSessionId: Uuid; + leaseTtlSeconds: number; +} + +export interface LeaseCommandV1 extends ServiceCommandBaseV1 { + leaseId: Uuid; + sessionId: Uuid; + fencingToken: FencingTokenV1; +} + +export interface HeartbeatLeaseCommandV1 extends LeaseCommandV1 { + extendSeconds: number; +} + +export interface CheckpointCommandV1 extends LeaseCommandV1 { + sequence: number; + resumableSummary: string; + artifactIds: readonly Uuid[]; + contextUsagePercent: number; +} + +export interface SubmitForReviewCommandV1 extends LeaseCommandV1 { + artifactIds: readonly Uuid[]; + summary: string; +} + +export interface ReleaseLeaseCommandV1 extends LeaseCommandV1 { + reason: + | 'worker_requested' + | 'ack_timeout' + | 'heartbeat_timeout' + | 'task_submitted' + | 'policy_revoked' + | 'shutdown'; +} + +export interface AssignmentCycleCommandV1 { + context: InternalKanbanMutationContextV1; + limit: number; +} + +export interface ExpirySweepCommandV1 { + context: InternalKanbanMutationContextV1; + limit: number; +} + +export interface RecoverCoordinatorCommandV1 { + context: InternalKanbanMutationContextV1; +} + +interface CoordinatorRejectionBaseV1 { + kind: 'coordinator_rejection'; + retryable: false; + requestOutcome: 'not_applied'; + correlationId: Uuid; + idempotencyKey: string; + message: string; +} + +export type CoordinatorPolicyRejectionV1 = + | (CoordinatorRejectionBaseV1 & { code: 'WORKSPACE_MISMATCH' }) + | (CoordinatorRejectionBaseV1 & { + code: 'TASK_NOT_ELIGIBLE'; + explanation: EligibilityExplanationV1; + }) + | (CoordinatorRejectionBaseV1 & { code: 'APPROVAL_REQUIRED' }) + | (CoordinatorRejectionBaseV1 & { code: 'APPROVAL_STALE' }) + | (CoordinatorRejectionBaseV1 & { code: 'ASSIGNMENT_STALE' }) + | (CoordinatorRejectionBaseV1 & { code: 'ASSIGNMENT_TARGET_MISMATCH' }) + | (CoordinatorRejectionBaseV1 & { code: 'POLICY_REVISION_MISMATCH' }) + | (CoordinatorRejectionBaseV1 & { code: 'ARTIFACT_WORKSPACE_MISMATCH' }) + | (CoordinatorRejectionBaseV1 & { code: 'LEASE_ALREADY_ACTIVE' }) + | (CoordinatorRejectionBaseV1 & { code: 'LEASE_NOT_FOUND' }) + | (CoordinatorRejectionBaseV1 & { code: 'LEASE_NOT_ACTIVE' }) + | (CoordinatorRejectionBaseV1 & { + code: 'ACK_DEADLINE_EXPIRED'; + expiredAt: IsoTimestamp; + }) + | (CoordinatorRejectionBaseV1 & { + code: 'FENCING_TOKEN_STALE'; + currentFencingToken: FencingTokenV1; + }) + | (CoordinatorRejectionBaseV1 & { code: 'SESSION_MISMATCH' }) + | (CoordinatorRejectionBaseV1 & { + code: 'HEARTBEAT_EXPIRED'; + expiredAt: IsoTimestamp; + }) + | (CoordinatorRejectionBaseV1 & { + code: 'CHECKPOINT_SEQUENCE_CONFLICT'; + currentSequence: number; + }) + | (CoordinatorRejectionBaseV1 & { code: 'RETRY_EXHAUSTED' }) + | (CoordinatorRejectionBaseV1 & { + code: 'NON_IDEMPOTENT_RETRY_REQUIRES_ORCHESTRATOR'; + }); + +/** Explicit mapping to the Gateway mutation failure union; no arbitrary booleans. */ +export type CoordinatorFailureV1 = + | DeliberateWriteDenialV1 + | VersionConflictV1 + | RetryableTransportErrorV1 + | CoordinatorPolicyRejectionV1; + +export interface CoordinatorSuccessV1 { + ok: true; + value: T; + correlationId: Uuid; +} +export interface CoordinatorFailureResultV1 { + ok: false; + failure: CoordinatorFailureV1; +} +export type CoordinatorResultV1 = CoordinatorSuccessV1 | CoordinatorFailureResultV1; + +export interface ExpirySweepResultV1 { + examined: number; + released: readonly Uuid[]; + retryScheduled: readonly Uuid[]; + quarantined: readonly Uuid[]; + exhausted: readonly Uuid[]; +} + +export interface RestartRecoveryResultV1 { + activeLeaseIds: readonly Uuid[]; + expiredLeaseIds: readonly Uuid[]; + pendingAssignmentIds: readonly Uuid[]; + pendingOutboxEventIds: readonly Uuid[]; +} + +/** Persistence/Gateway adapter owned by KBN-210. */ +export interface MechanicalCoordinatorServicePortV1 { + /** Loads immutable snapshots, invokes pure engine, and persists proposals atomically. */ + runAssignmentCycle( + command: AssignmentCycleCommandV1, + ): Promise>; + + /** Query path loads by ID; public health observation cannot authorize mutation. */ + getEligibilityExplanation( + context: KanbanEvaluationContextV1, + taskId: Uuid, + ): Promise>; + + /** + * Accepts IDs only. Implementation reloads and locks assignment + approval + + * task + target session in PostgreSQL, then verifies workspace, task version, + * target agent/session, state, expiry, policy revision, and current approval. + */ + acquireApprovedLease( + command: AcquireApprovedLeaseCommandV1, + ): Promise>; + + acknowledgeLease(command: LeaseCommandV1): Promise>; + heartbeatLease(command: HeartbeatLeaseCommandV1): Promise>; + appendCheckpoint( + command: CheckpointCommandV1, + ): Promise>; + submitForReview( + command: SubmitForReviewCommandV1, + ): Promise>; + releaseLease(command: ReleaseLeaseCommandV1): Promise>; + expireAndRecover( + command: ExpirySweepCommandV1, + ): Promise>; + recoverFromPostgres( + command: RecoverCoordinatorCommandV1, + ): Promise>; +} + +/** Compile-time mapping guarantee: Coordinator Gateway failures are Kanban failures or exact policy rejections. */ +export function isKanbanMutationFailureV1( + failure: CoordinatorFailureV1, +): failure is KanbanMutationFailureV1 { + return ( + failure.kind === 'deliberate_fail_closed_denial' || + failure.kind === 'retryable_transport_error' || + failure.kind === 'version_conflict' + ); +} diff --git a/docs/native-kanban-sot/contracts/recovery-posture.v1.ts b/docs/native-kanban-sot/contracts/recovery-posture.v1.ts new file mode 100644 index 00000000..3604519a --- /dev/null +++ b/docs/native-kanban-sot/contracts/recovery-posture.v1.ts @@ -0,0 +1,369 @@ +/** + * Mosaic Native Kanban — frozen recovery-posture contract v1. + * Recovery posture is configurable; SOT, write-health, Coordinator authority, + * and gate semantics are not fields and cannot be overridden. + */ + +export const RECOVERY_POSTURE_CONTRACT_VERSION = '1.0.0' as const; +export const recoveryTiers = ['lite', 'standard', 'high-assurance'] as const; +export type RecoveryTier = (typeof recoveryTiers)[number]; + +export interface OffClusterStorageV1 { + required: true; + encrypted: true; + separateFailureDomain: true; + minimumCopies: number; + storageClass: 'encrypted-object-storage' | 'encrypted-backup-target'; +} + +export interface RecoveryPostureV1 { + contractVersion: typeof RECOVERY_POSTURE_CONTRACT_VERSION; + tier: RecoveryTier; + targetRpoMinutes: number; + targetRtoMinutes: number; + baseBackupIntervalHours: number; + /** null means WAL archival/PITR is disabled. */ + walArchiveIntervalMinutes: number | null; + /** 0 means PITR is disabled. */ + pitrRetentionDays: number; + restoreTestIntervalDays: number; + breakGlassDrillIntervalDays: number; + offClusterStorage: OffClusterStorageV1; +} + +export const recoveryPostureDefaults: Readonly> = { + lite: { + contractVersion: RECOVERY_POSTURE_CONTRACT_VERSION, + tier: 'lite', + targetRpoMinutes: 24 * 60, + targetRtoMinutes: 24 * 60, + baseBackupIntervalHours: 24, + walArchiveIntervalMinutes: null, + pitrRetentionDays: 0, + restoreTestIntervalDays: 90, + breakGlassDrillIntervalDays: 365, + offClusterStorage: { + required: true, + encrypted: true, + separateFailureDomain: true, + minimumCopies: 1, + storageClass: 'encrypted-backup-target', + }, + }, + standard: { + contractVersion: RECOVERY_POSTURE_CONTRACT_VERSION, + tier: 'standard', + targetRpoMinutes: 60, + targetRtoMinutes: 8 * 60, + baseBackupIntervalHours: 24, + walArchiveIntervalMinutes: 15, + pitrRetentionDays: 14, + restoreTestIntervalDays: 90, + breakGlassDrillIntervalDays: 180, + offClusterStorage: { + required: true, + encrypted: true, + separateFailureDomain: true, + minimumCopies: 1, + storageClass: 'encrypted-object-storage', + }, + }, + 'high-assurance': { + contractVersion: RECOVERY_POSTURE_CONTRACT_VERSION, + tier: 'high-assurance', + targetRpoMinutes: 15, + targetRtoMinutes: 4 * 60, + baseBackupIntervalHours: 24, + walArchiveIntervalMinutes: 5, + pitrRetentionDays: 35, + restoreTestIntervalDays: 30, + breakGlassDrillIntervalDays: 90, + offClusterStorage: { + required: true, + encrypted: true, + separateFailureDomain: true, + minimumCopies: 1, + storageClass: 'encrypted-object-storage', + }, + }, +}; + +/** Shape schema. Normative cross-field semantics are enforced by validateRecoveryPostureV1. */ +export const recoveryPostureJsonSchemaV1 = { + $id: 'https://mosaicstack.dev/contracts/recovery-posture.v1.schema.json', + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'object', + additionalProperties: false, + required: [ + 'contractVersion', + 'tier', + 'targetRpoMinutes', + 'targetRtoMinutes', + 'baseBackupIntervalHours', + 'walArchiveIntervalMinutes', + 'pitrRetentionDays', + 'restoreTestIntervalDays', + 'breakGlassDrillIntervalDays', + 'offClusterStorage', + ], + properties: { + contractVersion: { const: RECOVERY_POSTURE_CONTRACT_VERSION }, + tier: { enum: recoveryTiers }, + targetRpoMinutes: { type: 'integer', minimum: 1 }, + targetRtoMinutes: { type: 'integer', minimum: 1 }, + baseBackupIntervalHours: { type: 'integer', minimum: 1 }, + walArchiveIntervalMinutes: { + anyOf: [{ type: 'integer', minimum: 1 }, { type: 'null' }], + }, + pitrRetentionDays: { type: 'integer', minimum: 0 }, + restoreTestIntervalDays: { type: 'integer', minimum: 1 }, + breakGlassDrillIntervalDays: { type: 'integer', minimum: 1 }, + offClusterStorage: { + type: 'object', + additionalProperties: false, + required: ['required', 'encrypted', 'separateFailureDomain', 'minimumCopies', 'storageClass'], + properties: { + required: { const: true }, + encrypted: { const: true }, + separateFailureDomain: { const: true }, + minimumCopies: { type: 'integer', minimum: 1 }, + storageClass: { + enum: ['encrypted-object-storage', 'encrypted-backup-target'], + }, + }, + }, + }, +} as const; + +export const recoveryValidationCodes = [ + 'INVALID_SHAPE', + 'UNKNOWN_FIELD', + 'PITR_REQUIRES_WAL', + 'WAL_REQUIRES_PITR', + 'RPO_BETTER_THAN_MECHANISM', + 'OFF_CLUSTER_REQUIRED', + 'HIGH_ASSURANCE_WEAKENED', +] as const; +export type RecoveryValidationCode = (typeof recoveryValidationCodes)[number]; + +export interface RecoveryValidationIssueV1 { + code: RecoveryValidationCode; + path: string; + message: string; +} +export type RecoveryValidationResultV1 = + | { ok: true; value: RecoveryPostureV1 } + | { ok: false; issues: RecoveryValidationIssueV1[] }; + +const topLevelFields = new Set([ + 'contractVersion', + 'tier', + 'targetRpoMinutes', + 'targetRtoMinutes', + 'baseBackupIntervalHours', + 'walArchiveIntervalMinutes', + 'pitrRetentionDays', + 'restoreTestIntervalDays', + 'breakGlassDrillIntervalDays', + 'offClusterStorage', +]); +const storageFields = new Set([ + 'required', + 'encrypted', + 'separateFailureDomain', + 'minimumCopies', + 'storageClass', +]); + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} +function isPositiveInteger(value: unknown): value is number { + return Number.isInteger(value) && Number(value) > 0; +} +function isNonnegativeInteger(value: unknown): value is number { + return Number.isInteger(value) && Number(value) >= 0; +} + +/** + * Normative parser/refinement. Deployment code MUST call this function (or a + * byte-for-byte behaviorally equivalent generated validator), not JSON Schema + * shape validation alone. + */ +export function validateRecoveryPostureV1(input: unknown): RecoveryValidationResultV1 { + const issues: RecoveryValidationIssueV1[] = []; + if (!isRecord(input)) { + return { + ok: false, + issues: [{ code: 'INVALID_SHAPE', path: '$', message: 'posture must be an object' }], + }; + } + + for (const key of Object.keys(input)) { + if (!topLevelFields.has(key)) { + issues.push({ code: 'UNKNOWN_FIELD', path: `$.${key}`, message: 'unknown field' }); + } + } + + const tier = input['tier']; + const storage = input['offClusterStorage']; + const integerFields = [ + 'targetRpoMinutes', + 'targetRtoMinutes', + 'baseBackupIntervalHours', + 'restoreTestIntervalDays', + 'breakGlassDrillIntervalDays', + ] as const; + + if (input['contractVersion'] !== RECOVERY_POSTURE_CONTRACT_VERSION) { + issues.push({ + code: 'INVALID_SHAPE', + path: '$.contractVersion', + message: `must equal ${RECOVERY_POSTURE_CONTRACT_VERSION}`, + }); + } + if (!recoveryTiers.includes(tier as RecoveryTier)) { + issues.push({ code: 'INVALID_SHAPE', path: '$.tier', message: 'unknown recovery tier' }); + } + for (const field of integerFields) { + if (!isPositiveInteger(input[field])) { + issues.push({ + code: 'INVALID_SHAPE', + path: `$.${field}`, + message: 'must be a positive integer', + }); + } + } + if (!isNonnegativeInteger(input['pitrRetentionDays'])) { + issues.push({ + code: 'INVALID_SHAPE', + path: '$.pitrRetentionDays', + message: 'must be a nonnegative integer', + }); + } + if ( + input['walArchiveIntervalMinutes'] !== null && + !isPositiveInteger(input['walArchiveIntervalMinutes']) + ) { + issues.push({ + code: 'INVALID_SHAPE', + path: '$.walArchiveIntervalMinutes', + message: 'must be null or a positive integer', + }); + } + + if (!isRecord(storage)) { + issues.push({ + code: 'INVALID_SHAPE', + path: '$.offClusterStorage', + message: 'must be an object', + }); + } else { + for (const key of Object.keys(storage)) { + if (!storageFields.has(key)) { + issues.push({ + code: 'UNKNOWN_FIELD', + path: `$.offClusterStorage.${key}`, + message: 'unknown field', + }); + } + } + if ( + storage['required'] !== true || + storage['encrypted'] !== true || + storage['separateFailureDomain'] !== true + ) { + issues.push({ + code: 'OFF_CLUSTER_REQUIRED', + path: '$.offClusterStorage', + message: 'storage must be required, encrypted, and in a separate failure domain', + }); + } + if (!isPositiveInteger(storage['minimumCopies'])) { + issues.push({ + code: 'INVALID_SHAPE', + path: '$.offClusterStorage.minimumCopies', + message: 'must be a positive integer', + }); + } + if ( + storage['storageClass'] !== 'encrypted-object-storage' && + storage['storageClass'] !== 'encrypted-backup-target' + ) { + issues.push({ + code: 'INVALID_SHAPE', + path: '$.offClusterStorage.storageClass', + message: 'unsupported storage class', + }); + } + } + + const wal = input['walArchiveIntervalMinutes']; + const pitr = input['pitrRetentionDays']; + if (pitr !== 0 && wal === null) { + issues.push({ + code: 'PITR_REQUIRES_WAL', + path: '$.pitrRetentionDays', + message: 'PITR retention requires WAL archival', + }); + } + if (wal !== null && pitr === 0) { + issues.push({ + code: 'WAL_REQUIRES_PITR', + path: '$.walArchiveIntervalMinutes', + message: 'WAL archival requires positive PITR retention', + }); + } + + if ( + isPositiveInteger(input['targetRpoMinutes']) && + isPositiveInteger(input['baseBackupIntervalHours']) && + (wal === null || isPositiveInteger(wal)) + ) { + const mechanismMinutes = wal === null ? input['baseBackupIntervalHours'] * 60 : wal; + if (mechanismMinutes > input['targetRpoMinutes']) { + issues.push({ + code: 'RPO_BETTER_THAN_MECHANISM', + path: '$.targetRpoMinutes', + message: `configured mechanism can only support ${mechanismMinutes} minutes`, + }); + } + } + + if (tier === 'high-assurance') { + const weakened = + !isPositiveInteger(input['targetRpoMinutes']) || + input['targetRpoMinutes'] > 15 || + !isPositiveInteger(input['targetRtoMinutes']) || + input['targetRtoMinutes'] > 4 * 60 || + !isPositiveInteger(input['baseBackupIntervalHours']) || + input['baseBackupIntervalHours'] > 24 || + !isPositiveInteger(wal) || + wal > 5 || + !isNonnegativeInteger(pitr) || + pitr < 35 || + !isPositiveInteger(input['restoreTestIntervalDays']) || + input['restoreTestIntervalDays'] > 30 || + !isPositiveInteger(input['breakGlassDrillIntervalDays']) || + input['breakGlassDrillIntervalDays'] > 90; + if (weakened) { + issues.push({ + code: 'HIGH_ASSURANCE_WEAKENED', + path: '$', + message: 'high-assurance posture may be strengthened but not weakened', + }); + } + } + + if (issues.length > 0) return { ok: false, issues }; + return { ok: true, value: input as unknown as RecoveryPostureV1 }; +} + +export interface RecoveryPostureOverrideAuditV1 { + actorId: string; + reason: string; + effectiveAt: string; + policyRevision: string; + previous: RecoveryPostureV1; + next: RecoveryPostureV1; +} diff --git a/docs/native-kanban-sot/tsconfig.json b/docs/native-kanban-sot/tsconfig.json new file mode 100644 index 00000000..8ab269f4 --- /dev/null +++ b/docs/native-kanban-sot/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "incremental": false, + "declaration": false, + "declarationMap": false, + "sourceMap": false, + "baseUrl": ".", + "paths": { + "drizzle-orm": ["../../packages/db/node_modules/drizzle-orm/index.d.ts"], + "drizzle-orm/pg-core": ["../../packages/db/node_modules/drizzle-orm/pg-core/index.d.ts"] + } + }, + "include": ["contracts/*.ts"] +} diff --git a/docs/reports/native-kanban-sot/canon-final-rereview-go.md b/docs/reports/native-kanban-sot/canon-final-rereview-go.md new file mode 100644 index 00000000..f48b42e4 --- /dev/null +++ b/docs/reports/native-kanban-sot/canon-final-rereview-go.md @@ -0,0 +1,59 @@ +VERDICT: GO + +# Native Kanban/SOT canon independent re-review 2 + +Independent read-only re-review of the complete updated staged canon. Prior proposal-audit blocker is closed; no KCR-001–016 regression or new blocker found. + +## Prior blocker closure + +- `contracts/kanban-schema.v1.ts:836-837` declares the required unique `task_events(workspace_id,id)` key before proposal declaration. +- `contracts/kanban-schema.v1.ts:885-894` adds both composite proposal audit FKs—submission and accepted-command event—to that exact workspace-aware key with `RESTRICT`. +- Declaration/migration order is executable and explicit in `SHARED-CONTRACT.md:79-91`: events/key first, proposal table second, both FKs third/fourth, then command enablement. This avoids forward-reference/circular-DDL ambiguity. +- Submission/acceptance semantics are frozen in `SHARED-CONTRACT.md:87-91`: preallocate proposal ID; create exact `change_proposal.submitted` event and proposal in one transaction; on acceptance lock proposal/target, execute the normal command, and bind only a same-workspace/target event with submission causation and `payload.changeProposalId` equal to the locked proposal. +- Required missing, foreign-workspace, unrelated-proposal, unrelated-target, and unrelated-command negatives are explicit in `REQUIREMENTS.md` REQ-SOT-004 and `SHARED-CONTRACT.md:121`; KBN-100/110/140 own migration, service, and integration evidence. + +## KCR closure matrix + +| KCR | Status | +| ------------------------------------------------------ | ------ | +| 001 health/proof | CLOSED | +| 002 error discrimination | CLOSED | +| 003 approval/assignment binding | CLOSED | +| 004 monotonic fencing/composites | CLOSED | +| 005 tenant-safe relations | CLOSED | +| 006 outage proposal persistence/commands/audit binding | CLOSED | +| 007 dependency/API freeze sequencing | CLOSED | +| 008 concrete N-1 map | CLOSED | +| 009 dependency uniqueness | CLOSED | +| 010 project congruence | CLOSED | +| 011 immutable audit retention | CLOSED | +| 012 retry/quarantine/vocabulary | CLOSED | +| 013 archive/tags target semantics | CLOSED | +| 014 recovery validator/owner slice | CLOSED | +| 015 pure Coordinator split | CLOSED | +| 016 health code/state pairing | CLOSED | + +Fixed invariants remain consistent: PostgreSQL is sole writable SOT; writes require transaction-local proof and fail closed; exports never import sources; notes are attributable proposals only; Coordinator has no scope/gate/certify/merge authority; Certifier has no merge authority. + +## Reproducible validation evidence + +Executed read-only with current-stack config/toolchain `/src/mosaic-mono-v1`: + +```text +./node_modules/.bin/prettier --config /src/mosaic-mono-v1/.prettierrc --check +PASS: All matched files use Prettier code style. + +strict TypeScript --noEmit --strict --skipLibCheck --target ES2022 --module NodeNext --moduleResolution NodeNext +PASS + +cascade/TODO/TBD/stale-hold grep plus composite-FK/semantic-marker invariant checks +PASS +``` + +The TypeScript check used a disposable copy under `/home/hermes/agent-work` solely to provide external-file NodeNext dependency resolution; the reviewed staging artifacts were not modified. + +## Residual findings + +None blocking. Implementation must execute the frozen KBN-100/KBN-110/KBN-140 proposal-event-chain tests and SecReview evidence before feature release, as already required by the canon. + +No artifact source repository, branch, PR, or provider state was modified. diff --git a/docs/reports/native-kanban-sot/canon-initial-review-no-go.md b/docs/reports/native-kanban-sot/canon-initial-review-no-go.md new file mode 100644 index 00000000..6a453587 --- /dev/null +++ b/docs/reports/native-kanban-sot/canon-initial-review-no-go.md @@ -0,0 +1,357 @@ +# Independent Review — Native Kanban/SOT Canon + +**Reviewer:** `enhance-sol` (independent of author `planner-sol`) +**Date:** 2026-07-13 +**Review mode:** design/contract only; read-only against the staged canon +**Source plan:** `/home/hermes/agent-work/planning/mosaic-native-kanban-sot-plan.md` (`sha256:96ea4fb91436ec9a53f371d27276e27f62ecf817662599ff9152df0db55296e5`) +**Canon reviewed:** every listed artifact under `/home/hermes/agent-work/planning/kanban-canon/`, including the four TypeScript contracts; the author scratchpad was also read as validation context. + +## Executive verdict + +# NO-GO + +The canon is not freeze-ready. I found **8 BLOCKERs**, **7 MAJORs**, and **1 MINOR**. The prose preserves the ratified authority model well, but the frozen types/schema leave concrete fail-closed, approval, fencing, tenant, outage-proposal, migration, and parallelization gaps. Those gaps would force implementation lanes either to invent contract semantics or to ship paths that violate fixed invariants. + +### Blocking findings + +1. Health/write authorization can be represented as contradictory, stale, or caller-asserted state. +2. Coordinator failures collapse authoritative denial, unknown transport outcome, and version conflict into one permissive shape. +3. Assignment proposals and approval proofs have no authoritative relational binding; lease acquisition accepts a forgeable proof DTO. +4. Fencing uniqueness is present, but monotonic fencing and same-task lease/checkpoint binding are not. +5. Workspace-safe accountable-owner, assignment-principal, and evidence/artifact relationships are not frozen. +6. Attributable post-recovery proposals have neither a canonical table nor command contract. +7. The slice graph starts schema/UI work before prerequisite threat and exact API/DTO freezes and contradicts coder4 lane order. +8. P0 claims a migration map while publishing only generic rules; the concrete N-1 transition from current `origin/main` is absent. + +--- + +## Findings + +### KCR-001 — BLOCKER — “Healthy” is not a proof and can be contradictory or stale + +**Location** + +- `contracts/health-state.v1.ts:21-31` — `KanbanHealthResponseV1` permits every combination of `state`, `readHealthProven`, and `writeHealthProven`. +- `contracts/mechanical-coordinator.v1.ts:40-49` — `CoordinatorContextV1` accepts a caller-supplied `healthState` enum only. +- `contracts/mechanical-coordinator.v1.ts:255-293` — every Coordinator operation, including mutating operations, accepts that context. +- `SHARED-CONTRACT.md:171-184` — mutations are allowed only after live PostgreSQL read/write probes. + +**Violation** + +Fixed invariant 2 / `REQ-SOT-002`: mutations must fail closed unless write health is positively proven. The current type permits `{ state: 'healthy', writeHealthProven: false }`, and the Coordinator mutation boundary can be invoked with a stale or fabricated `{ healthState: 'healthy' }`. A Valkey/client-derived enum could therefore be mistaken for write authorization. + +**Minimal fix** + +1. Make `KanbanHealthResponseV1` a discriminated union with only these legal combinations: `healthy => read=true/write=true`, `read-only-degraded => read=true/write=false`, and `write-unavailable => read=false/write=false`. +2. Do not accept write authority from a public DTO. Require Gateway/domain code to obtain and revalidate a fresh internal PostgreSQL write-health proof at mutation time (including `checkedAt`, bounded validity/policy revision, and transaction-local enforcement). +3. Split pure evaluation context from mutation context; mutation methods must accept only an unforgeable/internal healthy context or perform the probe themselves. +4. Add negative contract tests for contradictory state, expired proof, Valkey-only liveness, and caller-forged `healthy`. + +### KCR-002 — BLOCKER — Coordinator error shape can conflate denial, unknown outcome, and conflict + +**Location** + +- `contracts/mechanical-coordinator.v1.ts:184-216` — one `CoordinatorFailureV1` allows every code to pair with arbitrary `retryable` and either `requestOutcome` value. +- `contracts/health-state.v1.ts:51-106` — the Gateway health contract correctly distinguishes deliberate denial, transport uncertainty, and version conflict. +- `SHARED-CONTRACT.md:177-216` — frozen client semantics require those cases not to be conflated. + +**Violation** + +Charter E and `REQ-SOT-002`. The current Coordinator result can legally encode `WRITE_HEALTH_UNPROVEN` as `retryable: true, requestOutcome: 'unknown'`, or `VERSION_CONFLICT` as retryable. That permits blind retry or a false “unknown” outcome after an authoritative fail-closed denial. + +**Minimal fix** + +Replace `CoordinatorFailureV1` with a discriminated union keyed by code/kind: + +- deliberate health denial: `not_applied`, `retryable:false`; +- version conflict: `not_applied`, `retryable:false`, current version; +- stale fence/session/eligibility/approval failures: exact non-retry semantics; +- transport failure: a separate `retryable_transport_error`, `unknown`, same idempotency key. + +Reuse or map explicitly to `KanbanMutationFailureV1`, and add exhaustive client tests proving 503 authoritative bodies, 502/504/timeouts, and 409 cannot cross-map. + +### KCR-003 — BLOCKER — Approval proof is forgeable and is not linked to the persisted proposal + +**Location** + +- `contracts/mechanical-coordinator.v1.ts:107-137` — proposal and approval DTOs. +- `contracts/mechanical-coordinator.v1.ts:265-270` — `acquireApprovedLease` accepts the entire `ApprovalProofV1` by value. +- `contracts/kanban-schema.v1.ts:650-688` — `task_assignments` has no proposal expiry, task version, session binding, or proposal/approval FK. +- `contracts/kanban-schema.v1.ts:823-863` — `approval_decisions` can target only a task or mission and has no proposal/assignment relation. +- `SHARED-CONTRACT.md:128-137` — lease acquisition requires authoritative approval under the exact policy revision. + +**Violation** + +Fixed invariant 5 and `REQ-COORD-002/003`. A caller can construct an `ApprovalProofV1`; the schema cannot prove that it belongs to the proposal, workspace, task version, agent/session, unexpired policy revision, or still-current approval. The DTO state vocabulary (`awaiting_approval | policy_pre_authorized`) also does not map directly to the persisted assignment states (`proposed | approved | ...`). + +**Minimal fix** + +Persist one authoritative proposal/assignment identity with task version, target agent/session, expiry, state, and policy revision. Add a workspace-aware approval relation to that identity. Change lease acquisition to accept IDs, then reload and lock proposal + approval + task inside PostgreSQL and verify workspace, current version, target session, state, expiry, and policy revision before creating the lease. Freeze one state vocabulary across schema and DTOs. + +### KCR-004 — BLOCKER — Fencing is unique but not monotonically increasing; relational binding is incomplete + +**Location** + +- `contracts/kanban-schema.v1.ts:694-743` — `task_leases` has positive/unique fencing tokens but no monotonic per-task counter. +- `contracts/kanban-schema.v1.ts:748-784` — checkpoints independently carry task, lease, and fencing token. +- `contracts/kanban-schema.v1.ts:905-910` — token equality is deferred to prose; same-task lease binding is not stated. +- `contracts/mechanical-coordinator.v1.ts:140-175` — worker commands depend on fencing safety. + +**Violation** + +Fixed invariant 12 / `REQ-COORD-003`. Uniqueness permits token 10 followed by token 9. A lease can reference assignment A while naming task B in the same workspace, and a checkpoint can reference lease A while naming task B. `bigint(..., { mode: 'number' })` also eventually loses integer precision in JavaScript. + +**Minimal fix** + +Add a durable per-task fencing counter (or equivalent PostgreSQL sequence row) incremented atomically under task lock and use the returned value for every new lease. Add workspace-aware composite constraints tying lease to its exact task+assignment and checkpoint to exact task+lease+fence. Use bigint-safe representation (`bigint`/serialized decimal), and test monotonicity, concurrent claims, stale lower tokens, and mismatched same-workspace IDs. + +### KCR-005 — BLOCKER — Hard tenant boundary is not frozen for several polymorphic relationships + +**Location** + +- `contracts/kanban-schema.v1.ts:315-318` and `475-478` — project/task accountable owners are unvalidated `(kind, text id)` pairs. +- `contracts/kanban-schema.v1.ts:659-663` — assignment principals are unvalidated `(kind, text id)` pairs. +- `contracts/kanban-schema.v1.ts:758` and `841` — checkpoint/evidence artifact relationships are JSON arrays without workspace-aware FKs. +- `SHARED-CONTRACT.md:89-96` — only selected polymorphic checks are delegated to domain transactions; owner/principal/evidence checks are not included. +- `REQUIREMENTS.md:101-108` — every relationship must reject cross-workspace IDs. + +**Violation** + +Fixed invariant 7 / `REQ-TEN-001` and `REQ-ID-001`. The frozen schema can name a team or agent from another workspace as owner/assignee, and can embed foreign-workspace artifact IDs in checkpoint or approval evidence arrays. A global user ID is also insufficient without active workspace membership validation. + +**Minimal fix** + +Use workspace-aware owner/assignment join tables or separate nullable user/team/agent columns with exactly-one checks and composite FKs where possible. Model checkpoint/evidence artifact links as workspace-scoped join rows, or freeze explicit transaction checks for every ID. Require active workspace membership for user principals and workspace-agent/session consistency for agent principals. Add DB/repository/API/Coordinator cross-workspace negative tests without existence oracles. + +### KCR-006 — BLOCKER — Post-recovery outage proposals have no canonical persistence or command surface + +**Location** + +- `REQUIREMENTS.md:93-99` — proposal submission and authorized accept/reject are required. +- `SHARED-CONTRACT.md:26-29` — outage notes may return only as authenticated proposals. +- `SHARED-CONTRACT.md:243-267` — the thin command/query contract contains no proposal submit/get/accept/reject operations. +- `contracts/kanban-schema.v1.ts:1-916` — no proposal table captures proposed command, target/version, attribution, lifecycle, or decision. +- `TASKS.md:99-108` — KBN-110 does not own an outage-proposal command path. + +**Violation** + +Fixed invariant 4 / `REQ-SOT-004`. An implementation lane would have to invent storage or misuse artifacts/approval gates. Either path risks silently applying an outage note or creating shadow state. + +**Minimal fix** + +Add a workspace-scoped `change_proposals`/`outage_proposals` contract with authenticated proposer, source note digest, target aggregate, expected version, proposed typed command/payload, pending/accepted/rejected state, decision actor/reason/time, idempotency key, and audit linkage. Add explicit submit/query/accept/reject Gateway commands. Acceptance must execute the normal command in a healthy transaction; a proposal itself can never claim, order, satisfy a gate, or mutate the target. + +### KCR-007 — BLOCKER — Parallel slice ordering is not freeze-safe and contains a direct lane-order contradiction + +**Location** + +- `TASKS.md:43-60` — dependency graph makes KBN-010 and KBN-100 siblings. +- `TASKS.md:88-97` — KBN-100 nevertheless depends on KBN-010 threat findings that alter constraints. +- `SHARED-CONTRACT.md:243` and `INDEX.md:44-50` — exact route names/DTO placement remain unresolved. +- `TASKS.md:110-130` — KBN-120/130 depend on a frozen endpoint/DTO contract, while mocks may begin before KBN-110 lands. +- `TASKS.md:145-153` — KBN-200 says lane-serial after KBN-120. +- `TASKS.md:248-254` — wave table runs KBN-200 before KBN-120. + +**Violation** + +Charter C and the mandatory freeze-before-parallelize gate. Schema can begin before tenant/threat findings are complete; web/CLI consumers have only semantic operations, not exact DTO/endpoint contracts; coder4 has two opposite legal orders. This does not create same-file edits immediately, but it guarantees contract invention or rework across active lanes. + +**Minimal fix** + +1. Make KBN-010 (or an explicit constraint-impact gate from it) a completed prerequisite of KBN-100. +2. Add a small serialized KBN-105 endpoint/DTO/endpoint-registry freeze, with exact request/response/error DTOs, before KBN-120 and KBN-130 implementation. +3. Choose one coder4 lane order and use it consistently in slice text, graph, and wave table. +4. Name the exact MCP-owned files or assign their Gateway changes to coder3 before coder4 starts. + +### KCR-008 — BLOCKER — Claimed P0 migration map is absent; concrete N-1 hazards remain unresolved + +**Location** + +- `MISSION-MANIFEST.md:153-157` — P0 says to publish a migration map and states the build hold is lifted at line 3. +- `SHARED-CONTRACT.md:101-121` — only generic expand/backfill/contract rules are supplied. +- `contracts/kanban-schema.v1.ts:1-916` — target-state declarations reuse live table names and make target fields required. +- Current foundation evidence: `origin/main:packages/db/src/schema.ts:120-301` has no workspace keys, nullable project/mission links, legacy text status vocabularies, `tasks.tags`, `tasks.assignee`, `tasks.due_date`, mission JSON milestones/config, `mission_tasks.status`, and legacy agent fields. + +**Violation** + +Charter D / `REQ-MIG-001` and the P0 exit claim. The generic rule is correct, but coder2 lacks the required field-by-field transition map. A direct Drizzle reconciliation could attempt type narrowing/status conversion, add required workspace/project/owner columns too early, or drop legacy columns before N-1 readers and writers are retired. + +**Minimal fix** + +Publish a concrete current-main delta map before lifting the hold. For each existing table/column, specify expand, backfill, compatibility read/write, switch, and contract release. At minimum cover: + +- nullable-first `workspace_id`, required project/owner fields, and workspace backfill; +- legacy task/project/mission status aliases or shadow columns before v1 emission; +- `mission_tasks.status` read retirement and write-source prohibition; +- mapping/retention for tags, assignee, due date, mission description/config/milestones, and agent fields; +- current milestone circular FK ordering; +- empty, production-shape, partial-resume, and rollback/downgrade tests already named in §4. + +Explicitly require legacy columns to remain in the unified Drizzle declaration during the expand/N-1 window. + +### KCR-009 — MAJOR — Dependency uniqueness permits parallel duplicate edges + +**Location** + +- `contracts/kanban-schema.v1.ts:561-567` — unique key includes `dependencyType`. +- `SHARED-CONTRACT.md:47-49` — calls for a unique directed edge. +- `REQUIREMENTS.md:142-149` — duplicate edge attempts must fail. + +**Violation** + +`REQ-DEP-001`. The same predecessor/successor pair can be inserted three times, once per dependency type. That is not a unique directed edge and complicates readiness semantics. + +**Minimal fix** + +Make `(workspace_id, predecessor_task_id, successor_task_id)` unique independent of type, or explicitly redefine the requirement as one edge per type and freeze deterministic multi-edge completion semantics. The source plan says unique directed edge, so the former is the minimal faithful fix. + +### KCR-010 — MAJOR — Same-workspace planning relationships can contradict the project hierarchy + +**Location** + +- `contracts/kanban-schema.v1.ts:325` — `projects.currentMilestoneId` has no FK in the declaration. +- `contracts/kanban-schema.v1.ts:427-448` — mission/milestone association checks workspace but not common project. +- `contracts/kanban-schema.v1.ts:490-516` — a task’s project, mission, milestone, and parent only need share a workspace, not a project. +- `contracts/kanban-schema.v1.ts:905-908` — only current milestone is mentioned as a deferred invariant. + +**Violation** + +`REQ-PLAN-001` and schema correctness. A task in project A can point to a mission/milestone/parent task from project B in the same workspace. A mission can associate a milestone from another project despite having one required project. + +**Minimal fix** + +Add project-congruent composite keys/FKs (or freeze mandatory transaction checks) for task→mission, task→milestone, task→parent, mission→milestone, and project→current milestone. Add same-workspace/same-project negative tests. + +### KCR-011 — MAJOR — Immutable/append-only records can be erased by parent cascades + +**Location** + +- `contracts/kanban-schema.v1.ts:798-818` — `task_events` is described as append-only but remains under a workspace cascade. +- `contracts/kanban-schema.v1.ts:911` — only application-role UPDATE/DELETE privilege removal is stated. +- Numerous canonical relationships use `onDelete('cascade')`, including workspace roots and artifact/checkpoint/event owners. +- `REQUIREMENTS.md:41-43` and `154-170` — audit must be append-only, attributable, and reconstructable. + +**Violation** + +`REQ-AUD-001`. Revoking direct DELETE on `task_events` does not prevent a parent delete from cascading into the audit log. Checkpoints and immutable artifacts also lack explicit append-only privilege/retention semantics. + +**Minimal fix** + +Use lifecycle/archive states and `RESTRICT` for canonical parent deletion during normal operation. Freeze a separate, audited retention/break-glass purge procedure. Apply INSERT/SELECT-only or equivalent immutability controls to task events, checkpoints, and immutable artifacts, and test that parent deletion cannot silently erase them. + +### KCR-012 — MAJOR — Coordinator persistence lacks durable quarantine/retry state and DTO/schema alignment + +**Location** + +- `contracts/mechanical-coordinator.v1.ts:239-244` — expiry returns `quarantined` IDs. +- `contracts/kanban-schema.v1.ts:457-490` — task has only untyped `retryPolicy` metadata and no quarantine/execution disposition. +- `contracts/mechanical-coordinator.v1.ts:173` — `evidenceIds` has no corresponding evidence table/type; schema has artifacts. +- `contracts/kanban-schema.v1.ts:479`, `663`, and agent role JSON — specialist roles are free text despite the frozen role vocabulary in `mechanical-coordinator.v1.ts:19-29`. + +**Violation** + +`REQ-COORD-004` and internal consistency. PostgreSQL cannot deterministically reconstruct why/when a task was quarantined, its bounded retry state, or which typed evidence was submitted. Free-text roles allow the schema and engine to disagree. + +**Minimal fix** + +Freeze a durable execution/retry/quarantine record (attempt count, next eligibility, terminal reason, actor/policy, timestamps, version) or typed task columns with events. Align `evidenceIds` to artifact IDs or add a real evidence entity. Use one specialist-role enum/check across tasks, assignments, agents/sessions, DTOs, and Coordinator. + +### KCR-013 — MAJOR — Thin MVP promises task archive and tag filtering without target-state semantics + +**Location** + +- `REQUIREMENTS.md:182-193` — users must archive tasks and filter by tags. +- `SHARED-CONTRACT.md:252-267` — mutations include cancel but not archive task. +- `contracts/kanban-schema.v1.ts:457-490` — no task archive field and no typed tags field/table. +- Current `origin/main` already has `tasks.tags`, making omission from the target declaration a migration-loss hazard. + +**Violation** + +`REQ-UI-001/002` and internal acceptance consistency. “Archive” cannot be implemented without inventing whether it means cancelled, hidden, or soft-deleted; tag filtering has no frozen storage/query contract. + +**Minimal fix** + +Either remove task archive/tag acceptance from P1, or add explicit non-lifecycle archival semantics (`archived_at/by/reason`) and a workspace-safe tags model/query contract. Preserve/migrate the current tags column until the selected model is live. + +### KCR-014 — MAJOR — Recovery contract states critical rules only in comments and has no owning implementation slice + +**Location** + +- `contracts/recovery-posture.v1.ts:97-147` — exported JSON Schema validates only local field shapes. +- `contracts/recovery-posture.v1.ts:150-156` — PITR/WAL, effective RPO, off-cluster, high-assurance minima, and audit rules are comments only. +- `REQUIREMENTS.md:270-277` — parser rejection of impossible combinations is acceptance-critical. +- `TASKS.md:75-244` — no bounded slice owns recovery config parsing, override audit, backup/WAL setup, or restore/break-glass evidence. + +**Violation** + +`REQ-REC-001`. A consumer using the advertised JSON Schema can accept weakened high-assurance values, PITR without WAL, or an impossible RPO. The task plan has no lane accountable for closing that acceptance criterion. + +**Minimal fix** + +Export a normative `validateRecoveryPostureV1`/schema refinement with machine-testable cross-field checks and add a bounded Infra/recovery slice (serialized if it touches shared config) owning config parsing, override audit, mechanism verification, restore test, and break-glass evidence. Recovery config must continue to expose no authority/gate knobs. + +### KCR-015 — MAJOR — Pure Coordinator slice cannot implement two frozen methods without persistence access + +**Location** + +- `contracts/mechanical-coordinator.v1.ts:259-263` — `explainEligibility` receives only `taskId`, not a structured snapshot. +- `contracts/mechanical-coordinator.v1.ts:289-293` — `recoverFromPostgres` explicitly reads PostgreSQL. +- `TASKS.md:145-153` — KBN-200 is a pure engine with no SQL, Drizzle, Gateway, or Valkey. +- `TASKS.md:157-164` — persistence belongs to coder3/KBN-210. + +**Violation** + +Charter C and internal consistency. coder4 cannot implement the frozen port in a pure package without crossing coder3’s persistence boundary. If coder3 implements the port instead, KBN-200’s acceptance and ownership are misassigned. + +**Minimal fix** + +Split the contract into a pure decision engine that receives complete immutable snapshots and a persistence/orchestration service port implemented by KBN-210. Move `recoverFromPostgres` and ID-based loading to the adapter/service; make pure explanation accept a snapshot. + +### KCR-016 — MINOR — Health denial code/state pairs are not correlated by type + +**Location** + +- `contracts/health-state.v1.ts:35-61` — either denial code can pair with either degraded state. +- `SHARED-CONTRACT.md:188-190` — prose defines `KANBAN_WRITE_UNAVAILABLE` specifically for `write-unavailable`. + +**Violation** + +Health contract precision. A client can receive a semantically inconsistent authoritative body even after KCR-001’s broader state fix. + +**Minimal fix** + +Make deliberate denial a two-variant union with exact code/state pairing. + +--- + +## Clean checks / invariants that do hold + +The review did **not** find a gap in these areas: + +- The canon consistently selects current `mosaicstack/stack` + Drizzle/PostgreSQL and rejects greenfield/Prisma revival. +- Every artifact states PostgreSQL is the sole writable SOT and Valkey/files are non-authoritative. +- Generated `TASKS.md`, `mission.json`, and exports are consistently declared read-only and never import sources. KBN-300’s importer is scoped to immutable legacy JSON/Vikunja snapshots, not generated projections. +- Recovery config exposes recovery fields only; it contains no direct fail-open, SOT, Coordinator-authority, or gate-waiver knob. +- The Coordinator interface contains no `createTask`, acceptance-edit, gate-waive, certify, merge, release, or provider-close method. `submitForReview` is type-limited to `in_review`, not `done` or `certified`. +- Certifier is consistently final independent gate with no merge authority. +- The seven canonical task status values match across requirements, shared prose, schema, and Coordinator’s ready/in-review surfaces. +- One-active-lease partial uniqueness, no-self-edge, outbox aggregate-revision/event-type uniqueness, optimistic task/project/mission/milestone versions, and N-1 test categories are explicitly present. +- The file-tree partition is mostly well separated once the ordering/freeze defects in KCR-007 are corrected. + +## Required re-review scope + +After remediation, re-review at minimum: + +1. health/coordinator discriminated unions and mutation-time health proof; +2. proposal/approval/assignment/lease relational model; +3. monotonic fencing and composite bindings; +4. tenant-safe polymorphic relationships; +5. outage-proposal persistence and commands; +6. concrete current-main migration map; +7. corrected dependency graph and exact API/DTO freeze; +8. recovery validator/owner slice; +9. all schema and DTO vocabulary alignment. + +## Overall verdict + +**NO-GO — 8 BLOCKERs must be resolved before the v1 contract is frozen or parallel implementation begins.** diff --git a/docs/reports/native-kanban-sot/ultron-final-go.md b/docs/reports/native-kanban-sot/ultron-final-go.md new file mode 100644 index 00000000..92604f7c --- /dev/null +++ b/docs/reports/native-kanban-sot/ultron-final-go.md @@ -0,0 +1,38 @@ +# #751 Native Kanban/SOT canonical publication — Ultron final gate + +**Verdict: GO** — zero BLOCKER/HIGH findings. + +## Scope / integrity + +- Reviewed `/home/hermes/agent-work/stack-kanban-canon` staged delta only: exactly 16 documentation/contract artifacts; no unstaged delta; `git diff --cached --check` passes. +- This is a publication canon, not a runtime implementation. The explicit implementation hold prevents feature work until canon merge and prerequisite release (`docs/requirements/native-kanban-sot.md:8-9`; `docs/native-kanban-sot/TASKS.md:45-67`). + +## Acceptance mapping and findings + +| Requirement area | Final evidence / result | +| ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Sole PostgreSQL SOT, generated projections, outage proposals | Requirements D3/D4 and fixed invariants prohibit alternate writers and import (`docs/requirements/native-kanban-sot.md:22-23,32-44`). Health contract keeps public observation separate from branded transaction-local proof (`contracts/health-state.v1.ts:44-84`) and freezes 503/409/502/504 mappings (`:91-184`). Proposal table uses workspace-aware event FKs (`contracts/kanban-schema.v1.ts:847-908`); exact submission/acceptance transaction semantics are specified (`SHARED-CONTRACT.md:81-89`). PASS. | +| Workspace tenancy, planning, assignments, evidence | Workspace-composite task and proposal relations plus active-member rules are explicit (`SHARED-CONTRACT.md:40-48`; `kanban-schema.v1.ts:587-637,875-908`). Lease/checkpoint relations bind workspace/task/assignment/session/fence, with one active lease and bigint fencing (`:1062-1114`). PASS. | +| Coordinator, gates, concurrency/recovery | Pure Coordinator has snapshot-only decision methods (`mechanical-coordinator.v1.ts:186-198`); persistence port owns locked ID validation and recovery (`:371-407`). Requirements forbid Coordinator scope/gate/certification/merge authority and Certifier merge authority (`requirements:39-40`; `MISSION-MANIFEST.md` authority table). Recovery validator rejects unknown fields, PITR/WAL/RPO/storage/high-assurance violations (`recovery-posture.v1.ts:193-369`). PASS. | +| Migration/N-1/API/task decomposition | N-1 expand/backfill/compatibility/switch/contract order and proposal DDL sequence are concrete (`SHARED-CONTRACT.md:69-115`). Frozen exact Gateway/DTO registry and non-overlapping lane ownership/prerequisites are present (`SHARED-CONTRACT.md:244-282`; `TASKS.md:45-67,81-259`). PASS. | +| Documentation / seven owner decisions / evidence | D1–D7 are all explicitly ratified (`requirements:20-26`); all 26 REQ sections contain acceptance criteria. Index/manifest/task graph link requirements, frozen contracts, ownership, and evidence. Relative-link audit passes. PASS. | + +## Independent verification performed + +```text +git diff --cached --check PASS +./node_modules/.bin/prettier --check PASS +./node_modules/.bin/tsc --noEmit --strict PASS +Python relative Markdown link audit PASS (0 errors) +Python requirement acceptance audit PASS (26 requirements; 0 missing acceptance sections) +Static staged scope/status check PASS (16 staged docs-only; no unstaged delta) +``` + +The full schema-contract strict type check cannot resolve `drizzle-orm` from this docs-only worktree; this is an environment dependency-resolution limitation, not a contract diagnostic. Independent external publication validation and final re-review record the strict all-four-contract check against the current Stack Drizzle toolchain as PASS. + +## Residual items + +- **LOW:** implementation must deliver the declared KBN-100/KBN-110/KBN-140 proposal-event-chain, tenant, failure-mapping, and SecReview evidence before P0/P1 release. This is a forward implementation obligation already frozen in the canon, not a publication defect. +- **LOW:** selected infrastructure backup provider/recovery tier and migration/cutover thresholds remain owner-controlled implementation decisions, bounded by the normative recovery contract and change control. + +No source, staging, commit, provider, CI, or deployment state was mutated. diff --git a/docs/requirements/native-kanban-sot.md b/docs/requirements/native-kanban-sot.md new file mode 100644 index 00000000..291f5797 --- /dev/null +++ b/docs/requirements/native-kanban-sot.md @@ -0,0 +1,368 @@ +# Native Kanban and Canonical Task SOT — Canonical Requirements + +**Status:** RATIFIED and independently approved for canonical publication under issue [#751](https://git.mosaicstack.dev/mosaicstack/stack/issues/751) +**Date:** 2026-07-14 +**Decision owner:** Jason +**Publication owner:** web1 control plane (`mos-claude`; `mosaic-100` acting during Claude quota outage) +**Implementation foundation:** current `mosaicstack/stack` main only +**Implementation hold:** no feature implementation begins until this canon is squash-merged to `main` with terminal-green CI. + +## 1. Purpose + +Deliver Mosaic Stack's native project/task control plane and thin writable Kanban on one authoritative PostgreSQL model. This document formalizes the ratified source plan; it does not create a parallel design. + +Normative terms **MUST**, **MUST NOT**, **SHOULD**, and **MAY** are binding as used here. + +## 2. Ratified decisions + +| # | Ratified decision | Canonical result | +| --- | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| D1 | Foundation | Extend current `mosaicstack/stack` main with its existing Drizzle/PostgreSQL, NestJS Gateway, Next.js, Better Auth, and Valkey/BullMQ conventions. No greenfield service and no Prisma revival. | +| D2 | Tenant boundary | `workspace_id` is the hard tenant boundary from the first migration. Teams are authorization groups inside a workspace, never tenant substitutes. | +| D3 | Outage authority — Option A with amendment | PostgreSQL is the sole writable SOT and mutations fail closed whenever DB write-health cannot be proven. The amendment permits deployment-specific **recovery posture only**; it does not permit an alternate writer. Human outage notes are attributable post-recovery proposals, never shadow state. | +| D4 | Generated files | `TASKS.md`, `mission.json`, and any file export are generated, read-only, non-authoritative, and never import sources. Generate on demand; commit only where repository review policy requires a snapshot. | +| D5 | Status model | Task statuses are `backlog`, `ready`, `in_progress`, `blocked`, `in_review`, `done`, `cancelled`. Runtime readiness is orthogonal and computed. | +| D6 | Coordinator approval | Hybrid: manual Project Sub-Orchestrator approval by default; automatic routing only under an explicit, approved, versioned low-risk policy. | +| D7 | Initial migration scope | Project, mission, milestone, task, tags/archive, dependency, assignment, outage proposal, evidence/link, and orchestration state only. Calendar, email, GLPI cache, and personal-brain features remain out of scope. | + +## 3. Fixed invariants — every deployment + +These are not tier settings and cannot be weakened by deployment configuration. + +1. PostgreSQL is the **sole writable source of truth**. +2. The implementation uses Drizzle on current stack main. +3. Kanban and orchestration mutations **fail closed** unless DB write-health is positively proven `healthy`. +4. No failed mutation is redirected to Markdown, JSON, browser storage, Valkey, queue payloads, scratchpads, or provider issues. +5. `TASKS.md` and all file exports are generated, read-only, non-authoritative, and never parsed for import. +6. Human notes created during an outage become attributable proposals only after recovery. They do not reserve work, change status, satisfy a gate, or establish ordering. +7. Valkey is derived, expendable coordination infrastructure. PostgreSQL retains task truth, leases, fencing, audit, and the transactional outbox. +8. The Mechanical Coordinator is non-LLM and deterministic. It may evaluate eligibility, dependencies, approval policy, leases, fencing, heartbeat, retry, expiry, and quarantine. It cannot invent scope, alter acceptance criteria, waive gates, certify, or merge. +9. **Certifier** is the final independent quality-gate role. Certifier may pass, reject, or escalate with evidence; it has no merge authority. +10. Every business and orchestration record is workspace-scoped; cross-workspace relationships are rejected. +11. Every mutation is idempotent and expected-version checked where it changes an aggregate. +12. Stale worker mutations are rejected by monotonically increasing fencing tokens. +13. Audit events are append-only and attributable; authoritative state is reconstructable from PostgreSQL without Valkey or files. + +## 4. Configurable recovery posture only + +Deployment tiers configure durability and operational recovery targets. They never configure SOT authority, fail-open writes, or gate bypass. + +### 4.1 Tier defaults + +| Setting | Lite | Standard | High-assurance | +| --------------------------- | --------------------------------------: | ------------------------------------------------------------: | ----------------------------------------------------------------------: | +| Target RPO | 24 hours | 1 hour | **15 minutes** | +| Target RTO | 24 hours | 8 hours | **4 hours** | +| Base backup cadence | Daily | Daily | **Daily** | +| WAL archive cadence | Disabled | Every 15 minutes | **Every 5 minutes** | +| PITR retention | 0 days / disabled | 14 days | **35 days** | +| Restore test frequency | Quarterly | Quarterly | **Monthly** | +| Break-glass drill frequency | Annually | Semiannually | **Quarterly** | +| Off-cluster storage | One encrypted off-cluster backup target | Encrypted off-cluster object storage, separate failure domain | **Encrypted off-cluster base backups and WAL, separate failure domain** | + +A deployment MAY override defaults only through the validated recovery-posture contract. An override MUST record actor, reason, effective time, and policy revision. A claimed RPO MUST be no smaller than the actual backup/WAL mechanism can support. Enabling PITR requires WAL archival and off-cluster storage. + +## 5. Functional requirements and acceptance criteria + +### REQ-SOT-001 — Sole writable PostgreSQL authority + +**Requirement:** All project, mission, milestone, task/tag/archive, dependency, assignment, execution/quarantine, lease, checkpoint, approval, outage proposal, event, link, artifact, and outbox mutations MUST commit through Gateway domain services into PostgreSQL. + +**Acceptance:** + +- Mutation journey tests show web, CLI, MCP, and agents invoke typed Gateway commands. +- Static/process inventory finds no file, Valkey, browser, or provider issue writer acting as canonical state. +- PostgreSQL state survives Valkey loss and reconstructs the same aggregate revisions. + +### REQ-SOT-002 — Fail-closed mutation health + +**Requirement:** A mutation MUST execute only while health state is `healthy`. `read-only-degraded` and `write-unavailable` MUST return the frozen deliberate-denial error contract and MUST NOT enqueue a hidden write. + +**Acceptance:** + +- Public health response is a discriminated union; contradictory state/proof combinations fail contract validation. +- Mutation methods accept only a fresh internal PostgreSQL transaction-local write proof, never caller-asserted/public health state. +- Negative tests reject expired proofs, policy-revision mismatch, Valkey-only liveness, and caller-forged `healthy`. +- Fault tests force both degraded states and prove row counts, outbox, files, and Valkey remain unchanged. +- Exact failure mapping proves authoritative 503 denial, retryable 502/504/timeout uncertainty, and 409 version conflict cannot cross-map. +- Replaying the same idempotency key after recovery returns one canonical result. + +### REQ-SOT-003 — Generated projections + +**Requirement:** `TASKS.md`, `mission.json`, and other exports MUST contain a non-authoritative header, workspace/project IDs, generated time, and source revision. No production parser may mutate DB from an export. + +**Acceptance:** + +- Generated output matches the API snapshot revision. +- Hand editing a projection fails CI validation or is overwritten by regeneration. +- Repository search finds no import path from generated projections. + +### REQ-SOT-004 — Attributable outage proposals + +**Requirement:** Human outage notes MAY be captured outside the system but, after recovery, can enter Mosaic only through workspace-scoped `change_proposals` attributed to an authenticated active member. A proposal stores source-note digest, target aggregate/version, typed command/payload, idempotency, lifecycle, decision actor/reason/time, and audit links. It MUST NOT silently change canonical state. + +**Acceptance:** + +- `(workspace_id, submitted_audit_event_id)` and `(workspace_id, accepted_command_audit_event_id)` are composite foreign keys to `task_events(workspace_id, id)`; missing and foreign-workspace event IDs fail before commit. +- Submission preallocates the proposal ID and atomically inserts `change_proposal.submitted` for that exact workspace/proposal with the new proposal referencing it. +- Accept locks proposal and target, obtains fresh write proof, checks expected version, executes the normal typed command, and atomically links that command's event for the same workspace/target and proposal causation. +- Negative tests reject missing submission events, foreign-workspace submission/acceptance events, and same-workspace events for an unrelated proposal, aggregate, target, or command. +- Tests prove a pending/rejected proposal cannot claim/order work, satisfy a dependency/gate, or mutate any target directly. + +### REQ-TEN-001 — Workspace hard tenancy + +**Requirement:** Every canonical business/orchestration row MUST carry `workspace_id`. Workspace-aware constraints and authorization MUST prevent cross-tenant relationships and reads/writes. + +**Acceptance:** + +- API, repository, import, WebSocket, and Coordinator negative tests reject foreign-workspace IDs without existence oracles. +- Project/task owners use exactly-one user/team references; assignment principals use exactly-one user/team/agent reference; agent/session targets are workspace-consistent. +- User owners, principals, proposers, and decision actors require ACTIVE workspace membership in the authoritative transaction. +- Dependency, project hierarchy, assignment, lease, checkpoint, approval-evidence, link, artifact, proposal target, and both proposal-audit-event composite relationships reject mixed workspaces. +- Tenant context is derived from authenticated authority, never accepted blindly from request data. + +### REQ-ID-001 — Workspace identity and service scope + +**Requirement:** Users, teams, agents, and agent sessions MUST be bound to a workspace with explicit role/capability scope. Agents MUST NOT receive raw DB credentials. + +**Acceptance:** + +- Workspace membership and service-identity tests enforce command-family scope. +- Revoked/disabled agents and ended sessions cannot claim, heartbeat, or submit. + +### REQ-PLAN-001 — Normalized planning hierarchy + +**Requirement:** Canonical planning entities are projects, milestones, missions, mission-milestone associations, and tasks. A task belongs to one required project and at most one mission/milestone/parent task. + +**Acceptance:** + +- CRUD tests preserve workspace, hierarchy, versions, and lifecycle constraints. +- Mission membership does not duplicate task status. +- Composite project-congruent constraints reject task→mission, task→milestone, task→parent, mission→milestone, and project→current-milestone mismatches. +- Parent and association constraints reject cycles/orphans where applicable. + +### REQ-TASK-001 — Canonical task fields + +**Requirement:** Tasks MUST support title, description, structured acceptance criteria, canonical status, priority, fractional board rank, accountable owner, assigned specialist role, due/not-before dates, estimate, progress, explicit blocker, retry policy, normalized workspace tags, non-lifecycle archival (`archived_at/by/reason`), metadata, monotonic fencing counter, and optimistic version. + +**Acceptance:** + +- API and UI round-trip every field without silent loss. +- Current `tasks.tags`, `assignee`, and `due_date` remain declared/preserved during N-1 and backfill to the canonical model without loss. +- Archive hides work without changing its canonical lifecycle status and requires actor/reason/time. +- Invalid status, rank, progress, date, owner, tag, archive, or retry data is rejected. +- Concurrent expected-version updates produce a visible conflict. + +### REQ-TASK-002 — Fixed lifecycle and computed readiness + +**Requirement:** Human workflow status MUST use the seven ratified values. Dependency/schedule/policy/lease/retry conditions MUST be exposed as computed readiness, not hidden status rewrites. + +**Acceptance:** + +- A dependency becoming incomplete changes readiness but does not silently rewrite the Kanban column. +- Readiness explanation identifies all active gates. +- State-machine tests reject illegal transitions and require reasons for blocked/cancelled paths. + +### REQ-DEP-001 — Dependency DAG + +**Requirement:** Workspace-local directed dependencies MUST be unique and acyclic. A task is dependency-eligible only after every blocking predecessor is `done` and completion conditions pass. + +**Acceptance:** + +- `(workspace_id, predecessor_task_id, successor_task_id)` is unique independent of dependency type. +- Cycle, duplicate, self-edge, and cross-workspace attempts fail before commit. +- Property/concurrency tests prove all blocking predecessors are evaluated. +- UI displays dependency and readiness errors accessibly. + +### REQ-ASN-001 — Assignment is not a lease + +**Requirement:** Assignment history and execution leases MUST be separate records. One persisted assignment identity freezes task version, exact target agent/session (or exactly-one non-agent principal), specialist role, expiry, state, policy revision, proposer, reason, and timestamps. Approval decisions relate to that assignment with workspace-aware constraints. + +**Acceptance:** + +- One assignment-state vocabulary is identical across schema, DTO, and engine. +- Lease acquisition accepts IDs only, then reloads and locks assignment, approval, task, and target session to verify workspace, current task version, exact target, state, expiry, and policy revision. +- Reassignment preserves history; assignment may exist without a lease; lease expiry does not erase ownership/evidence. + +### REQ-AUD-001 — Semantic audit and outbox + +**Requirement:** Mutating commands MUST append semantic `task_events` with actor, correlation, causation, idempotency key, and aggregate versions in the same transaction as state. Notifications MUST flow from a transactional outbox. + +**Acceptance:** + +- Atomicity tests prove state/event/outbox commit or roll back together. +- Proposal submission and acceptance tests prove their workspace-bound event links identify the exact submission and executed normal command, not merely an existing event UUID. +- Duplicate idempotency keys return the prior result without duplicate events. +- `task_events`, checkpoints, immutable artifacts, and evidence joins are INSERT/SELECT-only for application roles; parent hard deletes are RESTRICTed. +- Normal lifecycle uses archive/cancel, never hard delete; retention purge requires audited break-glass authority and evidence. +- Valkey outage leaves outbox pending and later replayable. + +### REQ-API-001 — Typed Gateway command boundary + +**Requirement:** Gateway MUST expose workspace-safe project/task/dependency/assignment/link/artifact/change-proposal queries and explicit lifecycle commands. Generic patching MUST NOT bypass claim, heartbeat, review, certify, proposal acceptance, or completion invariants. + +**Acceptance:** + +- KBN-105 freezes exact route, request, success, denial, conflict, and transport-normalization DTOs before CLI/web implementation. +- DTO validation, authorization, contract, and integration tests cover each command. +- Exact MCP-owned Gateway files are coder3-owned; coder4 consumes only frozen Gateway contracts. +- Endpoint registry aligns web, CLI, MCP, and generated client paths. +- Direct SQL and raw Valkey writes are absent from clients. + +### REQ-UI-001 — Writable thin Kanban/List MVP + +**Requirement:** Existing Tasks and Projects surfaces MUST become a real-data writable MVP with one shared query contract. + +**Acceptance:** + +- Users can create/edit/cancel/archive tasks, open task detail, and move cards within/across columns. +- Server validates transition and persists fractional board rank. +- Refresh, reconnect, CLI, MCP, and generated projection show the same revision. + +### REQ-UI-002 — Tenant and work context + +**Requirement:** UI MUST show workspace context and support filters for project, mission, milestone, status, priority, owner/specialist, due state, and tags. + +**Acceptance:** + +- Context is visible on every mutation surface. +- Filter tests cannot expose foreign-workspace data. +- Empty/loading/error states are explicit. + +### REQ-UI-003 — Dependency, ownership, lease, and audit visibility + +**Requirement:** Task detail MUST separate accountable owner, specialist assignment, active session/lease expiry, dependencies/readiness, acceptance criteria, blocker, external links, and audit timeline. + +**Acceptance:** + +- Each concept renders from its canonical endpoint. +- A lease is never displayed as ownership or completion. +- Conflict and stale-reconnect states require refresh rather than silent overwrite. + +### REQ-UI-004 — Accessible interaction + +**Requirement:** Kanban MUST support keyboard-accessible moves, non-drag alternatives, responsive layout, and semantic status/error announcements. + +**Acceptance:** + +- Keyboard journey performs every card transition available by drag. +- Automated accessibility checks and manual responsive checks pass. + +### REQ-COORD-001 — Non-LLM Mechanical Coordinator + +**Requirement:** Coordinator decisions MUST be deterministic from structured data and versioned policy. It MUST NOT invoke an LLM to interpret scope or acceptance criteria. + +**Acceptance:** + +- Pure decision engine receives complete immutable snapshots and performs no ID loading, SQL, Gateway, Valkey, or recovery I/O. +- Persistence/service adapter owns ID loading, transaction-local write proof, locking, persistence, and `recoverFromPostgres`. +- Same snapshot and policy revision produce the same eligibility/order explanation. +- Dependency, schedule, durable retry/quarantine, approval, role, and capacity inputs are auditable. +- Code/config inspection finds no model/provider dependency in the scheduling engine. + +### REQ-COORD-002 — Eligibility and approval routing + +**Requirement:** Only `ready` tasks under active project/mission, passed dependencies/schedule/retry/release policy, and without active lease may be proposed. Manual approval is default; auto-route requires an explicit approved policy revision. + +**Acceptance:** + +- Unapproved or gated tasks are never leased. +- Every persisted assignment proposal includes task version, exact target agent/session, expiry, state, deterministic reasons, and policy revision. +- Approval is relationally bound to the assignment identity and cannot be supplied as a forgeable proof-by-value DTO. +- Override/reject/reassign requires an attributable reason. + +### REQ-COORD-003 — Atomic lease, heartbeat, fencing, and recovery + +**Requirement:** Lease acquisition MUST be atomic in PostgreSQL, permit at most one active lease per task, atomically increment the durable per-task fencing counter under task lock, use bigint-safe tokens, require timely acknowledgement/heartbeat, and reject stale workers. Lease and checkpoint relations MUST bind the exact workspace+task+assignment/session+fence. + +**Acceptance:** + +- Concurrent claim tests yield one winner and strictly increasing fencing tokens. +- Lower/expired tokens and mismatched same-workspace task/assignment/lease/checkpoint IDs fail. +- Token values round-trip as bigint/decimal strings without JavaScript precision loss. +- Coordinator restart reconstructs lease/retry/quarantine state from PostgreSQL alone. + +### REQ-COORD-004 — Retry and quarantine + +**Requirement:** Missing acknowledgement, agent loss, or execution failure MUST produce a deterministic release, bounded backoff retry, or quarantine outcome according to retry policy. Ambiguous/non-idempotent work requires Sub-Orchestrator action. + +**Acceptance:** + +- Durable execution state records disposition, attempt/max, next eligibility, terminal reason, actor/policy, timestamps, and version. +- Retry budget/backoff are bounded and tested. +- Exhausted or non-idempotent failures quarantine with workspace-scoped artifact evidence. +- One specialist-role vocabulary is enforced across schema, sessions, assignments, DTOs, and engine. +- No task loops indefinitely or silently returns to ready. + +### REQ-GATE-001 — Role and authority chain + +**Requirement:** Canonical flow is User → Interaction → Portfolio Orchestrator → Project Sub-Orchestrator → Gateway → domain services → Mechanical Coordinator → specialists → Certifier. + +**Acceptance:** + +- Role bindings and approvals are queryable and audited. +- Coordinator cannot create scope or waive gates. +- Certifier cannot merge or close provider artifacts. + +### REQ-GATE-002 — Independent review and certification + +**Requirement:** Author and reviewer MUST differ. Auth, security, tenant, secrets, and data-integrity surfaces MUST receive mandatory SecReview. Certifier is the final quality gate after remediation. + +**Acceptance:** + +- Gate tests reject author self-review and missing required SecReview. +- Certifier receives complete traceability/evidence and returns pass/reject/escalate. +- A Certifier pass does not grant merge authority. + +### REQ-REC-001 — Recovery posture validation + +**Requirement:** A deployment MUST select a validated Lite, Standard, or High-assurance posture and MAY override only recovery knobs. + +**Acceptance:** + +- Runtime invokes normative `validateRecoveryPostureV1`, not shape-only JSON Schema validation. +- Validator rejects PITR/WAL mismatch, impossible RPO, unknown fields, non-encrypted/non-separated storage, and weakened High-assurance values. +- A bounded recovery/infra slice owns parser wiring, override audit, mechanism verification, restore test, and break-glass evidence. +- High-assurance defaults equal RPO 15m/RTO 4h, encrypted off-cluster WAL every 5m, 35d PITR, daily base backup, monthly restore test, and quarterly break-glass. + +### REQ-MIG-001 — One-way shadow migration + +**Requirement:** Migration from jarvis-brain/Vikunja MUST use inventory, immutable source snapshots/checksums, one-way shadow import, read reconciliation, write freeze, final delta, cutover, and read-only stabilization. Dual writes are forbidden. + +**Acceptance:** + +- P0 publishes the current `origin/main` field-by-field expand/backfill/compatibility/switch/contract map before any schema lane starts. +- Legacy columns remain in the unified Drizzle declaration for the entire expand/N-1 window. +- Dry-run/apply/verify modes are idempotent and workspace-safe. +- Import lineage preserves source system/key/file/checksum/batch and rejected-record reports. +- Empty DB, production-shape, partial-resume, downgrade/rollback, status-shadow, workspace-backfill, and `mission_tasks.status` retirement tests pass. +- Shadow records cannot auto-dispatch. + +### REQ-MIG-002 — Cutover and rollback safety + +**Requirement:** Cutover MUST disable legacy writers and switch all clients to Gateway. Before first DB mutation rollback may switch authority back; afterward rollback requires freeze, DB-delta export/reconciliation, and owner decision. + +**Acceptance:** + +- Process inventory proves no active jarvis-brain/Vikunja project/task writer. +- Cutover rehearsal meets signed reconciliation thresholds. +- No reverse and forward sync run concurrently. + +## 6. Explicit non-goals + +The P0–P3 canon does not authorize: + +- replacing Gitea issue/PR storage; +- calendar, email, GLPI cache, CRM, billing, time tracking, or personal-brain migration; +- arbitrary custom workflows/statuses/fields; +- a writable offline/file/Valkey/browser fallback; +- direct client database access; +- LLM scheduling or autonomous scope invention; +- Coordinator gate waiver, certification, merge, release, or provider issue closure; +- Certifier merge authority; +- full mission designer, portfolio analytics, critical-path UX, or advanced board customization in the thin MVP; +- P4/P5 features unless separately released. + +## 7. Global release evidence + +P0–P3 may close only when requirements traceability maps every requirement above to automated and situational evidence, including cross-workspace denials, DB/Valkey fault injection, concurrent leases, stale fencing, generated-file immutability, UI conflict/reconnect behavior, migration reconciliation, independent review, mandatory SecReview, and final Certifier evidence. diff --git a/docs/scratchpads/751-native-kanban-canon.md b/docs/scratchpads/751-native-kanban-canon.md new file mode 100644 index 00000000..d041535f --- /dev/null +++ b/docs/scratchpads/751-native-kanban-canon.md @@ -0,0 +1,152 @@ +# Issue #751 — Native Kanban/SOT canonical publication + +## Objective + +Publish the owner-ratified P0–P3 requirements, mission manifest, task decomposition, and frozen shared contracts before feature implementation. + +## Authority and decisions + +- Owner: Jason +- Plan owner/orchestrator: web1 control plane; takeover by mosaic-100 during Claude quota outage +- Tracking: Mosaic Stack issue #751 +- Foundation: current Stack main + Drizzle/PostgreSQL +- Fixed invariants: PostgreSQL sole writable SOT; writes fail closed; exports never import; outage notes become attributable proposals; mechanical Coordinator has no scope/gate/certify/merge authority; Certifier has no merge authority. +- Recovery posture only is configurable through Lite, Standard, and High-assurance profiles. + +## Execution log + +- 2026-07-14: Existing planner-sol canon remediation reviewed from staging. KCR-001–016 claimed resolved; static checks passed. +- 2026-07-14: Independent GPT/Terra re-review dispatched to rev1. +- 2026-07-14: Re-review returned NO-GO: proposal audit-event IDs were not workspace-bound, leaving attribution forgeable; formatter evidence was not reproducible. Focused remediation round 2 routed to planner-sol. +- 2026-07-14: Remediation bound proposal audit links to `task_events(workspace_id,id)`, froze same-transaction semantic validation and negative tests, and made formatter/type/static checks reproducible. +- 2026-07-14: Independent rev1 re-review returned GO with KCR-001–016 closed and no new blocker. Canon copied into the issue #751 publication worktree; feature implementation remains held until merge. +- 2026-07-14: Independent publication validation returned FAIL on formatting/trailing whitespace, stale staging wording, ignored review evidence, and missing worktree dependencies. Bounded publication remediation routed to planner-sol; no runtime source change authorized. +- 2026-07-14: Publication remediation installed locked dependencies outside the repository cache, fixed formatting and wording, and preserved docs-only scope. Independent gaterun revalidation returned PASS across staged scope, formatting, lint, typecheck, strict contract compile, links, rollups, review artifacts, and fixed invariants. +- 2026-07-14: Ultron final gate returned GO with zero BLOCKER/HIGH findings; residual LOW items remain explicit implementation obligations. +- 2026-07-14: First commit attempt was correctly blocked by the lint-staged hook because docs contract `.ts` files were outside TypeScript project-service scope. Added a strict no-emit workstream `tsconfig.json` with exact Drizzle declaration paths; targeted contract TSC, contract ESLint, format, full lint/typecheck, strictness, and docs-only scope independently passed. + +## Verification evidence + +- Initial review: `docs/reports/native-kanban-sot/canon-initial-review-no-go.md` +- Final GO: `docs/reports/native-kanban-sot/canon-final-rereview-go.md` +- Ultron GO: `docs/reports/native-kanban-sot/ultron-final-go.md` +- Pending: PR gates, squash merge, terminal-green CI, and issue closure. + +## Publication remediation session — planner-sol + +**Assignment:** Remediate only issue #751 publication blockers from `/home/hermes/agent-work/reviews/751-kanban-canon-publication-validation.md`; no source, package, lock, provider, CI, build, deploy, commit, or push action. + +**Plan:** + +1. Install the locked pnpm dependencies without modifying package metadata. +2. Replace only stale publication/staging wording while retaining the canon-merge and KBN prerequisite implementation hold. +3. Format all changed candidate Markdown and contract TypeScript; remove trailing whitespace. +4. Run worktree `format:check`, `lint`, `typecheck`, strict no-emit contract compilation, tracked/untracked whitespace checks, and static invariants. +5. Verify package/lock/source paths are unchanged and append exact evidence here. + +**Budget:** No explicit token cap; bounded docs-only remediation, no exploratory/runtime work. +**TDD:** Not applicable—documentation/contract-publication formatting and wording only; strict/static validation is the primary gate. + +## Publication remediation results + +### Changes + +- `docs/native-kanban-sot/INDEX.md`: replaced staging/pending-GO wording with current publication and independent-GO wording; retained the merge hold and dependency-ordered KBN prerequisite hold. +- `docs/native-kanban-sot/TASKS.md`: replaced “Mos using this staging set” with “Mos / publication control plane”; made the post-merge KBN prerequisite hold explicit. +- Formatted all changed candidate Markdown and four contract TypeScript files with current-worktree Prettier 3.8.1. +- Removed trailing whitespace from candidate Markdown, including both linked review reports. +- Preserved both review reports and their links; they remain ignored by `.gitignore:11` for coordinator force-tracking. + +### Dependency installation + +The first target-worktree install attempt used the environment's default root-owned pnpm store and failed without changing package metadata: + +```text +cd /home/hermes/agent-work/stack-kanban-canon && pnpm install --frozen-lockfile +EACCES: permission denied, open '/root/.local/share/pnpm/store/v10/server/server.json' +``` + +Successful locked install using an authorized cache outside the repository: + +```bash +cd /home/hermes/agent-work/stack-kanban-canon +pnpm install --frozen-lockfile --store-dir /home/hermes/agent-work/pnpm-store +``` + +Result: PASS, 1,240 packages installed; lockfile resolution skipped as up to date. `node_modules` remains ignored. Tool versions: pnpm 10.6.2, Prettier 3.8.1, TypeScript 5.9.3, Drizzle ORM 0.45.1, Turbo 2.8.16. + +### Exact quality-gate results + +```text +pnpm format:check +PASS — All matched files use Prettier code style. + +pnpm lint +PASS — 23 successful lint tasks. + +pnpm typecheck +PASS — 42 successful tasks. Turbo invoked configured dependency build prerequisites as part of the repository's exact typecheck graph; no standalone build command was run. +``` + +Candidate formatting commands: + +```bash +pnpm exec prettier --write <3 tracked rollups + 9 native-kanban artifacts + requirements + scratchpad> +pnpm exec prettier --check +pnpm exec prettier --ignore-path /dev/null --write \ + docs/reports/native-kanban-sot/canon-initial-review-no-go.md \ + docs/reports/native-kanban-sot/canon-final-rereview-go.md +pnpm exec prettier --ignore-path /dev/null --check \ + docs/reports/native-kanban-sot/canon-initial-review-no-go.md \ + docs/reports/native-kanban-sot/canon-final-rereview-go.md +``` + +Result: PASS. The explicit `/dev/null` ignore path is required because `docs/reports/` is intentionally ignored pending coordinator force-tracking. + +Tracked and untracked whitespace checks: + +```text +git diff --check +PASS + +git diff --no-index --check /dev/null +PASS for all candidates +``` + +Strict contract compilation initially could not resolve pnpm-isolated `drizzle-orm` from the external docs directory. A temporary, removed dependency-context symlink made current-worktree resolution explicit: + +```bash +LINK=docs/native-kanban-sot/node_modules +ln -s ../../packages/db/node_modules "$LINK" +trap 'unlink "$LINK"' EXIT +pnpm exec tsc \ + --noEmit \ + --strict \ + --skipLibCheck \ + --target ES2022 \ + --module NodeNext \ + --moduleResolution NodeNext \ + docs/native-kanban-sot/contracts/*.ts +``` + +Result: `strict-contract-noemit=PASS`; temporary link removed. + +Static result: + +```text +proposal-audit-links=PASS +kcr-invariant-regression=PASS +publication-wording=PASS +vocabulary-alignment=PASS +``` + +### Scope-integrity evidence + +Baseline and final hashes are identical: + +```text +package.json 93a50eaefc7a0446a56234e427df03f6a2256f8da17c0bede17c22206928c8c0 +pnpm-lock.yaml 8b6448d51ac7797c8f782af52a080c0e38ab8bf364f32624f94e636bf5743229 +``` + +`tracked-package-lock-source-unchanged=PASS`: every tracked/untracked nonignored change remains under `docs/`; no package, lock, application source, plugin source, configuration, CI trigger, standalone build/deploy, container, provider, commit, or push action occurred. From 5e832049bbd8bed3226569eb033111748bd973dc Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Tue, 14 Jul 2026 19:16:11 +0000 Subject: [PATCH 043/152] docs(mos): preserve Option 2 qualification evidence (#759) --- docs/SITEMAP.md | 7 + docs/architecture/ADR-MOS-EGRESS-GATEWAYS.md | 151 +++++++++++ .../2026-07-14-option2-runtime-portability.md | 238 ++++++++++++++++++ 3 files changed, 396 insertions(+) create mode 100644 docs/architecture/ADR-MOS-EGRESS-GATEWAYS.md create mode 100644 docs/tess/qualification/2026-07-14-option2-runtime-portability.md diff --git a/docs/SITEMAP.md b/docs/SITEMAP.md index d228cf1d..520ffafb 100644 --- a/docs/SITEMAP.md +++ b/docs/SITEMAP.md @@ -41,3 +41,10 @@ - [Retention and deprecation evidence](tess/M5-MIGRATION-RETENTION-DEPRECATION.md) - [Verification matrix](tess/VERIFICATION-MATRIX.md) - [Documentation checklist](tess/M5-003-DOCUMENTATION-CHECKLIST.md) +- [Independent Option 2 runtime-portability qualification (2026-07-14)](tess/qualification/2026-07-14-option2-runtime-portability.md) + +## Runtime-neutral Mos portability + +- [Optional AI egress gateway ADR](architecture/ADR-MOS-EGRESS-GATEWAYS.md) — placement and gates for LiteLLM, Bifrost, and purpose-built translation proxies. +- [Runtime-neutral Mos identity and failover mission](https://git.mosaicstack.dev/mosaicstack/stack/issues/754) +- [Logical identity and connector lease/fencing implementation](https://git.mosaicstack.dev/mosaicstack/stack/issues/755) diff --git a/docs/architecture/ADR-MOS-EGRESS-GATEWAYS.md b/docs/architecture/ADR-MOS-EGRESS-GATEWAYS.md new file mode 100644 index 00000000..de479f8f --- /dev/null +++ b/docs/architecture/ADR-MOS-EGRESS-GATEWAYS.md @@ -0,0 +1,151 @@ +# ADR: Optional AI egress gateways for runtime-neutral Mos + +**Status:** Proposed for controlled prototypes; not approved as Mosaic core + +**Date:** 2026-07-14 + +**Issues:** #754, #755 + +**Decision owner:** Mosaic Gateway / provider-adapter architecture + +## Context + +The emergency Mos continuity path kept Claude Code as the harness and translated Anthropic Messages traffic to Codex OAuth through a small localhost proxy. That preserved the existing Claude Discord plugin and transcript, but exposed two architectural facts: + +1. Harness identity, channel entitlement, provider credentials, and inference transport are separate concerns. +2. A generic AI gateway can improve provider routing, budgets, and observability, but must not become Mosaic's identity, authorization, tenant, or orchestration boundary. + +The Tess qualification report also found that current provider rebinding is not identity-continuous failover. Mosaic still needs a logical agent identity, durable connector lease/fencing, canonical handoff/checkpoint, exactly-once receipts, concrete harness adapters, and cross-harness rollback E2E. + +## Decision + +Mosaic MAY support LiteLLM, Bifrost, the purpose-built Claude/Codex proxy, or future gateways as optional egress implementations behind `IProviderAdapter` / `AgentRuntimeProvider`. + +Mosaic Gateway remains authoritative for: + +- authenticated actor and tenant identity; +- logical agent identity and connector binding; +- authorization, approval, and policy; +- lease epoch and stale-holder fencing; +- audit correlation and redaction; +- canonical handoff/checkpoint state; +- idempotency and side-effect receipts. + +An egress gateway MUST NOT: + +- receive channel ingress directly; +- authorize tools or connector ownership; +- define Mosaic tenant or agent identity; +- persist raw Mosaic handoffs or channel credentials; +- bypass adapter capability negotiation; +- silently fail over when policy, lease, or provider health is uncertain. + +Allowed topology: + +```text +Discord / Matrix / CLI / web + ↓ +Mosaic Gateway: identity, authz, lease/fence, approvals, audit + ↓ +IProviderAdapter / AgentRuntimeProvider + ↓ +optional egress gateway + ↓ +upstream provider or subscription-backed OAuth session +``` + +## Candidate assessment + +### Purpose-built `raine/claude-code-proxy` + +**Disposition:** Approved only for the verified emergency localhost bridge. + +Strengths: + +- explicit Codex device OAuth flow; +- small operational surface; +- Anthropic Messages translation suitable for Claude Code; +- model and reasoning-effort enforcement; +- straightforward loopback systemd supervision and rollback. + +Constraints: + +- not a Mosaic multi-tenant control plane; +- Claude built-in channels still depend on Claude subscription entitlement and feature lookup; +- model aliases can obscure the upstream model unless proxy policy/logs are treated as evidence; +- no replacement for connector leasing, canonical handoff, or exactly-once effects. + +### LiteLLM + +**Disposition:** Candidate for a formal adapter-only prototype and terms/security review. + +Current documentation states that ChatGPT subscription access is available through an OAuth device-code flow. LiteLLM also provides broad provider routing, virtual keys, budgets, observability, and OpenAI/Anthropic-compatible surfaces. + +Required prototype gates: + +- verify the exact ChatGPT subscription OAuth flow and supported models against current provider terms; +- document token location, encryption, revocation, refresh, scope, and incident response; +- prove tenant isolation and prevent virtual keys from becoming Mosaic principals; +- verify streaming, tool calls, reasoning controls, cancellation, and idempotency metadata; +- fail closed instead of selecting an unhealthy provider merely to return a result; +- demonstrate that Mosaic audit correlation survives gateway retries/failover; +- keep channel ingress and connector credentials outside LiteLLM. + +Source references: + +- [LiteLLM ChatGPT subscription provider](https://docs.litellm.ai/docs/providers/chatgpt) +- [LiteLLM providers](https://docs.litellm.ai/docs/providers) + +### Bifrost + +**Disposition:** Candidate for governance/routing research; subscription OAuth compatibility unverified. + +Useful concepts include virtual keys, budgets, rate limits, weighted load balancing, and automatic provider failover. Those features may inform Mosaic egress policy, but Bifrost virtual keys are downstream credentials—not Mosaic actors or tenants. + +Required prototype gates: + +- verify Codex/ChatGPT subscription OAuth rather than assuming API-key compatibility; +- map budgets and virtual keys to server-derived Mosaic tenants without duplicating authority; +- prove failover does not violate connector lease, approval, or exactly-once semantics; +- ensure request/response logs are redacted before persistence; +- disable or constrain automatic failover when policy or side-effect state is ambiguous. + +Source references: + +- [Bifrost overview](https://docs.getbifrost.ai/overview) +- [Bifrost repository](https://github.com/maximhq/bifrost) + +### `teremterem/claude-code-gpt-5-codex` + +**Disposition:** Not selected as the emergency implementation; useful as a historical LiteLLM recipe. + +The reviewed repository uses `OPENAI_API_KEY`, tells previously authenticated Claude users to log out, and documents a Claude Web Search schema incompatibility. Logging Claude out conflicts with the channel-entitlement requirement observed in the live Mos cutover. The repository therefore does not, as provided, satisfy subscription-OAuth plus built-in-channel continuity. + +Source references: + +- [Repository](https://github.com/teremterem/claude-code-gpt-5-codex) +- [Environment template](https://github.com/teremterem/claude-code-gpt-5-codex/blob/main/.env.template) + +## Security consequences + +- Subscription OAuth grants are high-value credentials and require the same lifecycle controls as service credentials. +- Downstream virtual keys reduce provider-key exposure but do not establish user, tenant, or agent authority. +- Automatic retry/failover can duplicate tool or external side effects unless Mosaic owns operation IDs and receipts. +- Gateway telemetry can contain prompts, tool schemas, and model output; redaction and retention policy must apply before persistence. +- A localhost unauthenticated translation endpoint must remain loopback-only and process-isolated. + +## Acceptance before production use + +1. Threat model and provider-terms review approved. +2. Credential lifecycle and revocation drill documented and exercised. +3. Adapter contract tests pass for streaming, tools, cancellation, reasoning policy, errors, and audit correlation. +4. Tenant-bound authorization remains entirely in Mosaic Gateway. +5. Failure injection proves no duplicate side effects across retries or provider failover. +6. Rollback to the prior provider path is exercised. +7. Independent code and security reviews approve the exact deployed revision. + +## Follow-up + +- #754 owns cross-harness logical identity, checkpoint, receipt, adapter, and failover work. +- #755 / PR #757 implements the first logical identity and connector lease/fencing boundary. +- A later issue should prototype LiteLLM and Bifrost behind the provider adapter after #755 is merged and independently qualified. diff --git a/docs/tess/qualification/2026-07-14-option2-runtime-portability.md b/docs/tess/qualification/2026-07-14-option2-runtime-portability.md new file mode 100644 index 00000000..fa60c9ff --- /dev/null +++ b/docs/tess/qualification/2026-07-14-option2-runtime-portability.md @@ -0,0 +1,238 @@ +# Tess / Option 2 runtime-portability qualification — 2026-07-14 + +**Issue context:** #706–#711 and runtime-neutral Mos follow-up #754 + +**Qualified revision:** `d0771835542d` (`origin/main` at review time) + +**Reviewer/runtime:** Independent Pi lane requested as `openai-codex/gpt-5.6-sol:high` + +**Runtime resolution note:** Mosaic warned that `gpt-5.6-sol` was not present in the provider model catalog and proceeded with it as a custom model ID. This warning was part of the original qualification log and is material provenance; downstream claims must not treat catalog recognition as verified. + +**Verdict:** REQUEST CHANGES + +**Evidence type:** Point-in-time qualification; later commits and PR #757 must be reviewed separately + +## Purpose and provenance + +This report preserves the complete independent qualification that was previously available only in `/tmp/tess-option2-qualification.log`. It distinguishes passing component tests from the missing operational proof required for identity-continuous Mos failover. + +No credential values, OAuth tokens, Discord tokens, device codes, or auth-file contents are included. Commands and results are retained so another environment can reproduce or challenge the findings. + +--- + +# 1. Verdict + +## **REQUEST CHANGES** + +The current Option 2 implementation is a useful portability foundation, but it is **not qualified against AC-TESS-01..11** and is not equivalent to true same-Mos-identity failover. + +Primary blockers: + +1. **AC-TESS-01/02:** The required `mosaic tess` command does not exist; only `mosaic interaction` is registered (`packages/mosaic/src/commands/interaction.ts:60`). The cross-surface test proves CLI enrollment followed by Discord approval/stop, not bidirectional Discord/CLI chat streaming. +2. **AC-TESS-04:** Fleet/tmux and Matrix providers are implemented as libraries but are not registered in the production gateway. `AgentModule` registers only Hermes (`apps/gateway/src/agent/agent.module.ts:34`). +3. **Mos handoff is not operational or durable:** Production uses `InMemoryInteractionCoordinationPort` (`apps/gateway/src/coord/coord.module.ts:18`), with no Mos-side consumer. Restart loses handoff ownership, idempotency, activity, and results. +4. **AC-TESS-06/10:** Restart tests are good local persistence tests, but no real connector/harness failover or exercised rollback exists. Rollback is documentation-only. +5. **AC-TESS-08:** The parity suite validates a selected shared intersection using mocked transports. Matrix is not production-wired and tmux drops the runtime message idempotency key before delivery. +6. **AC-TESS-09:** M5 qualification remains `not-started`; no live Discord, Matrix homeserver, tmux/Mos consumer, Claude Code/Pi/Codex failover, or deployment rollback was tested. +7. **PR #750 mismatch:** Its description promises send-error coverage as HTTP 400, but both gateway and TUI test use HTTP 403 (`packages/mosaic/src/tui/gateway-api.interaction-errors.test.ts:25-34`). + +### AC disposition + +| AC | Result | Evidence | +|---|---|---| +| 01 | **Fail** | No `mosaic tess`; no bidirectional same-session chat/stream E2E | +| 02 | **Fail** | Generic CLI exists, but fleet/Matrix providers are unreachable in production | +| 03 | Pass | Pi profile/model/reasoning/effective-policy tests passed | +| 04 | **Fail** | No registered fleet provider or real Mos consumer | +| 05 | Partial | Hermes normalization/fail-closed matrix passes; live capability path is limited | +| 06 | Partial | PGlite restart/idempotency passes; no actual harness failover | +| 07 | Partial | Focused denial/replay tests pass; full M5 abuse qualification absent | +| 08 | Partial | Mocked shared-intersection parity passes; Matrix not operationally wired | +| 09 | **Fail** | Baselines/CI green, but required E2E/security/rollback qualification absent | +| 10 | **Fail** | Inventory incomplete/inconsistent; rollback not exercised | +| 11 | Pass/ledger stale | Documentation and sitemap exist; plugin/catalog ledger remains unresolved | + +--- + +# 2. Exact test commands and results + +Initial focused attempts failed before collection because this detached worktree had no dependencies: + +```bash +pnpm --filter @mosaicstack/agent exec vitest run ... +``` + +Result: startup failure, `Cannot find module 'vitest/config'`. + +Setup used: + +```bash +corepack pnpm --store-dir /home/jarvis/.local/share/pnpm/store/v10 \ + install --frozen-lockfile --ignore-scripts +``` + +Result: PASS, 1,240 packages linked. + +```bash +corepack pnpm turbo run build \ + --filter='@mosaicstack/gateway^...' \ + --filter='@mosaicstack/mosaic^...' +``` + +Result: **17/17 dependency builds successful**. + +### Focused suites + +```bash +corepack pnpm --filter @mosaicstack/agent exec vitest run \ + src/runtime-provider-parity.test.ts \ + src/matrix-native-runtime-provider.test.ts \ + src/tmux-fleet-runtime-provider.test.ts \ + src/durable-session.test.ts \ + src/hermes-runtime-provider.test.ts +``` + +Result: **5 files, 39/39 tests passed**. + +```bash +corepack pnpm --filter @mosaicstack/gateway exec vitest run \ + src/agent/durable-session.repository.test.ts \ + src/__tests__/integration/tess-cross-surface.integration.test.ts \ + src/plugin/discord-ingress.security.spec.ts \ + src/coord/interaction-coordination.service.test.ts \ + src/coord/interaction-coordination.routing.e2e.test.ts \ + src/agent/hermes-runtime-reachability.e2e.test.ts +``` + +Result: **6 files, 36/36 tests passed**. PGlite close/reopen recovery passed in 504 ms. + +```bash +corepack pnpm --filter @mosaicstack/mosaic exec vitest run \ + src/fleet/matrix-native-runtime-transport.test.ts \ + src/fleet/tess-service-profile.test.ts \ + src/commands/interaction.test.ts \ + src/tui/gateway-api.interaction-errors.test.ts +``` + +Result: **4 files, 15/15 tests passed**. + +```bash +corepack pnpm --filter @mosaicstack/coord exec vitest run \ + src/__tests__/interaction-coordination.test.ts +``` + +Result: **1 file, 7/7 tests passed**. + +```bash +corepack pnpm --filter @mosaicstack/gateway exec vitest run \ + src/agent/interaction.controller.test.ts \ + src/commands/command-authorization.service.spec.ts \ + src/agent/__tests__/runtime-provider-registry.service.test.ts +``` + +Result: **3 files, 27/27 tests passed**. + +Focused total: **124/124 tests passed** after dependency setup. + +### Baselines + +```bash +TURBO_FORCE=true corepack pnpm typecheck +``` + +Result: **42/42 tasks successful**. + +```bash +TURBO_FORCE=true corepack pnpm lint +``` + +Result: **23/23 tasks successful**. + +```bash +corepack pnpm format:check +``` + +Result: **PASS — all files matched Prettier style**. + +```bash +~/.config/mosaic/tools/woodpecker/pipeline-status.sh \ + -r mosaicstack/stack -n 1796 +``` + +Result: **SUCCESS** at `d0771835542d`; all test, build, sanitization, typecheck, lint, format, and publish steps green. + +No tracked files outside the pre-existing `.mosaic/orchestrator/*` launcher changes were modified. + +--- + +# 3. Stale ledger inconsistencies + +1. `docs/tess/MISSION-MANIFEST.md` still says: + - current milestone M1; + - progress 0/5; + - M2/M3/M5 not started. +2. `docs/tess/TASKS.md` says: + - M4-V failed; + - M4-W-001 and TESS-PLG-001 in progress; + - M5-V not started. +3. Provider issue state conflicts: + - #707–#709 remain open although M1–M3 rows are recorded done/pass. + - #710 and #711 are closed although M4-V failed and M5-V is not started. +4. M5 work was marked done despite depending on failed M4-V. +5. TESS-M3-002 says `mosaic tess` is done, but only `mosaic interaction` exists. +6. PR #750 removed stale service references from operational docs, but `docs/tess/TASKS.md` still contains `MosCoordinationService` in historical notes. +7. `docs/tess/MIGRATION-INVENTORY.md` remains an “initial inventory” with several capabilities marked `adapt`; `M5-MIGRATION-INVENTORY.md` marks grouped capabilities deferred/fail-closed. Neither supplies the complete owner/evidence matrix AC-TESS-10 requires. +8. TESS-M2-FUP-001 remains real: the unkeyed SHA-256 compatibility branch still exists at `durable-session.repository.ts:427-431`. +9. TESS-PLG-001 claims catalog registration was folded into W-001, but production evidence shows provider registration in the gateway—not a completed `packages/mosaic` plugin catalog. + +--- + +# 4. Gap to true same-Mos-identity failover + +Current code can relaunch the same roster name under another runtime and can rebind a durable interaction session to another provider/runtime ID. That is **replacement**, not identity-continuous failover. + +Missing pieces: + +- No canonical logical Mos identity independent of harness-native session IDs. +- No exclusive connector lease or monotonic fencing epoch; session rebinding is effectively last-write-wins. +- No stale-holder rejection preventing the old harness from continuing side effects. +- No normalized Claude Code/Pi/Codex checkpoint/import/export adapters. +- No durable Mos coordination transport or Mos consumer. +- No canonical handoff containing mission/task refs, git state, causal sequence, pending operations, capability requirements, and acknowledgements. +- No end-to-end receipt journal across connectors. +- Matrix has deterministic transaction IDs, but tmux delivery discards `RuntimeMessage.idempotencyKey`. +- No fault-injection test transferring Mos among Claude Code, Pi, and Codex and then rolling back. + +--- + +# 5. Minimal follow-up issue decomposition + +| Order | Issue | Minimum acceptance criteria | +|---|---|---| +| 1 | **Logical identity and security fencing** | Server-derived `{tenant, logicalAgentId, connectorId, harness, leaseEpoch, scopes, expiry}`; signed/fenced execution grant; stale/forged/cross-tenant grants denied and audited; no connector credential in handoffs | +| 2 | **Durable connector lease** | PostgreSQL-backed exclusive lease with CAS, monotonic epoch, TTL/heartbeat, explicit takeover, and gateway rejection of stale holders; connectors for Claude Code, Pi, and Codex | +| 3 | **Canonical handoff/checkpoint** | Versioned, sealed schema containing canonical mission/task/git references, checkpoint digest, causal sequence, required capabilities, pending/ambiguous operation references, and source/destination acknowledgement; no raw secrets or mandatory harness transcript | +| 4 | **Exactly-once connector journal** | Durable operation IDs and receipts; idempotency propagated through every adapter; Matrix transaction mapping; tmux replaced or wrapped with receiver-side durable dedupe; ambiguous effects remain held for authorized reconciliation | +| 5 | **Cross-harness failover and rollback E2E** | Real Mos identity moves Claude Code → Pi → Codex and back; inject crashes before/after lease transfer, handoff persistence, send, and acknowledgement; stale connector fenced; no duplicate side effects; canonical state preserved; rollback evidence published | +| 6 | **Generic gateway research ADR** | Evaluate LiteLLM subscription OAuth and Bifrost concepts without adding either to core; include terms/security review, credential lifecycle, tenant mapping, budgets, failover semantics, and adapter-only prototype | + +## Generic gateway placement + +Allowed topology: + +```text +Discord / CLI / web + ↓ +Mosaic Gateway: auth, tenant scope, policy, approvals, audit + ↓ +IProviderAdapter / AgentRuntimeProvider + ↓ +optional LiteLLM or Bifrost egress proxy + ↓ +upstream provider +``` + +- **LiteLLM ChatGPT subscription OAuth:** research-only, opt-in, behind an adapter. Subscription credentials require explicit terms, revocation, scope, token-storage, and audit review. They must never become Mosaic identity or core configuration. +- **Bifrost:** virtual keys are downstream proxy credentials, not Mosaic principals. Budget and failover concepts may inform Mosaic routing, but tenant policy, authorization, and audit remain in Mosaic. +- Neither product may introduce schemas into Mosaic core, receive direct calls from channels/agents, or bypass `IProviderAdapter`/`AgentRuntimeProvider`. +- Mosaic should also correct its existing “all providers unhealthy → use one anyway” fallback behavior before adopting more automatic failover (`routing-engine.service.ts:204-212`). From 48b2bc42c9c8937810df3e4d10aea8c8a7838cac Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Tue, 14 Jul 2026 19:33:09 +0000 Subject: [PATCH 044/152] docs(mos): format Option 2 qualification report (#762) --- .../2026-07-14-option2-runtime-portability.md | 42 +++++++++---------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/docs/tess/qualification/2026-07-14-option2-runtime-portability.md b/docs/tess/qualification/2026-07-14-option2-runtime-portability.md index fa60c9ff..7af2cba3 100644 --- a/docs/tess/qualification/2026-07-14-option2-runtime-portability.md +++ b/docs/tess/qualification/2026-07-14-option2-runtime-portability.md @@ -38,19 +38,19 @@ Primary blockers: ### AC disposition -| AC | Result | Evidence | -|---|---|---| -| 01 | **Fail** | No `mosaic tess`; no bidirectional same-session chat/stream E2E | -| 02 | **Fail** | Generic CLI exists, but fleet/Matrix providers are unreachable in production | -| 03 | Pass | Pi profile/model/reasoning/effective-policy tests passed | -| 04 | **Fail** | No registered fleet provider or real Mos consumer | -| 05 | Partial | Hermes normalization/fail-closed matrix passes; live capability path is limited | -| 06 | Partial | PGlite restart/idempotency passes; no actual harness failover | -| 07 | Partial | Focused denial/replay tests pass; full M5 abuse qualification absent | -| 08 | Partial | Mocked shared-intersection parity passes; Matrix not operationally wired | -| 09 | **Fail** | Baselines/CI green, but required E2E/security/rollback qualification absent | -| 10 | **Fail** | Inventory incomplete/inconsistent; rollback not exercised | -| 11 | Pass/ledger stale | Documentation and sitemap exist; plugin/catalog ledger remains unresolved | +| AC | Result | Evidence | +| --- | ----------------- | ------------------------------------------------------------------------------- | +| 01 | **Fail** | No `mosaic tess`; no bidirectional same-session chat/stream E2E | +| 02 | **Fail** | Generic CLI exists, but fleet/Matrix providers are unreachable in production | +| 03 | Pass | Pi profile/model/reasoning/effective-policy tests passed | +| 04 | **Fail** | No registered fleet provider or real Mos consumer | +| 05 | Partial | Hermes normalization/fail-closed matrix passes; live capability path is limited | +| 06 | Partial | PGlite restart/idempotency passes; no actual harness failover | +| 07 | Partial | Focused denial/replay tests pass; full M5 abuse qualification absent | +| 08 | Partial | Mocked shared-intersection parity passes; Matrix not operationally wired | +| 09 | **Fail** | Baselines/CI green, but required E2E/security/rollback qualification absent | +| 10 | **Fail** | Inventory incomplete/inconsistent; rollback not exercised | +| 11 | Pass/ledger stale | Documentation and sitemap exist; plugin/catalog ledger remains unresolved | --- @@ -207,14 +207,14 @@ Missing pieces: # 5. Minimal follow-up issue decomposition -| Order | Issue | Minimum acceptance criteria | -|---|---|---| -| 1 | **Logical identity and security fencing** | Server-derived `{tenant, logicalAgentId, connectorId, harness, leaseEpoch, scopes, expiry}`; signed/fenced execution grant; stale/forged/cross-tenant grants denied and audited; no connector credential in handoffs | -| 2 | **Durable connector lease** | PostgreSQL-backed exclusive lease with CAS, monotonic epoch, TTL/heartbeat, explicit takeover, and gateway rejection of stale holders; connectors for Claude Code, Pi, and Codex | -| 3 | **Canonical handoff/checkpoint** | Versioned, sealed schema containing canonical mission/task/git references, checkpoint digest, causal sequence, required capabilities, pending/ambiguous operation references, and source/destination acknowledgement; no raw secrets or mandatory harness transcript | -| 4 | **Exactly-once connector journal** | Durable operation IDs and receipts; idempotency propagated through every adapter; Matrix transaction mapping; tmux replaced or wrapped with receiver-side durable dedupe; ambiguous effects remain held for authorized reconciliation | -| 5 | **Cross-harness failover and rollback E2E** | Real Mos identity moves Claude Code → Pi → Codex and back; inject crashes before/after lease transfer, handoff persistence, send, and acknowledgement; stale connector fenced; no duplicate side effects; canonical state preserved; rollback evidence published | -| 6 | **Generic gateway research ADR** | Evaluate LiteLLM subscription OAuth and Bifrost concepts without adding either to core; include terms/security review, credential lifecycle, tenant mapping, budgets, failover semantics, and adapter-only prototype | +| Order | Issue | Minimum acceptance criteria | +| ----- | ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | **Logical identity and security fencing** | Server-derived `{tenant, logicalAgentId, connectorId, harness, leaseEpoch, scopes, expiry}`; signed/fenced execution grant; stale/forged/cross-tenant grants denied and audited; no connector credential in handoffs | +| 2 | **Durable connector lease** | PostgreSQL-backed exclusive lease with CAS, monotonic epoch, TTL/heartbeat, explicit takeover, and gateway rejection of stale holders; connectors for Claude Code, Pi, and Codex | +| 3 | **Canonical handoff/checkpoint** | Versioned, sealed schema containing canonical mission/task/git references, checkpoint digest, causal sequence, required capabilities, pending/ambiguous operation references, and source/destination acknowledgement; no raw secrets or mandatory harness transcript | +| 4 | **Exactly-once connector journal** | Durable operation IDs and receipts; idempotency propagated through every adapter; Matrix transaction mapping; tmux replaced or wrapped with receiver-side durable dedupe; ambiguous effects remain held for authorized reconciliation | +| 5 | **Cross-harness failover and rollback E2E** | Real Mos identity moves Claude Code → Pi → Codex and back; inject crashes before/after lease transfer, handoff persistence, send, and acknowledgement; stale connector fenced; no duplicate side effects; canonical state preserved; rollback evidence published | +| 6 | **Generic gateway research ADR** | Evaluate LiteLLM subscription OAuth and Bifrost concepts without adding either to core; include terms/security review, credential lifecycle, tenant mapping, budgets, failover semantics, and adapter-only prototype | ## Generic gateway placement From c32d85a3376657c50328decdd3d7fee7c823796e Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Tue, 14 Jul 2026 19:53:12 +0000 Subject: [PATCH 045/152] docs(fleet): define declarative configuration M0 (#760) --- docs/PRD.md | 46 ++++++++++ docs/TASKS.md | 35 ++++++-- docs/fleet/FLEET-CONFIG-DOCS-IA-CHECKLIST.md | 86 +++++++++++++++++++ ...Y-EXAMPLE-PROFILE-DISPOSITION-INVENTORY.md | 56 ++++++++++++ 4 files changed, 218 insertions(+), 5 deletions(-) create mode 100644 docs/fleet/FLEET-CONFIG-DOCS-IA-CHECKLIST.md create mode 100644 docs/fleet/LEGACY-EXAMPLE-PROFILE-DISPOSITION-INVENTORY.md diff --git a/docs/PRD.md b/docs/PRD.md index 6589f52d..b0213926 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -79,6 +79,52 @@ Jarvis (v0.2.0) is a self-hosted AI assistant with a Python FastAPI backend and --- +## Fleet Declarative Configuration Management Workstream (FCM, #758) + +### Problem and objective + +The local Mosaic fleet has a roster, generated agent environment files, user-systemd units, tmux +sessions, heartbeat files, examples, profiles, and separate gateway-backed agent records. These +planes have drifted and are not one safe operator lifecycle. The objective is one **local fleet +roster** as the desired-state SSOT, with generated environment, systemd, tmux, and heartbeat +artifacts as rebuildable projections; it does not merge the local fleet control plane with the +gateway-backed agent catalog. + +### Normative requirements + +| ID | Requirement | +| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `FCM-REQ-01` | The roster SHALL be the sole writable desired-state source for local fleet membership, launch policy, and persisted lifecycle target. Generated environment files, systemd enablement, tmux sessions, and heartbeat state SHALL be non-authoritative projections. | +| `FCM-REQ-02` | The implementation SHALL provide one executable structural contract for YAML/JSON input and one shared semantic validator. Roster load, profile validation, provision, migration, and apply SHALL reuse the existing baseline-plus-`roles.local` profile/persona resolver; a parallel role resolver is forbidden. | +| `FCM-REQ-03` | The local fleet CLI SHALL expose documented programmatic validate, show, plan, apply/reconcile, create, inspect, update, delete, start, stop, restart, status, verify, and doctor operations with stable JSON and exit-code behavior. Existing `fleet add/remove` compatibility aliases may remain during the stated deprecation window. | +| `FCM-REQ-04` | A fresh create SHALL persist `enabled:true` and `desired_state:stopped` unless an explicit persisted start is requested. The model SHALL distinguish enabled state, persisted desired state, and observed state. Migration, apply, reboot, and rollback SHALL not start an agent that was observed stopped before cutover. | +| `FCM-REQ-05` | The launch chain SHALL consume deterministic, digest-stamped generated input only. Optional local overrides SHALL be parsed as strict data, may not shadow authoritative generated keys, and may not contain arbitrary commands, credential values, channels, or unknown `MOSAIC_AGENT_*` keys. Forbidden legacy keys, including `MOSAIC_AGENT_COMMAND`, SHALL be privately quarantined before launch and reported only by key name and content hash. | +| `FCM-REQ-06` | Mutations and apply SHALL validate before mutation, use an expected generation/lock, write projections atomically, produce a deterministic plan, and emit recovery information on partial failure. Reconciliation SHALL act only on local, enabled, roster-owned projections and SHALL not kill unmanaged tmux sessions by fuzzy name. | +| `FCM-REQ-07` | Canonical required classes are `code`, `review`, `validator`, `orchestrator`, `team-leader`, `enhancer`, and `interaction`. `validator` issues an independent final certificate but has no merge authority; `merge-gate` remains sole approve-to-land/merge authority. Team-leader capacity is bounded by an orchestrator-issued lease, and interaction is request/status only. Tess and Ultron are configurable instance/display names, not required machine identities. | +| `FCM-REQ-08` | v1 migration SHALL be field-complete, reversible, and explicit about aliases, unresolved classes, lifecycle inference, generated-file regeneration, local override quarantine, schema-only remote/connector fields, and rollback. Every shipped example, profile, and service preset SHALL be migrated and executable, retained as an explicitly versioned v1 fixture, or retired with a replacement and deprecation note. | +| `FCM-REQ-09` | M1–M5 SHALL remain local tmux/systemd control-plane work. Remote/SSH reconciliation, connector mutation, secret references, arbitrary command/channel overrides, gateway/API convergence, and UI configuration storage are excluded and require a separate PRD/threat model. | +| `FCM-REQ-10` | Documentation and examples are delivery gates. The M0 checklist at [docs/fleet/FLEET-CONFIG-DOCS-IA-CHECKLIST.md](./fleet/FLEET-CONFIG-DOCS-IA-CHECKLIST.md) and the baseline disposition inventory at [docs/fleet/LEGACY-EXAMPLE-PROFILE-DISPOSITION-INVENTORY.md](./fleet/LEGACY-EXAMPLE-PROFILE-DISPOSITION-INVENTORY.md) SHALL be maintained as acceptance evidence. | + +### Acceptance criteria + +1. `AC-FCM-01`: A valid local v2 roster can be parsed from YAML or JSON, validated structurally and semantically through the shared resolver, and rendered canonically; invalid fields, duplicate names, unresolved classes, unsupported runtime/model combinations, socket ambiguity, and incompatible options fail closed. +2. `AC-FCM-02`: `plan` reports deterministic desired-versus-observed differences for roster, generated environment, systemd enablement, tmux/session, heartbeat, installed-asset revision, and provable orphans without mutation; `apply --check` reports drift without mutation. +3. `AC-FCM-03`: Local create/update/delete is generation-guarded, atomic, idempotent, and safe by default; it permits supported runtime/model/harness/effort/workdir/role changes without direct editing of generated environment files and does not start a newly created agent unless explicitly persisted. +4. `AC-FCM-04`: The generated-env/local-override launch chain rejects generated-key shadowing, arbitrary command override, unknown keys, shell evaluation, and sensitive-value diagnostics before any agent starts; known-safe legacy input is regenerated or strictly relocated, and forbidden input is quarantined. +5. `AC-FCM-05`: Local lifecycle reconciliation implements the persisted/transient start-stop rules, exact default/named tmux socket targeting, systemd/tmux status, stale generated state, unmanaged-session reporting, and rollback without surprise restarts or fuzzy destructive targeting. +6. `AC-FCM-06`: A v1 roster migration previews field-by-field disposition, preserves observed stopped/running state, inventories rather than reconciles remote/schema-only entries, supports a canary and rollback, and classifies every shipped example, profile, and service preset according to the M0 inventory. +7. `AC-FCM-07`: Required role authority is validated: validator certificate is consumed but does not merge, merge-gate is the sole merge authority, team-leader leases do not change roster/credentials/authority, and interaction/Tess cannot claim orchestration or merge powers. +8. `AC-FCM-08`: Documentation, examples, migration, troubleshooting, operational recovery, package/update asset drift, schema/example/profile validation, independent code/security review, validator certificate, and terminal-green CI are complete before #758 closes. + +### M0 implementation gate + +No source, schema, role, example, profile, systemd, or live-fleet change is authorized before M0 +lands. M0 consists only of these normative requirements, the complete task DAG, the scoped +documentation IA checklist, and the legacy example/profile disposition inventory. Subsequent cards +are defined in [docs/TASKS.md](./TASKS.md) and must remain one card/one PR. + +--- + ## Tess Interaction Agent Workstream (TESS) ### Problem and Objective diff --git a/docs/TASKS.md b/docs/TASKS.md index 4a4e7b8a..40973731 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -14,11 +14,12 @@ ## Workstream Rollup -| id | status | workstream | progress | tasks file | notes | -| --- | ----------------- | ---------------------- | ---------------- | --------------------------------------------------------------- | -------------------------------------------------------------------------------- | -| W1 | planning-complete | Federation v1 (FED) | 0 / 7 milestones | [docs/federation/TASKS.md](./federation/TASKS.md) | M1 task breakdown populated; M2–M7 deferred to mission planning | -| W2 | planning-complete | Tess interaction agent | 0 / 5 milestones | [docs/tess/TASKS.md](./tess/TASKS.md) | Issue #706; independent planning gate PASS; M1 issue #707 ready | -| W3 | planning-complete | Native Kanban/SOT | 0 / 4 phases | [docs/native-kanban-sot/TASKS.md](./native-kanban-sot/TASKS.md) | Issue #751; canon independently approved; implementation held until canon merges | +| id | status | workstream | progress | tasks file | notes | +| --- | ----------------- | ------------------------------ | ---------------- | --------------------------------------------------------------- | --------------------------------------------------------------------------------- | +| W1 | planning-complete | Federation v1 (FED) | 0 / 7 milestones | [docs/federation/TASKS.md](./federation/TASKS.md) | M1 task breakdown populated; M2–M7 deferred to mission planning | +| W2 | planning-complete | Tess interaction agent | 0 / 5 milestones | [docs/tess/TASKS.md](./tess/TASKS.md) | Issue #706; independent planning gate PASS; M1 issue #707 ready | +| W3 | planning-complete | Native Kanban/SOT | 0 / 4 phases | [docs/native-kanban-sot/TASKS.md](./native-kanban-sot/TASKS.md) | Issue #751; canon independently approved; implementation held until canon merges | +| W4 | planning-complete | Fleet configuration management | 0 / 12 cards | This file (§ Fleet configuration management #758) | Issue #758; M0 docs gate defines the implementation DAG before any fleet mutation | ## Cross-Cutting Tracking @@ -42,6 +43,30 @@ Active workstream is **W1 — Federation v1**. Workers should: 2. Read [docs/federation/TASKS.md](./federation/TASKS.md) for the next pending task 3. Follow per-task agent + tier guidance from the workstream manifest +## Fleet configuration management (#758) — M0–M5 implementation DAG + +> **PRD:** [Fleet declarative configuration management](./PRD.md#fleet-declarative-configuration-management-workstream-fcm-758) · **M0 acceptance:** [docs IA checklist](./fleet/FLEET-CONFIG-DOCS-IA-CHECKLIST.md) · **baseline dispositions:** [legacy example/profile inventory](./fleet/LEGACY-EXAMPLE-PROFILE-DISPOSITION-INVENTORY.md) +> +> Every row below is one independently reviewable card and **one PR**. `depends_on` is a +> hard DAG edge; no card may silently absorb another card's scope. All source cards require +> the repository quality gates, independent code and security review, terminal-green CI, and +> the applicable acceptance evidence before merge. Issue #758 remains open until M5 closes. + +| id | status | description | issue | agent | repo | branch | depends_on | estimate | notes | +| ---------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ------ | ----------------- | --------------------------------------- | ---------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------- | +| FCM-M0-001 | in-progress | Publish normative PRD requirements/acceptance criteria, this M0–M5 DAG, docs-IA checklist, and legacy example/profile disposition inventory; no implementation changes | #758 | sonnet | mosaicstack/stack | `docs/758-fleet-config-management` | — | 18K | M0 exit: approved docs; every shipped example/profile/service preset classified; docs-only PR | +| FCM-M1-001 | not-started | Implement narrow local-tmux v2 roster structural contract/compiler with YAML/JSON canonicalization and schema/parser parity tests | #758 | codex | mosaicstack/stack | `feat/758-roster-v2-compiler` | FCM-M0-001 | 30K | No lifecycle, remote, connector, secret, channel, or gateway work | +| FCM-M1-002 | not-started | Reuse existing profile/persona/provision resolver for roster semantics; add canonical class/authority validation and approved aliases | #758 | codex | mosaicstack/stack | `feat/758-shared-role-resolution` | FCM-M0-001 | 25K | Validator is certificate-only; merge-gate remains sole merge authority | +| FCM-M1-003 | not-started | Convert the M0 legacy inventory into executable example/profile/service-preset validation and explicit v1-version/retirement checks | #758 | codex | mosaicstack/stack | `test/758-example-profile-dispositions` | FCM-M1-001, FCM-M1-002 | 20K | Every shipped artifact must validate, be versioned v1, or be retired with replacement | +| FCM-M2-001 | not-started | Migrate generic launch chain to deterministic `.env.generated` plus strict data-only `.env.local`; quarantine forbidden legacy keys | #758 | codex | mosaicstack/stack | `feat/758-generated-env-boundary` | FCM-M1-001, FCM-M1-002 | 30K | No arbitrary command compatibility path; diagnostics expose key names/hashes only | +| FCM-M2-002 | not-started | Add generation-guarded local fleet agent create/get/update/delete mutations with plan/dry-run, atomic roster writes, and recovery output | #758 | codex | mosaicstack/stack | `feat/758-fleet-agent-crud` | FCM-M1-001, FCM-M2-001 | 30K | Fresh create persists stopped unless explicit persisted start | +| FCM-M3-001 | not-started | Implement local roster-owned reconcile/apply plus lifecycle/status/verify/doctor contracts and stable JSON/exit codes | #758 | codex | mosaicstack/stack | `feat/758-local-reconciler` | FCM-M2-001, FCM-M2-002 | 35K | Exact systemd/tmux ownership; remote/schema-only entries are inventory only | +| FCM-M3-002 | not-started | Add isolated systemd/tmux lifecycle, drift, socket, unmanaged-session, crash, and rollback acceptance coverage | #758 | sonnet | mosaicstack/stack | `test/758-reconciler-lifecycle-gates` | FCM-M3-001 | 25K | Proves stopped-state preservation and zero fuzzy destructive targeting | +| FCM-M4-001 | not-started | Implement field-complete v1-to-v2 inventory/preview/migrator with alias, lifecycle, env-quarantine, and remote/connector disposition evidence | #758 | codex | mosaicstack/stack | `feat/758-v1-v2-migrator` | FCM-M1-003, FCM-M3-001 | 35K | Preview first; no unreviewed lifecycle inference | +| FCM-M4-002 | not-started | Add reversible canary migration, rollback, stale-projection/orphan classification, and current-host 9-managed/3-unmanaged fixture coverage | #758 | sonnet | mosaicstack/stack | `test/758-migration-rollback-gates` | FCM-M4-001, FCM-M3-002 | 25K | Never starts a previously stopped agent or kills an unproven unmanaged session | +| FCM-M5-001 | not-started | Deliver the accepted fleet documentation IA, how-to/operations/migration references, and link/example validation | #758 | haiku | mosaicstack/stack | `docs/758-fleet-config-operator-docs` | FCM-M1-003, FCM-M2-002, FCM-M3-001, FCM-M4-001 | 24K | Must close every checklist item or record an approved deferral | +| FCM-M5-002 | not-started | Package/update asset-drift checks, rolling local canary, independent validation certificate, and release evidence | #758 | sonnet | mosaicstack/stack | `feat/758-fleet-config-release-gate` | FCM-M3-002, FCM-M4-002, FCM-M5-001 | 30K | Final #758 gate: quality, independent code/security review, validator certificate, merge-gate approval, green CI | + ## Thin-core prompt diet (#528) — feat/contract-thin-core - Status: PR open, awaiting maintainer merge ratification (fleet-governing change). diff --git a/docs/fleet/FLEET-CONFIG-DOCS-IA-CHECKLIST.md b/docs/fleet/FLEET-CONFIG-DOCS-IA-CHECKLIST.md new file mode 100644 index 00000000..b6f9afcf --- /dev/null +++ b/docs/fleet/FLEET-CONFIG-DOCS-IA-CHECKLIST.md @@ -0,0 +1,86 @@ +# Fleet Configuration Management — Documentation IA Acceptance Checklist + +**Issue:** #758 · **Scope:** M0 documentation gate for the local fleet declarative-configuration program. + +This checklist is an acceptance contract for documentation and examples. It does not authorize +schema, runtime, systemd, role, profile, or live-fleet changes. An item is complete only when its +named artifact exists, is linked from the fleet documentation entry point, and its evidence is +recorded in the M0 task/PR. + +## M0 baseline acceptance + +- [ ] `docs/PRD.md` states the roster as desired-state SSOT; generated environment, systemd, + tmux, and heartbeat artifacts as non-authoritative projections; and fail-closed handling of + unsupported or quarantined legacy input. +- [ ] `docs/PRD.md` defines the required classes and authority boundary: `validator` certifies but + does not merge; `merge-gate` remains sole approve-to-land/merge authority; `team-leader` + capacity is lease-bounded; `interaction` is request/status only; instance names such as Tess + and Ultron remain configurable. +- [ ] `docs/PRD.md` defines local lifecycle semantics for `enabled`, persisted desired state, and + observed state, including stopped-state preservation through migration, apply, and reboot. +- [ ] `docs/PRD.md` defines the generated-env/local-override boundary, explicitly denies arbitrary + command overrides in M1–M5, and requires key-name/hash-only quarantine diagnostics. +- [ ] `docs/PRD.md` identifies the M1–M5 local-tmux scope and excludes remote reconciliation, + connector mutation, secret references, arbitrary commands/channels, gateway convergence, and + UI configuration storage. +- [ ] `docs/TASKS.md` contains the complete M0–M5 one-card/one-PR dependency DAG for #758 with + agent tier, branch, dependency, estimate, and evidence expectations. +- [ ] `docs/fleet/LEGACY-EXAMPLE-PROFILE-DISPOSITION-INVENTORY.md` classifies every current shipped + fleet example, profile, and service preset before M1 implementation starts. + +## Required documentation IA for M1–M5 + +| Path | Minimum content | Delivery gate | +| ------------------------------------------------------- | ---------------------------------------------------------------------------- | ------------- | +| `docs/fleet/README.md` | Fleet configuration entry point, desired-vs-observed decision tree, link map | M5 | +| `docs/fleet/concepts/desired-vs-observed-state.md` | SSOT/projection model, drift, generation and ownership | M5 | +| `docs/fleet/concepts/identity-class-runtime.md` | Stable name, display alias, class, runtime/provider/model separation | M5 | +| `docs/fleet/concepts/role-authority-and-leases.md` | Required roles, validator/merge-gate separation, lease limits | M5 | +| `docs/fleet/concepts/generated-env-launch-chain.md` | Generated/local files, precedence, quarantine and non-shell parsing | M5 | +| `docs/fleet/reference/roster-v2.schema.json` | Executable v2 structural contract | M1 | +| `docs/fleet/reference/roster-v2-fields.md` | Every field, default, constraint, compatibility behavior and examples | M1 | +| `docs/fleet/reference/cli.md` | `config`, `agent`, lifecycle, plan/apply, JSON and exit-code contracts | M2–M3 | +| `docs/fleet/reference/role-classes.md` | Canonical classes, aliases, authority matrix and instance-name rule | M1 | +| `docs/fleet/reference/lifecycle-transitions.md` | Create/start/stop/restart/apply/reboot/rollback transition table | M3 | +| `docs/fleet/reference/status-and-drift.md` | Desired/observed/managed state, orphans, revision mismatch, doctor output | M3 | +| `docs/fleet/how-to/create-update-delete-agent.md` | Safe CRUD, expected generation, dry-run and rollback | M2 | +| `docs/fleet/how-to/start-stop-restart.md` | Persisted versus one-shot lifecycle actions | M3 | +| `docs/fleet/how-to/configure-tess-interaction.md` | Configurable interaction instance; no hardcoded identity | M5 | +| `docs/fleet/how-to/configure-ultron-validator.md` | Configurable validator instance; no merge authority | M5 | +| `docs/fleet/how-to/customize-roles.md` | Existing baseline + `roles.local` resolution and validation | M1 | +| `docs/fleet/operations/reconcile-and-recover.md` | Plan/apply failure recovery, generation lock and canary rollout | M3 | +| `docs/fleet/operations/env-quarantine.md` | Legacy-key inventory, private quarantine and redaction behavior | M2 | +| `docs/fleet/operations/systemd-tmux-troubleshooting.md` | Socket ambiguity, ownership proof, systemd/tmux drift | M3 | +| `docs/fleet/operations/backup-restore.md` | Roster/projection backup and rollback boundaries | M4 | +| `docs/fleet/operations/upgrade-assets.md` | Source-vs-installed asset revision detection and safe refresh | M5 | +| `docs/fleet/migration/v1-to-v2.md` | Normative field map, observed-state preservation and rollback | M4 | +| `docs/fleet/migration/example-profile-disposition.md` | Final disposition of every shipped example/profile | M1–M4 | +| `docs/fleet/migration/legacy-class-aliases.md` | Alias, unresolved-class, and retirement rules | M1 | + +## PRD acceptance-criteria mapping + +| PRD acceptance criterion | Owning card(s) | Required evidence | +| ------------------------------------------------------------ | ---------------------------------- | --------------------------------------------------------------------------------------- | +| `AC-FCM-01` schema, semantic validation, canonical rendering | FCM-M1-001, FCM-M1-002 | YAML/JSON positive/negative and schema/parser/resolver parity tests | +| `AC-FCM-02` deterministic plan and no-mutation check | FCM-M3-001 | Stable JSON/exit-code and desired-versus-observed fixture tests | +| `AC-FCM-03` safe generation-guarded CRUD | FCM-M2-002 | Create/update/delete idempotency, expected-generation, dry-run, and recovery tests | +| `AC-FCM-04` generated/local boundary and quarantine | FCM-M2-001 | Launch-chain, shadow, injection, redaction, and forbidden-key tests | +| `AC-FCM-05` lifecycle/reconcile/socket/drift safety | FCM-M3-001, FCM-M3-002 | Isolated systemd/tmux, stopped-state, orphan, socket, and rollback evidence | +| `AC-FCM-06` v1 migration and example/profile disposition | FCM-M4-001, FCM-M4-002, FCM-M1-003 | Preview/canary/rollback fixture plus executable disposition inventory | +| `AC-FCM-07` authority and lease boundaries | FCM-M1-002 | Role/authority/lease denial tests and resolved role contracts | +| `AC-FCM-08` documentation and final release gate | FCM-M5-001, FCM-M5-002 | Checklist closure, link/example validation, reviews, certificate, and terminal-green CI | + +## Cross-cutting evidence gates + +- [ ] Every retained or migrated YAML/JSON example, profile, and service preset validates through the + same executable schema and shared baseline-plus-`roles.local` resolver used by the CLI. +- [ ] Every retired example/profile/service preset has a replacement link and deprecation note; no + unresolved legacy class or tool-policy alias remains silently shipped. +- [ ] Documentation examples contain no secret values, arbitrary command override, or product-hardcoded + Tess/Ultron identity. +- [ ] CLI snippets distinguish local fleet desired-state commands from the separate gateway-backed + `mosaic agent` catalog. +- [ ] Migration, quarantine, lifecycle, status, and troubleshooting documentation state that values of + legacy sensitive keys are never printed. +- [ ] M5 release review verifies links, schema/example validation, and that all checklist rows have + owner/evidence or an explicit approved deferral. diff --git a/docs/fleet/LEGACY-EXAMPLE-PROFILE-DISPOSITION-INVENTORY.md b/docs/fleet/LEGACY-EXAMPLE-PROFILE-DISPOSITION-INVENTORY.md new file mode 100644 index 00000000..318b4e9a --- /dev/null +++ b/docs/fleet/LEGACY-EXAMPLE-PROFILE-DISPOSITION-INVENTORY.md @@ -0,0 +1,56 @@ +# Fleet Configuration Management — Legacy Example, Profile, and Service Disposition Inventory + +**Issue:** #758 · **Baseline:** `origin/main` `49e8a541` · **Status:** M0 inventory; no source +examples or profiles are changed by this document. + +The v2 compiler may not silently accept an unresolved class. Before M1 exits, every shipped file +below must be either migrated and executable, retained as an explicitly versioned v1 fixture, or +retired with a replacement/deprecation note. Class resolution must use the existing +profile/persona/provision baseline-plus-`roles.local` resolver; this inventory does not create a +parallel resolver. + +## Examples + +| Shipped file | Current class evidence | M0 disposition decision | Required M1/M4 evidence | +| ---------------------------------------------------- | ------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | +| `framework/fleet/examples/coding.yaml` | `orchestrator`, `enhancer`, `implementer`, `reviewer` | Migrate: `implementer → code`, `reviewer → review`; retain orchestration/enhancer intent | v2 fixture validates; role aliases and authority matrix tested | +| `framework/fleet/examples/general.yaml` | `orchestrator`, `enhancer`, `worker` | Migrate only after operator chooses a concrete canonical role for `worker`; no implicit conversion | Explicit replacement class, or versioned v1 fixture/retirement note | +| `framework/fleet/examples/hybrid.yaml` | `orchestrator`, `enhancer`, `implementer`, `researcher`, `reviewer` | Migrate aliases; resolve `researcher` through existing role resolver or retain/version | Shared resolver validation; no ad-hoc class scanner | +| `framework/fleet/examples/local-canary.yaml` | `orchestrator`, `implementer`, `reviewer` | Migrate aliases; preserve its local-tmux canary purpose | v2 fixture validates and preserves safe stopped/running behavior | +| `framework/fleet/examples/minimal.yaml` | `canary` | Retire or version as v1 unless an existing canonical role contract is selected deliberately | Replacement link/deprecation note or CI-valid v1 fixture | +| `framework/fleet/examples/operator-interaction.yaml` | `operator-interaction` | Migrate alias to `interaction`; preserve instance/display name as configuration, not schema identity | v2 interaction fixture validates; no Tess literal is required | +| `framework/fleet/examples/research.yaml` | `orchestrator`, `enhancer`, `researcher`, `analyst` | Resolve `researcher`/`analyst` through baseline + `roles.local`, or version/retire | Resolver evidence and explicit disposition for each unresolved class | + +## Profiles + +| Shipped file | Current class evidence | M0 disposition decision | Required M1/M4 evidence | +| -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------- | +| `framework/fleet/profiles/business.yaml` | `ceo`, `coo`, `cfo`, `product-manager`, `marketing-lead`, `sales-lead`, `operations-manager`, `customer-success-manager`, `code`, `review` | Retain only if every class resolves through the existing role library/`roles.local`; otherwise version/retire rather than weakening validation | Shared resolver CI result for every class; documented role source or replacement | +| `framework/fleet/profiles/marketing.yaml` | `marketing-lead`, `content-strategist`, `copywriter`, `seo-specialist`, `social-media-manager`, `brand-strategist`, `growth-marketer`, `ux-designer` | Same resolver-or-version/retire rule | Per-class resolver CI result and replacement/deprecation record if unresolved | +| `framework/fleet/profiles/personal-assistant.yaml` | `personal-assistant`, `executive-assistant`, `scheduler`, `inbox-manager`, `researcher` | Same resolver-or-version/retire rule | Per-class resolver CI result; do not infer `interaction` equivalence | +| `framework/fleet/profiles/research.yaml` | `lead-researcher`, `researcher`, `data-analyst`, `data-scientist`, `market-analyst`, `documentation`, `review` | Same resolver-or-version/retire rule | Per-class resolver CI result and explicit compatibility posture | +| `framework/fleet/profiles/software-delivery.yaml` | `orchestrator`, `board`, `planner`, `decomposition`, `code`, `review`, `security-review`, `site-tester`, `documentation`, `merge-gate`, `rebase`, `operator`, `session-review`, `enhancer` | Retain as the governance reference; add `validator`, `team-leader`, and `interaction` only through approved role/profile work, not silent substitution | CI validates all current classes; separate fixture proves required M1 authority seats | + +## Service presets + +| Shipped file | Current policy evidence | M0 disposition decision | Required M1/M4 evidence | +| ---------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `framework/fleet/services/operator-interaction.yaml` | Generic policy only: `runtime: pi`, `model: openai/gpt-5.6-sol`, `reasoning: high`, `tool_policy: operator-interaction`; provisioning supplies the agent name as data | Retain as a generic service policy, not a Tess identity. Migrate `tool_policy: operator-interaction` only through the approved interaction tool-policy alias/semantic resolver; do not infer a class or machine name from this file. | Service-policy fixture validates runtime/model/reasoning and alias behavior; generic provisioning proves a configured interaction instance is supplied without a hardcoded Tess name. | + +## Required disposition controls + +1. **No silent aliasing:** only `implementer → code`, `reviewer → review`, and + `operator-interaction → interaction` are approved deterministic aliases in this M0 baseline. + `worker`, `analyst`, `canary`, and domain-specific classes require resolver evidence or an + explicit version/retirement decision. +2. **No identity hardcoding:** Tess and Ultron are optional instance/display names. An example/profile + may demonstrate the capability but must not make a product name a required class or machine ID. +3. **No lifecycle inference from an example:** examples describe desired configuration only; migration + of an installed v1 roster separately preserves observed stopped/running state. +4. **No secret or command migration:** examples/profiles must not introduce credential values or + `MOSAIC_AGENT_COMMAND`; those legacy keys are M2 quarantine inputs, never v2 authoring fields. +5. **Service presets are included:** service policies are inventoried alongside examples/profiles. + They may express launch/tool policy, but do not create a class, a canonical agent identity, or a + second validation path. +6. **Evidence is executable:** M1/M4 CI must enumerate these exact files, validate retained/migrated + inputs through the shared resolver, and fail if a file lacks its documented disposition. From ba13c0889021927098749f606b4eb28997edd457 Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Tue, 14 Jul 2026 20:26:15 +0000 Subject: [PATCH 046/152] Fixes #756 (#763) --- .../tess-cross-surface.integration.test.ts | 2 + .../__tests__/agent-service-ownership.test.ts | 12 + apps/gateway/src/agent/agent.service.ts | 42 +- apps/gateway/src/chat/chat.dto.ts | 4 + .../src/chat/chat.gateway-redaction.spec.ts | 55 +- apps/gateway/src/chat/chat.gateway.ts | 309 ++++-- .../plugin/discord-ingress.security.spec.ts | 340 ++++++- apps/gateway/src/plugin/plugin.module.ts | 14 + docs/PRD.md | 85 +- docs/SITEMAP.md | 8 + docs/architecture/channel-protocol.md | 304 +++--- docs/guides/admin-guide.md | 66 +- docs/reports/code-review/756-code-review.md | 27 + .../756-discord-plugin-checklist.md | 36 + docs/reports/security/756-security-review.md | 37 + .../756-official-discord-plugin.md | 57 ++ docs/tess/PLUGIN-GUIDE.md | 25 + docs/tess/USER-GUIDE.md | 8 + eslint.config.mjs | 1 + packages/types/src/channel/channel-adapter.ts | 43 + packages/types/src/channel/channel.dto.ts | 97 ++ packages/types/src/channel/index.ts | 2 + packages/types/src/chat/events.ts | 2 + packages/types/src/index.ts | 1 + plugins/discord/README.md | 67 ++ plugins/discord/package.json | 5 +- plugins/discord/src/index.test.ts | 950 ++++++++++++++++++ plugins/discord/src/index.ts | 629 ++++++++++-- plugins/discord/vitest.config.ts | 10 + pnpm-lock.yaml | 41 +- 30 files changed, 2914 insertions(+), 365 deletions(-) create mode 100644 docs/reports/code-review/756-code-review.md create mode 100644 docs/reports/documentation/756-discord-plugin-checklist.md create mode 100644 docs/reports/security/756-security-review.md create mode 100644 docs/scratchpads/756-official-discord-plugin.md create mode 100644 packages/types/src/channel/channel-adapter.ts create mode 100644 packages/types/src/channel/channel.dto.ts create mode 100644 packages/types/src/channel/index.ts create mode 100644 plugins/discord/README.md create mode 100644 plugins/discord/src/index.test.ts diff --git a/apps/gateway/src/__tests__/integration/tess-cross-surface.integration.test.ts b/apps/gateway/src/__tests__/integration/tess-cross-surface.integration.test.ts index fc8b6668..22abe32c 100644 --- a/apps/gateway/src/__tests__/integration/tess-cross-surface.integration.test.ts +++ b/apps/gateway/src/__tests__/integration/tess-cross-surface.integration.test.ts @@ -72,6 +72,7 @@ describe('interaction Discord/CLI durable-session integration', () => { process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([ { instanceId: 'Nova', + agentConfigId: 'agent-config-nova', guildId: 'guild-1', channelId: 'channel-1', pairedUsers: { @@ -133,6 +134,7 @@ describe('interaction Discord/CLI durable-session integration', () => { interactionBindings: [ { instanceId: 'Nova', + agentConfigId: 'agent-config-nova', guildId: 'guild-1', channelId: 'channel-1', pairedUsers: { diff --git a/apps/gateway/src/agent/__tests__/agent-service-ownership.test.ts b/apps/gateway/src/agent/__tests__/agent-service-ownership.test.ts index 83e03684..dc6b9bfd 100644 --- a/apps/gateway/src/agent/__tests__/agent-service-ownership.test.ts +++ b/apps/gateway/src/agent/__tests__/agent-service-ownership.test.ts @@ -115,6 +115,18 @@ describe('AgentService owner/tenant scope enforcement', () => { ).rejects.toBeInstanceOf(ForbiddenException); await service.prompt(CONVERSATION_ID, 'owner prompt', OWNER_SCOPE); expect(session.piSession.prompt).toHaveBeenCalledWith('owner prompt'); + await service.prompt(CONVERSATION_ID, '', OWNER_SCOPE, [ + { + id: 'attachment-001', + name: 'diagram.png', + url: 'https://cdn.example.test/diagram.png', + mimeType: 'image/png', + }, + ]); + expect(session.piSession.prompt).toHaveBeenLastCalledWith( + '\n\n[Untrusted channel attachments]\n' + + '{"id":"attachment-001","name":"diagram.png","mimeType":"image/png","url":"https://cdn.example.test/diagram.png"}', + ); await expect(service.destroySession(CONVERSATION_ID, FOREIGN_SCOPE)).rejects.toBeInstanceOf( ForbiddenException, diff --git a/apps/gateway/src/agent/agent.service.ts b/apps/gateway/src/agent/agent.service.ts index 4ac42d57..38192969 100644 --- a/apps/gateway/src/agent/agent.service.ts +++ b/apps/gateway/src/agent/agent.service.ts @@ -15,6 +15,7 @@ import { type ToolDefinition, } from '@mariozechner/pi-coding-agent'; import type { Brain } from '@mosaicstack/brain'; +import type { ChannelAttachmentDto } from '@mosaicstack/types'; import type { Memory, OperatorMemoryPlugin } from '@mosaicstack/memory'; import { BRAIN } from '../brain/brain.tokens.js'; import { MEMORY } from '../memory/memory.tokens.js'; @@ -43,6 +44,8 @@ export interface ConversationHistoryMessage { role: 'user' | 'assistant' | 'system'; content: string; createdAt: Date; + /** Validated, URI-referenced channel attachments preserved on session resume. */ + attachments?: readonly ChannelAttachmentDto[]; } export interface AgentSessionOptions { @@ -428,7 +431,7 @@ export class AgentService implements OnModuleDestroy { const formatMessage = (msg: ConversationHistoryMessage): string => { const roleLabel = msg.role === 'user' ? 'User' : msg.role === 'assistant' ? 'Assistant' : 'System'; - return `**${roleLabel}:** ${msg.content}`; + return `**${roleLabel}:** ${msg.content}${this.attachmentContext(msg.attachments ?? [])}`; }; const formatted = history.map((msg) => formatMessage(msg)); @@ -487,6 +490,21 @@ export class AgentService implements OnModuleDestroy { return result; } + private attachmentContext(attachments: readonly ChannelAttachmentDto[]): string { + if (attachments.length === 0) return ''; + return `\n\n[Untrusted channel attachments]\n${attachments + .map((attachment: ChannelAttachmentDto): string => + JSON.stringify({ + id: attachment.id, + name: attachment.name, + mimeType: attachment.mimeType, + url: attachment.url, + ...(attachment.sizeBytes !== undefined ? { sizeBytes: attachment.sizeBytes } : {}), + }), + ) + .join('\n')}`; + } + private resolveModel(options?: AgentSessionOptions) { if (!options?.provider && !options?.modelId) { return this.providerService.getDefaultModel() ?? null; @@ -673,7 +691,19 @@ export class AgentService implements OnModuleDestroy { session.channels.delete(channel); } - async prompt(sessionId: string, message: string, scope: ActorTenantScope): Promise { + async prompt(sessionId: string, message: string, scope: ActorTenantScope): Promise; + async prompt( + sessionId: string, + message: string, + scope: ActorTenantScope, + attachments: readonly ChannelAttachmentDto[] | undefined, + ): Promise; + async prompt( + sessionId: string, + message: string, + scope: ActorTenantScope, + attachments: readonly ChannelAttachmentDto[] = [], + ): Promise { const session = this.sessions.get(sessionId); if (!session) { throw new Error(`No agent session found: ${sessionId}`); @@ -681,12 +711,16 @@ export class AgentService implements OnModuleDestroy { this.assertSessionScope(session, scope); session.promptCount += 1; + // Channel attachments are untrusted URI references. Preserve exact, + // authenticated metadata for the agent without treating it as authority. + const attachmentContext = this.attachmentContext(attachments); + // Prepend session-scoped system override if present (renew TTL on each turn) - let effectiveMessage = message; + let effectiveMessage = `${message}${attachmentContext}`; if (this.systemOverride) { const override = await this.systemOverride.get(sessionId, scope); if (override) { - effectiveMessage = `[System Override]\n${override}\n\n${message}`; + effectiveMessage = `[System Override]\n${override}\n\n${effectiveMessage}`; await this.systemOverride.renew(sessionId, scope); this.logger.debug(`Applied system override for session ${sessionId}`); } diff --git a/apps/gateway/src/chat/chat.dto.ts b/apps/gateway/src/chat/chat.dto.ts index 8e90297b..9bd35867 100644 --- a/apps/gateway/src/chat/chat.dto.ts +++ b/apps/gateway/src/chat/chat.dto.ts @@ -1,3 +1,4 @@ +import type { ChannelAttachmentDto } from '@mosaicstack/types'; import { IsOptional, IsString, IsUUID, MaxLength } from 'class-validator'; export class ChatRequestDto { @@ -32,4 +33,7 @@ export class ChatSocketMessageDto { @IsOptional() @IsUUID() agentId?: string; + + /** Validated channel attachment references; binary content is not embedded. */ + attachments?: readonly ChannelAttachmentDto[]; } diff --git a/apps/gateway/src/chat/chat.gateway-redaction.spec.ts b/apps/gateway/src/chat/chat.gateway-redaction.spec.ts index 23c70607..f43dce34 100644 --- a/apps/gateway/src/chat/chat.gateway-redaction.spec.ts +++ b/apps/gateway/src/chat/chat.gateway-redaction.spec.ts @@ -4,6 +4,10 @@ import { ChatGateway } from './chat.gateway.js'; const CONVERSATION_ID = 'conversation-1'; const CANARY = 'sk_canary12345678'; +function clientConversationKey(clientId: string, conversationId: string): string { + return `${clientId}\u0000${conversationId}`; +} + type GatewayInternals = { clientSessions: Map; relayEvent(client: unknown, conversationId: string, event: unknown): void; @@ -40,6 +44,7 @@ describe('ChatGateway redaction boundary', (): void => { emit: vi.fn(), }; const session = { + clientId: client.id, conversationId: CONVERSATION_ID, cleanup: vi.fn(), assistantText: '', @@ -47,7 +52,7 @@ describe('ChatGateway redaction boundary', (): void => { pendingToolCalls: new Map(), scope: { userId: 'user-1', tenantId: 'tenant-1' }, }; - gateway.clientSessions.set(client.id, session); + gateway.clientSessions.set(clientConversationKey(client.id, CONVERSATION_ID), session); gateway.relayEvent(client, CONVERSATION_ID, { type: 'message_update', @@ -139,6 +144,51 @@ describe('ChatGateway redaction boundary', (): void => { }); }); + it('isolates concurrent conversation streams sharing one Discord socket', (): void => { + const { gateway } = buildGateway(); + const client = { + connected: true, + id: 'discord-client', + data: { user: { id: 'user-1' } }, + emit: vi.fn(), + }; + const firstConversation = 'Nova:discord:thread-1'; + const secondConversation = 'Nova:discord:thread-2'; + const createSession = (conversationId: string) => ({ + clientId: client.id, + conversationId, + cleanup: vi.fn(), + assistantText: '', + toolCalls: [], + pendingToolCalls: new Map(), + scope: { userId: 'user-1', tenantId: 'tenant-1' }, + }); + const firstSession = createSession(firstConversation); + const secondSession = createSession(secondConversation); + gateway.clientSessions.set(clientConversationKey(client.id, firstConversation), firstSession); + gateway.clientSessions.set(clientConversationKey(client.id, secondConversation), secondSession); + + gateway.relayEvent(client, firstConversation, { + type: 'message_update', + assistantMessageEvent: { type: 'text_delta', delta: 'first response ' }, + }); + gateway.relayEvent(client, secondConversation, { + type: 'message_update', + assistantMessageEvent: { type: 'text_delta', delta: 'second response ' }, + }); + + expect(firstSession.assistantText).toBe('first response '); + expect(secondSession.assistantText).toBe('second response '); + expect(client.emit).toHaveBeenCalledWith('agent:text', { + conversationId: firstConversation, + text: 'first response ', + }); + expect(client.emit).toHaveBeenCalledWith('agent:text', { + conversationId: secondConversation, + text: 'second response ', + }); + }); + it('persists only redacted assistant content with classifications', (): void => { const { gateway, brain } = buildGateway(); const client = { @@ -147,7 +197,8 @@ describe('ChatGateway redaction boundary', (): void => { data: { user: { id: 'user-1' } }, emit: vi.fn(), }; - gateway.clientSessions.set(client.id, { + gateway.clientSessions.set(clientConversationKey(client.id, CONVERSATION_ID), { + clientId: client.id, conversationId: CONVERSATION_ID, cleanup: vi.fn(), assistantText: CANARY, diff --git a/apps/gateway/src/chat/chat.gateway.ts b/apps/gateway/src/chat/chat.gateway.ts index 9f2541dd..188f6cd0 100644 --- a/apps/gateway/src/chat/chat.gateway.ts +++ b/apps/gateway/src/chat/chat.gateway.ts @@ -17,6 +17,7 @@ import { parseDiscordInteractionBindings, resolveDiscordInteractionActorId, resolveDiscordInteractionBinding, + type DiscordAttachment, type DiscordIngressEnvelope, type DiscordIngressPayload, } from '@mosaicstack/discord-plugin'; @@ -30,6 +31,7 @@ import type { SystemReloadPayload, RoutingDecisionInfo, AbortPayload, + ChannelAttachmentDto, } from '@mosaicstack/types'; import { AgentService, type ConversationHistoryMessage } from '../agent/agent.service.js'; import { @@ -56,6 +58,7 @@ import { DiscordReplayProtector } from '../plugin/discord-replay-protector.js'; /** Per-client state tracking streaming accumulation for persistence. */ interface ClientSession { + clientId: string; conversationId: string; cleanup: () => void; /** Accumulated assistant response text for the current turn. */ @@ -76,6 +79,68 @@ interface ClientSession { */ const modelOverrides = new Map(); const MAX_REDACTION_BUFFER_LENGTH = 8_192; +const MAX_CHANNEL_ATTACHMENTS = 10; +const MAX_ATTACHMENT_METADATA_BYTES = 16_384; +const MAX_ATTACHMENT_ID_LENGTH = 128; +const MAX_ATTACHMENT_NAME_LENGTH = 255; +const MAX_ATTACHMENT_URL_LENGTH = 2_048; +const MAX_ATTACHMENT_MIME_LENGTH = 255; + +function isSafeAttachmentUrl(value: string): boolean { + if (value.length === 0 || value.length > MAX_ATTACHMENT_URL_LENGTH) return false; + try { + const url = new URL(value); + return ( + url.protocol === 'https:' && + !url.username && + !url.password && + !url.hash && + url.search.length === 0 + ); + } catch { + return false; + } +} + +function hasValidAttachmentBounds(value: { + id: string; + name: string; + url: string; + sizeBytes?: number; +}): boolean { + return ( + value.id.length > 0 && + value.id.length <= MAX_ATTACHMENT_ID_LENGTH && + value.name.length > 0 && + value.name.length <= MAX_ATTACHMENT_NAME_LENGTH && + isSafeAttachmentUrl(value.url) && + (value.sizeBytes === undefined || (Number.isFinite(value.sizeBytes) && value.sizeBytes >= 0)) + ); +} + +function isDiscordAttachment(value: unknown): value is DiscordAttachment { + if (typeof value !== 'object' || value === null) return false; + const attachment = value as Partial; + return ( + typeof attachment.id === 'string' && + typeof attachment.name === 'string' && + typeof attachment.url === 'string' && + (attachment.contentType === null || + (typeof attachment.contentType === 'string' && + attachment.contentType.length <= MAX_ATTACHMENT_MIME_LENGTH)) && + (attachment.sizeBytes === undefined || typeof attachment.sizeBytes === 'number') && + hasValidAttachmentBounds(attachment as DiscordAttachment) + ); +} + +function hasValidAttachmentArray(value: unknown, guard: (attachment: unknown) => boolean): boolean { + return ( + Array.isArray(value) && + value.length <= MAX_CHANNEL_ATTACHMENTS && + JSON.stringify(value).length <= MAX_ATTACHMENT_METADATA_BYTES && + value.every(guard) + ); +} function isDiscordIngressEnvelope(value: unknown): value is DiscordIngressEnvelope { if (typeof value !== 'object' || value === null) return false; @@ -88,23 +153,49 @@ function isDiscordIngressEnvelope(value: unknown): value is DiscordIngressEnvelo return false; } const payload = envelope.payload as Record; - return [ - payload['correlationId'], - payload['messageId'], - payload['guildId'], - payload['channelId'], - payload['userId'], - payload['conversationId'], - payload['content'], - ].every((field: unknown): boolean => typeof field === 'string'); + return ( + [ + payload['correlationId'], + payload['messageId'], + payload['guildId'], + payload['channelId'], + payload['userId'], + payload['conversationId'], + payload['content'], + ].every((field: unknown): boolean => typeof field === 'string') && + (payload['threadId'] === undefined || typeof payload['threadId'] === 'string') && + (payload['attachments'] === undefined || + hasValidAttachmentArray(payload['attachments'], isDiscordAttachment)) + ); +} + +function isChannelAttachment(value: unknown): value is ChannelAttachmentDto { + if (typeof value !== 'object' || value === null) return false; + const attachment = value as Partial; + return ( + typeof attachment.id === 'string' && + typeof attachment.name === 'string' && + typeof attachment.url === 'string' && + (attachment.mimeType === null || + (typeof attachment.mimeType === 'string' && + attachment.mimeType.length <= MAX_ATTACHMENT_MIME_LENGTH)) && + (attachment.sizeBytes === undefined || typeof attachment.sizeBytes === 'number') && + hasValidAttachmentBounds(attachment as ChannelAttachmentDto) + ); } function isChatSocketMessage(value: unknown): value is ChatSocketMessageDto { if (typeof value !== 'object' || value === null) return false; - const payload = value as { content?: unknown; conversationId?: unknown }; + const payload = value as { + content?: unknown; + conversationId?: unknown; + attachments?: unknown; + }; return ( typeof payload.content === 'string' && - (payload.conversationId === undefined || typeof payload.conversationId === 'string') + (payload.conversationId === undefined || typeof payload.conversationId === 'string') && + (payload.attachments === undefined || + hasValidAttachmentArray(payload.attachments, isChannelAttachment)) ); } @@ -174,20 +265,24 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa handleDisconnect(client: Socket): void { this.logger.log(`Client disconnected: ${client.id}`); - const session = this.clientSessions.get(client.id); - if (session) { + for (const [key, session] of this.clientSessions) { + if (session.clientId !== client.id) continue; session.cleanup(); this.agentService.removeChannel( session.conversationId, `websocket:${client.id}`, session.scope, ); - this.clientSessions.delete(client.id); + this.clientSessions.delete(key); + this.textEgressBuffers.delete(key); + this.thinkingEgressBuffers.delete(key); + this.overflowedEgress.delete(`${key}:agent:text`); + this.overflowedEgress.delete(`${key}:agent:thinking`); } - this.textEgressBuffers.delete(client.id); - this.thinkingEgressBuffers.delete(client.id); - this.overflowedEgress.delete(this.egressKey(client, 'agent:text')); - this.overflowedEgress.delete(this.egressKey(client, 'agent:thinking')); + } + + private clientConversationKey(client: Pick, conversationId: string): string { + return `${client.id}\u0000${conversationId}`; } private getClientScope(client: Socket): ActorTenantScope | null { @@ -218,7 +313,25 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa } discordIngress = this.resolveDiscordIngress(client, rawData); if (!discordIngress) return; - data = { conversationId: discordIngress.conversationId, content: discordIngress.content }; + data = { + conversationId: discordIngress.conversationId, + content: discordIngress.content, + ...(discordIngress.attachments + ? { + attachments: discordIngress.attachments.map( + (attachment): ChannelAttachmentDto => ({ + id: attachment.id, + name: attachment.name, + url: attachment.url, + mimeType: attachment.contentType, + ...(attachment.sizeBytes !== undefined + ? { sizeBytes: attachment.sizeBytes } + : {}), + }), + ), + } + : {}), + }; } else { if (!isChatSocketMessage(rawData)) { this.logger.warn(`Rejected malformed chat message from ${client.id}`); @@ -227,6 +340,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa data = rawData; } const conversationId = data.conversationId ?? uuid(); + const clientConversationKey = this.clientConversationKey(client, conversationId); const discordServiceUserId = process.env['DISCORD_SERVICE_USER_ID']; if (discordIngress && !discordServiceUserId) { this.logger.warn( @@ -281,7 +395,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa this.logger.log( `Using /model override "${modelOverride}" for conversation=${conversationId}`, ); - } else if (!resolvedProvider && !resolvedModelId) { + } else if (!resolvedProvider && !resolvedModelId && !discordIngress) { // No explicit provider/model from client — use routing engine (M4-012) try { const routingDecision = await this.routingEngine.resolve(data.content, userId); @@ -304,12 +418,24 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa } } + let resolvedAgentConfigId = data.agentId; + if (discordIngress) { + const binding = this.discordBindingFor(discordIngress, 'send'); + const agentConfig = binding + ? await this.brain.agents.findById(binding.agentConfigId) + : undefined; + if (!binding || !agentConfig || agentConfig.name !== binding.instanceId) { + throw new Error('Configured Discord logical agent is not provisioned'); + } + resolvedAgentConfigId = agentConfig.id; + } + // M5-004: Use existingSessionId as sessionId when available (session reuse) const sessionIdToCreate = existingSessionId ?? conversationId; agentSession = await this.agentService.createSession(sessionIdToCreate, { provider: resolvedProvider, modelId: resolvedModelId, - agentConfigId: data.agentId, + agentConfigId: resolvedAgentConfigId, userId, tenantId: scope.tenantId, conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined, @@ -360,6 +486,17 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa discordUserId: discordIngress?.userId, } : {}), + ...(data.attachments && data.attachments.length > 0 + ? { + channelAttachments: data.attachments.map( + (attachment): ChannelAttachmentDto => ({ + ...attachment, + name: redactSensitiveContent(attachment.name).content, + url: redactSensitiveContent(attachment.url).content, + }), + ), + } + : {}), classifications: redactSensitiveContent(data.content).classifications, }, }, @@ -374,7 +511,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa } // Always clean up previous listener to prevent leak - const existing = this.clientSessions.get(client.id); + const existing = this.clientSessions.get(clientConversationKey); if (existing) { existing.cleanup(); } @@ -389,10 +526,11 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa ); // Preserve routing decision from the existing client session if we didn't get a new one - const prevClientSession = this.clientSessions.get(client.id); + const prevClientSession = this.clientSessions.get(clientConversationKey); const routingDecisionToStore = sessionRoutingDecision ?? prevClientSession?.lastRoutingDecision; - this.clientSessions.set(client.id, { + this.clientSessions.set(clientConversationKey, { + clientId: client.id, conversationId, cleanup, assistantText: '', @@ -438,7 +576,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa // Dispatch to agent try { - await this.agentService.prompt(conversationId, data.content, scope); + await this.agentService.prompt(conversationId, data.content, scope, data.attachments); } catch (err) { this.logger.error( `Agent prompt failed for client=${client.id}, conversation=${conversationId}`, @@ -645,9 +783,9 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa }; // Emit to all clients currently subscribed to this conversation - for (const [clientId, session] of this.clientSessions) { + for (const session of this.clientSessions.values()) { if (session.conversationId === conversationId && this.scopesEqual(session.scope, scope)) { - const socket = this.server.sockets.sockets.get(clientId); + const socket = this.server.sockets.sockets.get(session.clientId); if (socket?.connected) { socket.emit('session:info', payload); } @@ -677,16 +815,10 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa !this.durableSessions ) return; - const binding = resolveDiscordInteractionBinding( - parseDiscordInteractionBindings(process.env['DISCORD_INTERACTION_BINDINGS']), - ingress.guildId, - ingress.channelId, - ingress.userId, - 'approve', - ); + const binding = this.discordBindingFor(ingress, 'approve'); const actorId = binding && resolveDiscordInteractionActorId(binding, ingress.userId); - const agentName = process.env['MOSAIC_AGENT_NAME']?.trim(); - if (!actorId || !agentName || binding.instanceId !== agentName) { + const agentName = binding?.instanceId; + if (!actorId || !agentName) { this.logger.warn( `Rejected Discord approval without a matching runtime agent from ${client.id}`, ); @@ -774,13 +906,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa const tenantId = process.env['DISCORD_SERVICE_TENANT_ID']?.trim(); if (!ingress || !approvalRef || !tenantId || !this.runtimeRegistry || !this.durableSessions) return; - const binding = resolveDiscordInteractionBinding( - parseDiscordInteractionBindings(process.env['DISCORD_INTERACTION_BINDINGS']), - ingress.guildId, - ingress.channelId, - ingress.userId, - 'stop', - ); + const binding = this.discordBindingFor(ingress, 'stop'); const actorId = binding && resolveDiscordInteractionActorId(binding, ingress.userId); if (!actorId) return; @@ -854,17 +980,18 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa return null; } try { - const binding = resolveDiscordInteractionBinding( - parseDiscordInteractionBindings(process.env['DISCORD_INTERACTION_BINDINGS']), - payload.guildId, - payload.channelId, - payload.userId, - operation, - ); + const binding = this.discordBindingFor(payload, operation); if (!binding) { this.logger.warn(`Rejected unpaired Discord ingress from ${client.id}`); return null; } + const expectedConversationId = `${binding.instanceId}:discord:${payload.threadId ?? payload.channelId}`; + if (payload.conversationId !== expectedConversationId) { + this.logger.warn( + `Rejected Discord ingress for a different logical agent from ${client.id}`, + ); + return null; + } } catch { this.logger.warn( `Rejected Discord ingress without valid binding configuration from ${client.id}`, @@ -880,6 +1007,19 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa return payload; } + private discordBindingFor( + payload: DiscordIngressPayload, + operation: 'send' | 'approve' | 'stop', + ) { + return resolveDiscordInteractionBinding( + parseDiscordInteractionBindings(process.env['DISCORD_INTERACTION_BINDINGS']), + payload.guildId, + payload.channelId, + payload.userId, + operation, + ); + } + private readDiscordAllowlist(name: string): string[] { return (process.env[name] ?? '') .split(',') @@ -958,11 +1098,15 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa const messages = await this.brain.conversations.findMessages(conversationId, userId); if (messages.length === 0) return []; - return messages.map((msg) => ({ - role: msg.role as 'user' | 'assistant' | 'system', - content: msg.content, - createdAt: msg.createdAt, - })); + return messages.map((msg) => { + const attachments = this.persistedChannelAttachments(msg.metadata); + return { + role: msg.role as 'user' | 'assistant' | 'system', + content: msg.content, + createdAt: msg.createdAt, + ...(attachments ? { attachments } : {}), + }; + }); } catch (err) { this.logger.error( `Failed to load conversation history for conversation=${conversationId}`, @@ -972,6 +1116,14 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa } } + private persistedChannelAttachments(metadata: unknown): readonly ChannelAttachmentDto[] | null { + if (typeof metadata !== 'object' || metadata === null) return null; + const attachments = (metadata as { channelAttachments?: unknown }).channelAttachments; + return hasValidAttachmentArray(attachments, isChannelAttachment) + ? (attachments as readonly ChannelAttachmentDto[]) + : null; + } + private appendAndFlushRedactedEgress( client: Socket, conversationId: string, @@ -979,18 +1131,19 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa buffers: Map, delta: string, ): void { - const key = this.egressKey(client, eventName); + const sessionKey = this.clientConversationKey(client, conversationId); + const key = this.egressKey(client, conversationId, eventName); if (this.overflowedEgress.has(key)) return; - const buffered = `${buffers.get(client.id) ?? ''}${delta}`; + const buffered = `${buffers.get(sessionKey) ?? ''}${delta}`; if (buffered.length > MAX_REDACTION_BUFFER_LENGTH) { - buffers.delete(client.id); + buffers.delete(sessionKey); this.overflowedEgress.add(key); client.emit(eventName, { conversationId, text: '[REDACTED_STREAM_OVERFLOW]' }); return; } - buffers.set(client.id, buffered); + buffers.set(sessionKey, buffered); this.flushRedactedEgress(client, conversationId, eventName, buffers, false); } @@ -1006,21 +1159,22 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa buffers: Map, final: boolean, ): void { - const key = this.egressKey(client, eventName); + const sessionKey = this.clientConversationKey(client, conversationId); + const key = this.egressKey(client, conversationId, eventName); if (this.overflowedEgress.has(key)) { if (final) this.overflowedEgress.delete(key); return; } - const buffered = buffers.get(client.id) ?? ''; + const buffered = buffers.get(sessionKey) ?? ''; const releaseLength = final ? buffered.length : this.safeRedactionPrefixLength(buffered); const released = buffered.slice(0, releaseLength); const pending = buffered.slice(releaseLength); if (pending) { - buffers.set(client.id, pending); + buffers.set(sessionKey, pending); } else { - buffers.delete(client.id); + buffers.delete(sessionKey); } if (released) { @@ -1089,8 +1243,12 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa return retainedFrom; } - private egressKey(client: Socket, eventName: 'agent:text' | 'agent:thinking'): string { - return `${client.id}:${eventName}`; + private egressKey( + client: Socket, + conversationId: string, + eventName: 'agent:text' | 'agent:thinking', + ): string { + return `${this.clientConversationKey(client, conversationId)}:${eventName}`; } private relayEvent(client: Socket, conversationId: string, event: AgentSessionEvent): void { @@ -1101,26 +1259,27 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa return; } + const sessionKey = this.clientConversationKey(client, conversationId); switch (event.type) { case 'agent_start': { // Reset accumulation buffers for the new turn - const cs = this.clientSessions.get(client.id); + const cs = this.clientSessions.get(sessionKey); if (cs) { cs.assistantText = ''; cs.toolCalls = []; cs.pendingToolCalls.clear(); } - this.textEgressBuffers.set(client.id, ''); - this.thinkingEgressBuffers.set(client.id, ''); - this.overflowedEgress.delete(this.egressKey(client, 'agent:text')); - this.overflowedEgress.delete(this.egressKey(client, 'agent:thinking')); + this.textEgressBuffers.set(sessionKey, ''); + this.thinkingEgressBuffers.set(sessionKey, ''); + this.overflowedEgress.delete(this.egressKey(client, conversationId, 'agent:text')); + this.overflowedEgress.delete(this.egressKey(client, conversationId, 'agent:thinking')); client.emit('agent:start', { conversationId }); break; } case 'agent_end': { // Gather usage stats from the Pi session - const activeClientSession = this.clientSessions.get(client.id); + const activeClientSession = this.clientSessions.get(sessionKey); const agentSession = activeClientSession ? this.agentService.getSession(conversationId, activeClientSession.scope) : undefined; @@ -1173,7 +1332,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa } // Persist the assistant message with metadata - const cs = this.clientSessions.get(client.id); + const cs = this.clientSessions.get(sessionKey); const userId = (client.data.user as { id: string } | undefined)?.id; if (cs && userId && cs.assistantText.trim().length > 0) { const metadata: Record = { @@ -1225,7 +1384,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa const assistantEvent = event.assistantMessageEvent; if (assistantEvent.type === 'text_delta') { // Keep raw stream material in memory only; persist and emit only redacted text. - const cs = this.clientSessions.get(client.id); + const cs = this.clientSessions.get(sessionKey); if (cs) { cs.assistantText += assistantEvent.delta; } @@ -1250,7 +1409,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa case 'tool_execution_start': { // Track pending tool call for later recording - const cs = this.clientSessions.get(client.id); + const cs = this.clientSessions.get(sessionKey); if (cs) { cs.pendingToolCalls.set(event.toolCallId, { toolName: event.toolName, @@ -1267,7 +1426,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa case 'tool_execution_end': { // Finalise tool call record - const cs = this.clientSessions.get(client.id); + const cs = this.clientSessions.get(sessionKey); if (cs) { const pending = cs.pendingToolCalls.get(event.toolCallId); cs.toolCalls.push({ diff --git a/apps/gateway/src/plugin/discord-ingress.security.spec.ts b/apps/gateway/src/plugin/discord-ingress.security.spec.ts index 0c8bb46f..3f12aa2a 100644 --- a/apps/gateway/src/plugin/discord-ingress.security.spec.ts +++ b/apps/gateway/src/plugin/discord-ingress.security.spec.ts @@ -24,6 +24,7 @@ const ENV_KEYS = [ 'DISCORD_ALLOWED_CHANNEL_IDS', 'DISCORD_ALLOWED_USER_IDS', 'MOSAIC_AGENT_NAME', + 'MOSAIC_AGENT_CONFIG_ID', ] as const; const savedEnv = new Map(); @@ -33,12 +34,14 @@ function configureDiscordEnv(role: 'admin' | 'member' = 'admin'): void { process.env['DISCORD_SERVICE_USER_ID'] = 'discord-service'; process.env['DISCORD_SERVICE_TENANT_ID'] = 'tenant-discord'; process.env['MOSAIC_AGENT_NAME'] = 'Nova'; + process.env['MOSAIC_AGENT_CONFIG_ID'] = 'agent-config-nova'; process.env['DISCORD_ALLOWED_GUILD_IDS'] = 'guild-001'; process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001'; process.env['DISCORD_ALLOWED_USER_IDS'] = 'user-001'; process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([ { instanceId: 'Nova', + agentConfigId: 'agent-config-nova', guildId: 'guild-001', channelId: 'channel-001', pairedUsers: { @@ -141,7 +144,7 @@ function createPayload(overrides: Partial = {}): DiscordI guildId: 'guild-001', channelId: 'channel-001', userId: 'user-001', - conversationId: 'discord-channel-001', + conversationId: 'Nova:discord:channel-001', content: 'hello Tess', ...overrides, }; @@ -153,6 +156,7 @@ describe('Discord ingress security', () => { JSON.stringify([ { instanceId: 'Nova', + agentConfigId: 'agent-config-nova', guildId: 'guild-001', channelId: 'channel-001', pairedUsers: { 'user-001': 'admin' }, @@ -170,6 +174,7 @@ describe('Discord ingress security', () => { [ { instanceId: 'Nova', + agentConfigId: 'agent-config-nova', guildId: 'guild-001', channelId: 'channel-001', pairedUsers: { 'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' } }, @@ -307,41 +312,46 @@ describe('Discord ingress security', () => { ); }); - it.each([ - [ - 'binding', - () => { - process.env['MOSAIC_AGENT_NAME'] = 'Other'; - }, - ], - [ - 'durable session', - (durable: { getSnapshot: ReturnType }) => { - durable.getSnapshot.mockResolvedValueOnce({ - identity: { agentName: 'Other', providerId: 'fleet', runtimeSessionId: 'runtime-1' }, - }); - }, - ], - ])( - 'rejects approval when the %s targets a different runtime agent', - async (_source, configure) => { - configureDiscordEnv(); - const { gateway, client, durable } = discordGateway('admin'); - configure(durable); + it('rejects approval when the durable session targets a different logical agent', async () => { + configureDiscordEnv(); + const { gateway, client, durable } = discordGateway('admin'); + durable.getSnapshot.mockResolvedValueOnce({ + identity: { agentName: 'Other', providerId: 'fleet', runtimeSessionId: 'runtime-1' }, + }); - await gateway.handleDiscordApproval( - client as never, - ingressEnvelope('/approve', 'mismatched-agent-approve'), - ); + await gateway.handleDiscordApproval( + client as never, + ingressEnvelope('/approve', 'mismatched-agent-approve'), + ); - expect(client.emit).toHaveBeenCalledWith('discord:approval', { - correlationId: 'correlation-001', - success: false, - approvalId: undefined, - expiresAt: undefined, - }); - }, - ); + expect(client.emit).toHaveBeenCalledWith('discord:approval', { + correlationId: 'correlation-001', + success: false, + approvalId: undefined, + expiresAt: undefined, + }); + }); + + it('rejects privileged envelopes with a forged current conversation route', async () => { + configureDiscordEnv(); + const { gateway, client } = discordGateway('admin'); + + await gateway.handleDiscordApproval( + client as never, + ingressEnvelope('/approve', 'forged-approval-route', { + conversationId: 'Nova:discord:other-channel', + }), + ); + await gateway.handleDiscordStop( + client as never, + ingressEnvelope('/stop forged', 'forged-stop-route', { + conversationId: 'Nova:discord:other-channel', + }), + ); + + expect(client.emit).not.toHaveBeenCalledWith('discord:approval', expect.anything()); + expect(client.emit).not.toHaveBeenCalledWith('discord:stop', expect.anything()); + }); it('rejects unpaired and non-admin Discord users for approval and stop', async () => { configureDiscordEnv(); @@ -398,6 +408,267 @@ describe('Discord ingress security', () => { ]); }); + it.each([ + 'https://user:password@cdn.example.test/diagram.png', + 'https://cdn.example.test/diagram.png?token=secret', + 'https://cdn.example.test/diagram.png?X-Amz-Signature=secret', + 'https://cdn.example.test/diagram.png?auth=secret', + 'https://cdn.example.test/diagram.png?hm=secret', + ])('rejects credential-bearing attachment URLs before gateway dispatch', async (url) => { + configureDiscordEnv(); + const { gateway, client } = discordGateway('admin'); + + await gateway.handleMessage( + client as never, + ingressEnvelope('', `credential-url-${url.length}`, { + conversationId: 'Nova:discord:channel-001', + attachments: [ + { id: 'attachment-credential', name: 'diagram.png', url, contentType: 'image/png' }, + ], + }), + ); + + expect(client.emit).not.toHaveBeenCalledWith('message:ack', expect.anything()); + }); + + it("selects each binding's trusted logical-agent config when creating Discord sessions", async () => { + configureDiscordEnv(); + process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001,channel-002'; + process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([ + { + instanceId: 'Nova', + agentConfigId: 'agent-config-nova', + guildId: 'guild-001', + channelId: 'channel-001', + pairedUsers: { + 'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' }, + }, + }, + { + instanceId: 'Orion', + agentConfigId: 'agent-config-orion', + guildId: 'guild-001', + channelId: 'channel-002', + pairedUsers: { + 'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' }, + }, + }, + ]); + const session = { + provider: 'configured-provider', + modelId: 'configured-model', + piSession: { + thinkingLevel: 'medium', + getAvailableThinkingLevels: (): string[] => ['medium'], + }, + }; + const createSession = vi.fn().mockResolvedValue(session); + const agentService = { + getSession: vi.fn().mockReturnValue(undefined), + createSession, + recordMessage: vi.fn(), + onEvent: vi.fn().mockReturnValue((): void => undefined), + addChannel: vi.fn(), + prompt: vi.fn().mockResolvedValue(undefined), + }; + const brain = { + agents: { + findById: vi.fn((id: string) => + Promise.resolve({ + id, + name: id === 'agent-config-orion' ? 'Orion' : 'Nova', + }), + ), + }, + conversations: { + findById: vi.fn().mockResolvedValue({ id: 'Nova:discord:channel-001' }), + findMessages: vi.fn().mockResolvedValue([]), + create: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + addMessage: vi.fn().mockResolvedValue(undefined), + }, + }; + const routingEngine = { resolve: vi.fn() }; + const gateway = new ChatGateway( + agentService as never, + {} as never, + brain as never, + {} as never, + {} as never, + routingEngine as never, + ); + const client = { + id: 'discord-client-new-session', + data: { discordService: true }, + emit: vi.fn(), + }; + + await gateway.handleMessage( + client as never, + ingressEnvelope('start configured session', 'configured-session-001', { + conversationId: 'Nova:discord:channel-001', + }), + ); + + await gateway.handleMessage( + client as never, + ingressEnvelope('start second configured session', 'configured-session-002', { + channelId: 'channel-002', + conversationId: 'Orion:discord:channel-002', + }), + ); + + expect(createSession).toHaveBeenCalledWith( + 'Nova:discord:channel-001', + expect.objectContaining({ + agentConfigId: 'agent-config-nova', + userId: 'discord-service', + tenantId: 'tenant-discord', + }), + ); + expect(createSession).toHaveBeenCalledWith( + 'Orion:discord:channel-002', + expect.objectContaining({ agentConfigId: 'agent-config-orion' }), + ); + expect(routingEngine.resolve).not.toHaveBeenCalled(); + }); + + it('retains validated persisted attachments in resumed conversation history', async () => { + const attachment = { + id: 'attachment-history', + name: 'diagram.png', + url: 'https://cdn.example.test/diagram.png', + mimeType: 'image/png', + sizeBytes: 4_096, + }; + const gateway = new ChatGateway( + {} as never, + {} as never, + { + conversations: { + findMessages: vi.fn().mockResolvedValue([ + { + role: 'user', + content: '', + createdAt: new Date('2026-07-14T12:00:00.000Z'), + metadata: { channelAttachments: [attachment] }, + }, + ]), + }, + } as never, + {} as never, + {} as never, + {} as never, + ) as unknown as { + loadConversationHistory( + conversationId: string, + userId: string, + ): Promise>; + }; + + await expect( + gateway.loadConversationHistory('Nova:discord:channel-001', 'discord-service'), + ).resolves.toEqual([expect.objectContaining({ attachments: [attachment] })]); + }); + + it('rejects malformed signed attachment payloads before gateway dispatch', async () => { + configureDiscordEnv(); + const { gateway, client } = discordGateway('admin'); + const malformedPayload: Record = { + ...createPayload({ + messageId: 'malformed-attachments-001', + conversationId: 'Nova:discord:channel-001', + }), + attachments: { id: 'not-an-array' }, + }; + const envelope = createDiscordIngressEnvelope( + malformedPayload as unknown as DiscordIngressPayload, + SERVICE_TOKEN, + ); + + await gateway.handleMessage(client as never, envelope); + + expect(client.emit).not.toHaveBeenCalledWith('message:ack', expect.anything()); + }); + + it('preserves authenticated attachment metadata through persistence and agent dispatch', async () => { + configureDiscordEnv(); + const prompt = vi.fn().mockResolvedValue(undefined); + const addMessage = vi.fn().mockResolvedValue(undefined); + const session = { + provider: 'test-provider', + modelId: 'test-model', + piSession: { + thinkingLevel: 'medium', + getAvailableThinkingLevels: (): string[] => ['medium'], + }, + }; + const agentService = { + getSession: vi.fn().mockReturnValue(session), + recordMessage: vi.fn(), + onEvent: vi.fn().mockReturnValue((): void => undefined), + addChannel: vi.fn(), + prompt, + }; + const brain = { + conversations: { + findById: vi.fn().mockResolvedValue({ id: 'Nova:discord:channel-001' }), + create: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + addMessage, + }, + }; + const gateway = new ChatGateway( + agentService as never, + {} as never, + brain as never, + {} as never, + {} as never, + {} as never, + ); + const client = { + id: 'discord-client-001', + data: { discordService: true }, + emit: vi.fn(), + }; + const attachment = { + id: 'attachment-001', + name: 'diagram.png', + url: 'https://cdn.example.test/diagram.png', + contentType: 'image/png', + sizeBytes: 4_096, + }; + + await gateway.handleMessage( + client as never, + ingressEnvelope('', 'attachment-message-001', { + conversationId: 'Nova:discord:channel-001', + attachments: [attachment], + }), + ); + + const expectedAttachment = { + id: attachment.id, + name: attachment.name, + url: attachment.url, + mimeType: attachment.contentType, + sizeBytes: attachment.sizeBytes, + }; + expect(prompt).toHaveBeenCalledWith( + 'Nova:discord:channel-001', + '', + { userId: 'discord-service', tenantId: 'tenant-discord' }, + [expectedAttachment], + ); + expect(addMessage).toHaveBeenCalledWith( + expect.objectContaining({ + conversationId: 'Nova:discord:channel-001', + metadata: expect.objectContaining({ channelAttachments: [expectedAttachment] }), + }), + 'discord-service', + ); + }); + it('accepts a thread message through its allowed bound parent channel', () => { const emitted = vi.fn(); const plugin = new DiscordPlugin({ @@ -410,6 +681,7 @@ describe('Discord ingress security', () => { interactionBindings: [ { instanceId: 'Nova', + agentConfigId: 'agent-config-nova', guildId: 'guild-001', channelId: 'channel-001', pairedUsers: { 'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' } }, diff --git a/apps/gateway/src/plugin/plugin.module.ts b/apps/gateway/src/plugin/plugin.module.ts index fdf13cf9..ecb45a4b 100644 --- a/apps/gateway/src/plugin/plugin.module.ts +++ b/apps/gateway/src/plugin/plugin.module.ts @@ -61,6 +61,16 @@ function requiredDiscordAllowlist(name: string): string[] { return value; } +function optionalPositiveInteger(name: string): number | undefined { + const raw = process.env[name]; + if (raw === undefined) return undefined; + const value = Number(raw); + if (!Number.isInteger(value) || value <= 0) { + throw new Error(`${name} must be a positive integer when configured`); + } + return value; +} + function createPluginRegistry(): IChannelPlugin[] { const plugins: IChannelPlugin[] = []; const discordToken = process.env['DISCORD_BOT_TOKEN']; @@ -82,6 +92,10 @@ function createPluginRegistry(): IChannelPlugin[] { guildId: discordGuildId, gatewayUrl: discordGatewayUrl, serviceToken: discordServiceToken, + messageRateLimitPerMinute: optionalPositiveInteger( + 'DISCORD_MESSAGE_RATE_LIMIT_PER_MINUTE', + ), + threadRateLimitPerMinute: optionalPositiveInteger('DISCORD_THREAD_RATE_LIMIT_PER_MINUTE'), allowedGuildIds: requiredDiscordAllowlist('DISCORD_ALLOWED_GUILD_IDS'), allowedChannelIds: requiredDiscordAllowlist('DISCORD_ALLOWED_CHANNEL_IDS'), allowedUserIds: requiredDiscordAllowlist('DISCORD_ALLOWED_USER_IDS'), diff --git a/docs/PRD.md b/docs/PRD.md index b0213926..ddee2d70 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -221,6 +221,69 @@ Delivery uses five gated milestones: runtime contracts/security; Pi service/stat --- +## Official Channel Plugin Workstream (#756) + +### Problem and Objective + +The Discord plugin currently couples Discord event handling, gateway bridging, and reply routing in one implementation and activates only on mentions. Mosaic needs an official channel adapter that behaves the same no matter whether the bound logical agent currently runs through Claude, Codex, Pi, OpenCode, or a future harness. The Discord connection and conversation address must remain stable while the gateway changes the runtime provider behind that logical session. + +The objective is to make Discord the first implementation of a transport-neutral official channel contract, with explicit authorization and deterministic channel/thread routing that future Matrix, Slack, and other adapters can share. + +### Scope + +#### In Scope + +1. `CHN-001`: Transport-neutral channel adapter, route, message, attachment, authorization-principal, response-target, and health contracts in `@mosaicstack/types`, including trusted per-binding logical-agent configuration selection. +2. `CHN-002`: Stable channel conversation addresses based on logical agent plus channel/thread identity; harness, model, and runtime-provider IDs are forbidden from channel session keys. +3. `DSC-001`: An authorized untagged message in a configured agent-bound channel routes to the agent and receives its response in that channel. +4. `DSC-002`: A bot mention in a configured parent channel creates a Discord thread, or reuses the thread already attached to that same native message; the mentioned turn and subsequent thread turns route and respond in that thread. +5. `DSC-003`: A message already inside an authorized thread inherits authorization from its configured parent and never attempts a nested thread. +6. `DSC-004`: Guild, parent channel, user, pairing, and role authorization remains default-deny before thread creation or gateway dispatch. +7. `DSC-005`: Discord service authentication, HMAC envelope integrity, replay protection, attachments, approvals, response chunking, and correlation behavior remain intact. +8. `DSC-006`: The Discord adapter exposes lifecycle and health behavior through the shared channel contract without importing a harness SDK. + +#### Out of Scope + +1. The logical-agent lease, fencing epoch, execution grant, checkpoint, or cross-harness takeover implementation tracked by #754/#755. +2. Dynamic Discord authorization administration in the web UI. +3. Multi-guild tenant isolation, DMs, slash commands, voice, reactions, or production bot deployment. +4. Implementing Matrix or Slack adapters in this slice. + +### Non-Functional Requirements + +1. **Security:** no thread or dispatch side effect occurs until guild, parent channel, user, pairing, role, and bounded per-user/channel rate checks pass; attachment metadata is shape- and size-bounded; credentials never enter source, messages, session keys, or logs. +2. **Portability:** channel contracts and stable conversation IDs contain no Claude, Codex, Pi, OpenCode, model, process, or provider-specific field; each configuration-owned binding selects its trusted logical agent without changing the channel identity. +3. **Reliability:** repeated messages for one channel/thread resolve the same conversation handle; reconnecting the adapter does not require a harness-specific rebinding. +4. **Maintainability:** Discord-specific API translation stays in the Discord package; gateway and future adapters depend on transport-neutral contracts. +5. **Observability:** thread creation or routing failure is reported without message content or credential material. + +### Acceptance Criteria + +1. `AC-CHN-01`: Contract and behavior tests prove the plugin route contains only logical agent plus channel/thread identity and produces the same stable conversation handle regardless of underlying harness selection. +2. `AC-CHN-02`: A mentioned authorized parent-channel message creates a thread (or reuses its already-attached thread), dispatches to the thread conversation, and targets the response to that thread. +3. `AC-CHN-03`: An untagged authorized parent-channel message dispatches to the parent conversation and targets the response to the parent channel. +4. `AC-CHN-04`: Untagged follow-ups inside an authorized thread dispatch and respond in that same thread without creating a nested thread. +5. `AC-CHN-05`: Unauthorized guilds, channels, users, unpaired users, insufficient roles, and rate-limited senders produce no thread and no gateway dispatch. +6. `AC-CHN-06`: Shared channel contracts are exported from `@mosaicstack/types`, Discord implements the lifecycle/health seam, and no harness SDK is imported by the plugin. +7. `AC-CHN-07`: Focused routing/auth tests, package tests, typecheck, lint, formatting, coverage, independent code/security review, and terminal-green CI pass. + +### Constraints, Risks, and Assumptions + +- Dependency: Mosaic gateway remains the policy, durable-session, audit, and runtime-provider boundary. +- Constraint: This work must not modify orchestrator-to-Pi migration or #754/#755 lease/fencing files. +- Risk: accepting untagged messages could create noisy or unintended agent input. Mitigation: only explicitly configured channels and paired, role-authorized users are accepted, with bounded per-user/channel message and thread rates. +- Risk: Discord thread creation can fail because of channel permissions, archived state, or API rate limits. Mitigation: fail without dispatching a turn whose response destination cannot be honored, and emit sanitized diagnostics. +- `ASSUMPTION:` Configured channels are dedicated agent interaction surfaces, so authorized untagged human messages are intentional agent input. +- `ASSUMPTION:` Mention in a parent channel selects a public thread; messages already in a thread remain there because Discord has no nested threads. +- `ASSUMPTION:` One Discord bot may serve multiple configuration-owned logical-agent bindings. +- `ASSUMPTION:` Static allowlists and paired-user roles are the authorization administration surface for this slice. + +### Testing and Delivery Intent + +Use TDD for remote-ingress routing and permission boundaries. Required evidence includes parent-channel mention, untagged parent message, existing-thread follow-up, existing-thread mention, thread reuse, unauthorized side-effect denial, stable harness-neutral conversation identity, adapter health, and regression coverage for signed envelopes and approvals. Deliver through issue #756, a reviewed squash PR to `main`, terminal-green CI, and issue closure. + +--- + ## Architecture ### High-Level System Diagram @@ -575,7 +638,8 @@ Discord remote control channel. Architecture inspired by OpenClaw (https://githu - Single-guild binding only (v0.1.0) — prevents data leaks between servers - Receives Discord messages, dispatches through gateway routing - Streams agent responses back to Discord (chunked for 2000-char limit) -- Supports mention-based activation, thread management for multi-turn +- Routes authorized untagged messages in-channel; mentions create threads (or reuse the same message's attached thread) for multi-turn topics +- Uses stable logical-agent/channel conversation addresses independent of the active harness/provider - Bot pairing and permission management (Discord user → Mosaic user mapping) - DM support for private conversations @@ -748,10 +812,12 @@ Telegram remote control channel. ### FR-9: Remote Control — Discord -- Discord bot that connects to the gateway -- Mention-based activation in channels +- Discord bot that connects to the gateway through a transport-neutral channel adapter contract +- Authorized messages in configured agent-bound channels work without a mention and respond in-channel +- Mentions in parent channels create threads, or reuse a thread already attached to that same native message, for multi-turn conversations +- Messages already in a thread remain there without requiring repeated mentions +- Stable logical-agent/channel conversation identity survives underlying harness/provider changes - DM support for private conversations -- Thread creation for multi-turn conversations - Chunked message delivery (Discord 2000-char limit) - Bot configuration via web dashboard - Permission management (which Discord users/roles can interact) @@ -935,10 +1001,13 @@ Telegram remote control channel. ### AC-3: Discord Remote Control -- [ ] Discord bot connects and responds to mentions -- [ ] Messages route through gateway to agent pool +- [ ] Discord bot connects through the harness-neutral channel contract +- [ ] Authorized untagged channel messages route through the gateway and respond in-channel +- [ ] Mentioned parent-channel messages create a thread (or reuse their already-attached thread) and respond there +- [ ] Existing-thread follow-ups stay in the thread without repeated mentions +- [ ] Channel/session identity remains stable while the underlying harness/provider changes - [ ] Responses stream back to Discord (chunked) -- [ ] Thread creation for multi-turn conversations +- [ ] Unauthorized guilds, channels, users, pairings, and roles create no thread and dispatch no message ### AC-4: Gateway Orchestration @@ -1137,7 +1206,7 @@ All work is **alpha** (< 0.1.0) until Jason approves 0.1.0 beta release. 6. ASSUMPTION: **Log summarization uses Haiku-tier LLM by default, configurable.** Haiku is well-suited for summarization (compression, not generation — source material is in context). Guardrails: structured output via Zod schema (force extraction of decisions/tools/outcomes/errors as discrete fields), chunked per-session processing (no bulk conflation), extraction-focused prompts. Raw logs stay in hot tier (7 days) as safety net. Users can override the summarization model via routing engine config if they want higher fidelity. Rationale: Haiku is 10-20x cheaper than Sonnet; log summarization runs on schedule against large volumes where cost matters. -7. ASSUMPTION: **Discord plugin starts minimal and single-guild only** — DM support, mention-based channel activation, thread management, chunked responses. Single guild binding to prevent data leaks between servers. Advanced features (voice, components, slash commands, multi-guild) are post-beta. Rationale: Proven pattern from OpenClaw; ship core interaction first; data isolation is non-negotiable. +7. ASSUMPTION: **Discord plugin starts minimal and single-guild only** — explicitly configured agent-bound channels accept authorized untagged messages in-channel, while mentions create threads or reuse a thread already attached to that same native message; responses are chunked. Single guild binding prevents data leaks between servers. DM support, voice, components, slash commands, and multi-guild operation are post-beta. Rationale: Ship the requested core interaction model while preserving default-deny data isolation. 8. ASSUMPTION: **Telegram plugin is lower priority than Discord** and may ship as v0.0.7 or later if Discord takes longer than expected. Rationale: Jason indicated Discord as the high-priority remote channel. diff --git a/docs/SITEMAP.md b/docs/SITEMAP.md index 520ffafb..4644e2e9 100644 --- a/docs/SITEMAP.md +++ b/docs/SITEMAP.md @@ -1,5 +1,13 @@ # Documentation Sitemap +## Official channel plugins + +- [Channel protocol architecture](architecture/channel-protocol.md) — shared lifecycle, message, stable-route, authorization, and response-target contracts. +- [Discord administrator configuration](guides/admin-guide.md#discord-ingress-security) — secrets, allowlists, bindings, role policy, and thread permissions. +- [Discord user workflow](tess/USER-GUIDE.md#discord-conversations) — in-channel messages, mention-created threads, and runtime-transparent continuity. +- [Channel plugin authoring](tess/PLUGIN-GUIDE.md#official-channel-adapter-contract) — requirements for future Matrix, Slack, and other official adapters. +- [Discord package guide](../plugins/discord/README.md) — package behavior, configuration shape, and development commands. + ## Native Kanban and canonical task SOT - [Canonical requirements](requirements/native-kanban-sot.md) — ratified P0–P3 requirements and acceptance criteria. diff --git a/docs/architecture/channel-protocol.md b/docs/architecture/channel-protocol.md index 315252a3..d2f8c35f 100644 --- a/docs/architecture/channel-protocol.md +++ b/docs/architecture/channel-protocol.md @@ -1,9 +1,9 @@ # Channel Protocol Architecture -**Status:** Draft +**Status:** Official adapter baseline implemented by #756; extended registry/multiplexing remains iterative **Authors:** Mosaic Core Team -**Last Updated:** 2026-03-22 -**Covers:** M7-001 (IChannelAdapter interface), M7-002 (ChannelMessage protocol), M7-003 (Matrix integration design), M7-004 (conversation multiplexing), M7-005 (remote auth bridging), M7-006 (agent-to-agent communication via Matrix), M7-007 (multi-user isolation in Matrix) +**Last Updated:** 2026-07-14 +**Covers:** M7-001 (OfficialChannelAdapter interface), M7-002 (ChannelMessageDto protocol), M7-003 (Matrix integration design), M7-004 (conversation multiplexing), M7-005 (remote auth bridging), M7-006 (agent-to-agent communication via Matrix), M7-007 (multi-user isolation in Matrix) --- @@ -11,93 +11,80 @@ The channel protocol defines a unified abstraction layer between Mosaic's core messaging infrastructure and the external communication channels it supports (Matrix, Discord, Telegram, TUI, WebUI, and future channels). -The protocol consists of two main contracts: +The implemented baseline is exported from `@mosaicstack/types` and consists of four contract groups: -1. `IChannelAdapter` — the interface each channel driver must implement. -2. `ChannelMessage` — the canonical message format that flows through the system. +1. `OfficialChannelAdapter` — transport lifecycle and connection health. +2. `ChannelMessageDto` / `ChannelAttachmentDto` — canonical transport data. +3. `ChannelConversationRouteDto` — stable logical-agent conversation and authorization address. +4. `ChannelResponseTargetDto` — channel/thread destination for replies. -All channel-specific translation logic lives inside the adapter implementation. The rest of Mosaic works exclusively with `ChannelMessage` objects. +All channel-specific translation logic lives inside the adapter implementation. Runtime selection does not: gateway durable-session and provider services may rebind the logical session from Claude to Codex, Pi, OpenCode, or another harness without reconnecting the channel adapter. --- -## M7-001: IChannelAdapter Interface +## M7-001: OfficialChannelAdapter Interface ```typescript -interface IChannelAdapter { - /** - * Stable, lowercase identifier for this channel (e.g. "matrix", "discord"). - * Used as a namespace key in registry lookups and log metadata. - */ +interface OfficialChannelAdapter { + /** Stable, lowercase adapter identifier such as "discord" or "matrix". */ readonly name: string; - - /** - * Establish a connection to the external channel backend. - * Called once at application startup. Must be idempotent (safe to call - * when already connected). - */ - connect(): Promise; - - /** - * Gracefully disconnect from the channel backend. - * Must flush in-flight sends and release resources before resolving. - */ - disconnect(): Promise; - - /** - * Return the current health of the adapter connection. - * Used by the admin health endpoint and alerting. - * - * - "connected" — fully operational - * - "degraded" — partial connectivity (e.g. read-only, rate-limited) - * - "disconnected" — no connection to channel backend - */ - health(): Promise<{ status: 'connected' | 'degraded' | 'disconnected' }>; - - /** - * Register an inbound message handler. - * The adapter calls `handler` for every message received from the channel. - * Multiple calls replace the previous handler (last-write-wins). - * The handler is async; the adapter must not deliver new messages until - * the previous handler promise resolves (back-pressure). - */ - onMessage(handler: (msg: ChannelMessage) => Promise): void; - - /** - * Send a ChannelMessage to the given channel/room/conversation. - * `channelId` is the channel-native identifier (e.g. Matrix room ID, - * Discord channel snowflake, Telegram chat ID). - */ - sendMessage(channelId: string, msg: ChannelMessage): Promise; - - /** - * Map a channel-native user identifier to the Mosaic internal userId. - * Returns null when no matching Mosaic account exists for the given - * channelUserId (anonymous or unlinked user). - */ - mapIdentity(channelUserId: string): Promise; + /** Establish both native-channel and gateway connections. */ + start(): Promise; + /** Gracefully close connections and release resources. */ + stop(): Promise; + /** Best-effort health; ordinary disconnection is a result, not an exception. */ + health(): Promise<{ + status: 'connected' | 'degraded' | 'disconnected'; + detail?: string; + }>; } ``` +The small lifecycle seam lets the gateway host official plugins uniformly without moving native message translation into gateway core. Message ingress remains adapter-owned; gateway policy, durable session routing, auditing, and runtime/provider selection remain gateway-owned. + +### Stable conversation route + +```typescript +interface ChannelConversationRouteDto { + bindingId: string; + logicalAgentId: string; + conversationId: string; + channelName: string; + authorizationChannelId: string; + responseTarget: { channelId: string; threadId?: string }; +} +``` + +Harness, provider, model, process, and native runtime-session identifiers are forbidden from this route. Runtime adapters consume the gateway's durable logical-session binding; channel adapters consume only the stable route and response target. + +### Typed ingress and egress ports + +`ChannelIngressPort` is the transport-neutral direct-integration seam for official adapters. The current deployed Discord adapter preserves its existing HMAC-signed Socket.IO compatibility ingress so gateway-side service authentication, replay protection, approval handling, and correlation semantics remain unchanged; it normalizes the same `ChannelIngressDto` before signing. The adapter uses a supplied `ChannelIngressPort` directly when a future gateway registration provides one. New adapters must use the shared ports rather than adding channel branches to gateway core. + +`ChannelBindingDto` contains the configuration-owned workspace/channel→logical-agent mapping and paired external principals; credentials are absent. After native allowlist, pairing, and role checks pass, an adapter submits `ChannelIngressDto` to `ChannelIngressPort.receive()`. It includes the normalized message, `ChannelAuthorizedPrincipalDto`, operation, correlation ID, native message ID, and stable route. Unauthorized input never reaches the port. + +Gateway policy and runtime routing produce `ChannelEgressDto`, which `ChannelEgressPort.send()` delivers to the route's response target. Discord's existing HMAC envelope is its authenticated wire encoding of this boundary; future Matrix/Slack adapters use their native authenticated transports while preserving the same actor/operation/correlation semantics. + ### Adapter Registration -Adapters are registered with the `ChannelRegistry` service at startup. The registry calls `connect()` on each adapter and monitors `health()` on a configurable interval (default: 30 s). +Adapters are registered with the gateway plugin host at startup. The host calls `start()`/`stop()` and may monitor `health()` on a configurable interval. A richer dynamic `ChannelRegistry` remains a compatible future extension of this lifecycle contract. ``` ChannelRegistry - └── register(adapter: IChannelAdapter): void - └── getAdapter(name: string): IChannelAdapter | null - └── listAdapters(): IChannelAdapter[] + └── register(adapter: OfficialChannelAdapter): void + └── getAdapter(name: string): OfficialChannelAdapter | null + └── listAdapters(): OfficialChannelAdapter[] └── healthAll(): Promise> ``` --- -## M7-002: ChannelMessage Protocol +## M7-002: ChannelMessageDto Protocol ### Canonical Message Format ```typescript -interface ChannelMessage { +interface ChannelMessageDto { /** * Globally unique message ID. * Format: UUID v4. Generated by the adapter when receiving, or by Mosaic @@ -110,6 +97,7 @@ interface ChannelMessage { * The adapter populates this from the inbound message. * For outbound messages, the caller supplies the target channel. */ + channelName: string; channelId: string; /** @@ -119,7 +107,7 @@ interface ChannelMessage { senderId: string; /** Sender classification. */ - senderType: 'user' | 'agent' | 'system'; + senderKind: 'user' | 'agent' | 'system'; /** * Textual content of the message. @@ -136,7 +124,7 @@ interface ChannelMessage { * - "image" — binary image; content is empty, see attachments * - "file" — binary file; content is empty, see attachments */ - contentType: 'text' | 'markdown' | 'code' | 'image' | 'file'; + contentKind: 'text' | 'markdown' | 'code' | 'image' | 'file'; /** * Arbitrary key-value metadata for channel-specific extension fields. @@ -144,7 +132,7 @@ interface ChannelMessage { * Adapters should store channel-native IDs here so round-trip correlation * is possible without altering the canonical fields. */ - metadata: Record; + metadata: Readonly>; /** * Optional thread or reply-chain identifier. @@ -163,18 +151,21 @@ interface ChannelMessage { * Binary or URI-referenced attachments. * Each attachment carries its MIME type and a URL or base64 payload. */ - attachments?: ChannelAttachment[]; + attachments?: readonly ChannelAttachmentDto[]; - /** Wall-clock timestamp when the message was sent/received. */ - timestamp: Date; + /** ISO-8601 wall-clock timestamp when the message was sent/received. */ + timestamp: string; } -interface ChannelAttachment { - /** Filename or identifier. */ +interface ChannelAttachmentDto { + /** Channel-native attachment identifier. */ + id: string; + + /** Filename or display name. */ name: string; - /** MIME type (e.g. "image/png", "application/pdf"). */ - mimeType: string; + /** MIME type when supplied by the channel. */ + mimeType: string | null; /** * URL pointing to the attachment, OR a `data:` URI with base64 payload. @@ -192,23 +183,23 @@ interface ChannelAttachment { ## Channel Translation Reference -The following sections document how each supported channel maps its native message format to and from `ChannelMessage`. +The following sections document how each supported channel maps its native message format to and from `ChannelMessageDto`. ### Matrix -| ChannelMessage field | Matrix equivalent | -| -------------------- | --------------------------------------------------------------------------------------------------------------------------------- | -| `id` | Generated UUID; `metadata.channelMessageId` = Matrix event ID (`$...`) | -| `channelId` | Matrix room ID (`!roomid:homeserver`) | -| `senderId` | Matrix user ID (`@user:homeserver`) | -| `senderType` | Always `"user"` for inbound; `"agent"` or `"system"` for outbound | -| `content` | `event.content.body` | -| `contentType` | `"markdown"` if `msgtype = m.text` and body contains markdown; `"text"` otherwise; `"image"` for `m.image`; `"file"` for `m.file` | -| `threadId` | `event.content['m.relates_to']['event_id']` when `rel_type = m.thread` | -| `replyToId` | Mosaic ID looked up from `event.content['m.relates_to']['m.in_reply_to']['event_id']` | -| `attachments` | Populated from `url` in `m.image` / `m.file` events | -| `timestamp` | `new Date(event.origin_server_ts)` | -| `metadata` | `{ channelMessageId, roomId, eventType, unsigned }` | +| ChannelMessageDto field | Matrix equivalent | +| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------- | +| `id` | Generated UUID; `metadata.channelMessageId` = Matrix event ID (`$...`) | +| `channelId` | Matrix room ID (`!roomid:homeserver`) | +| `senderId` | Matrix user ID (`@user:homeserver`) | +| `senderKind` | Always `"user"` for inbound; `"agent"` or `"system"` for outbound | +| `content` | `event.content.body` | +| `contentKind` | `"markdown"` if `msgtype = m.text` and body contains markdown; `"text"` otherwise; `"image"` for `m.image`; `"file"` for `m.file` | +| `threadId` | `event.content['m.relates_to']['event_id']` when `rel_type = m.thread` | +| `replyToId` | Mosaic ID looked up from `event.content['m.relates_to']['m.in_reply_to']['event_id']` | +| `attachments` | Populated from `url` in `m.image` / `m.file` events | +| `timestamp` | `new Date(event.origin_server_ts)` | +| `metadata` | `{ channelMessageId, roomId, eventType, unsigned }` | **Outbound:** Adapter sends `m.room.message` with `msgtype = m.text` (or `m.notice` for system messages). Markdown content is sent with `format = org.matrix.custom.html` and a rendered HTML body. @@ -216,21 +207,34 @@ The following sections document how each supported channel maps its native messa ### Discord -| ChannelMessage field | Discord equivalent | -| -------------------- | ----------------------------------------------------------------------- | -| `id` | Generated UUID; `metadata.channelMessageId` = Discord message snowflake | -| `channelId` | Discord channel ID (snowflake string) | -| `senderId` | Discord user ID (snowflake) | -| `senderType` | `"user"` for human members; `"agent"` for bot messages | -| `content` | `message.content` | -| `contentType` | `"markdown"` (Discord uses a markdown-like syntax natively) | -| `threadId` | `message.thread.id` when the message is inside a thread channel | -| `replyToId` | Mosaic ID looked up from `message.referenced_message.id` | -| `attachments` | `message.attachments` mapped to `ChannelAttachment` | -| `timestamp` | `new Date(message.timestamp)` | -| `metadata` | `{ channelMessageId, guildId, channelType, mentions, embeds }` | +| ChannelMessageDto field | Discord equivalent | +| ----------------------- | ----------------------------------------------------------------------- | +| `id` | Generated UUID; `metadata.channelMessageId` = Discord message snowflake | +| `channelId` | Discord channel ID (snowflake string) | +| `senderId` | Discord user ID (snowflake) | +| `senderKind` | `"user"` for human members; `"agent"` for bot messages | +| `content` | `message.content` | +| `contentKind` | `"markdown"` (Discord uses a markdown-like syntax natively) | +| `threadId` | `message.thread.id` when the message is inside a thread channel | +| `replyToId` | Mosaic ID looked up from `message.referenced_message.id` | +| `attachments` | `message.attachments` mapped to `ChannelAttachmentDto` | +| `timestamp` | `new Date(message.timestamp)` | +| `metadata` | `{ channelMessageId, guildId, channelType, mentions, embeds }` | -**Outbound:** Adapter calls Discord REST `POST /channels/{id}/messages`. Markdown content is sent as-is (Discord renders it). For `contentType = "code"` the adapter wraps in triple-backtick fences with the `metadata.language` tag. +**Outbound:** Adapter calls Discord REST `POST /channels/{id}/messages`. Markdown content is sent as-is (Discord renders it). For `contentKind = "code"` the adapter wraps in triple-backtick fences with the `metadata.language` tag. + +### Discord routing and thread policy + +A configured Discord binding maps `(guildId, parentChannelId)` to a stable logical agent and a trusted gateway agent-config ID. Gateway verifies that configuration's name matches the binding logical agent before session creation. The stable conversation handle is derived from logical agent plus response channel/thread and never includes the active harness, provider, model, process, or agent-config ID. + +| Inbound location/trigger | Conversation and response target | +| ------------------------------------------ | --------------------------------------------------------------- | +| Authorized untagged parent-channel message | Parent channel; response is sent in-channel | +| Authorized bot mention in parent channel | Thread already attached to that message, or a new public thread | +| Authorized message already in a thread | Existing thread; no repeated mention and no nested thread | +| `/approve` or `/stop ` | Current parent/thread durable session; no new topic is created | + +Authorization order is fixed: guild allowlist → parent-channel allowlist → user allowlist → configured binding/pairing → operation role → per-user/channel message and thread rate limits → thread creation/dispatch. A normal Discord channel's category parent is never treated as the thread authorization parent. If requested thread creation fails, dispatch does not occur because the adapter cannot honor the response target. ### Discord service ingress security @@ -240,21 +244,21 @@ The Discord adapter is an authenticated gateway service, not an anonymous Socket ### Telegram -| ChannelMessage field | Telegram equivalent | -| -------------------- | ------------------------------------------------------------------------------------------------------------- | -| `id` | Generated UUID; `metadata.channelMessageId` = Telegram `message_id` (integer) | -| `channelId` | Telegram `chat_id` (integer as string) | -| `senderId` | Telegram `from.id` (integer as string) | -| `senderType` | `"user"` for human senders; `"agent"` for bot-originated messages | -| `content` | `message.text` or `message.caption` | -| `contentType` | `"text"` for plain; `"markdown"` if `parse_mode = MarkdownV2`; `"image"` for `photo`; `"file"` for `document` | -| `threadId` | `message.message_thread_id` (for supergroup topics) | -| `replyToId` | Mosaic ID looked up from `message.reply_to_message.message_id` | -| `attachments` | `photo`, `document`, `video` fields mapped to `ChannelAttachment` | -| `timestamp` | `new Date(message.date * 1000)` | -| `metadata` | `{ channelMessageId, chatType, fromUsername, forwardFrom }` | +| ChannelMessageDto field | Telegram equivalent | +| ----------------------- | ------------------------------------------------------------------------------------------------------------- | +| `id` | Generated UUID; `metadata.channelMessageId` = Telegram `message_id` (integer) | +| `channelId` | Telegram `chat_id` (integer as string) | +| `senderId` | Telegram `from.id` (integer as string) | +| `senderKind` | `"user"` for human senders; `"agent"` for bot-originated messages | +| `content` | `message.text` or `message.caption` | +| `contentKind` | `"text"` for plain; `"markdown"` if `parse_mode = MarkdownV2`; `"image"` for `photo`; `"file"` for `document` | +| `threadId` | `message.message_thread_id` (for supergroup topics) | +| `replyToId` | Mosaic ID looked up from `message.reply_to_message.message_id` | +| `attachments` | `photo`, `document`, `video` fields mapped to `ChannelAttachmentDto` | +| `timestamp` | `new Date(message.date * 1000)` | +| `metadata` | `{ channelMessageId, chatType, fromUsername, forwardFrom }` | -**Outbound:** Adapter calls Telegram Bot API `sendMessage` with `parse_mode = MarkdownV2` for markdown content. For `contentType = "image"` or `"file"` it uses `sendPhoto` / `sendDocument`. +**Outbound:** Adapter calls Telegram Bot API `sendMessage` with `parse_mode = MarkdownV2` for markdown content. For `contentKind = "image"` or `"file"` it uses `sendPhoto` / `sendDocument`. --- @@ -262,19 +266,19 @@ The Discord adapter is an authenticated gateway service, not an anonymous Socket The TUI adapter bridges Mosaic's terminal interface (`packages/cli`) to the channel protocol so that TUI sessions can be treated as a first-class channel. -| ChannelMessage field | TUI equivalent | -| -------------------- | ------------------------------------------------------------------ | -| `id` | Generated UUID (TUI has no native message IDs) | -| `channelId` | `"tui:"` — the active conversation ID | -| `senderId` | Authenticated Mosaic `userId` | -| `senderType` | `"user"` for human input; `"agent"` for agent replies | -| `content` | Raw text from stdin / agent output | -| `contentType` | `"text"` for input; `"markdown"` for agent responses | -| `threadId` | Not used (TUI sessions are linear) | -| `replyToId` | Not used | -| `attachments` | File paths dragged/pasted into the TUI; resolved to `file://` URLs | -| `timestamp` | `new Date()` at the moment of send | -| `metadata` | `{ conversationId, sessionId, ttyWidth, colorSupport }` | +| ChannelMessageDto field | TUI equivalent | +| ----------------------- | ------------------------------------------------------------------ | +| `id` | Generated UUID (TUI has no native message IDs) | +| `channelId` | `"tui:"` — the active conversation ID | +| `senderId` | Authenticated Mosaic `userId` | +| `senderKind` | `"user"` for human input; `"agent"` for agent replies | +| `content` | Raw text from stdin / agent output | +| `contentKind` | `"text"` for input; `"markdown"` for agent responses | +| `threadId` | Not used (TUI sessions are linear) | +| `replyToId` | Not used | +| `attachments` | File paths dragged/pasted into the TUI; resolved to `file://` URLs | +| `timestamp` | `new Date()` at the moment of send | +| `metadata` | `{ conversationId, sessionId, ttyWidth, colorSupport }` | **Outbound:** The adapter writes rendered content to stdout. Markdown is rendered via a terminal markdown renderer (e.g. `marked-terminal`). Code blocks are syntax-highlighted when `metadata.colorSupport = true`. @@ -284,19 +288,19 @@ The TUI adapter bridges Mosaic's terminal interface (`packages/cli`) to the chan The WebUI adapter connects the Next.js frontend (`apps/web`) to the channel protocol over the existing Socket.IO gateway (`apps/gateway`). -| ChannelMessage field | WebUI equivalent | -| -------------------- | ------------------------------------------------------------ | -| `id` | Generated UUID; echoed back in the WebSocket event | -| `channelId` | `"webui:"` | -| `senderId` | Authenticated Mosaic `userId` | -| `senderType` | `"user"` for browser input; `"agent"` for agent responses | -| `content` | Message text from the input field | -| `contentType` | `"text"` or `"markdown"` | -| `threadId` | Not used (conversation model handles threading) | -| `replyToId` | Message ID the user replied to (UI reply affordance) | -| `attachments` | Files uploaded via the file picker; stored to object storage | -| `timestamp` | `new Date()` at send, or server timestamp from event | -| `metadata` | `{ conversationId, sessionId, clientTimezone, userAgent }` | +| ChannelMessageDto field | WebUI equivalent | +| ----------------------- | ------------------------------------------------------------ | +| `id` | Generated UUID; echoed back in the WebSocket event | +| `channelId` | `"webui:"` | +| `senderId` | Authenticated Mosaic `userId` | +| `senderKind` | `"user"` for browser input; `"agent"` for agent responses | +| `content` | Message text from the input field | +| `contentKind` | `"text"` or `"markdown"` | +| `threadId` | Not used (conversation model handles threading) | +| `replyToId` | Message ID the user replied to (UI reply affordance) | +| `attachments` | Files uploaded via the file picker; stored to object storage | +| `timestamp` | `new Date()` at send, or server timestamp from event | +| `metadata` | `{ conversationId, sessionId, clientTimezone, userAgent }` | **Outbound:** Adapter emits a `chat:message` Socket.IO event. The WebUI React component receives it and appends to the conversation list. Markdown content is rendered client-side via the existing markdown renderer component. @@ -304,7 +308,7 @@ The WebUI adapter connects the Next.js frontend (`apps/web`) to the channel prot ## Identity Mapping -`mapIdentity(channelUserId)` resolves a channel-native user identifier to a Mosaic `userId`. This is required to attribute inbound messages to authenticated Mosaic accounts. +Gateway identity-linking policy resolves a channel-native user identifier to a Mosaic `userId` and produces `ChannelAuthorizedPrincipalDto`. Adapters provide native identity evidence but cannot self-authorize Mosaic scope. Discord currently uses configuration-owned paired users; database-backed linking remains the canonical direction for dynamic Matrix/Slack identity. The implementation must query a `channel_identities` table (or equivalent) keyed on `(channel_name, channel_user_id)`. When no mapping exists the method returns `null` and the message is treated as anonymous (no Mosaic session context). @@ -323,8 +327,8 @@ Identity linking flows (OAuth dance, deep-link verification token, etc.) are out ## Error Handling Conventions -- `connect()` must throw a structured error (subclass of `ChannelConnectError`) if the initial connection cannot be established within a reasonable timeout (default: 10 s). -- `sendMessage()` must throw `ChannelSendError` on terminal failures (auth revoked, channel not found). Transient failures (rate limit, network blip) should be retried internally with exponential backoff before throwing. +- `start()` must establish the native channel transport or throw a structured connection error. An adapter hosted inside the gateway must not wait for a loopback connection to that same not-yet-listening process; it starts the native transport, lets Socket.IO reconnect, and reports `degraded` until both links are ready. +- `ChannelEgressPort.send()` implementations must throw a typed terminal error for revoked auth, an invalid route, or a missing channel. Only transient rate/network/server failures are retried with bounded exponential backoff; Discord retries reuse a stable enforced nonce to prevent duplicate chunks, while permanent 4xx failures are not retried. - `health()` must never throw — it returns `{ status: 'disconnected' }` on error. - Adapters must emit structured logs with `{ channel: adapter.name, event, ... }` metadata for observability. @@ -332,7 +336,7 @@ Identity linking flows (OAuth dance, deep-link verification token, etc.) are out ## Versioning -The `ChannelMessage` protocol follows semantic versioning. Non-breaking field additions (new optional fields) are minor version bumps. Breaking changes (type changes, required field additions) require a major version bump and a migration guide. +The `ChannelMessageDto` protocol follows semantic versioning. Non-breaking field additions (new optional fields) are minor version bumps. Breaking changes (type changes, required field additions) require a major version bump and a migration guide. Current version: **1.0.0** @@ -473,7 +477,7 @@ A single Mosaic conversation can be accessed simultaneously from multiple surfac ### Real-Time Sync Flow 1. A message arrives on any surface (TUI keystroke, browser send, Matrix event). -2. The surface's adapter normalizes the message to `ChannelMessage` and delivers it to `ConversationService`. +2. The surface's adapter normalizes the message to `ChannelMessageDto` and delivers it to `ConversationService`. 3. `ConversationService` persists the message to PostgreSQL, assigns a canonical `id`, and publishes a `message:new` event to the Valkey pub/sub channel keyed by `conversationId`. 4. All active surfaces subscribed to that `conversationId` receive the fanout event and push it to their respective clients: - TUI adapter: writes rendered output to the connected terminal session. @@ -561,7 +565,7 @@ Matrix sessions for linked users are persistent and long-lived. Unlike TUI sessi - Their `channel_identities` row exists (link not revoked). - They remain members of the relevant Matrix rooms. -Revoking a Matrix link (`DELETE /auth/channel-link/matrix/`) removes the `channel_identities` row and causes `mapIdentity()` to return `null`. The appservice optionally kicks the Matrix user from all Mosaic-managed rooms as part of the revocation flow (configurable, default: off). +Revoking a Matrix link (`DELETE /auth/channel-link/matrix/`) removes the `channel_identities` row and causes gateway principal resolution to deny the identity. The appservice optionally kicks the Matrix user from all Mosaic-managed rooms as part of the revocation flow (configurable, default: off). --- @@ -733,7 +737,7 @@ room_retention_policies created_at TIMESTAMP ``` -The retention policy is enforced by a background job in the gateway that calls Conduit's admin API to purge events older than the configured threshold. Purged events are removed from the Conduit store but Mosaic's PostgreSQL message store retains the canonical `ChannelMessage` record unless the Mosaic retention policy also covers it. +The retention policy is enforced by a background job in the gateway that calls Conduit's admin API to purge events older than the configured threshold. Purged events are removed from the Conduit store but Mosaic's PostgreSQL message store retains the canonical `ChannelMessageDto` record unless the Mosaic retention policy also covers it. Default retention values: diff --git a/docs/guides/admin-guide.md b/docs/guides/admin-guide.md index e6a098b4..e21c5c69 100644 --- a/docs/guides/admin-guide.md +++ b/docs/guides/admin-guide.md @@ -293,24 +293,64 @@ Each OIDC provider requires its client ID, client secret, and issuer URL togethe ### Plugins -| Variable | Description | -| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| `DISCORD_BOT_TOKEN` | Discord bot token (enables Discord plugin) | -| `DISCORD_SERVICE_TOKEN` | Required high-entropy service credential used to authenticate and sign Discord ingress; inject through the approved secret mechanism only | -| `DISCORD_SERVICE_USER_ID` | Required Mosaic service-principal user ID that owns persisted Discord conversations; the original Discord user ID remains audit metadata | -| `DISCORD_GUILD_ID` | Discord guild/server ID | -| `DISCORD_GATEWAY_URL` | Gateway URL for Discord plugin to call (default: `http://localhost:14242`) | -| `DISCORD_ALLOWED_GUILD_IDS` | Required comma-separated Discord guild snowflake allowlist; default-deny | -| `DISCORD_ALLOWED_CHANNEL_IDS` | Required comma-separated Discord channel snowflake allowlist; default-deny | -| `DISCORD_ALLOWED_USER_IDS` | Required comma-separated Discord user snowflake allowlist; default-deny | -| `TELEGRAM_BOT_TOKEN` | Telegram bot token (enables Telegram plugin) | -| `TELEGRAM_GATEWAY_URL` | Gateway URL for Telegram plugin to call | +| Variable | Description | +| --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| `DISCORD_BOT_TOKEN` | Discord bot token (enables Discord plugin) | +| `DISCORD_SERVICE_TOKEN` | Required high-entropy service credential used to authenticate and sign Discord ingress; inject through the approved secret mechanism only | +| `DISCORD_SERVICE_USER_ID` | Required Mosaic service-principal user ID that owns persisted Discord conversations; the original Discord user ID remains audit metadata | +| `DISCORD_GUILD_ID` | Discord guild/server ID | +| `DISCORD_GATEWAY_URL` | Gateway URL for Discord plugin to call (default: `http://localhost:14242`) | +| `DISCORD_ALLOWED_GUILD_IDS` | Required comma-separated Discord guild snowflake allowlist; default-deny | +| `DISCORD_ALLOWED_CHANNEL_IDS` | Required comma-separated Discord channel snowflake allowlist; default-deny | +| `DISCORD_ALLOWED_USER_IDS` | Required comma-separated Discord user snowflake allowlist; default-deny | +| `DISCORD_INTERACTION_BINDINGS` | Required JSON bindings from guild/channel to logical agent and paired Discord users with `viewer`, `operator`, or `admin` roles | +| `DISCORD_MESSAGE_RATE_LIMIT_PER_MINUTE` | Optional positive integer; authorized turns per guild/channel/user each minute (default: `30`) | +| `DISCORD_THREAD_RATE_LIMIT_PER_MINUTE` | Optional positive integer; mention-thread routes per guild/channel/user each minute (default: `5`) | +| `TELEGRAM_BOT_TOKEN` | Telegram bot token (enables Telegram plugin) | +| `TELEGRAM_GATEWAY_URL` | Gateway URL for Telegram plugin to call | ### Discord ingress security When `DISCORD_BOT_TOKEN` is configured, `DISCORD_SERVICE_TOKEN`, `DISCORD_SERVICE_USER_ID`, and all three Discord allowlists are required. Gateway startup fails rather than enabling a broad or unauthenticated remote-control surface. The service user ID identifies a provisioned Mosaic service principal for persistence; the original Discord user ID is retained in ingress audit metadata. The service token is a secret supplied by the approved runtime secret mechanism and is never committed or logged. -Inbound Discord messages must originate from an allowed guild, channel, and user, mention the bot, and carry a signed envelope containing the native Discord message ID and a generated correlation ID. The gateway validates the service identity, envelope signature, and allowlists again before dispatching. Replayed Discord message IDs are rejected during the bounded ingress replay window. Durable inbox/idempotency retention is introduced with Tess durable state. +Inbound Discord messages must originate from an allowed guild and configured parent channel, come from an allowed and paired user whose role permits sending, and carry a signed envelope containing the native Discord message ID and a generated correlation ID. Attachment references are limited in count, metadata size, field length, and declared size; only query-free HTTPS URLs without credentials or fragments are accepted, so bearer or presigned URLs never reach persistence or an agent prompt. The gateway validates the service identity, envelope signature, allowlists, pairing, and role again before dispatching. Replayed Discord message IDs are rejected during the bounded ingress replay window. Durable inbox/idempotency retention is introduced with Tess durable state. + +Configured channels are dedicated agent interaction surfaces. An authorized untagged message routes to the bound logical agent and the response returns in that channel. Mentioning the bot on a normal channel message creates a public Discord thread, or reuses the thread already attached to that same message; the response and later thread messages stay in that thread without repeated mentions. A normal channel's category is not an authorization parent—only a Discord thread inherits authorization from its configured parent channel. Runtime control commands such as `/approve` and `/stop ` remain on the current channel/thread because they target that durable session rather than opening a new topic. + +Authorization and per-user/channel rate limits are evaluated before thread creation, so an unlisted guild/channel/user, unpaired user, `viewer`, or rate-limited sender cannot create bot threads or dispatch gateway work. The bot needs Discord permissions to view/send in configured channels and create/send in public threads. If thread creation fails, the turn is not dispatched because the requested response destination cannot be honored. + +Conversation handles use the configured logical agent plus Discord channel/thread identity. They do not contain a Claude, Codex, Pi, OpenCode, model, process, or runtime-provider identifier; changing the runtime behind the logical session therefore does not require reconnecting the Discord bot. + +#### Interaction binding format + +`DISCORD_INTERACTION_BINDINGS` must be a non-empty JSON array. Each item requires `instanceId`, trusted `agentConfigId`, `guildId`, `channelId`, and a non-empty `pairedUsers` object. `instanceId` is the configured logical-agent name; its trusted database `agentConfigId` must resolve to an agent configuration with exactly that name, preserving provider/model/prompt/tool selection per binding. The guild/channel must also appear in their corresponding allowlists. IDs below are placeholders: + +```json +[ + { + "instanceId": "interaction-agent", + "agentConfigId": "agent-config-id", + "guildId": "guild-id", + "channelId": "channel-id", + "pairedUsers": { + "discord-user-id": { + "role": "operator", + "mosaicUserId": "mosaic-user-id" + } + } + } +] +``` + +| Pairing role | Send message | Create/continue thread | Approve | Stop | +| ------------ | ------------ | ---------------------- | ------- | ---- | +| `viewer` | No | No | No | No | +| `operator` | Yes | Yes | No | No | +| `admin` | Yes | Yes | Yes | Yes | + +A legacy role-only value such as `"discord-user-id": "operator"` remains valid for non-privileged ingress. Approval and stop require the object form with a provisioned `mosaicUserId`; gateway policy checks that Mosaic identity and consumes one exact-action approval once. Do not make the Discord service principal an approving administrator. + +After changing bindings or allowlists, restart the gateway/plugin through the normal service manager and verify both Discord and gateway connectivity. The adapter reports `connected` only when both links are ready, `degraded` when one is ready, and `disconnected` when neither is ready. Test one authorized untagged channel turn, one mention-created thread, one thread follow-up, and one unauthorized user denial without using production credential values in logs or evidence. ### Session retention and garbage collection diff --git a/docs/reports/code-review/756-code-review.md b/docs/reports/code-review/756-code-review.md new file mode 100644 index 00000000..fb8fd184 --- /dev/null +++ b/docs/reports/code-review/756-code-review.md @@ -0,0 +1,27 @@ +# Independent Code Review — #756 Official Discord Channel Plugin + +**Verdict: APPROVE** + +## Scope reviewed + +Complete current uncommitted #756 delta: multi-binding trusted agent selection, privileged ingress-route validation, attachment validation/persistence/resume, egress route lifecycle and idempotency, concurrent gateway stream state, Discord lifecycle/thread/rate behavior, compatibility ingress, and tests. + +## Review result + +No blocking or change-request finding remains. + +- **Trusted multi-agent routing:** each binding requires a provisioned `agentConfigId`; the gateway resolves that config server-side, verifies its logical-agent name, and never accepts a Discord-controlled agent selection or applies generic routing to Discord ingress. +- **Auth and route integrity:** allowlist, pairing, role, and canonical logical-agent/channel-or-thread conversation-route validation occur before gateway processing. Privileged approval/stop paths use the same binding and route validation. +- **Attachments:** ingress rejects malformed, over-bounded, credential-bearing, fragment-bearing, or query-bearing URLs. Valid attachment metadata, including `sizeBytes`, persists and is reconstructed into resume history as explicitly untrusted context. +- **Discord reliability:** the adapter supports degraded Socket.IO reconnect, parent/thread routing semantics, bounded pre-side-effect ingress rates, and terminal response-route cleanup. Egress validates route/message alignment, sends deterministic nonces, distinguishes permanent from transient errors, and uses bounded retries. +- **Concurrent state:** per-client/conversation keys isolate listener, redaction, tool, and stream state for simultaneous threads sharing a Discord socket; disconnect cleanup covers all associated conversations. +- **Harness neutrality:** contracts retain logical-agent/channel data only; no harness/provider identity leaks into adapter routes or message boundaries. + +## Verification performed + +- `git diff --check` — passed. +- `pnpm --filter @mosaicstack/discord-plugin typecheck` — passed. +- `pnpm --filter @mosaicstack/discord-plugin lint` — passed. +- `pnpm --filter @mosaicstack/discord-plugin test` — passed: 44 tests; coverage 92.18% statements/lines, 86.55% branches, 100% functions (all ≥85% threshold). +- `pnpm --filter @mosaicstack/gateway typecheck` — passed. +- `cd apps/gateway && pnpm exec vitest run src/plugin/discord-ingress.security.spec.ts src/chat/chat.gateway-redaction.spec.ts src/__tests__/integration/tess-cross-surface.integration.test.ts` — passed: 32 tests. diff --git a/docs/reports/documentation/756-discord-plugin-checklist.md b/docs/reports/documentation/756-discord-plugin-checklist.md new file mode 100644 index 00000000..4fae2032 --- /dev/null +++ b/docs/reports/documentation/756-discord-plugin-checklist.md @@ -0,0 +1,36 @@ +# Documentation Completion Checklist — #756 Official Discord plugin + +## Required artifacts + +- [x] `docs/PRD.md` includes the #756 workstream, assumptions, and acceptance criteria. +- [x] User workflow updated in `docs/tess/USER-GUIDE.md`. +- [x] Administrator configuration and authorization policy updated in `docs/guides/admin-guide.md`. +- [x] Developer/plugin authoring guidance updated in `docs/tess/PLUGIN-GUIDE.md`. +- [x] Channel architecture updated in `docs/architecture/channel-protocol.md`. +- [x] Package operations/development guide added at `plugins/discord/README.md`. +- [x] `docs/SITEMAP.md` links the official channel plugin documentation. + +## API coverage + +- [x] No HTTP or WebSocket endpoint was added, removed, or changed. +- [x] No OpenAPI update is needed. +- [x] Shared TypeScript contracts are documented in architecture and plugin-authoring guides. +- [x] Discord authentication, authorization, thread failure, and control-command behavior are documented. + +## Structural standards + +- [x] Working notes remain under `docs/scratchpads/`. +- [x] Review and checklist artifacts remain under `docs/reports/`. +- [x] No generated publishing output was added. +- [x] Existing repository documentation structure was preserved; no unrelated root cleanup was attempted. + +## Review gate + +- [x] Independent documentation/contract review passes (shared-contract review plus final code review of the current documentation delta). +- [x] Independent code review verifies documentation matches implementation (`docs/reports/code-review/756-code-review.md`: APPROVE). +- [x] Independent security review verifies documented controls (`docs/reports/security/756-security-review.md`: APPROVE). + +## Publishing + +- [x] Canonical source remains in-repository. +- [x] No external publishing action is in scope for this slice. diff --git a/docs/reports/security/756-security-review.md b/docs/reports/security/756-security-review.md new file mode 100644 index 00000000..8f124ccc --- /dev/null +++ b/docs/reports/security/756-security-review.md @@ -0,0 +1,37 @@ +# Security Review — Issue #756 + +**Scope:** final current uncommitted Discord plugin, shared channel contract, gateway ingress, AgentService, and plugin registration delta +**Snapshot:** `plugins/discord/src/index.ts` SHA-256 `5ee6b6aa4e2ff349f137f76b918d02c1254e12066cb48b136048e57bef36fdb2` +**Verdict:** **APPROVE** + +## Final remediation verification + +| Area | Current evidence | Result | +|---|---|---| +| Attachment confidentiality and integrity | Ingress accepts bounded attachment metadata only when URL is HTTPS, query-free, fragment-free, and credential-free. Count, aggregate size, field length, MIME, and finite non-negative size validation apply before dispatch; `sizeBytes` is signed and retained. | Pass | +| Trusted agent selection | Each binding names a required trusted `agentConfigId`; gateway resolves that config server-side and requires its configured name to equal the binding logical-agent ID. Client/provider input cannot select the agent for Discord ingress. | Pass | +| Privileged operation routing | Gateway revalidates the binding and requires the signed conversation identity to match the configured logical agent before approve/stop actions. Paired admin identity and one-time approval checks remain enforced. | Pass | +| Egress containment and delivery | Egress requires an aligned message/route, configured parent or exact observed thread target, and cleans response routes after completion, errors, typed-ingress failure, or bounded-map pressure. | Pass | +| Side-effect limits | Per guild/authorized-parent/user rolling message and thread limits execute before thread creation or dispatch. | Pass | + +## Security controls reviewed + +- Default-deny guild, parent-channel, user, configured pairing, and role checks precede rate consumption and all side effects. +- Gateway independently enforces Discord service authentication, HMAC integrity, allowlists, binding/role checks, attachment validation, and replay-ID rejection. +- Thread authorization uses only the Discord thread parent; category parents cannot authorize ingress. +- Agent-visible attachment references are explicitly labeled untrusted; binary content is not embedded. Persisted attachment name/URL values are redacted. +- Egress uses deterministic nonces, bounded transient retry, typed terminal errors, and does not send to forged targets. +- No secrets or message content were added to plugin logs. + +## Verification evidence + +| Command | Result | +|---|---| +| `pnpm --filter @mosaicstack/discord-plugin test` | PASS — 44 tests; v8 coverage: 92.18% statements/lines, 86.55% branches, 100% functions | +| `pnpm --filter @mosaicstack/discord-plugin typecheck` | PASS | +| `pnpm --filter @mosaicstack/types typecheck` | PASS | +| `pnpm --filter @mosaicstack/gateway typecheck` | PASS | +| `pnpm --filter @mosaicstack/gateway exec vitest run src/plugin/discord-ingress.security.spec.ts src/agent/__tests__/agent-service-ownership.test.ts` | PASS — 29 tests | +| `git diff --check` | PASS | + +No unresolved critical, high, medium, or low security findings were identified in the reviewed final delta. diff --git a/docs/scratchpads/756-official-discord-plugin.md b/docs/scratchpads/756-official-discord-plugin.md new file mode 100644 index 00000000..78b23a08 --- /dev/null +++ b/docs/scratchpads/756-official-discord-plugin.md @@ -0,0 +1,57 @@ +# Scratchpad — #756 Official Discord channel plugin + +- **Task / issue:** Official Discord channel plugin / #756 +- **Branch:** `feat/756-official-discord-plugin` +- **Worktree:** `/home/hermes/agent-work/stack-discord-plugin` +- **Base:** `origin/main` at `49e8a54105eddf41e8e0e44603ded616ee76044f` +- **Objective:** Deliver harness-neutral Discord routing, native mention-to-thread behavior, untagged in-channel interaction, fail-closed channel/user authorization, and transport-neutral contracts for future official channel plugins. +- **Collision boundary:** Do not modify orchestrator-to-Pi migration, logical-agent lease/fencing (#754/#755), runtime provider implementations, or orchestrator-owned `docs/TASKS.md`. +- **Working budget:** 55K tokens for requirements, implementation, tests, documentation, independent review, and delivery. No user-specified hard cap. Reduce optional refactoring before reducing acceptance coverage. + +## Assumptions + +1. **ASSUMPTION:** Every configured Discord channel is intentionally agent-bound, so an authorized human's untagged message is agent input and receives an in-channel response. Rationale: this satisfies the requested no-tag behavior without activating the bot in arbitrary channels. +2. **ASSUMPTION:** Mentioning the bot in a parent channel creates or reuses a public Discord thread; messages already in a thread remain in that thread without repeated mentions. Rationale: Discord does not support nested threads and the request describes tags as the thread-selection signal. +3. **ASSUMPTION:** One bot process may host multiple configured channel-to-logical-agent bindings. Rationale: bindings are already configuration-owned and this avoids per-agent Discord credentials. +4. **ASSUMPTION:** Static guild/channel/user allowlists and paired-user roles remain the administration surface for this slice. Rationale: dynamic admin UI is larger and can be added without changing adapter contracts. +5. **ASSUMPTION:** The stable conversation handle contains logical agent plus Discord channel/thread identity and never a harness/provider identifier. Rationale: runtime re-enrollment and the active lease work can change Claude/Codex/Pi/OpenCode behind the same channel connection. +6. **ASSUMPTION:** Canonical documentation remains in-repository for this slice; no external publishing is performed. + +## Plan + +1. Update `docs/PRD.md` before code with #756 scope, constraints, assumptions, and acceptance criteria. +2. Add failing Discord behavior and authorization tests first. +3. Add transport-neutral channel DTO/contracts in `@mosaicstack/types`. +4. Implement mention-to-thread, untagged in-channel, existing-thread, stable conversation, and adapter health behavior without runtime-specific imports. +5. Update admin/developer/channel protocol docs and documentation checklist. +6. Run focused tests, typecheck, lint, formatting, full applicable baseline, and coverage. +7. Run independent code and security review; remediate and re-review. +8. Commit, queue-guard, push, open PR to `main`, wait for green CI, squash merge, verify merged CI, and close #756. + +## Progress + +- 2026-07-14: Loaded mission state (none active), global/project guidance, current `origin/main`, existing Discord/Tess/fleet connector architecture, issue #709 history, and active portability issues #754/#755. +- 2026-07-14: Created issue #756 through the Mosaic wrapper. +- 2026-07-14: Created isolated worktree/branch from current `origin/main`; the stale root checkout and unrelated QA artifacts remain untouched. + +## TDD decision + +Required and applied. This change modifies authorization-sensitive remote ingress and routing behavior. Failing permission and routing tests will be captured before implementation. + +## Risks / blockers + +- Active logical-agent lease work may later add stronger fencing fields. This slice must expose a clean, harness-neutral seam without duplicating that schema. +- Discord thread creation is an external side effect. Unit tests use a typed fake; live credential smoke testing is out of scope and must not use committed secrets. +- Repository-wide checks may expose unrelated baseline debt; changed-scope evidence and CI remain mandatory. + +## Verification evidence + +- `pnpm format:check` — passed. +- `git diff --check` — passed. +- `pnpm typecheck` — passed (42 Turbo tasks). +- `pnpm lint` — passed (23 Turbo tasks). +- `pnpm build` — passed (23 Turbo tasks). +- `pnpm --filter @mosaicstack/discord-plugin test` — passed: 44 tests; V8 coverage 92.18% statements/lines, 86.55% branches, 100% functions (all configured thresholds ≥85%). +- Focused gateway verification passed: Discord ingress/security, cross-surface, ownership, redaction/concurrency, and agent attachment tests. +- `pnpm test` — changed-scope suites passed; repository baseline remains blocked by `apps/gateway/src/__tests__/cross-user-isolation.test.ts` requiring PostgreSQL at localhost port 5433 (`ECONNREFUSED`). The failure is unrelated to this change. +- Independent code/security re-reviews requested after final remediation; reports are stored under ignored `docs/reports/` evidence paths. diff --git a/docs/tess/PLUGIN-GUIDE.md b/docs/tess/PLUGIN-GUIDE.md index 4d40348c..efa54fdb 100644 --- a/docs/tess/PLUGIN-GUIDE.md +++ b/docs/tess/PLUGIN-GUIDE.md @@ -1,3 +1,28 @@ # Tess Plugin Authoring Plugins are replaceable adapters. Declare capabilities, derive scope from trusted context, preserve correlation IDs, redact before persistence/egress, and return unsupported operations as fail-closed results. Names and identities are configuration data, not literals in keys or defaults. + +## Official channel adapter contract + +Official Discord, Matrix, Slack, and future channel adapters share contracts exported from `@mosaicstack/types` under `channel/`: + +- `OfficialChannelAdapter` provides `name`, `start()`, `stop()`, and non-throwing connection `health()`. +- `ChannelMessageDto` and `ChannelAttachmentDto` normalize transport data with JSON-safe metadata. +- `ChannelBindingDto` and `ChannelAuthorizedPrincipalDto` normalize configuration-owned logical-agent binding and the already-allowlisted/paired external actor. +- `ChannelIngressDto` carries operation, correlation, native message ID, authorized principal, normalized message, and stable route into `ChannelIngressPort`. +- `ChannelConversationRouteDto` binds a configured channel to `logicalAgentId`, stable `conversationId`, authorization parent, and response target. +- `ChannelEgressDto` and `ChannelEgressPort` separate where a response is delivered from the gateway's runtime/provider selection. + +`ChannelConversationRouteDto` deliberately has no harness, provider, model, process, or native runtime-session field. The gateway owns runtime selection, durable enrollment, authorization, audit, and lease/fencing. A channel adapter must not call Claude, Codex, Pi, OpenCode, tmux, or Matrix runtime providers directly. Discord currently preserves its signed Socket.IO compatibility ingress for established gateway authentication/replay/approval controls while normalizing the same ingress DTO; supplied direct ports are the future registration path. + +## Adapter requirements + +1. Resolve configuration-owned channel and logical-agent bindings before dispatch. A binding may carry a trusted gateway agent-config reference, but the stable route contains only the logical agent; gateway verifies the reference resolves to that agent before runtime selection. +2. Apply channel-native allowlists and paired-user roles before any external side effect such as thread creation. +3. Preserve native message ID, correlation ID, channel/thread address, attachments, and response target. +4. Treat normal channel parents (for example Discord categories) separately from thread parents. +5. Keep reconnect and conversation identity independent of the active runtime provider. +6. Report sanitized connection/routing failures without message bodies or credentials. +7. Pass the shared route/authorization contract suite plus adapter-specific translation tests. + +Discord establishes the first policy: authorized untagged messages respond in the configured channel; a mention creates a thread or reuses the thread already attached to that message; existing thread messages stay there. Runtime control commands remain on the current durable session. Matrix and Slack should translate native rooms/threads into the same route and response-target semantics rather than adding transport branches to gateway core. diff --git a/docs/tess/USER-GUIDE.md b/docs/tess/USER-GUIDE.md index 90a85f15..908ba61d 100644 --- a/docs/tess/USER-GUIDE.md +++ b/docs/tess/USER-GUIDE.md @@ -1,5 +1,13 @@ # Tess User Guide +## Discord conversations + +In a configured Tess/interaction channel, an authorized untagged message is sent to the bound logical agent and its response appears in the channel. Mention the bot when starting a separate topic: Mosaic reuses a thread already attached to that same Discord message, or creates a new thread for the message, and responds there. Continue in that thread without tagging the bot again. Messages from unconfigured channels or users without an authorized pairing are ignored without creating a thread. + +`/approve` and `/stop ` operate on the current channel/thread session and do not open a new thread. The Discord connection is bound to the logical agent conversation, not Claude, Codex, Pi, OpenCode, or another harness; a runtime handoff behind Mosaic does not change where you continue the conversation. + +## CLI and HTTP interaction + All HTTP interaction calls require authenticated session credentials and `X-Correlation-Id`. Use `GET /api/interaction/{agentName}/sessions?provider=...` to list only visible runtime sessions, then enroll with `POST .../sessions/{sessionId}/enroll` body `{providerId,runtimeSessionId}`. Attach uses `{mode:"read"}`; send uses `{content,idempotencyKey}`. Stop requires `{approvalRef}` and fails with 403 without the exact durable approval. Recovery only requeues interrupted durable work. Memory is user-scoped: preferences support list/get/upsert/delete; insights support list/get/create/delete; search body is `{query,limit?,maxDistance?}`. Mos work is handed off with `POST /api/coord/mos/handoff`; observe and result use the returned handoff ID. diff --git a/eslint.config.mjs b/eslint.config.mjs index bc270143..0433f3a0 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -28,6 +28,7 @@ export default tseslint.config( 'apps/web/e2e/helpers/*.ts', 'apps/web/playwright.config.ts', 'apps/gateway/vitest.config.ts', + 'plugins/discord/vitest.config.ts', 'packages/db/vitest.config.ts', 'packages/storage/vitest.config.ts', 'packages/mosaic/vitest.config.ts', diff --git a/packages/types/src/channel/channel-adapter.ts b/packages/types/src/channel/channel-adapter.ts new file mode 100644 index 00000000..e10b9e6e --- /dev/null +++ b/packages/types/src/channel/channel-adapter.ts @@ -0,0 +1,43 @@ +import type { + ChannelAdapterHealthDto, + ChannelEgressDto, + ChannelIngressDto, +} from './channel.dto.js'; + +export type ChannelDeliveryErrorCode = + | 'invalid_route' + | 'destination_unavailable' + | 'delivery_failed'; + +/** Terminal adapter delivery failure surfaced to the gateway/caller. */ +export class ChannelDeliveryError extends Error { + readonly name = 'ChannelDeliveryError'; + + constructor( + readonly code: ChannelDeliveryErrorCode, + message: string, + readonly retryable = false, + options?: ErrorOptions, + ) { + super(message, options); + } +} + +/** Gateway policy boundary consumed by official channel adapters. */ +export interface ChannelIngressPort { + receive(ingress: ChannelIngressDto): Promise; +} + +/** Adapter egress boundary used by the gateway after agent output is ready. */ +export interface ChannelEgressPort { + send(egress: ChannelEgressDto): Promise; +} + +/** Shared lifecycle seam implemented by every official channel adapter. */ +export interface OfficialChannelAdapter { + readonly name: string; + start(): Promise; + stop(): Promise; + /** Health is best-effort and never throws for ordinary disconnected state. */ + health(): Promise; +} diff --git a/packages/types/src/channel/channel.dto.ts b/packages/types/src/channel/channel.dto.ts new file mode 100644 index 00000000..9da8160e --- /dev/null +++ b/packages/types/src/channel/channel.dto.ts @@ -0,0 +1,97 @@ +/** JSON-safe metadata carried across channel adapter boundaries. */ +export type ChannelMetadataValue = + | string + | number + | boolean + | null + | readonly ChannelMetadataValue[] + | { readonly [key: string]: ChannelMetadataValue }; + +export type ChannelSenderKind = 'user' | 'agent' | 'system'; +export type ChannelContentKind = 'text' | 'markdown' | 'code' | 'image' | 'file'; +export type ChannelAdapterStatus = 'connected' | 'degraded' | 'disconnected'; +export type ChannelAuthorizationRole = 'viewer' | 'operator' | 'admin'; +export type ChannelOperation = 'message.send' | 'approval.create' | 'session.stop'; + +export interface ChannelAttachmentDto { + id: string; + name: string; + mimeType: string | null; + url: string; + sizeBytes?: number; +} + +/** Canonical transport-neutral message shape for official channel adapters. */ +export interface ChannelMessageDto { + id: string; + channelName: string; + channelId: string; + senderId: string; + senderKind: ChannelSenderKind; + content: string; + contentKind: ChannelContentKind; + timestamp: string; + threadId?: string; + replyToId?: string; + attachments?: readonly ChannelAttachmentDto[]; + metadata: Readonly>; +} + +/** Where an adapter must deliver a response for one normalized conversation turn. */ +/** Provisioned external identity after adapter allowlist/pairing checks pass. */ +export interface ChannelAuthorizedPrincipalDto { + channelUserId: string; + role: ChannelAuthorizationRole; + /** Needed when gateway policy must authorize a privileged Mosaic operation. */ + mosaicUserId?: string; +} + +/** Configuration-owned binding. Credentials are intentionally absent. */ +export interface ChannelBindingDto { + bindingId: string; + channelName: string; + workspaceId: string; + channelId: string; + logicalAgentId: string; + principals: Readonly>; +} + +export interface ChannelResponseTargetDto { + channelId: string; + threadId?: string; +} + +/** + * Stable channel-to-session route. Runtime provider, harness, model, process, + * and native runtime session identifiers are intentionally absent. + */ +export interface ChannelConversationRouteDto { + bindingId: string; + logicalAgentId: string; + conversationId: string; + channelName: string; + authorizationChannelId: string; + responseTarget: ChannelResponseTargetDto; +} + +/** Authorized adapter-to-gateway ingress after native translation. */ +export interface ChannelIngressDto { + correlationId: string; + nativeMessageId: string; + operation: ChannelOperation; + principal: ChannelAuthorizedPrincipalDto; + message: ChannelMessageDto; + route: ChannelConversationRouteDto; +} + +/** Gateway-to-adapter egress; runtime/provider identity remains gateway-internal. */ +export interface ChannelEgressDto { + correlationId: string; + message: ChannelMessageDto; + route: ChannelConversationRouteDto; +} + +export interface ChannelAdapterHealthDto { + status: ChannelAdapterStatus; + detail?: string; +} diff --git a/packages/types/src/channel/index.ts b/packages/types/src/channel/index.ts new file mode 100644 index 00000000..fb6413c5 --- /dev/null +++ b/packages/types/src/channel/index.ts @@ -0,0 +1,2 @@ +export * from './channel-adapter.js'; +export * from './channel.dto.js'; diff --git a/packages/types/src/chat/events.ts b/packages/types/src/chat/events.ts index 432a53db..075431af 100644 --- a/packages/types/src/chat/events.ts +++ b/packages/types/src/chat/events.ts @@ -1,3 +1,4 @@ +import type { ChannelAttachmentDto } from '../channel/index.js'; import type { CommandManifestPayload, SlashCommandApprovalResultPayload, @@ -73,6 +74,7 @@ export interface ChatMessagePayload { provider?: string; modelId?: string; agentId?: string; + attachments?: readonly ChannelAttachmentDto[]; } /** Routing decision summary included in session:info for transparency */ diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index d35b52c4..3b2ab893 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -1,5 +1,6 @@ export const VERSION = '0.0.0'; +export * from './channel/index.js'; export * from './chat/index.js'; export * from './agent/index.js'; export * from './provider/index.js'; diff --git a/plugins/discord/README.md b/plugins/discord/README.md new file mode 100644 index 00000000..08d4dfec --- /dev/null +++ b/plugins/discord/README.md @@ -0,0 +1,67 @@ +# @mosaicstack/discord-plugin + +Official Discord channel adapter for Mosaic Stack. + +## Behavior + +- Runs independently of the bound agent's Claude, Codex, Pi, OpenCode, or future harness. +- Routes authorized untagged messages in configured agent channels and replies in-channel. +- Creates a public Discord thread when the bot is mentioned in a parent channel, or reuses the thread already attached to that same message. +- Keeps follow-ups in existing threads without requiring repeated mentions. +- Keeps `/approve` and `/stop ` on the current durable session. +- Applies guild, parent-channel, user, pairing, role, and per-minute abuse limits before thread creation or gateway dispatch. +- Authenticates to the gateway and signs ingress envelopes with the injected service token. + +## Required configuration + +| Variable | Purpose | +| --------------------------------------- | -------------------------------------------------------------------- | +| `DISCORD_BOT_TOKEN` | Discord bot credential | +| `DISCORD_SERVICE_TOKEN` | High-entropy plugin-to-gateway credential | +| `DISCORD_SERVICE_USER_ID` | Provisioned Mosaic service principal | +| `DISCORD_ALLOWED_GUILD_IDS` | Comma-separated guild allowlist | +| `DISCORD_ALLOWED_CHANNEL_IDS` | Comma-separated parent-channel allowlist | +| `DISCORD_ALLOWED_USER_IDS` | Comma-separated Discord user allowlist | +| `DISCORD_INTERACTION_BINDINGS` | JSON channel→logical-agent bindings and paired-user roles | +| `DISCORD_GATEWAY_URL` | Gateway base URL; defaults to the gateway's local development URL | +| `DISCORD_MESSAGE_RATE_LIMIT_PER_MINUTE` | Authorized turns per guild/channel/user per minute; default `30` | +| `DISCORD_THREAD_RATE_LIMIT_PER_MINUTE` | Mention-thread routes per guild/channel/user per minute; default `5` | + +Supply credentials through the approved runtime secret mechanism. Never commit tokens or binding data containing secrets. + +Example binding shape (identifiers are placeholders): + +```json +[ + { + "instanceId": "interaction-agent", + "agentConfigId": "agent-config-id", + "guildId": "guild-id", + "channelId": "channel-id", + "pairedUsers": { + "discord-user-id": { + "role": "operator", + "mosaicUserId": "mosaic-user-id" + } + } + } +] +``` + +Each binding's trusted `agentConfigId` must identify a provisioned database agent configuration whose name exactly matches its `instanceId`. Roles are `viewer`, `operator`, and `admin`. `viewer` cannot send agent turns. Runtime approval and stop operations require an `admin` pairing with a provisioned `mosaicUserId`. + +The bot needs Discord permissions to view/send messages in configured channels and create/send in public threads. A channel category is not an authorization boundary; threads inherit authorization only from their configured parent text channel. + +## Shared contract + +The adapter implements `OfficialChannelAdapter` from `@mosaicstack/types`. `ChannelConversationRouteDto` carries only stable logical-agent/channel identity and a response target. Gateway durable-session and provider layers own runtime selection and handoff; Discord code must not import a harness SDK. + +## Development + +```bash +pnpm --filter @mosaicstack/types build +pnpm --filter @mosaicstack/discord-plugin typecheck +pnpm --filter @mosaicstack/discord-plugin lint +pnpm --filter @mosaicstack/discord-plugin test +pnpm --filter @mosaicstack/discord-plugin build +``` diff --git a/plugins/discord/package.json b/plugins/discord/package.json index 72a648e5..cb1a96ae 100644 --- a/plugins/discord/package.json +++ b/plugins/discord/package.json @@ -19,13 +19,16 @@ "dev": "tsx watch src/index.ts", "lint": "eslint src", "typecheck": "tsc --noEmit", - "test": "vitest run --passWithNoTests" + "test": "vitest run --coverage", + "test:coverage": "vitest run --coverage" }, "dependencies": { + "@mosaicstack/types": "workspace:^", "discord.js": "^14.16.0", "socket.io-client": "^4.8.0" }, "devDependencies": { + "@vitest/coverage-v8": "^2.0.0", "tsx": "^4.0.0", "typescript": "^5.8.0", "vitest": "^2.0.0" diff --git a/plugins/discord/src/index.test.ts b/plugins/discord/src/index.test.ts new file mode 100644 index 00000000..81226346 --- /dev/null +++ b/plugins/discord/src/index.test.ts @@ -0,0 +1,950 @@ +import { EventEmitter } from 'node:events'; +import type { + ChannelConversationRouteDto, + ChannelEgressDto, + ChannelIngressDto, + ChannelIngressPort, +} from '@mosaicstack/types'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + createDiscordIngressEnvelope, + DiscordPlugin, + parseDiscordInteractionBindings, + resolveDiscordInteractionActorId, + resolveDiscordInteractionBinding, + verifyDiscordIngressEnvelope, + type DiscordIngressEnvelope, + type DiscordInteractionRole, + type DiscordPluginConfig, +} from './index.js'; + +const SERVICE_TOKEN = 'test-service-token'; + +interface FakeDiscordMessageOptions { + id?: string; + guildId?: string; + content: string; + mentioned?: boolean; + userId?: string; + channelId?: string; + parentChannelId?: string | null; + isThread?: boolean; + hasThread?: boolean; + existingThreadId?: string; + fetchedThreadId?: string; + createdThreadId?: string; + attachments?: Map; +} + +interface FakeDiscordAttachment { + id: string; + name: string; + url: string; + contentType: string | null; + size?: number; +} + +interface FakeDiscordMessage { + id: string; + guildId: string; + channelId: string; + author: { id: string; bot: boolean }; + mentions: { has(user: { id: string }): boolean }; + content: string; + createdAt: Date; + channel: { + parentId: string | null; + isThread(): boolean; + threads?: { fetch(id: string): Promise<{ id: string }> }; + }; + attachments: Map; + hasThread: boolean; + thread: { id: string } | null; + startThread: ReturnType; +} + +interface FakeGatewaySocket extends EventEmitter { + connected: boolean; + disconnect?: ReturnType; +} + +interface FakeLifecycleClient extends EventEmitter { + user: { id: string; tag: string }; + login: ReturnType; + destroy: ReturnType; + isReady(): boolean; + guilds: { cache: Map }; + channels: { cache: Map }; +} + +interface DiscordPluginInternals { + client: { + user: { id: string }; + isReady(): boolean; + channels?: { + cache: { get(id: string): { send(options: unknown): Promise } | undefined }; + }; + }; + socket: { + connected: boolean; + emit: ReturnType; + } | null; + conversationRoutes: Map; + handleDiscordMessage(message: FakeDiscordMessage): void | Promise; + sendToDiscord(conversationId: string, text: string): Promise; +} + +function lifecyclePlugin(): { + plugin: DiscordPlugin; + socket: FakeGatewaySocket; + client: FakeLifecycleClient; +} { + const socket = Object.assign(new EventEmitter(), { + connected: false, + disconnect: vi.fn(), + }); + const client = Object.assign(new EventEmitter(), { + user: { id: 'bot-001', tag: 'mosaic-bot' }, + login: vi.fn().mockResolvedValue('token'), + destroy: vi.fn().mockResolvedValue(undefined), + isReady: (): boolean => true, + guilds: { cache: new Map() }, + channels: { cache: new Map() }, + }); + const plugin = new DiscordPlugin( + { + token: 'unused', + gatewayUrl: 'http://gateway.invalid', + serviceToken: SERVICE_TOKEN, + allowedGuildIds: ['guild-001'], + allowedChannelIds: ['channel-001'], + allowedUserIds: ['user-001'], + interactionBindings: [ + { + instanceId: 'Nova', + agentConfigId: 'agent-config-nova', + guildId: 'guild-001', + channelId: 'channel-001', + pairedUsers: { + 'user-001': { role: 'operator', mosaicUserId: 'mosaic-user-001' }, + }, + }, + ], + }, + { + client: client as never, + socketFactory: vi.fn().mockReturnValue(socket) as never, + }, + ); + return { plugin, socket, client }; +} + +function createPlugin( + role: DiscordInteractionRole = 'operator', + paired = true, + ingressPort?: ChannelIngressPort, + configOverrides: Partial = {}, +): { + plugin: DiscordPlugin; + internals: DiscordPluginInternals; + emit: ReturnType; +} { + const plugin = new DiscordPlugin( + { + token: 'unused', + gatewayUrl: 'http://unused', + serviceToken: SERVICE_TOKEN, + allowedGuildIds: ['guild-001'], + allowedChannelIds: ['channel-001'], + allowedUserIds: ['user-001'], + interactionBindings: [ + { + instanceId: 'Nova', + agentConfigId: 'agent-config-nova', + guildId: 'guild-001', + channelId: 'channel-001', + pairedUsers: paired + ? { + 'user-001': { role, mosaicUserId: 'mosaic-user-001' }, + } + : {}, + }, + ], + ...configOverrides, + }, + { ingressPort }, + ); + const emit = vi.fn(); + const internals = plugin as unknown as DiscordPluginInternals; + internals.client = { user: { id: 'bot-001' }, isReady: (): boolean => true }; + internals.socket = { connected: true, emit }; + return { plugin, internals, emit }; +} + +function fakeMessage(options: FakeDiscordMessageOptions): FakeDiscordMessage { + const channelId = options.channelId ?? 'channel-001'; + const parentChannelId = options.parentChannelId; + const existingThreadId = options.existingThreadId; + return { + id: options.id ?? 'message-001', + guildId: options.guildId ?? 'guild-001', + channelId, + author: { id: options.userId ?? 'user-001', bot: false }, + mentions: { has: (): boolean => options.mentioned ?? false }, + content: options.content, + createdAt: new Date('2026-07-14T12:00:00.000Z'), + channel: { + parentId: parentChannelId ?? null, + isThread: (): boolean => + options.isThread ?? (parentChannelId !== undefined && parentChannelId !== null), + ...(options.fetchedThreadId + ? { + threads: { + fetch: vi.fn().mockResolvedValue({ id: options.fetchedThreadId }), + }, + } + : {}), + }, + attachments: options.attachments ?? new Map(), + hasThread: options.hasThread ?? existingThreadId !== undefined, + thread: existingThreadId ? { id: existingThreadId } : null, + startThread: vi.fn().mockResolvedValue({ id: options.createdThreadId ?? 'thread-created-001' }), + }; +} + +function emittedEnvelope(emit: ReturnType): DiscordIngressEnvelope { + const call = emit.mock.calls[0] as [string, DiscordIngressEnvelope] | undefined; + expect(call?.[0]).toBe('message'); + expect(call?.[1]).toBeDefined(); + return ( + call?.[1] ?? + createDiscordIngressEnvelope( + { + correlationId: 'unreachable', + messageId: 'unreachable', + guildId: 'unreachable', + channelId: 'unreachable', + userId: 'unreachable', + conversationId: 'unreachable', + content: 'unreachable', + }, + SERVICE_TOKEN, + ) + ); +} + +afterEach((): void => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +describe('Discord binding configuration', () => { + it('parses role-only and Mosaic-linked pairings', () => { + const bindings = parseDiscordInteractionBindings( + JSON.stringify([ + { + instanceId: 'Nova', + agentConfigId: 'agent-config-nova', + guildId: 'guild-001', + channelId: 'channel-001', + pairedUsers: { + 'legacy-user': 'operator', + 'linked-user': { role: 'admin', mosaicUserId: 'mosaic-admin-001' }, + 'linked-without-id': { role: 'operator' }, + }, + }, + ]), + ); + + expect(bindings).toHaveLength(1); + expect(resolveDiscordInteractionActorId(bindings[0]!, 'legacy-user')).toBeNull(); + expect(resolveDiscordInteractionActorId(bindings[0]!, 'linked-user')).toBe('mosaic-admin-001'); + expect(resolveDiscordInteractionActorId(bindings[0]!, 'linked-without-id')).toBeNull(); + expect(resolveDiscordInteractionActorId(bindings[0]!, 'missing-user')).toBeNull(); + expect( + resolveDiscordInteractionBinding(bindings, 'guild-001', 'channel-001', 'linked-user', 'bind'), + ).toBe(bindings[0]); + expect( + resolveDiscordInteractionBinding( + bindings, + 'guild-001', + 'channel-001', + 'linked-without-id', + 'attach', + ), + ).toBe(bindings[0]); + expect( + resolveDiscordInteractionBinding( + bindings, + 'other-guild', + 'channel-001', + 'linked-user', + 'send', + ), + ).toBeNull(); + }); + + it.each([ + ['missing value', undefined], + ['empty array', '[]'], + ['non-object binding', '[null]'], + ['missing binding fields', '[{"instanceId":"Nova"}]'], + [ + 'empty Discord user ID', + '[{"instanceId":"Nova","guildId":"g","channelId":"c","pairedUsers":{"":"operator"}}]', + ], + [ + 'invalid role-only pairing', + '[{"instanceId":"Nova","guildId":"g","channelId":"c","pairedUsers":{"u":"owner"}}]', + ], + [ + 'non-object pairing', + '[{"instanceId":"Nova","guildId":"g","channelId":"c","pairedUsers":{"u":42}}]', + ], + [ + 'invalid linked pairing', + '[{"instanceId":"Nova","guildId":"g","channelId":"c","pairedUsers":{"u":{"role":"admin","mosaicUserId":" "}}}]', + ], + ])('rejects %s', (_case: string, value: string | undefined) => { + expect(() => parseDiscordInteractionBindings(value)).toThrow(); + }); +}); + +describe('Discord ingress integrity', () => { + it.each([ + ['guild', { guildIds: ['other'], channelIds: ['channel-001'], userIds: ['user-001'] }], + ['channel', { guildIds: ['guild-001'], channelIds: ['other'], userIds: ['user-001'] }], + ['user', { guildIds: ['guild-001'], channelIds: ['channel-001'], userIds: ['other'] }], + ])('rejects an unallowlisted %s', (_field: string, allowlists) => { + const payload = { + correlationId: 'correlation-001', + messageId: 'message-001', + guildId: 'guild-001', + channelId: 'channel-001', + userId: 'user-001', + conversationId: 'Nova:discord:channel-001', + content: 'hello', + }; + const envelope = createDiscordIngressEnvelope(payload, SERVICE_TOKEN); + + expect(verifyDiscordIngressEnvelope(envelope, SERVICE_TOKEN, allowlists)).toBeNull(); + }); +}); + +describe('Discord adapter lifecycle', () => { + it('starts while the in-process gateway is still connecting, then reports connected', async () => { + const { plugin, socket, client } = lifecyclePlugin(); + + await plugin.start(); + + expect(client.login).toHaveBeenCalledWith('unused'); + expect(await plugin.health()).toEqual({ status: 'degraded' }); + socket.connected = true; + socket.emit('connect'); + expect(await plugin.health()).toEqual({ status: 'connected' }); + await plugin.stop(); + expect(socket.disconnect).toHaveBeenCalledOnce(); + expect(client.destroy).toHaveBeenCalledOnce(); + }); + + it('keeps running while Socket.IO reconnects after an initial gateway error', async () => { + const { plugin, socket } = lifecyclePlugin(); + const error = vi.spyOn(console, 'error').mockImplementation((): void => undefined); + + await plugin.start(); + socket.emit('connect_error', new Error('gateway not listening yet')); + + expect(error).toHaveBeenCalledWith( + '[discord] Gateway connection error: gateway not listening yet', + ); + expect(await plugin.health()).toEqual({ status: 'degraded' }); + }); + + it('cleans up when Discord login fails', async () => { + const { plugin, socket, client } = lifecyclePlugin(); + client.login.mockRejectedValueOnce(new Error('Discord authentication rejected')); + + await expect(plugin.start()).rejects.toThrow('Discord authentication rejected'); + + expect(socket.disconnect).toHaveBeenCalledOnce(); + expect(client.destroy).toHaveBeenCalledOnce(); + }); +}); + +describe('Discord project channel provisioning', () => { + it('returns null without a configured guild or visible guild', async () => { + const { plugin } = createPlugin(); + await expect( + plugin.createProjectChannel({ id: 'project-1', name: 'Alpha' }), + ).resolves.toBeNull(); + + const { client } = lifecyclePlugin(); + const configured = new DiscordPlugin( + { + token: 'unused', + gatewayUrl: 'http://unused', + serviceToken: SERVICE_TOKEN, + guildId: 'missing-guild', + allowedGuildIds: ['guild-001'], + allowedChannelIds: ['channel-001'], + allowedUserIds: ['user-001'], + interactionBindings: [], + }, + { client: client as never }, + ); + await expect( + configured.createProjectChannel({ id: 'project-1', name: 'Alpha' }), + ).resolves.toBeNull(); + }); + + it('creates a normalized Discord project channel', async () => { + const create = vi.fn().mockResolvedValue({ id: 'created-channel-001' }); + const { client } = lifecyclePlugin(); + client.guilds.cache.set('guild-001', { channels: { create } }); + const plugin = new DiscordPlugin( + { + token: 'unused', + gatewayUrl: 'http://unused', + serviceToken: SERVICE_TOKEN, + guildId: 'guild-001', + allowedGuildIds: ['guild-001'], + allowedChannelIds: ['channel-001'], + allowedUserIds: ['user-001'], + interactionBindings: [], + }, + { client: client as never }, + ); + + await expect( + plugin.createProjectChannel({ + id: 'project-1', + name: ' Project Alpha! ', + description: 'Alpha workspace', + }), + ).resolves.toEqual({ channelId: 'created-channel-001' }); + expect(create).toHaveBeenCalledWith( + expect.objectContaining({ name: 'mosaic-project-alpha', topic: 'Alpha workspace' }), + ); + + await plugin.createProjectChannel({ id: 'project-2', name: 'Beta' }); + expect(create).toHaveBeenLastCalledWith( + expect.objectContaining({ topic: 'Mosaic project: Beta' }), + ); + }); +}); + +function egressFor( + route: ChannelConversationRouteDto, + content = 'agent response', +): ChannelEgressDto { + return { + correlationId: 'egress-correlation-001', + route, + message: { + id: 'egress-message-001', + channelName: 'discord', + channelId: route.responseTarget.channelId, + senderId: route.logicalAgentId, + senderKind: 'agent', + content, + contentKind: 'markdown', + timestamp: '2026-07-14T12:00:00.000Z', + metadata: {}, + }, + }; +} + +describe('official Discord channel routing', () => { + it('normalizes an authorized turn through the shared channel ingress port', async () => { + const receive = vi.fn<(ingress: ChannelIngressDto) => Promise>().mockResolvedValue(); + const { internals, emit } = createPlugin('operator', true, { receive }); + internals.socket = null; + + await internals.handleDiscordMessage(fakeMessage({ content: 'normalized turn' })); + + expect(emit).not.toHaveBeenCalled(); + expect(receive).toHaveBeenCalledWith( + expect.objectContaining({ + operation: 'message.send', + principal: { + channelUserId: 'user-001', + role: 'operator', + mosaicUserId: 'mosaic-user-001', + }, + message: expect.objectContaining({ + channelName: 'discord', + channelId: 'channel-001', + content: 'normalized turn', + senderKind: 'user', + }), + route: expect.objectContaining({ + logicalAgentId: 'Nova', + conversationId: 'Nova:discord:channel-001', + }), + }), + ); + }); + + it('releases a response route when typed ingress rejects', async () => { + const receive = vi.fn().mockRejectedValue(new Error('gateway rejected ingress')); + const { internals } = createPlugin('operator', true, { receive }); + + await expect( + internals.handleDiscordMessage(fakeMessage({ content: 'rejected typed ingress' })), + ).rejects.toThrow('gateway rejected ingress'); + + expect(internals.conversationRoutes).toHaveLength(0); + }); + + it('routes an authorized untagged parent-channel message and responds in that channel', async () => { + const { internals, emit } = createPlugin(); + const message = fakeMessage({ content: 'channel conversation' }); + + await internals.handleDiscordMessage(message); + + expect(message.startThread).not.toHaveBeenCalled(); + const payload = verifyDiscordIngressEnvelope(emittedEnvelope(emit), SERVICE_TOKEN); + expect(payload).toMatchObject({ + channelId: 'channel-001', + conversationId: 'Nova:discord:channel-001', + content: 'channel conversation', + }); + expect(payload?.threadId).toBeUndefined(); + }); + + it('creates a thread for a mentioned parent-channel message and routes the response there', async () => { + const { internals, emit } = createPlugin(); + const message = fakeMessage({ + content: '<@bot-001> investigate this topic', + mentioned: true, + createdThreadId: 'thread-created-001', + }); + + await internals.handleDiscordMessage(message); + + expect(message.startThread).toHaveBeenCalledOnce(); + const payload = verifyDiscordIngressEnvelope(emittedEnvelope(emit), SERVICE_TOKEN); + expect(payload).toMatchObject({ + channelId: 'channel-001', + conversationId: 'Nova:discord:thread-created-001', + content: 'investigate this topic', + threadId: 'thread-created-001', + }); + }); + + it('fetches and reuses an existing thread that is missing from the cache', async () => { + const { internals, emit } = createPlugin(); + const message = fakeMessage({ + content: '<@bot-001> continue uncached topic', + mentioned: true, + hasThread: true, + fetchedThreadId: 'thread-fetched-001', + }); + + await internals.handleDiscordMessage(message); + + expect(message.startThread).not.toHaveBeenCalled(); + expect(verifyDiscordIngressEnvelope(emittedEnvelope(emit), SERVICE_TOKEN)).toMatchObject({ + conversationId: 'Nova:discord:thread-fetched-001', + threadId: 'thread-fetched-001', + }); + }); + + it('delivers the agent response to the thread selected by the mentioned turn', async () => { + const { internals } = createPlugin(); + const send = vi.fn().mockResolvedValue(undefined); + internals.client = { + user: { id: 'bot-001' }, + isReady: (): boolean => true, + channels: { + cache: { + get: (id: string): { send(options: unknown): Promise } | undefined => + id === 'thread-response-001' ? { send } : undefined, + }, + }, + }; + + await internals.handleDiscordMessage( + fakeMessage({ + content: '<@bot-001> threaded response', + mentioned: true, + createdThreadId: 'thread-response-001', + }), + ); + await internals.sendToDiscord('Nova:discord:thread-response-001', 'agent answer'); + + expect(send).toHaveBeenCalledWith( + expect.objectContaining({ content: 'agent answer', enforceNonce: true }), + ); + }); + + it('reuses a thread already attached to a mentioned message instead of creating another', async () => { + const { internals, emit } = createPlugin(); + const message = fakeMessage({ + content: '<@bot-001> continue existing topic', + mentioned: true, + existingThreadId: 'thread-existing-001', + }); + + await internals.handleDiscordMessage(message); + + expect(message.startThread).not.toHaveBeenCalled(); + expect(verifyDiscordIngressEnvelope(emittedEnvelope(emit), SERVICE_TOKEN)).toMatchObject({ + conversationId: 'Nova:discord:thread-existing-001', + threadId: 'thread-existing-001', + }); + }); + + it('keeps an untagged follow-up inside an authorized thread without nesting threads', async () => { + const { internals, emit } = createPlugin(); + const message = fakeMessage({ + content: 'thread follow-up', + channelId: 'thread-001', + parentChannelId: 'channel-001', + }); + + await internals.handleDiscordMessage(message); + + expect(message.startThread).not.toHaveBeenCalled(); + expect(verifyDiscordIngressEnvelope(emittedEnvelope(emit), SERVICE_TOKEN)).toMatchObject({ + channelId: 'channel-001', + conversationId: 'Nova:discord:thread-001', + content: 'thread follow-up', + threadId: 'thread-001', + }); + }); + + it.each([ + ['guild', { guildId: 'guild-not-allowed' }], + ['channel', { channelId: 'channel-not-allowed' }], + ['user', { userId: 'user-not-allowed' }], + ])( + 'rejects an unauthorized %s before thread creation or gateway dispatch', + async (_boundary: string, override: Partial) => { + const { internals, emit } = createPlugin(); + const message = fakeMessage({ + content: '<@bot-001> unauthorized topic', + mentioned: true, + ...override, + }); + + await internals.handleDiscordMessage(message); + + expect(message.startThread).not.toHaveBeenCalled(); + expect(emit).not.toHaveBeenCalled(); + }, + ); + + it.each([ + ['parent channel', {}], + ['existing thread', { channelId: 'thread-unpaired-001', parentChannelId: 'channel-001' }], + ])( + 'rejects an allowlisted but unpaired user in %s', + async (_location: string, override: Partial) => { + const { internals, emit } = createPlugin('operator', false); + const message = fakeMessage({ content: 'unpaired message', ...override }); + + await internals.handleDiscordMessage(message); + + expect(message.startThread).not.toHaveBeenCalled(); + expect(emit).not.toHaveBeenCalled(); + }, + ); + + it('rejects a paired viewer before thread creation or gateway dispatch', async () => { + const { internals, emit } = createPlugin('viewer'); + const message = fakeMessage({ + content: '<@bot-001> viewer cannot send', + mentioned: true, + }); + + await internals.handleDiscordMessage(message); + + expect(message.startThread).not.toHaveBeenCalled(); + expect(emit).not.toHaveBeenCalled(); + }); + + it('uses a normal channel ID rather than its category parent for authorization', async () => { + const { internals, emit } = createPlugin(); + const message = fakeMessage({ + content: 'message from categorized channel', + parentChannelId: 'category-001', + isThread: false, + }); + + await internals.handleDiscordMessage(message); + + expect(verifyDiscordIngressEnvelope(emittedEnvelope(emit), SERVICE_TOKEN)).toMatchObject({ + channelId: 'channel-001', + conversationId: 'Nova:discord:channel-001', + }); + }); + + it.each([ + ['parent channel', {}], + ['existing thread', { channelId: 'thread-attachment-001', parentChannelId: 'channel-001' }], + ])( + 'preserves an attachment-only turn in an authorized %s', + async (_location: string, override: Partial) => { + const { internals, emit } = createPlugin(); + const attachments = new Map([ + [ + 'attachment-001', + { + id: 'attachment-001', + name: 'diagram.png', + url: 'https://cdn.example.invalid/diagram.png', + contentType: 'image/png', + size: 4_096, + }, + ], + ]); + + await internals.handleDiscordMessage(fakeMessage({ content: '', attachments, ...override })); + + expect(verifyDiscordIngressEnvelope(emittedEnvelope(emit), SERVICE_TOKEN)).toMatchObject({ + content: '', + attachments: [ + { + id: 'attachment-001', + name: 'diagram.png', + contentType: 'image/png', + sizeBytes: 4_096, + }, + ], + }); + }, + ); + + it('does not dispatch when Discord cannot create the requested thread', async () => { + const { internals, emit } = createPlugin(); + const message = fakeMessage({ content: '<@bot-001> new topic', mentioned: true }); + message.startThread.mockRejectedValueOnce(new Error('missing thread permission')); + + await expect(internals.handleDiscordMessage(message)).rejects.toThrow( + 'missing thread permission', + ); + expect(emit).not.toHaveBeenCalled(); + }); + + it.each(['/approve', '/stop approval-001'])( + 'rejects operator use of privileged command %s before gateway dispatch', + async (command: string) => { + const { internals, emit } = createPlugin('operator'); + const message = fakeMessage({ content: `<@bot-001> ${command}`, mentioned: true }); + + await internals.handleDiscordMessage(message); + + expect(message.startThread).not.toHaveBeenCalled(); + expect(emit).not.toHaveBeenCalled(); + }, + ); + + it('keeps runtime control commands on the current durable session', async () => { + const { internals, emit } = createPlugin('admin'); + const message = fakeMessage({ content: '<@bot-001> /approve', mentioned: true }); + + await internals.handleDiscordMessage(message); + + expect(message.startThread).not.toHaveBeenCalled(); + expect(emit).toHaveBeenCalledWith('discord:approve', expect.any(Object)); + }); + + it('keeps the stable conversation address independent of a runtime harness', async () => { + const first = createPlugin(); + const second = createPlugin(); + + await first.internals.handleDiscordMessage( + fakeMessage({ id: 'message-claude', content: 'before runtime handoff' }), + ); + await second.internals.handleDiscordMessage( + fakeMessage({ id: 'message-pi', content: 'after runtime handoff' }), + ); + + const firstPayload = verifyDiscordIngressEnvelope(emittedEnvelope(first.emit), SERVICE_TOKEN); + const secondPayload = verifyDiscordIngressEnvelope(emittedEnvelope(second.emit), SERVICE_TOKEN); + expect(firstPayload?.conversationId).toBe('Nova:discord:channel-001'); + expect(secondPayload?.conversationId).toBe(firstPayload?.conversationId); + }); + + it('rate-limits authorized turns before thread creation or dispatch', async () => { + const { internals, emit } = createPlugin('operator', true, undefined, { + messageRateLimitPerMinute: 1, + threadRateLimitPerMinute: 1, + }); + const error = vi.spyOn(console, 'error').mockImplementation((): void => undefined); + const first = fakeMessage({ id: 'rate-first', content: 'first turn' }); + const second = fakeMessage({ + id: 'rate-second', + content: '<@bot-001> second turn', + mentioned: true, + }); + + await internals.handleDiscordMessage(first); + await internals.handleDiscordMessage(second); + + expect(emit).toHaveBeenCalledOnce(); + expect(second.startThread).not.toHaveBeenCalled(); + expect(error).toHaveBeenCalledWith(expect.stringContaining('Message rate limit reached')); + }); + + it('applies a stricter mention-thread rate limit before Discord side effects', async () => { + const { internals, emit } = createPlugin('operator', true, undefined, { + messageRateLimitPerMinute: 10, + threadRateLimitPerMinute: 1, + }); + vi.spyOn(console, 'error').mockImplementation((): void => undefined); + const first = fakeMessage({ id: 'thread-rate-first', content: 'first topic', mentioned: true }); + const second = fakeMessage({ + id: 'thread-rate-second', + content: 'second topic', + mentioned: true, + }); + + await internals.handleDiscordMessage(first); + await internals.handleDiscordMessage(second); + + expect(first.startThread).toHaveBeenCalledOnce(); + expect(second.startThread).not.toHaveBeenCalled(); + expect(emit).toHaveBeenCalledOnce(); + }); + + it('rejects unconfigured egress and handles missing or failed Discord destinations', async () => { + const { plugin, internals } = createPlugin(); + const route: ChannelConversationRouteDto = { + bindingId: 'guild-001:channel-001:Nova', + logicalAgentId: 'Nova', + conversationId: 'Nova:discord:channel-001', + channelName: 'discord', + authorizationChannelId: 'channel-001', + responseTarget: { channelId: 'channel-001' }, + }; + + await expect( + plugin.send( + egressFor({ + ...route, + bindingId: 'forged-binding', + }), + ), + ).rejects.toMatchObject({ code: 'invalid_route' }); + await expect( + plugin.send( + egressFor({ + ...route, + conversationId: 'Nova:discord:unrelated-conversation', + }), + ), + ).rejects.toMatchObject({ code: 'invalid_route' }); + await expect( + plugin.send({ + ...egressFor(route), + message: { ...egressFor(route).message, channelId: 'other-channel' }, + }), + ).rejects.toMatchObject({ code: 'invalid_route' }); + + const forgedSend = vi.fn().mockResolvedValue(undefined); + internals.client = { + user: { id: 'bot-001' }, + isReady: (): boolean => true, + channels: { + cache: { + get: (): { send(options: unknown): Promise } => ({ send: forgedSend }), + }, + }, + }; + await expect( + plugin.send( + egressFor({ + ...route, + conversationId: 'Nova:discord:forged-channel', + responseTarget: { channelId: 'forged-channel', threadId: 'forged-channel' }, + }), + ), + ).rejects.toMatchObject({ code: 'invalid_route' }); + expect(forgedSend).not.toHaveBeenCalled(); + + internals.client = { + user: { id: 'bot-001' }, + isReady: (): boolean => true, + channels: { cache: { get: (): undefined => undefined } }, + }; + await expect(plugin.send(egressFor(route))).rejects.toMatchObject({ + code: 'destination_unavailable', + }); + await expect( + internals.sendToDiscord('missing-conversation', 'missing route'), + ).rejects.toMatchObject({ code: 'invalid_route' }); + + vi.useFakeTimers(); + const send = vi + .fn() + .mockRejectedValue(Object.assign(new Error('Discord unavailable'), { status: 503 })); + internals.client.channels = { + cache: { get: (): { send(options: unknown): Promise } => ({ send }) }, + }; + const delivery = plugin.send(egressFor(route)); + const rejection = expect(delivery).rejects.toMatchObject({ + code: 'delivery_failed', + retryable: true, + }); + await vi.runAllTimersAsync(); + await rejection; + + expect(send).toHaveBeenCalledTimes(3); + expect(new Set(send.mock.calls.map(([options]) => options.nonce)).size).toBe(1); + expect(send).toHaveBeenCalledWith( + expect.objectContaining({ enforceNonce: true, content: 'agent response' }), + ); + + const permanentSend = vi + .fn() + .mockRejectedValue(Object.assign(new Error('Discord forbidden'), { status: 403 })); + internals.client.channels = { + cache: { + get: (): { send(options: unknown): Promise } => ({ send: permanentSend }), + }, + }; + await expect(plugin.send(egressFor(route))).rejects.toMatchObject({ + code: 'delivery_failed', + retryable: false, + }); + expect(permanentSend).toHaveBeenCalledOnce(); + }); + + it('chunks long egress responses at Discord-safe boundaries', async () => { + const { plugin, internals } = createPlugin(); + const send = vi.fn().mockResolvedValue(undefined); + internals.client = { + user: { id: 'bot-001' }, + isReady: (): boolean => true, + channels: { + cache: { get: (): { send(options: unknown): Promise } => ({ send }) }, + }, + }; + const route: ChannelConversationRouteDto = { + bindingId: 'guild-001:channel-001:Nova', + logicalAgentId: 'Nova', + conversationId: 'Nova:discord:channel-001', + channelName: 'discord', + authorizationChannelId: 'channel-001', + responseTarget: { channelId: 'channel-001' }, + }; + + await plugin.send(egressFor(route, `${'a'.repeat(1_500)}\n${'b'.repeat(1_500)}`)); + + expect(send).toHaveBeenCalledTimes(2); + }); + + it('reports channel adapter health without exposing runtime-provider state', async () => { + const { plugin, internals } = createPlugin(); + + expect(await plugin.health()).toEqual({ status: 'connected' }); + internals.socket = { connected: false, emit: vi.fn() }; + expect(await plugin.health()).toEqual({ status: 'degraded' }); + internals.client = { user: { id: 'bot-001' }, isReady: (): boolean => false }; + expect(await plugin.health()).toEqual({ status: 'disconnected' }); + internals.socket = { connected: true, emit: vi.fn() }; + expect(await plugin.health()).toEqual({ status: 'degraded' }); + }); +}); diff --git a/plugins/discord/src/index.ts b/plugins/discord/src/index.ts index 15d4997a..e7b88c34 100644 --- a/plugins/discord/src/index.ts +++ b/plugins/discord/src/index.ts @@ -1,10 +1,38 @@ -import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto'; -import { ChannelType, Client, GatewayIntentBits, type Message as DiscordMessage } from 'discord.js'; +import { createHash, createHmac, randomUUID, timingSafeEqual } from 'node:crypto'; +import { ChannelDeliveryError } from '@mosaicstack/types'; +import type { + ChannelAdapterHealthDto, + ChannelAuthorizedPrincipalDto, + ChannelConversationRouteDto, + ChannelEgressDto, + ChannelEgressPort, + ChannelIngressDto, + ChannelIngressPort, + ChannelMessageDto, + OfficialChannelAdapter, +} from '@mosaicstack/types'; +import { + ChannelType, + Client, + GatewayIntentBits, + ThreadAutoArchiveDuration, + type Message as DiscordMessage, +} from 'discord.js'; import { io, type Socket } from 'socket.io-client'; +export interface DiscordPluginDependencies { + ingressPort?: ChannelIngressPort; + client?: Client; + socketFactory?: (url: string, options: Parameters[1]) => Socket; +} + export interface DiscordPluginConfig { token: string; gatewayUrl: string; + /** Maximum authorized turns per Discord user/channel per minute. */ + messageRateLimitPerMinute?: number; + /** Maximum mention-triggered thread routes per Discord user/channel per minute. */ + threadRateLimitPerMinute?: number; /** Shared service credential injected by the approved secret mechanism. */ serviceToken: string; /** Which guild to bind to (single-guild only for v0.1.0). */ @@ -30,13 +58,23 @@ export interface DiscordInteractionUserBinding { export type DiscordInteractionPairing = DiscordInteractionRole | DiscordInteractionUserBinding; export interface DiscordInteractionBinding { + /** Stable logical agent identity used in channel conversation routes. */ instanceId: string; + /** Trusted gateway database agent-config ID selected for this binding. */ + agentConfigId: string; guildId: string; channelId: string; /** Pairing roster keyed by Discord user ID. */ pairedUsers: Readonly>; } +const DEFAULT_MESSAGE_RATE_LIMIT_PER_MINUTE = 30; +const DEFAULT_THREAD_RATE_LIMIT_PER_MINUTE = 5; +const RATE_LIMIT_WINDOW_MS = 60_000; +const DELIVERY_MAX_ATTEMPTS = 3; +const DELIVERY_RETRY_BASE_MS = 50; +const MAX_CONVERSATION_ROUTES = 1_000; + const operationRoles: Readonly< Record > = { @@ -90,6 +128,7 @@ export function parseDiscordInteractionBindings( const candidate = binding as Partial; if ( !candidate.instanceId || + !candidate.agentConfigId || !candidate.guildId || !candidate.channelId || !candidate.pairedUsers || @@ -130,6 +169,7 @@ export function parseDiscordInteractionBindings( ) as Record; return { instanceId: candidate.instanceId, + agentConfigId: candidate.agentConfigId, guildId: candidate.guildId, channelId: candidate.channelId, pairedUsers, @@ -154,6 +194,7 @@ export interface DiscordAttachment { name: string; url: string; contentType: string | null; + sizeBytes?: number; } export interface DiscordIngressEnvelope { @@ -229,27 +270,37 @@ export function verifyDiscordIngressEnvelope( return envelope.payload; } -export class DiscordPlugin { +export class DiscordPlugin implements OfficialChannelAdapter, ChannelEgressPort { + readonly name = 'discord'; + private client: Client; private socket: Socket | null = null; - /** Map Discord channel ID → Mosaic conversation ID. */ - private channelConversations = new Map(); + /** Bounded last-authorized routes for response-target egress validation. */ + private conversationRoutes = new Map(); /** Track in-flight responses to avoid duplicate streaming. */ private pendingResponses = new Map(); + private readonly messageRateWindows = new Map(); + private readonly threadRateWindows = new Map(); - constructor(private readonly config: DiscordPluginConfig) { - this.client = new Client({ - intents: [ - GatewayIntentBits.Guilds, - GatewayIntentBits.GuildMessages, - GatewayIntentBits.MessageContent, - GatewayIntentBits.DirectMessages, - ], - }); + constructor( + private readonly config: DiscordPluginConfig, + private readonly dependencies: DiscordPluginDependencies = {}, + ) { + this.client = + dependencies.client ?? + new Client({ + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMessages, + GatewayIntentBits.MessageContent, + GatewayIntentBits.DirectMessages, + ], + }); } async start(): Promise { - this.socket = io(`${this.config.gatewayUrl}/chat`, { + const socketFactory = this.dependencies.socketFactory ?? io; + this.socket = socketFactory(`${this.config.gatewayUrl}/chat`, { auth: { discordServiceToken: this.config.serviceToken }, transports: ['websocket'], }); @@ -276,11 +327,13 @@ export class DiscordPlugin { this.socket.on('agent:end', (data: { conversationId: string }) => { const text = this.pendingResponses.get(data.conversationId); + this.pendingResponses.delete(data.conversationId); if (text) { - this.pendingResponses.delete(data.conversationId); - this.sendToDiscord(data.conversationId, text).catch((err: unknown) => { + this.sendAgentResponse(data.conversationId, text).catch((err: unknown) => { console.error(`[discord] Error sending response for ${data.conversationId}:`, err); }); + } else { + this.conversationRoutes.delete(data.conversationId); } }); @@ -288,15 +341,33 @@ export class DiscordPlugin { this.pendingResponses.set(data.conversationId, ''); }); - this.client.on('messageCreate', (message: DiscordMessage) => - this.handleDiscordMessage(message), - ); + this.socket.on('error', (data: { conversationId?: unknown }) => { + if (typeof data.conversationId !== 'string') return; + this.pendingResponses.delete(data.conversationId); + this.conversationRoutes.delete(data.conversationId); + }); + + this.client.on('messageCreate', (message: DiscordMessage) => { + void Promise.resolve(this.handleDiscordMessage(message)).catch((error: unknown): void => { + const errorName = error instanceof Error ? error.name : 'UnknownError'; + console.error( + `[discord] Message routing failed. channel=${message.channelId} message=${message.id} error=${errorName}`, + ); + }); + }); this.client.on('ready', () => { console.log(`[discord] Bot logged in as ${this.client.user?.tag}`); }); - await this.client.login(this.config.token); + try { + await this.client.login(this.config.token); + } catch (error: unknown) { + this.socket.disconnect(); + this.socket = null; + await this.client.destroy(); + throw error; + } } async stop(): Promise { @@ -304,6 +375,14 @@ export class DiscordPlugin { await this.client.destroy(); } + async health(): Promise { + const discordReady = this.client.isReady(); + const gatewayConnected = this.socket?.connected === true; + if (discordReady && gatewayConnected) return { status: 'connected' }; + if (discordReady || gatewayConnected) return { status: 'degraded' }; + return { status: 'disconnected' }; + } + async createProjectChannel(project: { id: string; name: string; @@ -325,77 +404,224 @@ export class DiscordPlugin { topic: project.description ?? `Mosaic project: ${project.name}`, }); - this.channelConversations.set(channel.id, `discord-${channel.id}`); + // A project channel has no logical-agent conversation until a configured + // binding authorizes a message. Do not seed a legacy channel-only key. return { channelId: channel.id }; } - private handleDiscordMessage(message: DiscordMessage): void { - if (message.author.bot || !this.client.user) return; - if (!message.guildId || !this.isAllowedMessage(message)) return; + private handleDiscordMessage(message: DiscordMessage): void | Promise { + if (message.author.bot || !this.client.user || !message.guildId) return; + + const authorizationChannelId = this.authorizationChannelId(message); + if (!this.isAllowedMessage(message, authorizationChannelId)) return; const isMention = message.mentions.has(this.client.user); - if (!isMention) return; + const content = isMention + ? message.content.replace(new RegExp(`<@!?${this.client.user.id}>`, 'g'), '').trim() + : message.content.trim(); + if (!content && message.attachments.size === 0) return; + const operation = this.interactionOperation(content); + const binding = resolveDiscordInteractionBinding( + this.config.interactionBindings ?? [], + message.guildId, + authorizationChannelId, + message.author.id, + operation, + ); + // Pairing and operation-specific role checks happen before thread creation + // or any gateway dispatch, including privileged control events. + if (!binding) return; - const content = message.content - .replace(new RegExp(`<@!?${this.client.user.id}>`, 'g'), '') - .trim(); - if (!content) return; - if (!this.socket?.connected) { + const rateKey = `${message.guildId}:${authorizationChannelId}:${message.author.id}`; + if ( + !this.consumeRateLimit( + this.messageRateWindows, + rateKey, + this.config.messageRateLimitPerMinute ?? DEFAULT_MESSAGE_RATE_LIMIT_PER_MINUTE, + ) + ) { + console.error( + `[discord] Message rate limit reached. guild=${message.guildId} channel=${authorizationChannelId} user=${message.author.id}`, + ); + return; + } + + const createThread = isMention && operation === 'send'; + if ( + createThread && + !this.consumeRateLimit( + this.threadRateWindows, + rateKey, + this.config.threadRateLimitPerMinute ?? DEFAULT_THREAD_RATE_LIMIT_PER_MINUTE, + ) + ) { + console.error( + `[discord] Thread rate limit reached. guild=${message.guildId} channel=${authorizationChannelId} user=${message.author.id}`, + ); + return; + } + + if (!this.dependencies.ingressPort && !this.socket?.connected) { console.error( `[discord] Cannot forward message: not connected to gateway. channel=${message.channelId} message=${message.id}`, ); return; } - const channelId = message.channelId; - const parentChannelId = 'parentId' in message.channel ? message.channel.parentId : null; - const bindingChannelId = parentChannelId ?? channelId; - const binding = resolveDiscordInteractionBinding( - this.config.interactionBindings ?? [], - message.guildId, - bindingChannelId, - message.author.id, - 'send', + // Approval/stop commands act on the current durable session. They remain + // at the current channel/thread target rather than creating a new topic. + const route = this.resolveConversationRoute( + message, + binding, + authorizationChannelId, + createThread, ); - if (!binding) return; - const conversationId = - this.channelConversations.get(channelId) ?? `${binding.instanceId}:discord:${channelId}`; - this.channelConversations.set(channelId, conversationId); + if (route instanceof Promise) { + return route.then((resolved: ChannelConversationRouteDto): void | Promise => + this.dispatchDiscordIngress(message, content, operation, binding, resolved), + ); + } + return this.dispatchDiscordIngress(message, content, operation, binding, route); + } + private dispatchDiscordIngress( + message: DiscordMessage, + content: string, + operation: 'send' | 'approve' | 'stop', + binding: DiscordInteractionBinding, + route: ChannelConversationRouteDto, + ): void | Promise { + const guildId = message.guildId; + if (!guildId) return; + if (operation === 'send') this.rememberConversationRoute(route); + const correlationId = randomUUID(); + const ingress: ChannelIngressDto = { + correlationId, + nativeMessageId: message.id, + operation: + operation === 'approve' + ? 'approval.create' + : operation === 'stop' + ? 'session.stop' + : 'message.send', + principal: this.authorizedPrincipal(binding, message.author.id), + message: this.channelMessage(message, content, route), + route, + }; + if (this.dependencies.ingressPort) { + return this.dependencies.ingressPort.receive(ingress).catch((error: unknown) => { + if (operation === 'send') this.conversationRoutes.delete(route.conversationId); + throw error; + }); + } + const socket = this.socket; + if (!socket?.connected) { + console.error( + `[discord] Cannot dispatch routed message: gateway disconnected. channel=${message.channelId} message=${message.id}`, + ); + return; + } + this.emitDiscordIngress(socket, ingress); + } + + private authorizedPrincipal( + binding: DiscordInteractionBinding, + channelUserId: string, + ): ChannelAuthorizedPrincipalDto { + const pairing = binding.pairedUsers[channelUserId]; + if (!pairing) throw new Error('Authorized Discord pairing is unavailable'); + if (typeof pairing === 'string') return { channelUserId, role: pairing }; + return { + channelUserId, + role: pairing.role, + ...(pairing.mosaicUserId ? { mosaicUserId: pairing.mosaicUserId } : {}), + }; + } + + private channelMessage( + message: DiscordMessage, + content: string, + route: ChannelConversationRouteDto, + ): ChannelMessageDto { + const attachments = Array.from(message.attachments.values()).map((attachment) => ({ + id: attachment.id, + name: attachment.name, + url: attachment.url, + mimeType: attachment.contentType, + sizeBytes: attachment.size, + })); + const firstContentType = attachments[0]?.mimeType; + return { + id: randomUUID(), + channelName: this.name, + channelId: route.responseTarget.channelId, + senderId: message.author.id, + senderKind: 'user', + content, + contentKind: + content.length > 0 ? 'markdown' : firstContentType?.startsWith('image/') ? 'image' : 'file', + timestamp: + message.createdAt instanceof Date + ? message.createdAt.toISOString() + : new Date().toISOString(), + ...(route.responseTarget.threadId ? { threadId: route.responseTarget.threadId } : {}), + ...(attachments.length > 0 ? { attachments } : {}), + metadata: { + channelMessageId: message.id, + guildId: message.guildId ?? '', + }, + }; + } + + private emitDiscordIngress(socket: Socket, ingress: ChannelIngressDto): void { const envelope = createDiscordIngressEnvelope( { - correlationId: randomUUID(), - messageId: message.id, - guildId: message.guildId, - channelId: bindingChannelId, - userId: message.author.id, - conversationId, - content, - threadId: parentChannelId ? channelId : undefined, - attachments: Array.from(message.attachments.values()).map((attachment) => ({ + correlationId: ingress.correlationId, + messageId: ingress.nativeMessageId, + guildId: String(ingress.message.metadata['guildId'] ?? ''), + channelId: ingress.route.authorizationChannelId, + userId: ingress.principal.channelUserId, + conversationId: ingress.route.conversationId, + content: ingress.message.content, + ...(ingress.route.responseTarget.threadId + ? { threadId: ingress.route.responseTarget.threadId } + : {}), + attachments: ingress.message.attachments?.map((attachment) => ({ id: attachment.id, name: attachment.name, url: attachment.url, - contentType: attachment.contentType, + contentType: attachment.mimeType, + ...(attachment.sizeBytes !== undefined ? { sizeBytes: attachment.sizeBytes } : {}), })), }, this.config.serviceToken, ); - this.socket.emit( - /^\/approve$/i.test(content) + socket.emit( + ingress.operation === 'approval.create' ? 'discord:approve' - : content.startsWith('/stop ') + : ingress.operation === 'session.stop' ? 'discord:stop' : 'message', envelope, ); } - private isAllowedMessage(message: DiscordMessage): boolean { + private authorizationChannelId(message: DiscordMessage): string { + // A normal guild channel can itself have a category parent. Only Discord + // threads inherit authorization from a configured parent text channel. + const channel = message.channel as DiscordMessage['channel'] & { + isThread?: () => boolean; + parentId?: string | null; + }; + const isThread = + typeof channel.isThread === 'function' + ? channel.isThread() + : channel.parentId !== undefined && channel.parentId !== null; + return isThread && channel.parentId ? channel.parentId : message.channelId; + } + + private isAllowedMessage(message: DiscordMessage, authorizationChannelId: string): boolean { const guildId = message.guildId; - const parentChannelId = 'parentId' in message.channel ? message.channel.parentId : null; - // Threads inherit their authorization boundary from their configured parent. - const authorizationChannelId = parentChannelId ?? message.channelId; return ( guildId !== null && includesId(this.config.allowedGuildIds, guildId) && @@ -404,31 +630,282 @@ export class DiscordPlugin { ); } - private async sendToDiscord(conversationId: string, text: string): Promise { - const channelId = Array.from(this.channelConversations.entries()).find( - ([, convId]) => convId === conversationId, - )?.[0]; - - if (!channelId) { - console.error(`[discord] No channel found for conversation ${conversationId}`); - return; + private resolveConversationRoute( + message: DiscordMessage, + binding: DiscordInteractionBinding, + authorizationChannelId: string, + createThread: boolean, + ): ChannelConversationRouteDto | Promise { + if (authorizationChannelId !== message.channelId) { + return this.createConversationRoute( + binding, + authorizationChannelId, + message.channelId, + message.channelId, + ); + } + if (!createThread) { + return this.createConversationRoute(binding, authorizationChannelId, message.channelId); } + if (message.hasThread) { + const cachedThread = message.thread; + if (cachedThread) { + return this.createConversationRoute( + binding, + authorizationChannelId, + cachedThread.id, + cachedThread.id, + ); + } + if (!('threads' in message.channel)) { + return Promise.reject(new Error('Existing Discord thread manager is unavailable')); + } + return message.channel.threads + .fetch(message.id) + .then((thread): ChannelConversationRouteDto => { + if (!thread) throw new Error('Existing Discord thread is unavailable'); + return this.createConversationRoute( + binding, + authorizationChannelId, + thread.id, + thread.id, + ); + }); + } + return message + .startThread({ + name: `Mosaic conversation ${message.id.slice(-8)}`, + autoArchiveDuration: ThreadAutoArchiveDuration.OneHour, + reason: 'Authorized Mosaic mention', + }) + .then( + (thread): ChannelConversationRouteDto => + this.createConversationRoute(binding, authorizationChannelId, thread.id, thread.id), + ); + } + + private createConversationRoute( + binding: DiscordInteractionBinding, + authorizationChannelId: string, + responseChannelId: string, + threadId?: string, + ): ChannelConversationRouteDto { + // Recompute from configuration on every turn so a stale in-memory map can + // never carry a channel-only or differently bound agent identity forward. + const conversationId = `${binding.instanceId}:discord:${responseChannelId}`; + return { + bindingId: `${binding.guildId}:${binding.channelId}:${binding.instanceId}`, + logicalAgentId: binding.instanceId, + conversationId, + channelName: this.name, + authorizationChannelId, + responseTarget: { + channelId: responseChannelId, + ...(threadId ? { threadId } : {}), + }, + }; + } + + private consumeRateLimit( + windows: Map, + key: string, + limit: number, + now = Date.now(), + ): boolean { + const active = (windows.get(key) ?? []).filter( + (timestamp: number): boolean => now - timestamp < RATE_LIMIT_WINDOW_MS, + ); + if (active.length >= Math.max(1, limit)) { + windows.set(key, active); + return false; + } + active.push(now); + windows.set(key, active); + return true; + } + + private interactionOperation(content: string): 'send' | 'approve' | 'stop' { + if (/^\/approve$/i.test(content)) return 'approve'; + if (/^\/stop\s+\S+/i.test(content)) return 'stop'; + return 'send'; + } + + async send(egress: ChannelEgressDto): Promise { + if (!this.isConfiguredRoute(egress.route) || !this.isMessageAlignedWithRoute(egress)) { + throw new ChannelDeliveryError( + 'invalid_route', + `Discord egress route is not authorized for conversation ${egress.route.conversationId}`, + ); + } + const channelId = egress.route.responseTarget.channelId; const channel = this.client.channels.cache.get(channelId); if (!channel || !('send' in channel)) { - console.error( - `[discord] Channel ${channelId} not sendable for conversation ${conversationId}`, + throw new ChannelDeliveryError( + 'destination_unavailable', + `Discord destination is unavailable for conversation ${egress.route.conversationId}`, ); - return; } - for (const chunk of this.chunkText(text, 1900)) { + const chunks = this.chunkText(egress.message.content, 1900); + for (const [chunkIndex, chunk] of chunks.entries()) { + await this.sendChunkWithRetry( + channel as { + send(options: { content: string; nonce: string; enforceNonce: true }): Promise; + }, + chunk, + egress.correlationId, + chunkIndex, + egress.route.conversationId, + ); + } + } + + private async sendChunkWithRetry( + channel: { + send(options: { content: string; nonce: string; enforceNonce: true }): Promise; + }, + chunk: string, + correlationId: string, + chunkIndex: number, + conversationId: string, + ): Promise { + const nonce = createHash('sha256') + .update(`${correlationId}:${chunkIndex}`) + .digest('hex') + .slice(0, 25); + let lastError: unknown; + for (let attempt = 1; attempt <= DELIVERY_MAX_ATTEMPTS; attempt += 1) { try { - await (channel as { send: (content: string) => Promise }).send(chunk); - } catch (err: unknown) { - console.error(`[discord] Failed to send message to channel ${channelId}:`, err); + await channel.send({ content: chunk, nonce, enforceNonce: true }); + return; + } catch (error: unknown) { + lastError = error; + const retryable = this.isTransientDeliveryError(error); + if (!retryable) { + throw new ChannelDeliveryError( + 'delivery_failed', + `Discord delivery failed for conversation ${conversationId}`, + false, + { cause: error }, + ); + } + if (attempt < DELIVERY_MAX_ATTEMPTS) { + await new Promise((resolve): void => { + setTimeout(resolve, DELIVERY_RETRY_BASE_MS * 2 ** (attempt - 1)); + }); + } } } + throw new ChannelDeliveryError( + 'delivery_failed', + `Discord delivery failed for conversation ${conversationId}`, + true, + { cause: lastError }, + ); + } + + private isTransientDeliveryError(error: unknown): boolean { + if (typeof error !== 'object' || error === null) return false; + const candidate = error as { status?: unknown; code?: unknown }; + if ( + typeof candidate.status === 'number' && + (candidate.status === 429 || candidate.status >= 500) + ) { + return true; + } + return ( + typeof candidate.code === 'string' && + ['ECONNRESET', 'ETIMEDOUT', 'EAI_AGAIN', 'UND_ERR_CONNECT_TIMEOUT'].includes(candidate.code) + ); + } + + private async sendAgentResponse(conversationId: string, text: string): Promise { + const route = this.conversationRoutes.get(conversationId); + if (!route) { + throw new ChannelDeliveryError( + 'invalid_route', + `Discord response route is unavailable for conversation ${conversationId}`, + ); + } + try { + await this.send({ + correlationId: randomUUID(), + route, + message: { + id: randomUUID(), + channelName: this.name, + channelId: route.responseTarget.channelId, + senderId: route.logicalAgentId, + senderKind: 'agent', + content: text, + contentKind: 'markdown', + timestamp: new Date().toISOString(), + ...(route.responseTarget.threadId ? { threadId: route.responseTarget.threadId } : {}), + metadata: {}, + }, + }); + } finally { + this.conversationRoutes.delete(conversationId); + } + } + + /** Compatibility wrapper while Socket.IO agent events carry only conversation ID. */ + private async sendToDiscord(conversationId: string, text: string): Promise { + await this.sendAgentResponse(conversationId, text); + } + + private rememberConversationRoute(route: ChannelConversationRouteDto): void { + if ( + !this.conversationRoutes.has(route.conversationId) && + this.conversationRoutes.size >= MAX_CONVERSATION_ROUTES + ) { + throw new ChannelDeliveryError( + 'delivery_failed', + 'Discord has reached its active response-route limit', + true, + ); + } + this.conversationRoutes.set(route.conversationId, route); + } + + private isConfiguredRoute(route: ChannelConversationRouteDto): boolean { + if ( + route.channelName !== this.name || + route.conversationId !== + `${route.logicalAgentId}:${this.name}:${route.responseTarget.channelId}` + ) { + return false; + } + const bindingMatches = (this.config.interactionBindings ?? []).some( + (binding): boolean => + route.bindingId === `${binding.guildId}:${binding.channelId}:${binding.instanceId}` && + route.logicalAgentId === binding.instanceId && + route.authorizationChannelId === binding.channelId, + ); + if (!bindingMatches) return false; + if ( + route.responseTarget.channelId === route.authorizationChannelId && + route.responseTarget.threadId === undefined + ) { + return true; + } + const observed = this.conversationRoutes.get(route.conversationId); + return ( + observed?.bindingId === route.bindingId && + observed.logicalAgentId === route.logicalAgentId && + observed.authorizationChannelId === route.authorizationChannelId && + observed.responseTarget.channelId === route.responseTarget.channelId && + observed.responseTarget.threadId === route.responseTarget.threadId + ); + } + + private isMessageAlignedWithRoute(egress: ChannelEgressDto): boolean { + return ( + egress.message.channelName === this.name && + egress.message.channelId === egress.route.responseTarget.channelId && + egress.message.threadId === egress.route.responseTarget.threadId + ); } private chunkText(text: string, maxLength: number): string[] { diff --git a/plugins/discord/vitest.config.ts b/plugins/discord/vitest.config.ts index 8e730d50..ee8101ce 100644 --- a/plugins/discord/vitest.config.ts +++ b/plugins/discord/vitest.config.ts @@ -4,5 +4,15 @@ export default defineConfig({ test: { globals: true, environment: 'node', + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + thresholds: { + lines: 85, + functions: 85, + branches: 85, + statements: 85, + }, + }, }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5124acd5..71aeab5a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -732,6 +732,9 @@ importers: plugins/discord: dependencies: + '@mosaicstack/types': + specifier: workspace:^ + version: link:../../packages/types discord.js: specifier: ^14.16.0 version: 14.25.1 @@ -739,6 +742,9 @@ importers: specifier: ^4.8.0 version: 4.8.3 devDependencies: + '@vitest/coverage-v8': + specifier: ^2.0.0 + version: 2.1.9(vitest@2.1.9(@types/node@24.12.0)(jsdom@29.0.0(@noble/hashes@2.0.1))(lightningcss@1.31.1)) tsx: specifier: ^4.0.0 version: 4.21.0 @@ -1169,11 +1175,11 @@ packages: '@esbuild-kit/core-utils@3.3.2': resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} - deprecated: 'Merged into tsx: https://tsx.is' + deprecated: 'Merged into tsx: https://tsx.hirok.io' '@esbuild-kit/esm-loader@2.6.5': resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} - deprecated: 'Merged into tsx: https://tsx.is' + deprecated: 'Merged into tsx: https://tsx.hirok.io' '@esbuild/aix-ppc64@0.21.5': resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} @@ -2160,47 +2166,57 @@ packages: '@mariozechner/pi-agent-core@0.63.1': resolution: {integrity: sha512-h0B20xfs/iEVR2EC4gwiE8hKI1TPeB8REdRJMgV+uXKH7gpeIZ9+s8Dp9nX35ZR0QUjkNey2+ULk2DxQtdg14Q==} engines: {node: '>=20.0.0'} + deprecated: please use @earendil-works/pi-agent-core instead going forward '@mariozechner/pi-agent-core@0.63.2': resolution: {integrity: sha512-9QTS7ylcmoAIWXk0EVpwCCop3fK4NIqTAN8TiRuXvuKYx+wYmUJc+P5+RfehIZhwsy7g9O/rktz0c1YEUBFB0g==} engines: {node: '>=20.0.0'} + deprecated: please use @earendil-works/pi-agent-core instead going forward '@mariozechner/pi-agent-core@0.65.0': resolution: {integrity: sha512-QCDqkgxvCkizCgJOl0aFekT1gURppznzuBIGXS8dXWZMour/xX6YF7chxX56mZ0p0DXkILM1ixf5jXYBfDsP5w==} engines: {node: '>=20.0.0'} + deprecated: please use @earendil-works/pi-agent-core instead going forward '@mariozechner/pi-ai@0.63.1': resolution: {integrity: sha512-wjgwY+yfrFO6a9QdAfjWpH7iSrDean6GsKDDMohNcLCy6PreMxHOZvNM0NwJARL1tZoZovr7ikAQfLGFZbnjsw==} engines: {node: '>=20.0.0'} + deprecated: please use @earendil-works/pi-ai instead going forward hasBin: true '@mariozechner/pi-ai@0.63.2': resolution: {integrity: sha512-EJNPyzeZeifTJmkD8PPYQmSO4P4h8kFCrhUqU4NvFUkug+GNYr954KlxhYnXH0f77MpdIEpf/O5zdDrYJQyafA==} engines: {node: '>=20.0.0'} + deprecated: please use @earendil-works/pi-ai instead going forward hasBin: true '@mariozechner/pi-ai@0.65.0': resolution: {integrity: sha512-MsCsCHlHIlBYbg6jB2PJBeCNKbjzVZge7ddBNUJN2gsFY8sdjFh482+GB+r5Ou6k9Fnhi3nO779YDymo5+t89w==} engines: {node: '>=20.0.0'} + deprecated: please use @earendil-works/pi-ai instead going forward hasBin: true '@mariozechner/pi-coding-agent@0.63.1': resolution: {integrity: sha512-XSoMyLtuMA7ePK1UBWqSJ/BBdtBdJUHY9nbtnNyG6GeW7Gbgd+iqljIuwmAUf8wlYL981UIfYM/WIPQ6t/dIxw==} engines: {node: '>=20.6.0'} + deprecated: please use @earendil-works/pi-coding-agent instead going forward hasBin: true '@mariozechner/pi-coding-agent@0.65.0': resolution: {integrity: sha512-IEBZ74n17w8NxnG/X2ixErsSYcvLm/h5WKALNbPgPWJZqvafNtJ0GcrCfLCS6RVIq2o+O/a2QwsbSI6bgJ6W/A==} engines: {node: '>=20.6.0'} + deprecated: please use @earendil-works/pi-coding-agent instead going forward hasBin: true '@mariozechner/pi-tui@0.63.1': resolution: {integrity: sha512-G5p+eh1EPkFCNaaggX6vRrqttnDscK6npgmEOknoCQXZtch8XNgh9Lf3VJ0A2lZXSgR7IntG5dfXHPH/Ki64wA==} engines: {node: '>=20.0.0'} + deprecated: please use @earendil-works/pi-tui instead going forward '@mariozechner/pi-tui@0.65.0': resolution: {integrity: sha512-P5Uuf4x1sTplMNQw8NrC1Hyz0N/tZq9kC6CDRkTT7rZuxZEeXl9uhKvlLEGigdKVOVrWnPE7ip0jrO81POYy3g==} engines: {node: '>=20.0.0'} + deprecated: please use @earendil-works/pi-tui instead going forward '@matrix-org/matrix-sdk-crypto-nodejs@0.4.0': resolution: {integrity: sha512-+qqgpn39XFSbsD0dFjssGO9vHEP7sTyfs8yTpt8vuqWpUpF20QMwpCZi0jpYw7GxjErNTsMshopuo8677DfGEA==} @@ -3915,6 +3931,7 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + deprecated: Potential CWE-502 - Update to 1.3.1 or higher '@vitest/coverage-v8@2.1.9': resolution: {integrity: sha512-Z2cOr0ksM00MpEfyVE8KXIYPEcBFxdbLSs56L8PO0QQMxt/6bDj45uQfxoc96v05KW3clk7vvgP0qfDit9DmfQ==} @@ -4095,6 +4112,7 @@ packages: basic-ftp@5.2.0: resolution: {integrity: sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw==} engines: {node: '>=10.0.0'} + deprecated: Security vulnerability fixed in 5.2.1, please upgrade better-auth@1.5.5: resolution: {integrity: sha512-GpVPaV1eqr3mOovKfghJXXk6QvlcVeFbS3z+n+FPDid5rK/2PchnDtiaVCzWyXA9jH2KkirOfl+JhAUvnja0Eg==} @@ -7087,6 +7105,7 @@ packages: uuid@9.0.1: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true validator@13.15.26: @@ -10977,6 +10996,24 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitest/coverage-v8@2.1.9(vitest@2.1.9(@types/node@24.12.0)(jsdom@29.0.0(@noble/hashes@2.0.1))(lightningcss@1.31.1))': + dependencies: + '@ampproject/remapping': 2.3.0 + '@bcoe/v8-coverage': 0.2.3 + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6 + istanbul-reports: 3.2.0 + magic-string: 0.30.21 + magicast: 0.3.5 + std-env: 3.10.0 + test-exclude: 7.0.2 + tinyrainbow: 1.2.0 + vitest: 2.1.9(@types/node@24.12.0)(jsdom@29.0.0(@noble/hashes@2.0.1))(lightningcss@1.31.1) + transitivePeerDependencies: + - supports-color + '@vitest/expect@2.1.9': dependencies: '@vitest/spy': 2.1.9 From aa5b43bba2c804dc8dff22797519a8f975fa0b44 Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Tue, 14 Jul 2026 20:37:52 +0000 Subject: [PATCH 047/152] feat(fleet): add roster v2 structural compiler (#764) --- docs/fleet/reference/roster-v2-fields.md | 95 ++++ docs/fleet/reference/roster-v2.schema.json | 156 +++++++ packages/mosaic/src/fleet/roster-v2.spec.ts | 242 ++++++++++ packages/mosaic/src/fleet/roster-v2.ts | 473 ++++++++++++++++++++ 4 files changed, 966 insertions(+) create mode 100644 docs/fleet/reference/roster-v2-fields.md create mode 100644 docs/fleet/reference/roster-v2.schema.json create mode 100644 packages/mosaic/src/fleet/roster-v2.spec.ts create mode 100644 packages/mosaic/src/fleet/roster-v2.ts diff --git a/docs/fleet/reference/roster-v2-fields.md b/docs/fleet/reference/roster-v2-fields.md new file mode 100644 index 00000000..0e92d6cb --- /dev/null +++ b/docs/fleet/reference/roster-v2-fields.md @@ -0,0 +1,95 @@ +# Fleet Roster v2 Structural Contract + +**Status:** FCM-M1-001 local-tmux structural compiler contract. This document describes parsing, +strict structural validation, normalized in-memory representation, and deterministic rendering only. +It does not authorize role resolution, lifecycle reconciliation, mutation, migration, remote +placement, connector configuration, secret references, arbitrary commands, channels, gateway +mapping, or any live-fleet change. + +The executable schema is [`roster-v2.schema.json`](./roster-v2.schema.json). The compiler exports +the same schema and its test parses this file and compares it structurally with the executable contract. + +## Format and canonical shape + +The compiler accepts YAML or JSON. It reads only snake_case source fields and renders canonical, +snake_case YAML. Rendering sorts runtime keys and agents by stable name. Agent names, class names, +and tool-policy names are structural identifiers; whether a class or policy resolves is a later +shared-resolver concern. + +```yaml +version: 2 +generation: 1 +transport: tmux +tmux: + socket_name: mosaic-fleet + holder_session: _holder +defaults: + working_directory: ~/src + runtime: pi +runtimes: + pi: + reset_command: /new +agents: + - name: coder0 + alias: Coder 0 + class: code + runtime: pi + provider: openai + model: gpt-5.6-sol + reasoning: high + tool_policy: code + working_directory: ~/src + persistent_persona: false + reset_between_tasks: true + lifecycle: + enabled: true + desired_state: stopped + launch: + yolo: true +``` + +## Root fields + +| Field | Required | Constraint | Meaning | +| ------------ | -------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| `version` | yes | integer constant `2` | Identifies this contract. Version `1` is explicitly rejected by this compiler and remains on the existing v1 path until M4 migration. | +| `generation` | yes | positive safe integer | Desired-state generation. M2 uses it for mutation guards; M1 does not mutate it. | +| `transport` | yes | constant `tmux` | M1–M5 support local tmux only. | +| `tmux` | yes | strict object | Explicit local socket and holder-session configuration. | +| `defaults` | yes | strict object | Default work directory and one supported local runtime. | +| `runtimes` | yes | non-empty object | Declared local runtime reset policy map. | +| `agents` | yes | non-empty array | Local fleet entries. Duplicate stable names are rejected. | + +## Nested fields + +| Path | Required | Constraint | +| ---------------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------- | +| `tmux.socket_name` | yes | non-empty `[A-Za-z0-9_.-]+`; an explicit named socket prevents default-versus-named socket ambiguity | +| `tmux.holder_session` | yes | non-empty `[A-Za-z0-9_.-]+` | +| `defaults.working_directory` | yes | non-empty string | +| `defaults.runtime` | yes | `claude`, `codex`, `opencode`, or `pi`; it must be declared in `runtimes` | +| `runtimes..reset_command` | yes | non-empty string; runtime key must be a supported local runtime | +| `agents[].name` | yes | unique `[A-Za-z0-9][A-Za-z0-9_.-]*` stable machine identity | +| `agents[].alias` | yes | non-empty display string | +| `agents[].class` | yes | `[a-z][a-z0-9-]*`; structural only in M1, semantic role resolution is FCM-M1-002 | +| `agents[].runtime` | yes | `claude`, `codex`, `opencode`, or `pi`; it must be declared in `runtimes` | +| `agents[].provider`, `model`, `working_directory` | yes | non-empty strings; provider/model capability resolution is a later card | +| `agents[].reasoning` | yes | `low`, `medium`, or `high` | +| `agents[].tool_policy` | yes | `[a-z][a-z0-9-]*`; structural only in M1 | +| `agents[].persistent_persona`, `reset_between_tasks` | yes | booleans | +| `agents[].lifecycle.enabled` | yes | boolean; stored now, reconciled in FCM-M3-001 | +| `agents[].lifecycle.desired_state` | yes | `running` or `stopped` | +| `agents[].launch.yolo` | yes | boolean; structured data only, not an arbitrary command escape hatch | + +## Fail-closed boundary + +Every object is `additionalProperties: false`. The compiler rejects unknown, missing, malformed, +and wrong-type fields before producing a model. It specifically rejects remote/SSH/host/socket +per-agent fields, connector blocks, secret references, channel fields, arbitrary command fields, +and gateway fields because they are unsupported in the local-tmux M1 contract. It does not silently +ignore v1 camelCase input, version `1`, or a source that does not parse to an object. + +The v2 compiler is intentionally isolated from the existing v1 loader. Existing v1 rosters and +current examples/profiles continue on their current path; FCM-M4 owns explicit inventory, preview, +migration, and rollback. FCM-M2 owns generated-file/local-override quarantine, and FCM-M3 owns +runtime lifecycle and reconciliation. diff --git a/docs/fleet/reference/roster-v2.schema.json b/docs/fleet/reference/roster-v2.schema.json new file mode 100644 index 00000000..6e62a8bc --- /dev/null +++ b/docs/fleet/reference/roster-v2.schema.json @@ -0,0 +1,156 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://mosaicstack.dev/schemas/fleet/roster-v2.schema.json", + "title": "Mosaic local tmux fleet roster v2", + "type": "object", + "additionalProperties": false, + "required": ["version", "generation", "transport", "tmux", "defaults", "runtimes", "agents"], + "properties": { + "version": { + "const": 2 + }, + "generation": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "transport": { + "const": "tmux" + }, + "tmux": { + "type": "object", + "additionalProperties": false, + "required": ["socket_name", "holder_session"], + "properties": { + "socket_name": { + "type": "string", + "pattern": "^[A-Za-z0-9_.-]+$" + }, + "holder_session": { + "type": "string", + "pattern": "^[A-Za-z0-9_.-]+$" + } + } + }, + "defaults": { + "type": "object", + "additionalProperties": false, + "required": ["working_directory", "runtime"], + "properties": { + "working_directory": { + "type": "string", + "minLength": 1 + }, + "runtime": { + "enum": ["claude", "codex", "opencode", "pi"] + } + } + }, + "runtimes": { + "type": "object", + "minProperties": 1, + "propertyNames": { + "enum": ["claude", "codex", "opencode", "pi"] + }, + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "required": ["reset_command"], + "properties": { + "reset_command": { + "type": "string", + "minLength": 1 + } + } + } + }, + "agents": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "alias", + "class", + "runtime", + "provider", + "model", + "reasoning", + "tool_policy", + "working_directory", + "persistent_persona", + "reset_between_tasks", + "lifecycle", + "launch" + ], + "properties": { + "name": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9_.-]*$" + }, + "alias": { + "type": "string", + "minLength": 1 + }, + "class": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$" + }, + "runtime": { + "enum": ["claude", "codex", "opencode", "pi"] + }, + "provider": { + "type": "string", + "minLength": 1 + }, + "model": { + "type": "string", + "minLength": 1 + }, + "reasoning": { + "enum": ["low", "medium", "high"] + }, + "tool_policy": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$" + }, + "working_directory": { + "type": "string", + "minLength": 1 + }, + "persistent_persona": { + "type": "boolean" + }, + "reset_between_tasks": { + "type": "boolean" + }, + "lifecycle": { + "type": "object", + "additionalProperties": false, + "required": ["enabled", "desired_state"], + "properties": { + "enabled": { + "type": "boolean" + }, + "desired_state": { + "enum": ["running", "stopped"] + } + } + }, + "launch": { + "type": "object", + "additionalProperties": false, + "required": ["yolo"], + "properties": { + "yolo": { + "type": "boolean" + } + } + } + } + } + } + } +} diff --git a/packages/mosaic/src/fleet/roster-v2.spec.ts b/packages/mosaic/src/fleet/roster-v2.spec.ts new file mode 100644 index 00000000..358aebc7 --- /dev/null +++ b/packages/mosaic/src/fleet/roster-v2.spec.ts @@ -0,0 +1,242 @@ +import { readFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { + ROSTER_V2_JSON_SCHEMA, + RosterV2ValidationError, + parseRosterV2, + renderRosterV2Yaml, +} from './roster-v2.js'; + +const validRoster = ` +version: 2 +generation: 7 +transport: tmux +tmux: + socket_name: mosaic-fleet + holder_session: _holder +defaults: + working_directory: ~/src + runtime: pi +runtimes: + pi: + reset_command: /new +agents: + - name: coder0 + alias: Coder 0 + class: code + runtime: pi + provider: openai + model: gpt-5.6-sol + reasoning: high + tool_policy: code + working_directory: ~/src + persistent_persona: false + reset_between_tasks: true + lifecycle: + enabled: true + desired_state: stopped + launch: + yolo: true +`; + +describe('roster v2 structural compiler', (): void => { + it('parses YAML into a normalized typed model and renders canonical YAML', (): void => { + const roster = parseRosterV2(validRoster, 'yaml'); + + expect(roster).toEqual({ + version: 2, + generation: 7, + transport: 'tmux', + tmux: { socketName: 'mosaic-fleet', holderSession: '_holder' }, + defaults: { workingDirectory: '~/src', runtime: 'pi' }, + runtimes: { pi: { resetCommand: '/new' } }, + agents: [ + { + name: 'coder0', + alias: 'Coder 0', + className: 'code', + runtime: 'pi', + provider: 'openai', + model: 'gpt-5.6-sol', + reasoning: 'high', + toolPolicy: 'code', + workingDirectory: '~/src', + persistentPersona: false, + resetBetweenTasks: true, + lifecycle: { enabled: true, desiredState: 'stopped' }, + launch: { yolo: true }, + }, + ], + }); + expect(renderRosterV2Yaml(roster)).toBe(validRoster.trimStart()); + }); + + it('parses JSON and produces the same normalized model', (): void => { + const yaml = parseRosterV2(validRoster, 'yaml'); + const json = JSON.stringify({ + version: 2, + generation: 7, + transport: 'tmux', + tmux: { socket_name: 'mosaic-fleet', holder_session: '_holder' }, + defaults: { working_directory: '~/src', runtime: 'pi' }, + runtimes: { pi: { reset_command: '/new' } }, + agents: [ + { + name: 'coder0', + alias: 'Coder 0', + class: 'code', + runtime: 'pi', + provider: 'openai', + model: 'gpt-5.6-sol', + reasoning: 'high', + tool_policy: 'code', + working_directory: '~/src', + persistent_persona: false, + reset_between_tasks: true, + lifecycle: { enabled: true, desired_state: 'stopped' }, + launch: { yolo: true }, + }, + ], + }); + + expect(parseRosterV2(json, 'json')).toEqual(yaml); + }); + + it('sorts runtime and agent maps in the deterministic renderer', (): void => { + const roster = parseRosterV2( + validRoster + .replace( + 'runtimes:\n pi:\n reset_command: /new', + 'runtimes:\n pi:\n reset_command: /new\n codex:\n reset_command: /clear', + ) + .replace( + 'agents:\n - name: coder0', + 'agents:\n - name: reviewer\n alias: Reviewer\n class: review\n runtime: pi\n provider: openai\n model: gpt-5.6-sol\n reasoning: medium\n tool_policy: review\n working_directory: ~/src\n persistent_persona: false\n reset_between_tasks: true\n lifecycle:\n enabled: true\n desired_state: stopped\n launch:\n yolo: true\n - name: coder0', + ), + 'yaml', + ); + + const rendered = renderRosterV2Yaml(roster); + expect(rendered.indexOf(' codex:')).toBeLessThan(rendered.indexOf(' pi:')); + expect(rendered.indexOf(' - name: coder0')).toBeLessThan( + rendered.indexOf(' - name: reviewer'), + ); + }); + + it.each([ + ['v1 document', validRoster.replace('version: 2', 'version: 1'), /v1.*existing v1 path/i], + [ + 'unknown connector', + `${validRoster}\nconnector:\n kind: matrix\n`, + /unsupported field.*connector/i, + ], + [ + 'remote host', + validRoster.replace( + 'runtime: pi\n provider', + 'runtime: pi\n host: remote\n provider', + ), + /unsupported field.*host/i, + ], + [ + 'secret reference', + validRoster.replace('model: gpt-5.6-sol', 'model: gpt-5.6-sol\n secret_ref: vault://x'), + /unsupported field.*secret_ref/i, + ], + [ + 'channel override', + validRoster.replace('model: gpt-5.6-sol', 'model: gpt-5.6-sol\n channels: discord'), + /unsupported field.*channels/i, + ], + [ + 'arbitrary command', + validRoster.replace('model: gpt-5.6-sol', 'model: gpt-5.6-sol\n command: whoami'), + /unsupported field.*command/i, + ], + [ + 'gateway field', + `${validRoster}\ngateway:\n url: https://gateway.example\n`, + /unsupported field.*gateway/i, + ], + [ + 'missing required field', + validRoster.replace(' model: gpt-5.6-sol\n', ''), + /model.*required/i, + ], + [ + 'invalid type', + validRoster.replace('generation: 7', 'generation: seven'), + /generation.*integer/i, + ], + [ + 'unsafe generation', + validRoster.replace('generation: 7', 'generation: 9007199254740992'), + /generation.*integer/i, + ], + [ + 'duplicate names', + validRoster.replace( + ' - name: coder0', + ' - name: coder0\n alias: Duplicate\n class: code\n runtime: pi\n provider: openai\n model: gpt-5.6-sol\n reasoning: high\n tool_policy: code\n working_directory: ~/src\n persistent_persona: false\n reset_between_tasks: true\n lifecycle:\n enabled: true\n desired_state: stopped\n launch:\n yolo: true\n - name: coder0', + ), + /duplicate agent name/i, + ], + ['invalid name', validRoster.replace('name: coder0', 'name: ../coder0'), /invalid agent name/i], + [ + 'invalid transport', + validRoster.replace('transport: tmux', 'transport: matrix'), + /transport.*tmux/i, + ], + [ + 'invalid runtime', + validRoster.replace('runtime: pi', 'runtime: matrix'), + /runtime.*supported/i, + ], + [ + 'invalid reasoning', + validRoster.replace('reasoning: high', 'reasoning: extreme'), + /reasoning.*low.*medium.*high/i, + ], + [ + 'ambiguous socket', + validRoster.replace('socket_name: mosaic-fleet', 'socket_name: default/socket'), + /socket_name/i, + ], + [ + 'agent socket override', + validRoster.replace( + 'runtime: pi\n provider', + 'runtime: pi\n socket: another\n provider', + ), + /unsupported field.*socket/i, + ], + ])('rejects %s', (_name: string, source: string, expected: RegExp): void => { + expect((): void => { + parseRosterV2(source, 'yaml'); + }).toThrow(expected); + }); + + it('rejects malformed JSON as a validation error', (): void => { + expect((): void => { + parseRosterV2('{', 'json'); + }).toThrow(RosterV2ValidationError); + }); + + it('declares supported runtime map keys in the executable schema', (): void => { + expect(ROSTER_V2_JSON_SCHEMA).toMatchObject({ + properties: { + runtimes: { propertyNames: { enum: ['claude', 'codex', 'opencode', 'pi'] } }, + }, + }); + }); + + it('keeps the checked-in documentation schema structurally identical to the executable schema', async (): Promise => { + const schemaPath = fileURLToPath( + new URL('../../../../docs/fleet/reference/roster-v2.schema.json', import.meta.url), + ); + const documented = await readFile(schemaPath, 'utf8'); + + expect(JSON.parse(documented) as unknown).toEqual(ROSTER_V2_JSON_SCHEMA); + }); +}); diff --git a/packages/mosaic/src/fleet/roster-v2.ts b/packages/mosaic/src/fleet/roster-v2.ts new file mode 100644 index 00000000..87df60c1 --- /dev/null +++ b/packages/mosaic/src/fleet/roster-v2.ts @@ -0,0 +1,473 @@ +import YAML from 'yaml'; + +export const ROSTER_V2_SUPPORTED_RUNTIMES = ['claude', 'codex', 'opencode', 'pi'] as const; +export const ROSTER_V2_REASONING_LEVELS = ['low', 'medium', 'high'] as const; +export const ROSTER_V2_DESIRED_STATES = ['running', 'stopped'] as const; + +export type RosterV2RuntimeName = (typeof ROSTER_V2_SUPPORTED_RUNTIMES)[number]; +export type RosterV2ReasoningLevel = (typeof ROSTER_V2_REASONING_LEVELS)[number]; +export type RosterV2DesiredState = (typeof ROSTER_V2_DESIRED_STATES)[number]; +export type RosterV2InputFormat = 'json' | 'yaml'; + +export interface FleetRosterV2Tmux { + readonly socketName: string; + readonly holderSession: string; +} + +export interface FleetRosterV2Defaults { + readonly workingDirectory: string; + readonly runtime: RosterV2RuntimeName; +} + +export interface FleetRosterV2Runtime { + readonly resetCommand: string; +} + +export interface FleetRosterV2Lifecycle { + readonly enabled: boolean; + readonly desiredState: RosterV2DesiredState; +} + +export interface FleetRosterV2Launch { + readonly yolo: boolean; +} + +export interface FleetRosterV2Agent { + readonly name: string; + readonly alias: string; + readonly className: string; + readonly runtime: RosterV2RuntimeName; + readonly provider: string; + readonly model: string; + readonly reasoning: RosterV2ReasoningLevel; + readonly toolPolicy: string; + readonly workingDirectory: string; + readonly persistentPersona: boolean; + readonly resetBetweenTasks: boolean; + readonly lifecycle: FleetRosterV2Lifecycle; + readonly launch: FleetRosterV2Launch; +} + +export interface FleetRosterV2 { + readonly version: 2; + readonly generation: number; + readonly transport: 'tmux'; + readonly tmux: FleetRosterV2Tmux; + readonly defaults: FleetRosterV2Defaults; + readonly runtimes: Readonly>; + readonly agents: readonly FleetRosterV2Agent[]; +} + +export class RosterV2ValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'RosterV2ValidationError'; + } +} + +type JsonSchema = string | number | boolean | null | JsonSchema[] | { [key: string]: JsonSchema }; + +/** + * Executable v2 structural contract. The checked-in documentation schema is + * structurally compared to this value in roster-v2.spec.ts. + */ +export const ROSTER_V2_JSON_SCHEMA: JsonSchema = { + $schema: 'https://json-schema.org/draft/2020-12/schema', + $id: 'https://mosaicstack.dev/schemas/fleet/roster-v2.schema.json', + title: 'Mosaic local tmux fleet roster v2', + type: 'object', + additionalProperties: false, + required: ['version', 'generation', 'transport', 'tmux', 'defaults', 'runtimes', 'agents'], + properties: { + version: { const: 2 }, + generation: { type: 'integer', minimum: 1, maximum: Number.MAX_SAFE_INTEGER }, + transport: { const: 'tmux' }, + tmux: { + type: 'object', + additionalProperties: false, + required: ['socket_name', 'holder_session'], + properties: { + socket_name: { type: 'string', pattern: '^[A-Za-z0-9_.-]+$' }, + holder_session: { type: 'string', pattern: '^[A-Za-z0-9_.-]+$' }, + }, + }, + defaults: { + type: 'object', + additionalProperties: false, + required: ['working_directory', 'runtime'], + properties: { + working_directory: { type: 'string', minLength: 1 }, + runtime: { enum: [...ROSTER_V2_SUPPORTED_RUNTIMES] }, + }, + }, + runtimes: { + type: 'object', + minProperties: 1, + propertyNames: { enum: [...ROSTER_V2_SUPPORTED_RUNTIMES] }, + additionalProperties: { + type: 'object', + additionalProperties: false, + required: ['reset_command'], + properties: { reset_command: { type: 'string', minLength: 1 } }, + }, + }, + agents: { + type: 'array', + minItems: 1, + items: { + type: 'object', + additionalProperties: false, + required: [ + 'name', + 'alias', + 'class', + 'runtime', + 'provider', + 'model', + 'reasoning', + 'tool_policy', + 'working_directory', + 'persistent_persona', + 'reset_between_tasks', + 'lifecycle', + 'launch', + ], + properties: { + name: { type: 'string', pattern: '^[A-Za-z0-9][A-Za-z0-9_.-]*$' }, + alias: { type: 'string', minLength: 1 }, + class: { type: 'string', pattern: '^[a-z][a-z0-9-]*$' }, + runtime: { enum: [...ROSTER_V2_SUPPORTED_RUNTIMES] }, + provider: { type: 'string', minLength: 1 }, + model: { type: 'string', minLength: 1 }, + reasoning: { enum: [...ROSTER_V2_REASONING_LEVELS] }, + tool_policy: { type: 'string', pattern: '^[a-z][a-z0-9-]*$' }, + working_directory: { type: 'string', minLength: 1 }, + persistent_persona: { type: 'boolean' }, + reset_between_tasks: { type: 'boolean' }, + lifecycle: { + type: 'object', + additionalProperties: false, + required: ['enabled', 'desired_state'], + properties: { + enabled: { type: 'boolean' }, + desired_state: { enum: [...ROSTER_V2_DESIRED_STATES] }, + }, + }, + launch: { + type: 'object', + additionalProperties: false, + required: ['yolo'], + properties: { yolo: { type: 'boolean' } }, + }, + }, + }, + }, + }, +}; + +const ROOT_KEYS = ['version', 'generation', 'transport', 'tmux', 'defaults', 'runtimes', 'agents']; +const TMUX_KEYS = ['socket_name', 'holder_session']; +const DEFAULT_KEYS = ['working_directory', 'runtime']; +const RUNTIME_KEYS = ['reset_command']; +const AGENT_KEYS = [ + 'name', + 'alias', + 'class', + 'runtime', + 'provider', + 'model', + 'reasoning', + 'tool_policy', + 'working_directory', + 'persistent_persona', + 'reset_between_tasks', + 'lifecycle', + 'launch', +]; +const LIFECYCLE_KEYS = ['enabled', 'desired_state']; +const LAUNCH_KEYS = ['yolo']; +const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/; +const TMUX_IDENTIFIER = /^[A-Za-z0-9_.-]+$/; +const POLICY_IDENTIFIER = /^[a-z][a-z0-9-]*$/; + +/** Parses YAML or JSON and compiles only the local-tmux roster v2 contract. */ +export function parseRosterV2(source: string, format?: RosterV2InputFormat): FleetRosterV2 { + const parsed = parseSource(source, format); + return normalizeRosterV2(parsed); +} + +/** Renders canonical snake_case YAML with sorted runtime and agent entries. */ +export function renderRosterV2Yaml(roster: FleetRosterV2): string { + const normalized = normalizeRosterV2(toSourceShape(roster)); + return YAML.stringify(toSourceShape(normalized)); +} + +function parseSource(source: string, format?: RosterV2InputFormat): unknown { + const resolvedFormat = format ?? (source.trimStart().startsWith('{') ? 'json' : 'yaml'); + try { + if (resolvedFormat === 'json') return JSON.parse(source) as unknown; + return YAML.parse(source) as unknown; + } catch (error: unknown) { + const detail = error instanceof Error ? error.message : String(error); + throw new RosterV2ValidationError(`Roster v2 ${resolvedFormat} parse failed: ${detail}`); + } +} + +export function normalizeRosterV2(raw: unknown): FleetRosterV2 { + const root = requiredObject(raw, 'Roster v2'); + assertKnownKeys(root, 'Roster v2', ROOT_KEYS); + if (root.version === 1) { + throw new RosterV2ValidationError( + 'Roster v2 compiler rejects v1 input; use the existing v1 path until migration.', + ); + } + if (root.version !== 2) throw new RosterV2ValidationError('Roster v2 version must be 2.'); + + const generation = requiredPositiveInteger(root.generation, 'Roster v2 generation'); + const transport = requiredEnum(root.transport, 'Roster v2 transport', ['tmux'] as const); + const tmux = normalizeTmux(root.tmux); + const defaults = normalizeDefaults(root.defaults); + const runtimes = normalizeRuntimes(root.runtimes); + const agents = normalizeAgents(root.agents, runtimes); + + if (!runtimes[defaults.runtime]) { + throw new RosterV2ValidationError( + `Roster v2 defaults runtime "${defaults.runtime}" must be declared in runtimes.`, + ); + } + return { version: 2, generation, transport, tmux, defaults, runtimes, agents }; +} + +function normalizeTmux(value: unknown): FleetRosterV2Tmux { + const raw = requiredObject(value, 'Roster v2 tmux'); + assertKnownKeys(raw, 'Roster v2 tmux', TMUX_KEYS); + return { + socketName: requiredTmuxIdentifier(raw.socket_name, 'Roster v2 tmux socket_name'), + holderSession: requiredTmuxIdentifier(raw.holder_session, 'Roster v2 tmux holder_session'), + }; +} + +function normalizeDefaults(value: unknown): FleetRosterV2Defaults { + const raw = requiredObject(value, 'Roster v2 defaults'); + assertKnownKeys(raw, 'Roster v2 defaults', DEFAULT_KEYS); + return { + workingDirectory: requiredString(raw.working_directory, 'Roster v2 defaults working_directory'), + runtime: requiredRuntime(raw.runtime, 'Roster v2 defaults runtime'), + }; +} + +function normalizeRuntimes(value: unknown): Readonly> { + const raw = requiredObject(value, 'Roster v2 runtimes'); + const names = Object.keys(raw); + if (names.length === 0) + throw new RosterV2ValidationError('Roster v2 runtimes must not be empty.'); + + const result: Record = {}; + for (const name of names.sort()) { + const runtime = requiredRuntime(name, 'Roster v2 runtime name'); + const config = requiredObject(raw[name], `Roster v2 runtime "${runtime}"`); + assertKnownKeys(config, `Roster v2 runtime "${runtime}"`, RUNTIME_KEYS); + result[runtime] = { + resetCommand: requiredString( + config.reset_command, + `Roster v2 runtime "${runtime}" reset_command`, + ), + }; + } + return result; +} + +function normalizeAgents( + value: unknown, + runtimes: Readonly>, +): readonly FleetRosterV2Agent[] { + if (!Array.isArray(value) || value.length === 0) { + throw new RosterV2ValidationError('Roster v2 agents must be a non-empty array.'); + } + const seen = new Set(); + const agents = value.map((candidate: unknown, index: number): FleetRosterV2Agent => { + const raw = requiredObject(candidate, `Roster v2 agents[${index}]`); + assertKnownKeys(raw, `Roster v2 agents[${index}]`, AGENT_KEYS); + const name = requiredIdentifier(raw.name, `Roster v2 agents[${index}] name`); + if (seen.has(name)) + throw new RosterV2ValidationError(`Roster v2 has duplicate agent name: ${name}.`); + seen.add(name); + const runtime = requiredRuntime(raw.runtime, `Roster v2 agent "${name}" runtime`); + if (!runtimes[runtime]) { + throw new RosterV2ValidationError( + `Roster v2 agent "${name}" runtime "${runtime}" must be declared in runtimes.`, + ); + } + return { + name, + alias: requiredString(raw.alias, `Roster v2 agent "${name}" alias`), + className: requiredPolicyIdentifier(raw.class, `Roster v2 agent "${name}" class`), + runtime, + provider: requiredString(raw.provider, `Roster v2 agent "${name}" provider`), + model: requiredString(raw.model, `Roster v2 agent "${name}" model`), + reasoning: requiredEnum( + raw.reasoning, + `Roster v2 agent "${name}" reasoning`, + ROSTER_V2_REASONING_LEVELS, + ), + toolPolicy: requiredPolicyIdentifier( + raw.tool_policy, + `Roster v2 agent "${name}" tool_policy`, + ), + workingDirectory: requiredString( + raw.working_directory, + `Roster v2 agent "${name}" working_directory`, + ), + persistentPersona: requiredBoolean( + raw.persistent_persona, + `Roster v2 agent "${name}" persistent_persona`, + ), + resetBetweenTasks: requiredBoolean( + raw.reset_between_tasks, + `Roster v2 agent "${name}" reset_between_tasks`, + ), + lifecycle: normalizeLifecycle(raw.lifecycle, name), + launch: normalizeLaunch(raw.launch, name), + }; + }); + return agents.sort((left: FleetRosterV2Agent, right: FleetRosterV2Agent): number => + left.name.localeCompare(right.name), + ); +} + +function normalizeLifecycle(value: unknown, agentName: string): FleetRosterV2Lifecycle { + const raw = requiredObject(value, `Roster v2 agent "${agentName}" lifecycle`); + assertKnownKeys(raw, `Roster v2 agent "${agentName}" lifecycle`, LIFECYCLE_KEYS); + return { + enabled: requiredBoolean(raw.enabled, `Roster v2 agent "${agentName}" lifecycle enabled`), + desiredState: requiredEnum( + raw.desired_state, + `Roster v2 agent "${agentName}" lifecycle desired_state`, + ROSTER_V2_DESIRED_STATES, + ), + }; +} + +function normalizeLaunch(value: unknown, agentName: string): FleetRosterV2Launch { + const raw = requiredObject(value, `Roster v2 agent "${agentName}" launch`); + assertKnownKeys(raw, `Roster v2 agent "${agentName}" launch`, LAUNCH_KEYS); + return { yolo: requiredBoolean(raw.yolo, `Roster v2 agent "${agentName}" launch yolo`) }; +} + +function requiredObject(value: unknown, label: string): Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new RosterV2ValidationError(`${label} must be an object.`); + } + return value as Record; +} + +function assertKnownKeys( + value: Record, + label: string, + allowedKeys: readonly string[], +): void { + const allowed = new Set(allowedKeys); + const unknown = Object.keys(value).filter((key: string): boolean => !allowed.has(key)); + if (unknown.length > 0) { + throw new RosterV2ValidationError(`${label} has unsupported field(s): ${unknown.join(', ')}.`); + } +} + +function requiredString(value: unknown, label: string): string { + if (typeof value !== 'string' || value.trim() === '') { + throw new RosterV2ValidationError(`${label} is required and must be a non-empty string.`); + } + return value.trim(); +} + +function requiredIdentifier(value: unknown, label: string): string { + const result = requiredString(value, label); + if (!IDENTIFIER.test(result)) { + throw new RosterV2ValidationError(`Invalid agent name (${label}): ${result}.`); + } + return result; +} + +function requiredTmuxIdentifier(value: unknown, label: string): string { + const result = requiredString(value, label); + if (!TMUX_IDENTIFIER.test(result)) + throw new RosterV2ValidationError(`Invalid ${label}: ${result}.`); + return result; +} + +function requiredPolicyIdentifier(value: unknown, label: string): string { + const result = requiredString(value, label); + if (!POLICY_IDENTIFIER.test(result)) + throw new RosterV2ValidationError(`Invalid ${label}: ${result}.`); + return result; +} + +function requiredPositiveInteger(value: unknown, label: string): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 1) { + throw new RosterV2ValidationError(`${label} must be a positive integer.`); + } + return value; +} + +function requiredBoolean(value: unknown, label: string): boolean { + if (typeof value !== 'boolean') throw new RosterV2ValidationError(`${label} must be a boolean.`); + return value; +} + +function requiredRuntime(value: unknown, label: string): RosterV2RuntimeName { + return requiredEnum(value, label, ROSTER_V2_SUPPORTED_RUNTIMES); +} + +function requiredEnum(value: unknown, label: string, allowed: readonly T[]): T { + if (typeof value !== 'string' || !allowed.includes(value as T)) { + throw new RosterV2ValidationError( + `${label} must be one of the supported values: ${allowed.join(', ')}.`, + ); + } + return value as T; +} + +function toSourceShape(roster: FleetRosterV2): Record { + return { + version: roster.version, + generation: roster.generation, + transport: roster.transport, + tmux: { socket_name: roster.tmux.socketName, holder_session: roster.tmux.holderSession }, + defaults: { + working_directory: roster.defaults.workingDirectory, + runtime: roster.defaults.runtime, + }, + runtimes: Object.fromEntries( + Object.entries(roster.runtimes) + .sort(([left], [right]): number => left.localeCompare(right)) + .map(([name, runtime]): [string, unknown] => [ + name, + { reset_command: runtime.resetCommand }, + ]), + ), + agents: [...roster.agents] + .sort((left: FleetRosterV2Agent, right: FleetRosterV2Agent): number => + left.name.localeCompare(right.name), + ) + .map( + (agent: FleetRosterV2Agent): Record => ({ + name: agent.name, + alias: agent.alias, + class: agent.className, + runtime: agent.runtime, + provider: agent.provider, + model: agent.model, + reasoning: agent.reasoning, + tool_policy: agent.toolPolicy, + working_directory: agent.workingDirectory, + persistent_persona: agent.persistentPersona, + reset_between_tasks: agent.resetBetweenTasks, + lifecycle: { + enabled: agent.lifecycle.enabled, + desired_state: agent.lifecycle.desiredState, + }, + launch: { yolo: agent.launch.yolo }, + }), + ), + }; +} From 2e2280070ae67288be45f41743cf67052a8ca5a6 Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Tue, 14 Jul 2026 21:46:44 +0000 Subject: [PATCH 048/152] docs(#753): clear KBN-010 threat and schema gate (#765) --- .../KBN-010-THREAT-AUTH-CONSTRAINT-GATE.md | 415 ++++++++++++++++++ docs/native-kanban-sot/SHARED-CONTRACT.md | 93 ++-- docs/native-kanban-sot/TASKS.md | 12 +- .../contracts/kanban-schema.v1.ts | 1 + docs/scratchpads/753-kbn010-threat-gate.md | 101 +++++ 5 files changed, 588 insertions(+), 34 deletions(-) create mode 100644 docs/native-kanban-sot/KBN-010-THREAT-AUTH-CONSTRAINT-GATE.md create mode 100644 docs/scratchpads/753-kbn010-threat-gate.md diff --git a/docs/native-kanban-sot/KBN-010-THREAT-AUTH-CONSTRAINT-GATE.md b/docs/native-kanban-sot/KBN-010-THREAT-AUTH-CONSTRAINT-GATE.md new file mode 100644 index 00000000..29bc5fe9 --- /dev/null +++ b/docs/native-kanban-sot/KBN-010-THREAT-AUTH-CONSTRAINT-GATE.md @@ -0,0 +1,415 @@ +# KBN-010 — Threat, Authorization, and Constraint-Impact Gate + +- **Issue:** [#753](https://git.mosaicstack.dev/mosaicstack/stack/issues/753) +- **Gate status:** **PASS / GO** +- **Reviewed baseline:** `origin/main` at `49e8a54` (2026-07-14) +- **Frozen target:** `SHARED-CONTRACT.md` v1.0.0-rc.4 and `contracts/*.v1.ts` +- **Disposition input:** contract commit `3f6a3387b419eb99453ee10dd25ba888faaab0b5`, tree `7ebab8fa530a7180036928cea9527f808548aa14` +- **Scope:** documentation and future-test planning only; no runtime, schema, migration, API, configuration, dependency, CI, or deployment change + +## 1. Decision + +KBN-010 is **PASS / GO** against frozen contract rc.4. The original rc.3 finding remains historical detection evidence: + +- **KBN010-SI-001 — rc.3 invalid mission composite-FK candidate key.** At rc.3, `missionsV1` declared a primary key on `id` and a unique key on `(workspace_id, project_id, id)`, but not a candidate key on `(workspace_id, id)`. Both `artifacts_workspace_mission_fk` and `approval_decisions_workspace_mission_fk` referenced exactly `(missions.workspace_id, missions.id)`. PostgreSQL requires the referenced column list of a foreign key to match a non-partial unique/primary candidate key; uniqueness of `id` alone did not satisfy that two-column reference. The rc.3 DDL was therefore invalid, and KBN-010 correctly blocked it. + +Contract rc.4 resolves SI-001 by adding the non-partial `missions_workspace_id_uidx` candidate key on `(workspace_id, id)` while retaining the global `id` primary key and the project-congruent `(workspace_id, project_id, id)` key. Both polymorphic child FKs retain their exact workspace-safe ordered columns and `ON DELETE RESTRICT`; no target, tenancy, project-congruence, exactly-one-target, N-1, rollback, no-cascade, identity, approval, or fencing authority is weakened. + +Independent Homelab non-author schema/security review returned **APPROVE** for the exact rc.4 commit/tree/content and found no collision with #757 connector fencing. SI-001 has no unresolved contract/schema-design impact. + +This GO completes the KBN-010 analysis/review prerequisite only. It does **not** claim that runtime schema or migration DDL exists. KBN-100 remains held and may be released only after this PR squash-merges, the merged change reaches terminal-green CI on `main`, and issue #753 closes. + +### 1.1 Independent rc.4 evidence identity + +- **Commit:** `3f6a3387b419eb99453ee10dd25ba888faaab0b5` +- **Tree:** `7ebab8fa530a7180036928cea9527f808548aa14` +- **Stable full-index SHA-256:** `6b40a76265c4f3e6d1d30a7f262a2dd16e0d51997e99c146b59f527e6524cd42` +- **Stable patch-id:** `058cf98026fcd1043703c866aee047c8bb144740` +- **Verdict:** Homelab independent non-author schema/security review **APPROVE**. +- **Reviewed conclusions:** the candidate key repairs both dependent FKs; tenant safety, polymorphic exactly-one-target semantics, RESTRICT/no-cascade behavior, and N-1/rollback semantics remain valid; #757 uses separate tables/indexes/FKs/identity/fence authority and has no collision. + +A command-rendered patch SHA may differ when Git rendering options, headers, or command form differ. That rendering digest is non-authoritative. Canonical review identity is the Git commit object plus tree and exact file content; the stable full-index digest and stable patch-id above are corroborating identities. + +## 2. Method and trust boundaries + +### 2.1 Inputs inspected + +- Canonical requirements: `docs/requirements/native-kanban-sot.md`. +- Workstream manifest and read-only task plan. +- Frozen health, schema, Mechanical Coordinator, and recovery contracts in full. +- Actual current-main schema, Better Auth guard/scope helpers, project/task/mission/team controllers and repositories, fleet backlog, and `TASKS.md` parser/writer. +- Issue #753 through the Mosaic provider wrapper. + +### 2.2 Current-main exposure that the target must replace, not inherit + +| Current-main fact | Constraint on future implementation | +| --------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | +| Teams are global; projects, missions, tasks, agents, and fleet backlog have no `workspace_id`. | KBN-100 must add the workspace boundary and KBN-110 must query by server-derived workspace in every repository operation. | +| `AuthGuard` authenticates a Better Auth user, while `scopeFromUser` falls back through optional tenant/team/org claims and finally user ID. | Kanban tenancy must derive from an authenticated **active workspace membership**, not this compatibility fallback or caller data. | +| Team list/get/member endpoints return global team data to any authenticated user. | New Kanban endpoints must use a uniform no-oracle denial and must not reuse global team lookup as authorization. | +| Project/task repositories load and mutate by bare IDs; controller checks are separate and sometimes distinguish not-found from forbidden. | Workspace predicates and authorization must be inside the authoritative transaction/repository command path. | +| Tasks can have nullable project/mission links, free-text assignee, JSON tags, no aggregate version, and no fence. | Expand/backfill/quarantine must precede NOT NULL/composite constraints; new commands cannot trust legacy fields. | +| `mission_tasks.status` is a second status writer. | Pre-expand must prohibit it as a write source and later retire it only after N-1 evidence. | +| Fleet `backlog` has global JSON dependencies and TTL claims without workspace, assignment, approval, session, or fencing. | It must be frozen and imported as non-dispatching shadow data; it cannot be adapted into the canonical lease path. | +| `packages/coord/src/tasks-file.ts` parses and mutates `TASKS.md`. | KBN-120 must replace production use with generated, read-only projection code and prove there is no import/mutation path. | +| No Kanban transaction-local health proof, semantic audit/event chain, change proposals, canonical outbox, approval binding, or fenced lease model exists. | These are new frozen invariants, not behaviors that may be inferred from current endpoints. | + +## 3. Authorization matrix + +The exact route/DTO freeze belongs to KBN-105. This matrix fixes the minimum authorization behavior that freeze and later implementation must preserve. + +| Principal/state | Permitted authority | Required authoritative checks | Explicit denials | +| -------------------------------------------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | +| Unauthenticated caller | Public health observation only, if deployment exposes it | Health DTO validation; no proof field accepted | All canonical reads/mutations; health observation never authorizes a write | +| Active workspace `owner`/`admin` user | Policy-allowed workspace administration and domain commands | Better Auth session; active membership; server-derived workspace; command-family role; expected version/idempotency | Foreign workspace, suspended workspace, revoked membership, caller workspace override | +| Active workspace `member` user | Policy-allowed project/task/proposal commands | Active membership plus project/team capability and target checks in the same transaction | Admin, approval, purge, service-only Coordinator, and unrelated project commands | +| Active workspace `auditor` user | Workspace-scoped reads and audit/evidence inspection | Active membership and read capability | Every mutation, approval, lease, token issuance, purge | +| Active workspace `service` identity | Only explicitly issued command families | Credential maps to workspace+agent+session; agent enabled; session live; role/capability allowlist; token expiry/audience; DB recheck per command | Raw DB credentials, user/admin fallback, cross-workspace scope, command families absent from token and registry | +| Enabled agent with live session | Agent commands matching its declared and policy-approved specialist role/capabilities | Exact workspace+agent+session binding, heartbeat/state, assignment target, lease, current decimal-string fence | Ended/offline/degraded session where policy disallows; disabled agent; another assignment/session/fence | +| Mechanical Coordinator engine | Pure eligibility/order/expiry decisions from immutable snapshots | Complete workspace-local snapshot and policy revision | Authentication, ID loading, SQL, proof minting, scope invention, approval, certification, merge | +| Coordinator persistence service | Service-only assignment/lease/checkpoint/recovery commands | Fresh transaction-local proof; locks; current assignment/approval/task/session/policy/fence | Public/user proof-by-value, stale approval/policy, direct completion/certification/merge | +| Reviewer/SecReview/Certifier | Attributable evidence decisions allowed by gate policy | Active authority, author differs from reviewer, mandatory SecReview classification, immutable artifacts | Self-review; missing evidence; Certifier merge/issue-close/release | +| Break-glass retention operator | Narrow, time-bounded purge procedure only | Separate break-glass authority, reason, scope, approvals, immutable pre-purge evidence, semantic audit, post-action reconciliation | Normal application role DELETE/UPDATE, bulk unscoped purge, unaudited hard delete | +| Revoked/expired/disabled identity or ended session | None beyond policy-permitted public observation | Revocation/lifecycle checked from PostgreSQL on every command | Cached token/Valkey state cannot preserve authority | + +**No-oracle rule:** authentication may return 401, but once authenticated, a foreign-workspace, nonexistent, inaccessible, or wrong-project identifier must follow the one KBN-105-frozen 404/403 policy with the same response shape and no foreign metadata, timing-derived detail, or WebSocket/MCP discrepancy. + +## 4. Threat matrix + +Every disposition is against the frozen target, not a claim about current-main behavior. + +| ID | Attacker or failure | Asset | Precondition and abuse path | Frozen preventive/detective control | Required schema/API/negative-test evidence | Future owner | Residual risk | Disposition | +| --- | --------------------------------------------------------------------------------------- | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | +| T01 | Authenticated user supplies a foreign workspace/resource ID | Tenant confidentiality and integrity | Caller knows or guesses project/task/mission/team IDs and probes REST, MCP, WebSocket, repository, or Coordinator paths | `workspace_id` on every canonical row; composite relations; server-derived tenant; uniform no-oracle denial | Composite FK/unique DDL; every repository predicate includes workspace; N100-01/02, N110-01..05, N130-01 | KBN-100, 105, 110, 130 | Timing/volume side channels require operational review | Controlled after evidence | +| T02 | Revoked or inactive member retains an old session | Ownership and mutation authority | Authentication remains valid after workspace membership revocation | Active membership rechecked in the authoritative transaction for owners, principals, proposers, and decision actors | Active/inactive membership fixtures; N100-03, N110-06/07; no cached membership authority | KBN-100, 110 | Better Auth session may remain valid for unrelated features | Controlled after evidence | +| T03 | User joins/forges a team relation outside its workspace | Team-owned projects and tasks | Global-current-main team behavior or a stale membership is reused | Team is intra-workspace only; workspace/team composites; active workspace membership precedes team authorization | Cross-workspace team/member/owner insert and command denials; N100-04/05, N110-08 | KBN-100, 110 | Team-role policy mistakes remain possible | Controlled after evidence | +| T04 | Same-workspace IDs from a different project are combined | Planning hierarchy integrity | Valid mission/milestone/parent/current-milestone UUIDs are substituted | Project-congruent composite relations and serialized hierarchy validation | Mission/milestone/parent/current milestone mismatch and parent-cycle tests; N100-06..10, N110-09 | KBN-100, 110 | Deep hierarchy checks can be expensive | Controlled after evidence | +| T05 | Foreign or unrelated evidence/link/artifact IDs are attached | Review and audit truth | Caller has a valid same-workspace or foreign artifact UUID | Workspace-aware joins; immutable artifact digest/revision; semantic same-target validation in authoritative transaction | Mixed-workspace and same-workspace wrong-task/mission checkpoint/approval evidence tests; N100-11..14, N210-15/16 | KBN-100, 110, 210 | Same-workspace semantic validation is application-enforced | Controlled after evidence | +| T06 | Stolen, over-scoped, or replayed service token | Coordinator and task mutation authority | Service credential is accepted as admin/user or claims are trusted without DB state | Command-family least privilege; agent/session workspace binding; no raw DB credentials; enabled/live state checked per command | Auth registry fixtures prove audience/expiry/role/capability; revoked agent and ended session denials; N105-01, N110-10..13, N210-01/02 | KBN-105, 110, 210 | Credential theft until expiry/revocation check | Controlled after evidence | +| T07 | Caller forges public `healthy` or replays a stale health response | Sole-writer/fail-closed invariant | Public health body or caller field reaches mutation context | Public DTO is observation only; public DTOs reject proof/health fields; Gateway mints internal proof after live PG transaction probe | Contradictory union and forbidden-field tests; N105-02, N110-14..17 | KBN-105, 110, 140 | Health endpoint can still be used for reconnaissance | Controlled after evidence | +| T08 | Internal stale, wrong-policy, or wrong-transaction proof is reused | Transaction integrity | A branded value leaks or an adapter fails to revalidate it | Non-exported brand; transaction identity, `checkedAt <= now < validUntil`, and policy revision revalidated immediately before mutation | Wrong transaction, expiry boundary, future timestamp, policy mismatch, commit-after-expiry tests; N110-18..22 | KBN-110, 140 | In-process code can bypass TypeScript; runtime checks are mandatory | Controlled after evidence | +| T09 | DB/transport uncertainty is mislabeled as deliberate denial or conflict | Safe retry and exactly-once result | Timeout occurs before/after commit and client changes key or retries 503 | Exact 503/502/504/timeout/409 union; unknown outcome retries only with same idempotency key | Exhaustive fixture mapping and commit-before-timeout replay; N105-03, N110-23..27, N120-01/02 | KBN-105, 110, 120, 140 | External client may ignore retry rules | Controlled after evidence | +| T10 | Assignment payload forges task version, target agent/session, role, expiry, or proposer | Work routing authority | Lease service trusts command DTO rather than persisted assignment | Persisted assignment identity; exactly-one principal/proposer; exact agent/session composite; acquire accepts IDs then reloads+locks | Cross-workspace and same-workspace target substitutions, stale task version, invalid role, expired assignment; N100-15..18, N210-03..08 | KBN-100, 200, 210 | Compromised authorized proposer can make harmful proposals | Controlled by approval/audit | +| T11 | Approval proof is forged by value or borrowed from another assignment | Gate integrity | Caller submits `approved=true`, unrelated decision ID, stale policy, or self-approval | Relational approval bound to assignment; lock/reload; policy revision; author≠reviewer and mandatory SecReview | No proof-by-value DTO; wrong assignment/task/workspace/policy/actor/decision tests; N105-04, N210-09..14, N230-01 | KBN-105, 210, 230 | Colluding principals remain an organizational risk | Controlled after evidence | +| T12 | Revoked policy or expired proposal/assignment is raced against lease acquisition | Routing policy | Approval and lease transactions do not lock/revalidate current rows | Lock assignment, approval, task, target session; compare current policy and expiry inside fresh-proof transaction | Concurrent revoke/expire/acquire tests with one valid terminal result; N210-17..19 | KBN-210, 230 | Clock skew if DB time is not canonical | Controlled after evidence | +| T13 | Stale worker sends ack/heartbeat/checkpoint/review after reassignment | Canonical task and evidence state | Old process retains task/session IDs | Task-row-locked atomic monotonic bigint fence; every worker command carries exact lease/session/fence | Lower, expired, future, and other-task fences denied; old worker loses after new lease; N100-19/20, N210-20..24 | KBN-100, 210, 230 | Signed bigint exhaustion is theoretical | Controlled after evidence | +| T14 | JavaScript precision truncates a fence | Stale-worker exclusion | bigint token is serialized as number above `2^53-1` | Drizzle bigint and decimal-string wire type only | `9007199254740993` and near-`int8` boundary round trips; numeric JSON rejected; N105-05, N210-25 | KBN-105, 210 | Nonconforming external clients | Controlled after evidence | +| T15 | Checkpoint/evidence from another lease/task/session is submitted | Recovery and certification evidence | Same-workspace valid IDs are mixed | Exact lease composite binds workspace+task+assignment/session+fence; checkpoint composite binds lease+fence; evidence join plus semantic artifact-owner check | Same-workspace mismatched task/assignment/lease/session/checkpoint/artifact tests; N100-21..23, N210-26..31 | KBN-100, 210 | Artifact URI target may disappear outside DB | Controlled with digest/retention | +| T16 | Outage note or pending/rejected proposal mutates/orders work | Sole SOT and gate integrity | Importer/UI treats note/proposal as task state | Proposals are inert; only explicit accept invokes normal typed command after recovery | Row/outbox/task counts unchanged for pending/rejected; no readiness/dependency/lease effect; N110-28..31 | KBN-110, 140 | Humans may act outside Mosaic operationally | Accepted as attributable residual | +| T17 | Submission event is missing, foreign, or for another proposal | Proposal audit chain | Caller supplies an existing event UUID | Preallocated proposal ID; event-first same transaction; workspace composite FK; exact event type/aggregate/version semantic check | Missing/foreign/wrong-type/wrong-proposal event rolls back event+proposal; N100-24/25, N110-32..36 | KBN-100, 110 | Semantic checks are transaction code, not only FK | Controlled after evidence | +| T18 | Acceptance borrows an unrelated command event | Proposal and target integrity | Same-workspace event exists for another target/command/proposal | Accept locks proposal+target, executes normal command, requires workspace/target match, causation=submission event, payload proposal ID | Foreign, wrong target/type/command/causation/payload event aborts target/event/proposal atomically; N100-26, N110-37..43 | KBN-100, 110 | Event payload schema drift | Controlled by KBN-105 fixtures | +| T19 | Application role updates/deletes audit, approval evidence, checkpoint, or artifact | Nonrepudiation | Broad DB grants or parent cascade exists | INSERT/SELECT-only application roles; RESTRICT parent deletes; archive/cancel normal lifecycle | Role-level UPDATE/DELETE denied; parent delete RESTRICT; digest unchanged; N100-27..31 | KBN-100 | DB superuser can alter state | Break-glass/infra audit residual | +| T20 | Break-glass purge is used as routine deletion or erases its own evidence | Retention and incident forensics | Elevated credential available | Separate audited retention procedure, bounded scope, reason, pre/post evidence, authority separation | Normal role denied; expired/missing approval denied; purge cannot delete its authorizing audit package; N115-01, N230-02/03 | KBN-115, 230 | Privileged DBA compromise | Accepted operational residual | +| T21 | PostgreSQL unavailable or partitioned | Canonical state | Public health/Valkey remains live while transaction probe fails | Fail closed; no alternate writer/hidden queue; 503 only for proven not-applied; transport uncertainty remains unknown | Fault injection proves DB rows/outbox/files/Valkey unchanged on deliberate denial; commit-unknown replay; N110-44..48, N140-01 | KBN-110, 140, 230 | Availability loss is intentional | Accepted by Option A | +| T22 | Valkey unavailable, duplicated, stale, or partitioned | Scheduling notifications | Queue wake is treated as truth or publication fails | Valkey derived/expendable; transactional outbox in PG; idempotent publisher; recovery from PG | Commit with Valkey down leaves pending outbox; replay publishes once logically; stale wake reloads PG; N110-49, N140-02, N230-04..06 | KBN-110, 210, 230 | Duplicate at-least-once delivery | Consumers must be idempotent | +| T23 | Coordinator restarts between assignment, lease, checkpoint, or outbox steps | Durable orchestration truth | Process-local cache is treated as authority | PostgreSQL stores assignments, execution state, leases, fences, checkpoints, events, outbox; `recoverFromPostgres` | Restart at every transaction boundary reconstructs identical active/expired/pending sets without Valkey/files; N210-32..36, N230-07 | KBN-210, 230 | Recovery latency | Controlled after evidence | +| T24 | Dependency cycle or concurrent reciprocal edge | Readiness and dispatch safety | Two transactions each see an acyclic graph before inserting | Unique directed edge; no self-edge; serialized recursive cycle check; readiness evaluates all blockers | Self/duplicate/cycle and concurrent A→B/B→A tests; all predecessor property test; N100-32..35, N200-01/02 | KBN-100, 200, 230 | Very large DAG performance | Bounded operational residual | +| T25 | Parent-task cycle or project-incongruent relation | Planning hierarchy | Valid same-workspace IDs are arranged into an invalid tree | Project-congruent composites; serialized parent-cycle/orphan validation required by REQ-PLAN-001 | Self/indirect parent cycle, orphan, and cross-project mission/milestone/parent tests; N100-06..10 | KBN-100, 110 | Cycle validation is service/transaction enforced | Controlled after evidence | +| T26 | Concurrent update, duplicate retry, or idempotency payload drift | Aggregate consistency | Two clients use same version/key with different payloads | Expected-version check; semantic event and outbox in same transaction; key returns prior immutable result only for identical command | One update wins; stale gets 409; duplicate identical returns prior; payload drift rejected; N110-50..54, N140-03 | KBN-105, 110, 140 | Long-lived clients face visible conflicts | Intentional user-visible residual | +| T27 | State/event/outbox partial commit | Audit and notification consistency | Separate transactions or exception after state write | One PostgreSQL transaction for state+semantic event+outbox | Failure injected after each insert rolls all three back; success revisions align; N110-55..58 | KBN-110, 140 | Outbox publication remains asynchronous | Controlled after evidence | +| T28 | Malicious/incorrect importer injects foreign workspace data or dispatchable work | Migration integrity | Source keys collide, lineage is absent, or importer has direct DB authority | Immutable source snapshots/checksums; one-way Gateway/migration-only port; workspace-safe idempotent modes; shadow records cannot dispatch | Foreign/malformed/duplicate/partial-resume/lineage checksum and no-dispatch tests; N300-01..08 | KBN-300, 330 | Source data may be semantically ambiguous | Quarantine and owner sign-off | +| T29 | Cutover leaves legacy writer or forward/reverse sync active | Sole-writer invariant | Credentials/processes survive switch or rollback is improvised | Writer inventory, freeze, final delta, Gateway switch, credential shutdown, no dual write; rollback authority changes after first DB mutation | Process/credential inventory; concurrent-writer assertion; before/after-mutation rollback rehearsal; N320-01..06, N330-01 | KBN-320, 330, 340 | Missed external automation | Owner-gated residual | +| T30 | Generated `TASKS.md`/`mission.json` is edited or parsed into DB | Canonical state | Current-main parser/writer remains reachable or file watcher imports changes | Generated non-authoritative header/IDs/time/revision; no production importer; regenerate/overwrite only | Static import search, tamper/regeneration, read-only permission, source-revision parity; N120-03..07, N140-04 | KBN-120, 140 | Humans may mistake snapshots for live data | Header and docs mitigate | +| T31 | N-1 compatibility copies legacy ambiguity into canonical authority | Data integrity | Nullable/global/current-main fields are guessed during backfill | Nullable-first expand; deterministic mapping or quarantine; checksums; no new-only status before switch; legacy fields retained | Production-shape, ambiguous owner/assignee, status shadow, JSON/config/digest, rollback tests; N100-36..44 | KBN-100 | Quarantined records require human decision | Controlled by signed reconciliation | +| T32 | Recovery posture claims durability not provided by mechanisms | Availability and audit retention | Shape-only validation or optimistic RPO is accepted | Normative validator; WAL/PITR/RPO/storage/high-assurance constraints; mechanism and restore evidence | Unknown/impossible/weakened configuration plus actual mechanism/restore tests; N115-02..08 | KBN-115 | Backup operator or storage compromise | Separate failure domain residual | +| T33 | rc.3 frozen DDL could not create mission-scoped evidence/approval FKs | Tenant/evidence relational integrity | KBN-100 generated DDL from the rc.3 contract without an exact composite candidate key | rc.4 adds non-partial `missions_workspace_id_uidx(workspace_id,id)` before both dependent FKs while retaining global and project-congruent keys | KBN-100 must execute N100-45..50: exact-key reconciliation, candidate-before-FKs, duplicate feasibility, empty/prod/N-1/rollback, and both-child foreign-workspace negatives | KBN-100 after PR/CI/#753 release | Runtime DDL remains unimplemented and must prove the frozen order | **Resolved by rc.4 + independent APPROVE; implementation evidence remains required** | + +## 5. Constraint-impact matrix + +| Impact ID | Required invariant | Frozen schema impact | API/transaction impact | Required evidence | Owner | Status | +| --------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- | --------------------------------------------------------------------------------- | +| CI-01 | Hard workspace tenancy and no oracle | `workspace_id`, workspace-aware unique/FKs on all canonical rows | Server-derived workspace; uniform denial on all surfaces | N100-01..14; N110-01..09; N130-01 | KBN-100/105/110/130 | Resolved by frozen controls | +| CI-02 | Active user membership | Membership row plus unique `(workspace_id,user_id)`; active state retained | Recheck active membership in same authoritative transaction | N100-03; N110-06/07 | KBN-100/110 | Resolved; not FK-only | +| CI-03 | Service identity least privilege/revocation | Agent/session workspace, lifecycle, state, roles, capabilities | Token maps to exact agent/session; command-family allowlist; DB recheck; no admin/raw DB fallback | N105-01; N110-10..13; N210-01/02 | KBN-105/110/210 | Resolved at auth/API layer | +| CI-04 | Project-congruent hierarchy | Composite project/mission/milestone/parent/current-milestone relations | Lock/serialized parent-cycle and orphan validation | N100-06..10 | KBN-100/110 | Resolved; cycle behavior required | +| CI-05 | Health-proof authority | Internal branded proof has transaction/time/policy fields | Probe and revalidate on same PG transaction; no public field | N105-02/03; N110-14..27 | KBN-105/110 | Resolved by frozen controls | +| CI-06 | Assignment/approval identity | Exactly-one principal/proposer, exact agent/session assignment, relational approval | Reload+lock all IDs; compare version/target/state/expiry/policy/decision | N100-15..18; N210-03..19 | KBN-100/210 | Resolved by frozen controls | +| CI-07 | Monotonic bigint fencing | Durable bigint counter, exact lease/fence keys, one active lease | Atomic increment/RETURNING; decimal-string DTO; reject every stale worker command | N100-19..23; N210-20..31 | KBN-100/105/210 | Resolved by frozen controls | +| CI-08 | Proposal event chain | Both workspace-aware event FKs; event table created first | Exact submission/acceptance semantic checks in one transaction | N100-24..26; N110-28..43 | KBN-100/110 | Resolved; semantic checks not FK-only | +| CI-09 | Immutable audit/evidence retention | RESTRICT parents; INSERT/SELECT-only immutable tables | Archive/cancel normal flow; separately authorized purge | N100-27..31; N115-01; N230-02/03 | KBN-100/115/230 | Resolved by frozen controls | +| CI-10 | DB/Valkey/outbox/restart semantics | PG outbox and durable orchestration rows | Fail closed; same-key uncertainty retry; Valkey reloads PG; restart from PG | N110-44..49; N140-01/02; N230-04..07 | KBN-110/210/230 | Resolved by frozen controls | +| CI-11 | DAG/race/idempotency/version | Unique edge; self check; event idempotency; aggregate versions | Serialized recursive cycle check; payload binding; expected-version conflict | N100-32..35; N110-50..58; N200-01/02 | KBN-100/110/200 | Resolved by frozen controls | +| CI-12 | Import/cutover trust boundary | Lineage/artifact/event fields; shadow state cannot dispatch | One-way scoped importer, freeze, no direct DB/file authority, no dual writer | N300-01..08; N320-01..06 | KBN-300/320/330 | Resolved by frozen controls | +| CI-13 | Generated-file no-import | No canonical file schema/import contract | Projection-only package; static reachability check removes current parser from production Kanban paths | N120-03..07; N140-04 | KBN-120/140 | Resolved by frozen controls | +| CI-14 | Mission-scoped artifact and approval FKs | rc.4 adds non-partial `missions_workspace_id_uidx(workspace_id,id)` and retains global/project-congruent keys | KBN-100 must emit the candidate before both exact RESTRICT FKs and preserve N-1/rollback order | N100-45..50: exact reconciliation, duplicate feasibility, empty/prod/N-1/rollback, and separate artifact/approval foreign-workspace negatives | KBN-100 after PR/CI/#753 release | **Resolved by rc.4 and independent APPROVE; future executable evidence required** | + +## 6. Exact future negative-test catalog + +These names are normative evidence identifiers for future slices. Equivalent test-file names are acceptable only if traceability retains these IDs and expected outcomes. + +### KBN-100 — schema and migration + +- **N100-01** reject every canonical child row whose `workspace_id` differs from its parent. +- **N100-02** reject foreign-workspace link, artifact, proposal target, dependency, assignment, lease, checkpoint, approval, and event relationships. +- **N100-03** reject an inactive/revoked member as accountable owner, proposer, decision actor, archive actor, or user principal in the authoritative command transaction. +- **N100-04** reject a team/project relation crossing workspaces. +- **N100-05** reject a team authorization path when the user lacks active membership in the team's workspace. +- **N100-06** reject task→mission project mismatch. +- **N100-07** reject task→milestone and project→current-milestone project mismatch. +- **N100-08** reject task→parent project mismatch and self-parent. +- **N100-09** reject indirect parent cycles under concurrent transactions. +- **N100-10** reject mission→milestone project mismatch/orphan. +- **N100-11** reject checkpoint artifact from another workspace. +- **N100-12** reject checkpoint artifact owned by another same-workspace task/mission unless an explicitly frozen evidence rule permits it. +- **N100-13** reject approval evidence from another workspace. +- **N100-14** reject same-workspace approval evidence unrelated to the approval target. +- **N100-15** reject zero/multiple assignment principals and zero/multiple proposers. +- **N100-16** reject target session without its exact target agent. +- **N100-17** reject assignment task/agent/session crossing workspaces. +- **N100-18** reject non-positive task version and expired assignment acquisition. +- **N100-19** concurrent lease insert permits one active lease and returns one winner. +- **N100-20** successive leases return strictly increasing bigint fences. +- **N100-21** reject checkpoint with another task, lease, or fence. +- **N100-22** reject duplicate/non-monotonic checkpoint sequence. +- **N100-23** reject evidence join for a mismatched checkpoint/task. +- **N100-24** proposal insert without exact submission event fails atomically. +- **N100-25** foreign/wrong-type/wrong-proposal submission event fails atomically. +- **N100-26** foreign/wrong-target/unrelated acceptance event fails atomically. +- **N100-27** application role cannot UPDATE/DELETE `task_events`. +- **N100-28** application role cannot UPDATE/DELETE checkpoints/artifacts/evidence joins. +- **N100-29** parent hard delete is RESTRICTed while audit/evidence children exist. +- **N100-30** archive does not alter canonical lifecycle status. +- **N100-31** purge without break-glass authority/evidence is denied. +- **N100-32** reject dependency self-edge and duplicate directed pair regardless of type. +- **N100-33** reject direct and indirect dependency cycles. +- **N100-34** concurrent reciprocal dependency inserts cannot both commit. +- **N100-35** readiness remains false until every blocking predecessor and completion condition passes. +- **N100-36** empty DB migration succeeds after the contract amendment. +- **N100-37** production-shape expand retains all legacy declarations. +- **N100-38** crash/resume backfill is idempotent and checksum-stable. +- **N100-39** ambiguous workspace/owner/assignee is quarantined, never guessed. +- **N100-40** no `ready`/`in_review` status is emitted to N-1 readers before switch. +- **N100-41** `mission_tasks.status` cannot remain a write source. +- **N100-42** tags/assignee/date/mission JSON/config/description/agent fields reconcile without loss. +- **N100-43** claimed fleet backlog rows are quarantined and imported rows cannot dispatch. +- **N100-44** pre-switch rollback works while post-first-mutation rollback requires freeze/reconciliation. +- **N100-45** reconcile both exact child FK column lists to the rc.4 `(workspace_id,id)` mission candidate while retaining the global `id` primary key and `(workspace_id,project_id,id)` key. +- **N100-46** empty-DB migration creates `missions_workspace_id_uidx` before `artifacts_workspace_mission_fk` and `approval_decisions_workspace_mission_fk`. +- **N100-47** production-shape preflight finds no duplicate `(workspace_id,id)` groups, preserves global `id` uniqueness, and applies the candidate before both dependent FKs. +- **N100-48** N-1 startup/read/write remains unchanged; pre-switch rollback drops both dependents before the candidate and preserves the global/project-congruent keys. +- **N100-49** artifact insert using a valid mission ID paired with a foreign workspace fails before commit. +- **N100-50** approval-decision insert using a valid mission ID paired with a foreign workspace fails before commit. + +### KBN-105/KBN-110/KBN-120/KBN-130/KBN-140 — API and P1 + +- **N105-01** every route has an explicit user/service command-family policy; user/admin tokens cannot call service-only Coordinator mutations. +- **N105-02** public DTO validation rejects `writeProof`, internal context, body `workspaceId`, and caller-asserted health. +- **N105-03** fixture exhaustiveness prevents 503, 502/504/timeout, and 409 cross-mapping. +- **N105-04** approval DTO accepts an ID and decision command only, never approval proof-by-value. +- **N105-05** all fence fields accept/emit decimal strings and reject JSON numbers. +- **N110-01** listing with a foreign `workspaceId` or foreign filter ID follows the frozen no-oracle denial and returns no rows/counts/cursors. +- **N110-02** get by foreign or nonexistent aggregate ID has the same frozen denial shape and no foreign metadata. +- **N110-03** create/update/archive with a foreign owner, parent, project, mission, milestone, tag, or target ID is denied before mutation. +- **N110-04** dependency/proposal commands with foreign target IDs are denied with unchanged state/event/outbox counts. +- **N110-05** REST, MCP, WebSocket, and internal Coordinator paths produce equivalent no-oracle behavior for the same foreign ID. +- **N110-06** a revoked/inactive owner is denied even with a still-valid Better Auth session. +- **N110-07** stale membership/team cache cannot authorize a proposer, decision actor, archive actor, or principal after revocation. +- **N110-08** a team ID from another workspace cannot authorize or own the command target. +- **N110-09** same-workspace but wrong-project mission/milestone/parent IDs are denied inside the transaction. +- **N110-10** an expired service token is denied before repository access. +- **N110-11** an audience- or workspace-mismatched service token is denied without an existence oracle. +- **N110-12** an over-scoped service token cannot call a command family absent from its role/capability allowlist. +- **N110-13** disabled agent or ended session revokes service-token command authority immediately on PostgreSQL recheck. +- **N110-14** contradictory public health state/boolean combinations fail validation. +- **N110-15** Valkey-only liveness cannot mint or substitute a PostgreSQL write proof. +- **N110-16** caller-forged public `healthy` cannot enter internal mutation context. +- **N110-17** public REST/MCP/CLI bodies containing health/proof fields are rejected. +- **N110-18** an expired internal proof produces no state/event/outbox write. +- **N110-19** a future-dated or not-yet-valid proof produces no write. +- **N110-20** a policy-revision-mismatched proof produces no write. +- **N110-21** a proof minted on another transaction/connection produces no write. +- **N110-22** a proof that expires before the final pre-mutation check produces no write. +- **N110-23** deliberate read-only/write-unavailable denial maps only to authoritative 503/not-applied/non-retryable. +- **N110-24** timeout before commit maps to transport-unknown and permits only same-key retry. +- **N110-25** timeout after commit maps to transport-unknown and same-key retry returns the committed canonical result once. +- **N110-26** expected-version mismatch maps only to 409/not-applied/non-retryable. +- **N110-27** recovery replay with a changed idempotency key cannot masquerade as the original uncertain request. +- **N110-28** pending proposal cannot alter target fields/status/rank/version. +- **N110-29** rejected proposal cannot affect readiness, dependencies, or gates. +- **N110-30** pending/rejected proposal cannot create an assignment or lease. +- **N110-31** direct proposal-row state manipulation cannot bypass normal command execution. +- **N110-32** proposal submission without a submission event rolls back fully. +- **N110-33** foreign-workspace submission event rolls back fully. +- **N110-34** wrong aggregate/event type submission event rolls back fully. +- **N110-35** same-workspace event for another proposal rolls back fully. +- **N110-36** submission event with wrong previous/new version semantics rolls back fully. +- **N110-37** foreign-workspace acceptance event rolls back proposal, target, event, and outbox. +- **N110-38** same-workspace event for another target aggregate rolls back acceptance. +- **N110-39** event from an unrelated normal command rolls back acceptance. +- **N110-40** event caused by a different submission event rolls back acceptance. +- **N110-41** event whose payload lacks or changes `changeProposalId` rolls back acceptance. +- **N110-42** event for another proposal with the same target/command rolls back acceptance. +- **N110-43** missing accepted-command event after target handling rolls back the entire transaction. +- **N110-44** read-only-degraded denial changes no DB row/outbox/file/Valkey/provider state. +- **N110-45** write-unavailable denial changes no DB row/outbox/file/Valkey/provider state. +- **N110-46** PostgreSQL disconnect cannot redirect a command to any fallback writer. +- **N110-47** commit uncertainty remains `unknown` and never becomes a fabricated 503/not-applied result. +- **N110-48** same-key replay after recovery returns one canonical result with no duplicate event/outbox row. +- **N110-49** Valkey publication failure leaves committed PG outbox pending and replayable. +- **N110-50** two same-version updates produce one winner and one visible 409 loser. +- **N110-51** identical duplicate key+payload returns the prior immutable result without another event/outbox row. +- **N110-52** same key with payload/command drift is rejected as an idempotency conflict. +- **N110-53** the same key in another workspace cannot reveal or reuse the first workspace's result. +- **N110-54** stale reconnect/update cannot silently overwrite a newer aggregate revision. +- **N110-55** failure after state write but before semantic event rolls back state. +- **N110-56** failure after semantic event but before outbox rolls back state and event. +- **N110-57** failure after outbox insert but before commit rolls back state, event, and outbox. +- **N110-58** success commits matching aggregate/event/outbox revisions and correlation/causation. +- **N120-01** CLI never retries an authoritative 503 deliberate denial. +- **N120-02** CLI retries only transport-unknown outcomes and preserves the exact idempotency key. +- **N120-03** generated projection header contains non-authoritative warning, workspace/project IDs, generated time, and source revision. +- **N120-04** projection revision and records match the API snapshot revision exactly. +- **N120-05** hand-tampering is overwritten or rejected by regeneration and never mutates PostgreSQL. +- **N120-06** static/runtime reachability finds no parser/import path from `TASKS.md`, `mission.json`, or another export. +- **N120-07** projection writer has no domain mutation/raw SQL/Valkey authority. +- **N130-01** UI foreign/no-access/not-found state follows the frozen no-oracle response and renders no stale foreign data. +- **N140-01** real-Gateway DB fault journey proves fail-closed no-fallback behavior. +- **N140-02** real-Gateway Valkey-loss journey proves pending outbox replay. +- **N140-03** real-Gateway concurrent update/retry journey proves version and idempotency semantics. +- **N140-04** generated-file tamper journey proves projection parity and no import. + +### KBN-115/KBN-200/KBN-210/KBN-230 — recovery and coordination + +- **N115-01** retention purge without current break-glass authority, reason, immutable evidence, or bounded scope is denied and audited. +- **N115-02** recovery posture with an unknown top-level or storage field is rejected. +- **N115-03** PITR retention without WAL archival is rejected. +- **N115-04** WAL archival with zero PITR retention is rejected. +- **N115-05** claimed RPO better than the configured backup/WAL mechanism is rejected. +- **N115-06** unencrypted, optional, or same-failure-domain storage is rejected. +- **N115-07** weakened high-assurance values are rejected. +- **N115-08** shape-only validation cannot pass without normative mechanism and restore evidence. +- **N200-01** cyclic/incomplete dependency snapshots never become eligible. +- **N200-02** identical immutable snapshot+policy+time returns identical ordering and explanation with no I/O/model import. +- **N210-01** disabled agent cannot claim, ack, heartbeat, checkpoint, or submit review. +- **N210-02** ended/offline/mismatched session cannot claim, ack, heartbeat, checkpoint, or submit review. +- **N210-03** foreign-workspace task is rejected after lock/reload without an oracle. +- **N210-04** stale task version is rejected before fence increment. +- **N210-05** assignment target agent mismatch is rejected. +- **N210-06** target session mismatch is rejected. +- **N210-07** expired assignment is rejected. +- **N210-08** assignment in rejected/released/expired/superseded/leased-invalid state is rejected. +- **N210-09** missing approval is rejected. +- **N210-10** rejected/escalated/requested approval is rejected as approval authority. +- **N210-11** stale policy-revision approval is rejected. +- **N210-12** foreign-workspace approval is rejected without an oracle. +- **N210-13** approval for another assignment is rejected. +- **N210-14** author self-approval/review is rejected when independence is required. +- **N210-15** foreign-workspace artifact evidence is rejected. +- **N210-16** same-workspace artifact unrelated to the assignment/task/gate is rejected. +- **N210-17** concurrent policy revocation versus acquire cannot produce a lease under the revoked revision. +- **N210-18** concurrent assignment expiry versus acquire cannot produce a lease after expiry. +- **N210-19** concurrent session end versus acquire cannot produce a lease for the ended session. +- **N210-20** lower fencing token is rejected without writes. +- **N210-21** token from an older lease is rejected without writes. +- **N210-22** token paired with another task is rejected without writes. +- **N210-23** token paired with another session is rejected without writes. +- **N210-24** token on an expired/revoked/released lease is rejected without writes. +- **N210-25** fences above JavaScript safe integer round-trip exactly as decimal strings. +- **N210-26** lease task does not match assignment task and is rejected. +- **N210-27** lease agent/session does not match assignment target and is rejected. +- **N210-28** checkpoint task does not match lease task and is rejected. +- **N210-29** checkpoint fence does not match exact lease fence and is rejected. +- **N210-30** checkpoint sequence duplicate/regression is rejected. +- **N210-31** checkpoint artifact does not match workspace/task/evidence semantics and is rejected. +- **N210-32** restart after assignment persistence reconstructs the pending assignment. +- **N210-33** restart after lease commit reconstructs exact active lease and fence. +- **N210-34** restart after checkpoint commit reconstructs checkpoint/recovery state. +- **N210-35** restart during expiry/retry/quarantine reconstructs durable disposition and eligibility. +- **N210-36** restart with pending outbox reconstructs publication work without Valkey/files. +- **N230-01** author=self-review and missing mandatory SecReview cannot certify or complete. +- **N230-02** normal application role cannot execute retention purge. +- **N230-03** break-glass purge cannot delete or alter its own authorization/evidence chain. +- **N230-04** Valkey down leaves canonical work in PostgreSQL/outbox. +- **N230-05** duplicate wake produces one logical effect after PostgreSQL reload/idempotency. +- **N230-06** stale wake cannot revive an expired/revoked assignment or lease. +- **N230-07** restart with no Valkey/files reconstructs leases/retry/quarantine/outbox exactly. + +### KBN-300/KBN-320/KBN-330/KBN-340 — migration and cutover + +- **N300-01** source record targeting another workspace is denied/quarantined without an oracle. +- **N300-02** malformed source record is rejected with attributable reject evidence. +- **N300-03** duplicate source system/key/batch replay is idempotent. +- **N300-04** source snapshot/checksum drift aborts apply/verify. +- **N300-05** partial import resumes from durable lineage without duplicating state/events. +- **N300-06** imported shadow record cannot become ready, assigned, or leased automatically. +- **N300-07** missing source key/file/checksum/batch lineage prevents apply/sign-off. +- **N300-08** importer cannot use direct DB, generated file, Valkey, or provider issue as canonical write authority. +- **N320-01** cutover without a verified write freeze fails safe. +- **N320-02** active legacy writer process or credential blocks cutover. +- **N320-03** reverse and forward synchronization cannot run concurrently. +- **N320-04** failed final delta/reconciliation blocks client switch. +- **N320-05** rollback before first canonical DB mutation may switch authority back only after freeze assertion. +- **N320-06** rollback after first canonical mutation requires freeze, DB-delta export/reconciliation, and owner decision. +- **N330-01** rehearsal cannot sign off while counts/checksums/exceptions/writer inventory differ. +- **N340-01** cutover cannot proceed without owner authorization, terminal evidence, scoped identities, and zero active legacy writers. + +## 7. Requirements traceability + +| Requirement | Threats/impacts | Planned evidence | +| ---------------- | ---------------------------- | --------------------------------------------------------------- | +| REQ-SOT-001 | T16, T21, T22, T27, T29, T30 | N110-28..31, N110-44..49, N110-55..58, N120-03..07, N320-01..06 | +| REQ-SOT-002 | T07, T08, T09, T21 | N105-02/03, N110-14..27, N110-44..48 | +| REQ-SOT-003 | T30 | N120-03..07, N140-04 | +| REQ-SOT-004 | T16..18 | N100-24..26, N110-28..43 | +| REQ-TEN-001 | T01..05, T15, T33 | N100-01..14, N100-45..50, N110-01..09, N210-15/16 | +| REQ-ID-001 | T02, T03, T06, T10..12 | N105-01, N110-06..13, N210-01..19 | +| REQ-PLAN-001 | T04, T25 | N100-06..10 | +| REQ-TASK-001 | T13, T26, T31 | N100-20, N100-37..42, N110-50..54 | +| REQ-TASK-002 | T16, T24 | N110-28..31, N100-35, N200-01 | +| REQ-DEP-001 | T24 | N100-32..35, N200-01 | +| REQ-ASN-001 | T10..12 | N100-15..18, N210-03..19 | +| REQ-AUD-001 | T17..20, T22, T27 | N100-24..31, N110-32..43, N110-49, N110-55..58 | +| REQ-API-001 | T01, T06..18, T26 | N105-01..05 plus KBN-110 catalog | +| REQ-UI-002/003 | T01, T15, T26 | N130-01 and real-Gateway KBN-140 journeys | +| REQ-COORD-001 | T22..24 | N200-01/02, N210-32..36 | +| REQ-COORD-002 | T10..12, T16 | N210-03..19, N110-28..31 | +| REQ-COORD-003 | T13..15, T23 | N100-19..23, N210-20..36 | +| REQ-COORD-004 | T23, T26 | N210-32..36, N230-07 | +| REQ-GATE-001/002 | T11, T19, T20 | N210-09..14, N230-01..03 | +| REQ-REC-001 | T20, T32 | N115-01..08 | +| REQ-MIG-001/002 | T28, T29, T31 | N100-37..44, N300-01..08, N320-01..06, N330-01, N340-01 | + +REQ-UI-001 and REQ-UI-004 are downstream functional/accessibility requirements rather than schema-threat controls; they remain owned by KBN-130/KBN-140. Their security-relevant tenancy, conflict, and stale-reconnect portions are covered above. + +## 8. Issue #753 acceptance mapping + +| Issue requirement/criterion | Evidence in this document | Result | +| --------------------------------------------------------------- | --------------------------------------------------- | ---------------------------------------------- | +| Cross-workspace owners, principals, evidence, project hierarchy | T01–T05, T15, T25; CI-01–04 | Mapped | +| Active membership and service-token boundaries | Authorization matrix; T02, T03, T06; CI-02/03 | Mapped | +| Stale/forged health and transaction-local proof | T07–T09, T21; CI-05 | Mapped | +| Assignment/approval forgery and monotonic fencing | T10–T15; CI-06/07 | Mapped | +| Change-proposal abuse and event binding | T16–T18; CI-08 | Mapped | +| Immutable audit and break-glass | T19/T20; CI-09 | Mapped | +| PostgreSQL/Valkey failures | T21–T23; CI-10 | Mapped | +| Dependency/idempotency/version races | T24–T27; CI-11 | Mapped | +| Import/cutover and generated-file boundary | T28–T31; CI-12/13 | Mapped | +| Every schema/API/test impact explicit | Constraint matrix and negative-test catalog | Mapped | +| No unresolved schema impact | CI-14; rc.4 resolved-impact record | **PASS — none unresolved** | +| Independent SecReview | Homelab non-author exact commit/tree/content review | **PASS / APPROVE** | +| PR merge, terminal-green main CI, and #753 closure | Orchestrator-owned post-worker gates | Pending; KBN-100 remains held until completion | + +## 9. UNRESOLVED SCHEMA IMPACTS + +none + +### Resolved-impact record — KBN010-SI-001 + +- **Historical detection:** rc.3 lacked an exact `(workspace_id,id)` candidate key for the artifact and approval-decision mission FKs. This document's original BLOCKED verdict was correct and remains preserved in §1 and T33. +- **Resolution:** rc.4 adds non-partial `missions_workspace_id_uidx(workspace_id,id)` before both exact dependent FKs while retaining the global primary key and project-congruent key. +- **Reviewed object:** commit `3f6a3387b419eb99453ee10dd25ba888faaab0b5`, tree `7ebab8fa530a7180036928cea9527f808548aa14`. +- **Corroborating identities:** full-index SHA-256 `6b40a76265c4f3e6d1d30a7f262a2dd16e0d51997e99c146b59f527e6524cd42`; stable patch-id `058cf98026fcd1043703c866aee047c8bb144740`. +- **Independent verdict:** Homelab non-author schema/security review **APPROVE**. It confirmed PostgreSQL candidate/FK validity, unchanged tenant and polymorphic exactly-one-target safety, RESTRICT/no-cascade semantics, N-1/rollback validity, and no shared table/index/FK/identity/fence authority collision with #757. +- **Digest interpretation:** a command-rendered patch digest varied with rendering command/options and is non-authoritative. Git commit + tree + exact file content are canonical; stable full-index SHA-256 and stable patch-id corroborate that identity. +- **Residual implementation obligations:** KBN-100 must create the candidate before both dependent FKs; prove production-shape duplicate feasibility without weakening global uniqueness; pass empty/prod/N-1/rollback tests; reconcile both exact FK targets; and separately reject foreign-workspace mission references for artifacts and approval decisions (N100-45..50). +- **Implementation status:** no runtime schema, migration, API, or deployment implementation is claimed by this gate disposition. + +## 10. Residual risk and handoff + +- Active membership, polymorphic targets, same-task evidence semantics, parent/DAG cycle checks, token scope, and no-oracle behavior depend on authoritative transaction code and must not be treated as FK-only guarantees. +- DB superuser and break-glass compromise cannot be eliminated by application constraints; separation of duties, immutable external backup/audit evidence, drills, and monitoring remain required. +- PostgreSQL unavailability intentionally sacrifices writes for integrity. Transport-unknown outcomes remain safe only when clients preserve the exact idempotency key. +- Imported ambiguous records remain quarantined until owner sign-off; no automated mapping may convert ambiguity into authority. +- SI-001 is resolved at frozen contract/design-review level only. KBN-100 still owes N100-45..50 executable migration evidence. + +**Handoff status:** KBN-010 **PASS / GO** at rc.4. KBN-100 remains held until this PR squash-merges, terminal-green CI completes on `main`, and issue #753 closes; the orchestrator owns those remaining gates. diff --git a/docs/native-kanban-sot/SHARED-CONTRACT.md b/docs/native-kanban-sot/SHARED-CONTRACT.md index 43e44c32..f1c1b766 100644 --- a/docs/native-kanban-sot/SHARED-CONTRACT.md +++ b/docs/native-kanban-sot/SHARED-CONTRACT.md @@ -1,9 +1,21 @@ # Native Kanban/SOT — Remediated Shared Contract v1 -**Status:** INDEPENDENT REVIEW GO; freezes as v1 when issue #751 canon merges to `main` -**Version:** 1.0.0-rc.3 +**Status:** CONTROL-PLANE SI-001 AMENDMENT AUTHORIZED; prior KCR-001–016 independent-review GO retained; rc.4 requires independent schema/SecReview before KBN-100 +**Version:** 1.0.0-rc.4 **Date:** 2026-07-14 **Change authority:** Mosaic control plane/Jason only +**SI-001 amendment authority:** `web1:mosaic-100` control-plane decision under issue #753 + +## Amendment record + +### 1.0.0-rc.4 — KBN010-SI-001 + +- **Choice:** add the explicitly named, non-partial unique candidate key `missions_workspace_id_uidx` on `missions(workspace_id, id)` and retain `missions_workspace_project_id_uidx` on `(workspace_id, project_id, id)`. +- **Rationale:** mission `id` remains globally unique, while the composite candidate key makes the frozen tenant-safe generic mission relations valid. `artifacts` and `approval_decisions` are polymorphic exactly-one-target records and do not consistently carry `project_id`; widening both children would unnecessarily broaden v1 and its target semantics. +- **Exact effect:** `artifacts_workspace_mission_fk` and `approval_decisions_workspace_mission_fk` continue to reference the exact ordered columns `missions(workspace_id, id)` with RESTRICT deletion, now backed by a matching candidate key. +- **Non-effect:** no SOT, tenancy, project-congruence, proposal-audit, approval, fencing, immutability, no-cascade, API, or wire-version invariant changes. The `SuccessEnvelopeV1.contractVersion` remains `1.0.0`. +- **Historical evidence boundary:** `KBN-010-THREAT-AUTH-CONSTRAINT-GATE.md` intentionally remains the immutable rc.3 blocker verdict that detected SI-001; this rc.4 record and the #753 scratchpad append are the authorized disposition. Rewriting the gate verdict is outside this amendment's exclusive scope. +- **Gate:** this amendment resolves the DDL defect identified by KBN010-SI-001 but does not itself lift KBN-100; independent schema/SecReview remains required. ## 1. Authority @@ -43,6 +55,7 @@ Complete declaration: `contracts/kanban-schema.v1.ts`. - Specialist roles everywhere: `planning | enhance | coder | review | security-review | pr-monitor | certifier`. - Owner uses exactly-one user/team; assignment principal exactly-one user/team/agent; users require active membership; agent/session and all evidence are workspace-bound. - Task→mission/milestone/parent, mission→milestone, and project→current-milestone are project-congruent composite relations. +- Mission `id` remains globally unique. The additional non-partial `missions_workspace_id_uidx` candidate key on `(workspace_id, id)` exists only to support the frozen workspace-safe polymorphic artifact and approval-decision mission relations; the project-congruent `(workspace_id, project_id, id)` key remains authoritative wherever `project_id` is present. - Dependency identity is workspace+predecessor+successor independent of type. - Approval evidence and checkpoint evidence are workspace-scoped joins to immutable artifacts, never JSON ID arrays. - Proposal audit links are composite relations: `(workspace_id, submitted_audit_event_id)` and `(workspace_id, accepted_command_audit_event_id)` reference `task_events(workspace_id, id)` with RESTRICT deletion. @@ -76,7 +89,20 @@ Legacy columns remain declared in unified `schema.ts` for expand + full N-1/roll 6. **Switch:** stop N-1 writers; Gateway sole command boundary; enable canonical statuses. 7. **Contract release:** later release after rollback/N-1; remove compatibility/global uniques/legacy fields. -### 5.2 New audit/proposal DDL order +### 5.2 Mission candidate-key and dependent-FK DDL order + +KBN-100 migration DDL must execute the SI-001 portion in this order: + +1. expand/backfill `missions.workspace_id` and `missions.project_id` while preserving the global `missions.id` primary key and the project-congruent `missions_workspace_project_id_uidx` key; +2. prove duplicate-key feasibility on the production-shape dataset: `(workspace_id, id)` has no duplicate groups and global `id` uniqueness remains intact; +3. create the non-partial unique index `missions_workspace_id_uidx` on exact ordered columns `(workspace_id, id)`; +4. only after step 3, create/alter `artifacts` and add `artifacts_workspace_mission_fk` from `(workspace_id, mission_id)` to exact `missions(workspace_id, id)` with `ON DELETE RESTRICT`; +5. only after step 3, create/alter `approval_decisions` and add `approval_decisions_workspace_mission_fk` from `(workspace_id, mission_id)` to exact `missions(workspace_id, id)` with `ON DELETE RESTRICT`; +6. validate both constraints and prove a mission ID paired with a foreign workspace is rejected for each child. + +The candidate key is intentionally redundant with globally unique `missions.id`, but PostgreSQL requires a matching unique candidate key for the exact two-column FK target. It is additive and N-1-safe. Pre-switch rollback drops the two dependent FKs/tables before dropping this candidate key, preserves the global primary key and project-congruent key, and follows the existing freeze/reconciliation rule after the first canonical mutation. + +### 5.3 New audit/proposal DDL order KBN-100 migration DDL must execute in this order: @@ -88,36 +114,39 @@ KBN-100 migration DDL must execute in this order: The submission transaction inserts the event first using a preallocated proposal UUID, then the proposal. Acceptance inserts the normal command event before updating the locked proposal. Neither FK is omitted or replaced by a bare UUID/index check. -### 5.3 Field map +### 5.4 Field map -| Current | Expand/backfill | N-1 compatibility | Switch/contract | -| ---------------------------------------------- | -------------------------------------------------------------------------------------- | ----------------------------------------------- | ------------------------------------------------------------- | -| global `teams`, `team_members` | add workspace nullable; bootstrap; validate active owners | retain global slug/FKs | workspace composites; global unique contracts later | -| `projects.status` | add `canonical_status`; map active/paused/completed/archived | mirror representable values; no `planning` | canonical authority; legacy contracts later | -| project `owner_id/team_id/owner_type` | add exact accountable user/team; deterministic map or quarantine | preserve old reads and compare drift | canonical exact-one; remove legacy after parity | -| current milestone | create milestones then join table (no circular DDL) | absent to N-1 | join is authority | -| nullable `missions.project_id` | derive workspace/project; null/orphan exception, never guess | keep nullable legacy read | canonical required; validate/set NOT NULL later | -| mission `description` | add objective; preserve description; reviewed nonblank mapping | N-1 description | objective authority; retain until signed review | -| `missions.status` | add canonical; planning→draft, active/paused/completed/failed same | no new-only statuses emitted | canonical authority | -| mission `milestones` JSON | normalize with source digest; preserve malformed/original | N-1 reads JSON; no reverse sync | normalized authority; JSON removed after checksum sign-off | -| mission config/metadata/phase/user | retain all; map known typed policy only | all remain declared | remove only by signed consumer inventory | -| nullable `tasks.project_id` | derive explicit/mission project; orphan quarantine | retain nullable read/write during compatibility | canonical required; NOT NULL later | -| `tasks.mission_id` | add project-congruent composite | old relation readable | composite authority | -| `tasks.status` | canonical: not-started→backlog, in-progress→in_progress, others same | no ready/in_review emission | canonical authority | -| `tasks.assignee` | deterministic active user/team/agent assignment; raw value preserved if ambiguous | mirror text only if unambiguous | canonical owner/assignment; remove after no-loss sign-off | -| `tasks.tags` JSON | normalize trim/case/dedupe with original digest | transactionally mirror normalized rows | normalized authority; JSON later removed | -| `tasks.due_date` | copy exactly to `due_at` | mirror | due_at authority; legacy later | -| task common fields | preserve metadata byte-for-byte; add criteria/rank/retry/archive/version/fence | old reads valid | new fields canonical | -| `mission_tasks.status` | keep; prohibit as write source; linked status ignored; unlinked becomes task or reject | read-only compatibility value | membership uses task mission; status dropped after no readers | -| mission-task notes/PR/user | map to metadata/artifact/event/link/attribution; preserve | read-only | remove after parity | -| `agents.status` | add workspace/lifecycle/runtime/roles; status remains presence | retain all legacy fields | lifecycle/roles authority; status may remain telemetry | -| agent project/owner/prompt/tools/skills/config | preserve; validate tenant; derive typed capabilities without loss | N-1 reads | removal only by separate inventory | -| fleet `backlog` | map to designated-project tasks; edges; claimed rows quarantine | freeze claims before switch; read-only compare | task/lease authority; retire after stabilization | +| Current | Expand/backfill | N-1 compatibility | Switch/contract | +| ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | ---------------------------------------------------------------------------------- | +| global `teams`, `team_members` | add workspace nullable; bootstrap; validate active owners | retain global slug/FKs | workspace composites; global unique contracts later | +| `projects.status` | add `canonical_status`; map active/paused/completed/archived | mirror representable values; no `planning` | canonical authority; legacy contracts later | +| project `owner_id/team_id/owner_type` | add exact accountable user/team; deterministic map or quarantine | preserve old reads and compare drift | canonical exact-one; remove legacy after parity | +| current milestone | create milestones then join table (no circular DDL) | absent to N-1 | join is authority | +| nullable `missions.project_id` | derive workspace/project; null/orphan exception, never guess | keep nullable legacy read | canonical required; validate/set NOT NULL later | +| mission relational candidate keys | retain global `id` PK and project-congruent key; add non-partial `(workspace_id,id)` key before artifact/approval FKs | additive key is ignored safely by N-1 readers/writers | retain both composite keys; generic mission children use exact workspace+ID target | +| mission `description` | add objective; preserve description; reviewed nonblank mapping | N-1 description | objective authority; retain until signed review | +| `missions.status` | add canonical; planning→draft, active/paused/completed/failed same | no new-only statuses emitted | canonical authority | +| mission `milestones` JSON | normalize with source digest; preserve malformed/original | N-1 reads JSON; no reverse sync | normalized authority; JSON removed after checksum sign-off | +| mission config/metadata/phase/user | retain all; map known typed policy only | all remain declared | remove only by signed consumer inventory | +| nullable `tasks.project_id` | derive explicit/mission project; orphan quarantine | retain nullable read/write during compatibility | canonical required; NOT NULL later | +| `tasks.mission_id` | add project-congruent composite | old relation readable | composite authority | +| `tasks.status` | canonical: not-started→backlog, in-progress→in_progress, others same | no ready/in_review emission | canonical authority | +| `tasks.assignee` | deterministic active user/team/agent assignment; raw value preserved if ambiguous | mirror text only if unambiguous | canonical owner/assignment; remove after no-loss sign-off | +| `tasks.tags` JSON | normalize trim/case/dedupe with original digest | transactionally mirror normalized rows | normalized authority; JSON later removed | +| `tasks.due_date` | copy exactly to `due_at` | mirror | due_at authority; legacy later | +| task common fields | preserve metadata byte-for-byte; add criteria/rank/retry/archive/version/fence | old reads valid | new fields canonical | +| `mission_tasks.status` | keep; prohibit as write source; linked status ignored; unlinked becomes task or reject | read-only compatibility value | membership uses task mission; status dropped after no readers | +| mission-task notes/PR/user | map to metadata/artifact/event/link/attribution; preserve | read-only | remove after parity | +| `agents.status` | add workspace/lifecycle/runtime/roles; status remains presence | retain all legacy fields | lifecycle/roles authority; status may remain telemetry | +| agent project/owner/prompt/tools/skills/config | preserve; validate tenant; derive typed capabilities without loss | N-1 reads | removal only by separate inventory | +| fleet `backlog` | map to designated-project tasks; edges; claimed rows quarantine | freeze claims before switch; read-only compare | task/lease authority; retire after stabilization | -### 5.4 Required migration tests +### 5.5 Required migration tests Empty DB; exact production-shape snapshot; crash/resume; rollback before switch; N-1 startup/read/write; workspace/member negatives; status-shadow/no premature new status; `mission_tasks.status` write prohibition; tags/assignee/date/mission JSON/config/description/agent checksum; project congruence/current-milestone order; backlog freeze/no dispatch; and proof legacy declarations persist until contract release. +SI-001 adds frozen future executable evidence: empty and production-shape migrations create `missions_workspace_id_uidx` before either dependent FK; duplicate-key feasibility preflight returns no `(workspace_id,id)` duplicate groups without weakening global `id` uniqueness; N-1 startup/read/write behavior is unchanged; pre-switch rollback removes dependents before the candidate key; both exact FK column lists reconcile to the candidate key; and foreign-workspace mission references fail for both artifacts and approval decisions. TDD is not applicable to this design-only amendment; KBN-100 must implement these negative migration tests before runtime schema release. + Proposal-specific negatives must attempt: missing submission event, foreign-workspace submission event, foreign-workspace acceptance event, same-workspace event for another proposal, event for another target aggregate, and unrelated normal-command event. Every attempt must fail atomically with no accepted proposal and no target mutation. ## 6. Ownership and Coordinator split @@ -216,4 +245,10 @@ KBN-115/coder2 owns `packages/config/src/recovery-posture.ts`, tests, and recove Required release evidence includes empty/prod/partial/rollback/N-1 migration tests; cross-workspace and same-workspace wrong-project negatives; active-membership owners/principals; proposal inertness/normal acceptance; exact failure mapping; concurrent monotonic bigint fences; relational lease/checkpoint/evidence mismatch; immutability privileges/RESTRICT; recovery validation/mechanism evidence; endpoint registry alignment; accessible web journeys; author≠reviewer; mandatory SecReview; final Certifier pass/no merge authority. -The build hold remains active until independent re-review reports GO for KCR-001–016. Mos alone releases waves and serializes integration roots. +### 9.1 SI-001 amendment gate and #757 boundary + +- KBN-100 must provide the §5.2 candidate-key ordering, duplicate-feasibility, exact-FK reconciliation, empty/prod/N-1/rollback, and two-child foreign-workspace evidence before SI-001 can be certified closed. +- All prior KCR-001–016 decisions and fixed SOT/tenant/authority, proposal-audit, approval, task-fencing, immutability, and no-cascade invariants remain unchanged. +- Read-only PR #757 cross-check: its logical-agent connector lease/CAS fencing uses separate runtime tables/contracts and `lease_epoch`; rc.4 changes only the frozen `missions` candidate key. There is no shared table, index, FK, identity, fence, or authority semantic to consume or reconcile, and #757 remains owned by its existing lane. + +The build hold remains active until independent re-review reports GO for KCR-001–016 and the rc.4 SI-001 amendment. Mos alone releases waves and serializes integration roots. diff --git a/docs/native-kanban-sot/TASKS.md b/docs/native-kanban-sot/TASKS.md index c0e10efa..d868545b 100644 --- a/docs/native-kanban-sot/TASKS.md +++ b/docs/native-kanban-sot/TASKS.md @@ -70,19 +70,21 @@ No consumer implementation begins before KBN-105. No schema work begins before K ### KBN-000 — Remediate and publish canon -- **Owner:** Mos / publication control plane. -- **Mode:** SERIAL; publication gate in progress. +- **Status:** COMPLETE — PR #752 squash-merged as `49e8a54`; issue #751 closed; post-merge pipeline #1798 terminal success. +- **Owner:** Mosaic publication control plane. +- **Mode:** SERIAL; completed. - **IN:** Resolve KCR-001–016 in requirements, schema, health, Coordinator, recovery, migration map, and slices; independent re-review. - **OUT:** Feature implementation. - **Depends on:** none. - **Contract surfaces:** all canon. -- **Evidence:** strict TS; Prettier; per-finding traceability; independent author≠reviewer GO. +- **Evidence:** strict TS; Prettier; per-finding traceability; independent author≠reviewer GO; Ultron GO; terminal-green CI. ### KBN-010 — Threat, authorization, and constraint-impact gate -- **Owner:** coder3; independent `secrev`. +- **Status:** IN PROGRESS — issue [#753](https://git.mosaicstack.dev/mosaicstack/stack/issues/753). +- **Owner:** `kbn-coder3`; independent `secrev`. - **Mode:** SERIAL prerequisite of KBN-100. -- **Exclusive files:** Mos-selected threat/auth docs only. +- **Exclusive files:** `docs/native-kanban-sot/KBN-010-THREAT-AUTH-CONSTRAINT-GATE.md` and task scratchpad only. - **IN:** Cross-workspace owners/principals/evidence; active membership; stale/forged health; approval forgery; fence monotonicity; audit retention; proposal target/audit-event forgery; service tokens; DB/Valkey outage. - **OUT:** Runtime/schema edits. - **Depends on:** KBN-000 independent re-review GO. diff --git a/docs/native-kanban-sot/contracts/kanban-schema.v1.ts b/docs/native-kanban-sot/contracts/kanban-schema.v1.ts index 594a5567..5d0c3aad 100644 --- a/docs/native-kanban-sot/contracts/kanban-schema.v1.ts +++ b/docs/native-kanban-sot/contracts/kanban-schema.v1.ts @@ -485,6 +485,7 @@ export const missionsV1 = pgTable( foreignColumns: [projectsV1.workspaceId, projectsV1.id], }).onDelete('restrict'), uniqueIndex('missions_workspace_project_id_uidx').on(t.workspaceId, t.projectId, t.id), + uniqueIndex('missions_workspace_id_uidx').on(t.workspaceId, t.id), index('missions_workspace_project_status_idx').on( t.workspaceId, t.projectId, diff --git a/docs/scratchpads/753-kbn010-threat-gate.md b/docs/scratchpads/753-kbn010-threat-gate.md new file mode 100644 index 00000000..18be777b --- /dev/null +++ b/docs/scratchpads/753-kbn010-threat-gate.md @@ -0,0 +1,101 @@ +# Issue #753 — KBN-010 threat, authorization, and constraint-impact gate + +## Objective + +Complete the mandatory threat/auth/constraint-impact analysis that gates KBN-100 schema implementation. + +## Scope + +- In: threat matrix, frozen-control mapping, schema/API/test impact inventory, security evidence plan. +- Out: runtime, schema, migration, API, dependency, CI, deployment, and secret changes. +- Canonical requirements: `docs/requirements/native-kanban-sot.md`. +- Frozen contract: `docs/native-kanban-sot/SHARED-CONTRACT.md` and `contracts/*.v1.ts`. +- Tracking: Mosaic Stack issue #753. + +## Plan + +1. Independently inspect current-main implementation and frozen canon. +2. Enumerate tenant, identity, health-proof, approval, fencing, proposal, audit, token, and outage threats. +3. Map every threat to required constraints, command behavior, negative tests, and owning future slice. +4. Surface any unresolved schema impact as a blocker; do not silently amend the frozen contract. +5. Run documentation/static validation and submit for independent SecReview. + +## Execution log + +- 2026-07-14: KBN-000 completed through PR #752 and post-merge pipeline #1798. +- 2026-07-14: KBN-010 issue #753 created; task marked in progress; fresh GPT worker pending dispatch. + +## Verification evidence + +Pending worker validation, independent SecReview, Ultron gate, PR merge, post-merge CI, and issue closure. + +## 2026-07-14 worker analysis checkpoint + +- Inspected issue #753, canonical requirements, all frozen v1 contracts, and actual `origin/main` at `49e8a54` across DB schema, Better Auth scope, project/task/mission/team repositories/controllers, fleet backlog, and `TASKS.md` parsing/writing. +- Confirmed the worker branch has no source/runtime/schema delta from `origin/main`; orchestrator-owned `.mosaic` state remains dirty and untouched. +- Authored the threat, authorization, constraint-impact, negative-test, and requirements-traceability analysis in `docs/native-kanban-sot/KBN-010-THREAT-AUTH-CONSTRAINT-GATE.md`. +- Gate decision: **BLOCKED** by `KBN010-SI-001`. `missionsV1` lacks a unique candidate key on `(workspace_id, id)`, while `artifacts_workspace_mission_fk` and `approval_decisions_workspace_mission_fk` both reference that exact pair. PostgreSQL cannot create the frozen composite foreign keys as declared. +- Decision: do not select or apply a schema fix. Contract authority must version either a `(workspace_id, id)` mission candidate key or project-congruent child keys, then obtain independent re-review before KBN-100. +- Additional risks are controlled by frozen transaction/API behavior but require the exact future negative tests cataloged in the deliverable, especially active membership, service-token revocation, same-workspace semantic evidence checks, no-oracle behavior, and serialized parent/DAG cycle checks. +- OpenBrain startup recall was attempted but unavailable because `/home/hermes/.config/mosaic/credentials.json` is absent; no project state was written to an alternate memory silo. +- TDD: not applicable; this slice changes documentation/test-plan analysis only and implements no runtime behavior. + +## Validation log + +- `pnpm exec prettier --check docs/native-kanban-sot/KBN-010-THREAT-AUTH-CONSTRAINT-GATE.md docs/scratchpads/753-kbn010-threat-gate.md` — PASS. +- Changed-doc link validator — PASS (`relative_links=0`, one external issue link); `curl -fsSIL https://git.mosaicstack.dev/mosaicstack/stack/issues/753` — PASS. +- The first inline link-validator invocation had a Python f-string syntax error; corrected once and rerun successfully without changing the deliverable. +- `pnpm format:check` — PASS. +- `pnpm lint` — PASS (23 tasks successful). +- `pnpm typecheck` — PASS (42 tasks successful). +- `pnpm exec tsc -p docs/native-kanban-sot/tsconfig.json --noEmit` — PASS. +- Scoped diff review — PASS: authored delta is limited to the exclusive deliverable and this scratchpad; no runtime/schema/config/dependency/CI/deployment file is changed, and `docs/native-kanban-sot/TASKS.md` has no worker worktree delta from tracking commit `9b55de0`. +- Independent SecReview remains pending and cannot return PASS until contract authority resolves `KBN010-SI-001`. +- Final formatting/diff/test-ID completeness review — PASS (all catalog prefixes contiguous with no duplicate IDs). +- Remaining: scoped commit, queue guard, and push. + +## 2026-07-14 KBN010-SI-001 contract-authority amendment + +- Authority: `web1:mosaic-100` directed the minimal rc.4 amendment under issue #753: add a non-partial unique candidate key on `missions(workspace_id, id)` while retaining global `missions.id` uniqueness and the project-congruent `(workspace_id, project_id, id)` key. +- Rationale: artifacts and approval decisions are polymorphic exactly-one-target records and do not consistently carry `project_id`; widening both children would broaden frozen v1 semantics without improving tenant safety. +- Plan: amend only the frozen schema contract and shared contract, freeze exact DDL ordering and future migration negatives, run scoped/full validation and independent review, then commit and push without opening/merging a PR or closing #753. +- TDD: not applicable because this is a design-contract amendment with no runtime schema or migration implementation; rc.4 freezes future executable empty/prod/N-1/rollback/foreign-workspace and duplicate-key-feasibility tests. +- Budget: no explicit cap supplied; use a 20K-equivalent soft working cap and one bounded worker lane. +- Read-only #757 boundary: PR #757 adds separate logical-agent connector lease/CAS fencing (`logical_agent_connector_leases.lease_epoch`) in runtime schema and connector contracts. SI-001 changes only the frozen mission candidate key; it does not consume, alter, or reinterpret connector fencing, task fencing, lease authority, or #757 ownership. + +### Amendment verification evidence + +- Frozen schema: added exactly one non-partial `missions_workspace_id_uidx` on `(workspace_id, id)`; retained the global `id` primary key and `missions_workspace_project_id_uidx` on `(workspace_id, project_id, id)`. +- FK reconciliation: targeted static checks prove the candidate key precedes both `artifacts_workspace_mission_fk` and `approval_decisions_workspace_mission_fk`; each continues to reference exact ordered columns `(workspace_id, mission_id)` → `missions(workspace_id, id)` with RESTRICT deletion. +- Shared contract: candidate version is `1.0.0-rc.4`; authority, rationale, exact effect/non-effect, candidate-before-FK DDL order, duplicate feasibility, empty/prod/N-1/rollback/foreign-workspace future tests, and unchanged KCR/SOT/tenant/proposal/fencing/no-cascade invariants are explicit. +- Changed-file Prettier, contract ESLint, `pnpm exec tsc -p docs/native-kanban-sot/tsconfig.json`, and targeted SI-001 static checks — PASS. +- Full `pnpm format:check && pnpm lint && pnpm typecheck` — PASS (23 lint tasks and 42 typecheck/build tasks). +- Independent Codex security review — PASS, zero critical/high/medium/low findings; tenant isolation is preserved. +- Independent Codex code review confirmed the candidate key repairs both FK targets and reported no blocker on the authorized delta. Its initial request to rewrite the historical KBN-010 verdict conflicts with the exclusive scope and was resolved by documenting the immutable-evidence boundary in rc.4; its remaining finding concerns pre-existing live `.mosaic` session files, which are untouched and excluded from this commit. +- Scoped diff: only the two frozen contract files and this append-only scratchpad amendment are staged for delivery; no runtime schema, migration, task plan, gate verdict, provider artifact, or #757-owned file is included. + +## 2026-07-14 final rc.4 KBN-010 disposition + +- Control-plane direction authorized final disposition edits only to `docs/native-kanban-sot/KBN-010-THREAT-AUTH-CONSTRAINT-GATE.md` and this append-only scratchpad; contract author `web1:kbn-contract` remained idle and undisturbed. +- Exact reviewed contract object: commit `3f6a3387b419eb99453ee10dd25ba888faaab0b5`, tree `7ebab8fa530a7180036928cea9527f808548aa14`. +- Corroborating review identities: full-index SHA-256 `6b40a76265c4f3e6d1d30a7f262a2dd16e0d51997e99c146b59f527e6524cd42`; stable patch-id `058cf98026fcd1043703c866aee047c8bb144740`. A command-rendered patch digest varied by Git rendering command/options and is non-authoritative; commit+tree+exact file content are canonical. +- Independent Homelab non-author schema/security review verdict: **APPROVE**. It confirmed the rc.4 `missions_workspace_id_uidx(workspace_id,id)` repairs both dependent FKs while retaining the global PK and project-congruent key; tenant, polymorphic exactly-one-target, RESTRICT/no-cascade, N-1/rollback semantics remain valid. +- Read-only #757 cross-check: no shared table, index, FK, identity, fence, or authority collision with connector lease/CAS fencing. +- Final KBN-010 gate decision: **PASS / GO** at rc.4 with `UNRESOLVED SCHEMA IMPACTS` equal to exact `none`. Historical SI-001 detection remains in the gate document as evidence that rc.3 was invalid. +- No runtime schema/migration/API/config/dependency/CI/deployment implementation is claimed. KBN-100 must still implement candidate-before-dependent-FK ordering, duplicate feasibility, empty/prod/N-1/rollback evidence, exact FK reconciliation, and separate artifact/approval foreign-workspace negatives (N100-45..50). +- TDD remains not applicable because this continuation changes only documentation/evidence disposition and no runtime behavior. +- Remaining orchestrator-owned sequence: worker validation/commit/push → PR open/update → Ultron review → squash merge → terminal-green post-main CI → close #753 → release KBN-100. KBN-100 is not released earlier. + +### Final worker validation + +- Changed-doc Prettier and link checks — PASS; issue #753 external link returned successfully. +- Static future-test catalog check — PASS: all prefixes are individually enumerated, contiguous, and duplicate-free; N100 now spans N100-01..50. +- rc.4 disposition assertions — PASS: gate status PASS/GO, frozen target rc.4, exact `none` unresolved section, independent APPROVE, review identities, and N100-50 are present. +- Review identity reproduction — PASS: commit tree `7ebab8fa530a7180036928cea9527f808548aa14`, full-index SHA-256 `6b40a76265c4f3e6d1d30a7f262a2dd16e0d51997e99c146b59f527e6524cd42`, and stable patch-id `058cf98026fcd1043703c866aee047c8bb144740` match. +- rc.4 frozen-contract static check — PASS: the candidate key is unique in the declaration and precedes both dependent FKs; frozen contract files remain byte-identical to commit `3f6a3387b419eb99453ee10dd25ba888faaab0b5`. +- `pnpm exec tsc -p docs/native-kanban-sot/tsconfig.json --noEmit` — PASS. +- `pnpm format:check` — PASS. +- `pnpm lint` — PASS (23 tasks successful). +- `pnpm typecheck` — PASS (42 tasks successful). +- Scoped diff — PASS: only the gate document and this scratchpad are authored changes; contracts, TASKS, requirements, runtime/schema/migration/config/dependency/CI/deployment and #757-owned files are unchanged. Live `.mosaic` session state remains untouched and excluded. +- Remaining worker steps: final formatting/scoped staging, commit `docs(#753): clear KBN-010 schema gate`, queue guard, push, and control-plane notification. From eb4e14ae5cef873098251336310aded58e2d3f1e Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Tue, 14 Jul 2026 23:33:06 +0000 Subject: [PATCH 049/152] feat(mos): add logical identity connector fencing (#757) --- apps/gateway/src/agent/agent.module.ts | 14 + .../agent/connector-lease.integration.test.ts | 341 ++ ...nnector-lease.postgres.integration.test.ts | 76 + .../agent/connector-lease.repository.test.ts | 149 + .../src/agent/connector-lease.repository.ts | 354 ++ .../src/agent/connector-lease.service.ts | 285 ++ docs/PRD.md | 31 + docs/SITEMAP.md | 2 + .../mos-runtime-portability-m1.md | 49 + docs/guides/mos-connector-lease-operations.md | 43 + .../755-mos-logical-identity-fencing.md | 148 + docs/tess/TASKS.md | 1 + packages/agent/src/connector-lease.test.ts | 261 + packages/agent/src/connector-lease.ts | 381 ++ packages/agent/src/index.ts | 1 + packages/db/drizzle/0016_salty_morlocks.sql | 36 + packages/db/drizzle/meta/0016_snapshot.json | 4530 +++++++++++++++++ packages/db/drizzle/meta/_journal.json | 7 + packages/db/src/schema.ts | 63 + .../src/agent/connector-lease.dto.spec.ts | 59 + .../types/src/agent/connector-lease.dto.ts | 199 + packages/types/src/agent/index.ts | 1 + 22 files changed, 7031 insertions(+) create mode 100644 apps/gateway/src/agent/connector-lease.integration.test.ts create mode 100644 apps/gateway/src/agent/connector-lease.postgres.integration.test.ts create mode 100644 apps/gateway/src/agent/connector-lease.repository.test.ts create mode 100644 apps/gateway/src/agent/connector-lease.repository.ts create mode 100644 apps/gateway/src/agent/connector-lease.service.ts create mode 100644 docs/architecture/mos-runtime-portability-m1.md create mode 100644 docs/guides/mos-connector-lease-operations.md create mode 100644 docs/scratchpads/755-mos-logical-identity-fencing.md create mode 100644 packages/agent/src/connector-lease.test.ts create mode 100644 packages/agent/src/connector-lease.ts create mode 100644 packages/db/drizzle/0016_salty_morlocks.sql create mode 100644 packages/db/drizzle/meta/0016_snapshot.json create mode 100644 packages/types/src/agent/connector-lease.dto.spec.ts create mode 100644 packages/types/src/agent/connector-lease.dto.ts diff --git a/apps/gateway/src/agent/agent.module.ts b/apps/gateway/src/agent/agent.module.ts index 0ec2cefc..36169cd8 100644 --- a/apps/gateway/src/agent/agent.module.ts +++ b/apps/gateway/src/agent/agent.module.ts @@ -21,6 +21,12 @@ import { LogModule } from '../log/log.module.js'; import { CommandsModule } from '../commands/commands.module.js'; import { CommandRuntimeApprovalVerifier } from '../commands/runtime-approval-verifier.js'; import { GatewayHermesRuntimeTransport } from './hermes-runtime.transport.js'; +import { ConnectorLeaseRepository } from './connector-lease.repository.js'; +import { + CONNECTOR_LEASE_POLICY, + ConnectorLeaseService, + DenyConnectorLeasePolicy, +} from './connector-lease.service.js'; import { AGENT_RUNTIME_PROVIDER_REGISTRY, RUNTIME_APPROVAL_VERIFIER, @@ -46,6 +52,13 @@ export function createGatewayRuntimeProviderRegistry(): AgentRuntimeProviderRegi SkillLoaderService, DurableSessionRepository, DurableSessionService, + ConnectorLeaseRepository, + DenyConnectorLeasePolicy, + { + provide: CONNECTOR_LEASE_POLICY, + useExisting: DenyConnectorLeasePolicy, + }, + ConnectorLeaseService, { provide: AGENT_RUNTIME_PROVIDER_REGISTRY, useFactory: createGatewayRuntimeProviderRegistry, @@ -78,6 +91,7 @@ export function createGatewayRuntimeProviderRegistry(): AgentRuntimeProviderRegi SkillLoaderService, DurableSessionService, RuntimeProviderService, + ConnectorLeaseService, AGENT_RUNTIME_PROVIDER_REGISTRY, ], }) diff --git a/apps/gateway/src/agent/connector-lease.integration.test.ts b/apps/gateway/src/agent/connector-lease.integration.test.ts new file mode 100644 index 00000000..a54c123b --- /dev/null +++ b/apps/gateway/src/agent/connector-lease.integration.test.ts @@ -0,0 +1,341 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { Test, type TestingModule } from '@nestjs/testing'; +import { + connectorLeaseAuditLog, + createPgliteDb, + eq, + runPgliteMigrations, + type DbHandle, +} from '@mosaicstack/db'; +import type { ConnectorExecutionContext, FencedConnectorAdapter } from '@mosaicstack/types'; +import { DB } from '../database/database.module.js'; +import { ConnectorLeaseRepository } from './connector-lease.repository.js'; +import { + CONNECTOR_LEASE_POLICY, + ConnectorLeaseService, + type ConnectorLeasePolicy, + type ConnectorLeasePolicySubject, +} from './connector-lease.service.js'; + +const authorize = vi.fn().mockResolvedValue(true); +const policy: ConnectorLeasePolicy = { authorize }; +const context = { + actorScope: { userId: 'operator-a', tenantId: 'tenant-a' }, + correlationId: 'correlation-acquire', +}; + +describe('gateway connector lease fencing integration', (): void => { + let dataDir: string; + let handle: DbHandle; + let moduleRef: TestingModule; + let service: ConnectorLeaseService; + let repository: ConnectorLeaseRepository; + + beforeAll(async (): Promise => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-07-14T17:00:00.000Z')); + dataDir = await mkdtemp(join(tmpdir(), 'mosaic-gateway-connector-lease-')); + handle = createPgliteDb(dataDir); + await runPgliteMigrations(handle); + moduleRef = await Test.createTestingModule({ + providers: [ + ConnectorLeaseRepository, + ConnectorLeaseService, + { provide: DB, useValue: handle.db }, + { provide: CONNECTOR_LEASE_POLICY, useValue: policy }, + ], + }).compile(); + service = moduleRef.get(ConnectorLeaseService); + repository = moduleRef.get(ConnectorLeaseRepository); + }); + + afterAll(async (): Promise => { + vi.useRealTimers(); + await moduleRef.close(); + await handle.close(); + await rm(dataDir, { recursive: true, force: true }); + }); + + it('derives tenant authority at the gateway and validates a grant before side effects', async (): Promise => { + const lease = await service.acquire( + { + logicalAgentId: 'Mos', + bindingId: 'operator-chat', + connectorId: 'pi-worker-a', + scopes: ['runtime.send'], + ttlMs: 60_000, + }, + context, + ); + const grant = await service.issueGrant( + { lease, scopes: ['runtime.send'], ttlMs: 30_000 }, + { ...context, correlationId: 'correlation-grant' }, + ); + const execute = vi.fn(async (_message: string, leaseContext: ConnectorExecutionContext) => { + return leaseContext.leaseEpoch; + }); + const adapter: FencedConnectorAdapter = { execute }; + + await expect(service.executeGrant(grant, 'runtime.send', 'hello', adapter)).resolves.toBe('1'); + expect(execute).toHaveBeenCalledOnce(); + expect(authorize).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'grant.issue', + requestedScopes: ['runtime.send'], + requestedTtlMs: 30_000, + }), + ); + expect(execute.mock.calls[0]?.[1]).toMatchObject({ + identity: { tenantId: 'tenant-a', logicalAgentId: 'mos' }, + bindingId: 'operator-chat', + connectorId: 'pi-worker-a', + }); + }); + + it('normalizes lease-derived policy subjects before authorization', async (): Promise => { + const lease = await service.acquire( + { + logicalAgentId: 'mos', + bindingId: 'operator-chat-policy', + connectorId: 'pi-worker-a', + scopes: ['runtime.send'], + ttlMs: 60_000, + }, + { ...context, correlationId: 'correlation-policy-setup' }, + ); + const aliasedLease = { + ...lease, + identity: { ...lease.identity, logicalAgentId: ' MOS ' }, + bindingId: ' Operator-Chat-Policy ', + connectorId: ' PI-Worker-A ', + scopes: [' Runtime.Send '], + leaseEpoch: `00${lease.leaseEpoch}`, + }; + + await service.heartbeat(aliasedLease, 30_000, { + ...context, + correlationId: 'correlation-policy-heartbeat', + }); + expect(authorize).toHaveBeenLastCalledWith( + expect.objectContaining({ + action: 'lease.heartbeat', + logicalAgentId: 'mos', + bindingId: 'operator-chat-policy', + connectorId: 'pi-worker-a', + requestedScopes: ['runtime.send'], + }), + ); + + await service.issueGrant( + { lease: aliasedLease, scopes: [' Runtime.Send '], ttlMs: 1_000 }, + { ...context, correlationId: 'correlation-policy-grant' }, + ); + expect(authorize).toHaveBeenLastCalledWith( + expect.objectContaining({ + action: 'grant.issue', + logicalAgentId: 'mos', + bindingId: 'operator-chat-policy', + connectorId: 'pi-worker-a', + requestedScopes: ['runtime.send'], + }), + ); + + await service.release(aliasedLease, { + ...context, + correlationId: 'correlation-policy-release', + }); + expect(authorize).toHaveBeenLastCalledWith( + expect.objectContaining({ + action: 'lease.release', + logicalAgentId: 'mos', + bindingId: 'operator-chat-policy', + connectorId: 'pi-worker-a', + requestedScopes: ['runtime.send'], + }), + ); + }); + + it('denies stale, forged, expired, cross-tenant, and cross-binding grants before effects', async (): Promise => { + const bindingId = 'operator-chat-denials'; + const current = await service.acquire( + { + logicalAgentId: 'mos', + bindingId, + connectorId: 'pi-worker-a', + scopes: ['runtime.send'], + ttlMs: 60_000, + }, + { ...context, correlationId: 'correlation-denial-setup' }, + ); + const stale = await service.issueGrant( + { lease: current, scopes: ['runtime.send'], ttlMs: 30_000 }, + { ...context, correlationId: 'correlation-stale' }, + ); + await service.takeover( + { + logicalAgentId: 'mos', + bindingId, + connectorId: 'pi-worker-b', + scopes: ['runtime.send'], + ttlMs: 60_000, + expectedEpoch: current.leaseEpoch, + }, + { ...context, correlationId: 'correlation-takeover' }, + ); + const adapter = { execute: vi.fn().mockResolvedValue(undefined) }; + + await expect(service.executeGrant(stale, 'runtime.send', undefined, adapter)).rejects.toThrow(); + + const active = await service.current('mos', bindingId, context); + if (!active) throw new Error('active lease fixture is unavailable'); + const grant = await service.issueGrant( + { lease: active, scopes: ['runtime.send'], ttlMs: 1_000 }, + { ...context, correlationId: 'correlation-active' }, + ); + await expect( + service.executeGrant({ ...grant }, 'runtime.send', undefined, adapter), + ).rejects.toThrow(); + await expect( + service.executeGrant( + { ...grant, bindingId: 'other-binding' }, + 'runtime.send', + undefined, + adapter, + ), + ).rejects.toThrow(); + await expect( + service.issueGrant( + { lease: active, scopes: ['runtime.send'], ttlMs: 30_000 }, + { + actorScope: { userId: 'operator-b', tenantId: 'tenant-b' }, + correlationId: 'correlation-cross-tenant', + }, + ), + ).rejects.toThrow(); + const crossTenantAudit = await handle.db + .select() + .from(connectorLeaseAuditLog) + .where(eq(connectorLeaseAuditLog.correlationId, 'correlation-cross-tenant')); + expect(crossTenantAudit).toHaveLength(1); + expect(crossTenantAudit[0]).toMatchObject({ + tenantId: 'tenant-b', + logicalAgentId: 'untrusted', + bindingId: 'untrusted', + connectorId: 'untrusted', + reason: 'policy_denied', + }); + + vi.setSystemTime(new Date('2026-07-14T17:00:02.000Z')); + await expect(service.executeGrant(grant, 'runtime.send', undefined, adapter)).rejects.toThrow(); + expect(adapter.execute).not.toHaveBeenCalled(); + }); + + it('rejects submitted lifecycle scopes that differ from durable authority before policy or mutation', async (): Promise => { + authorize.mockResolvedValue(true); + const heartbeatLease = await service.acquire( + { + logicalAgentId: 'mos', + bindingId: 'operator-chat-heartbeat-scope', + connectorId: 'pi-worker-a', + scopes: ['runtime.send'], + ttlMs: 60_000, + }, + { ...context, correlationId: 'correlation-heartbeat-scope-setup' }, + ); + const releaseLease = await service.acquire( + { + logicalAgentId: 'mos', + bindingId: 'operator-chat-release-scope', + connectorId: 'pi-worker-a', + scopes: ['runtime.send'], + ttlMs: 60_000, + }, + { ...context, correlationId: 'correlation-release-scope-setup' }, + ); + const forgedHeartbeat = { ...heartbeatLease, scopes: ['tool.execute'] }; + const forgedRelease = { ...releaseLease, scopes: ['tool.execute'] }; + + authorize.mockImplementation(async (subject: ConnectorLeasePolicySubject) => { + return subject.requestedScopes.length === 1 && subject.requestedScopes[0] === 'tool.execute'; + }); + authorize.mockClear(); + + await expect( + service.heartbeat(forgedHeartbeat, 30_000, { + ...context, + correlationId: 'correlation-heartbeat-scope-forgery', + }), + ).rejects.toThrow('Connector authority policy denied'); + await expect( + service.release(forgedRelease, { + ...context, + correlationId: 'correlation-release-scope-forgery', + }), + ).rejects.toThrow('Connector authority policy denied'); + expect(authorize).not.toHaveBeenCalled(); + + const currentHeartbeat = await repository.findCurrent({ + identity: heartbeatLease.identity, + bindingId: heartbeatLease.bindingId, + }); + const currentRelease = await repository.findCurrent({ + identity: releaseLease.identity, + bindingId: releaseLease.bindingId, + }); + expect(currentHeartbeat).toMatchObject({ + leaseId: heartbeatLease.leaseId, + scopes: ['runtime.send'], + heartbeatAt: heartbeatLease.heartbeatAt, + expiresAt: heartbeatLease.expiresAt, + }); + expect(currentRelease).toMatchObject({ + leaseId: releaseLease.leaseId, + scopes: ['runtime.send'], + }); + expect(currentRelease?.releasedAt).toBeUndefined(); + + const forgedAudits = await handle.db + .select() + .from(connectorLeaseAuditLog) + .where(eq(connectorLeaseAuditLog.correlationId, 'correlation-heartbeat-scope-forgery')); + expect(forgedAudits).toHaveLength(1); + expect(forgedAudits[0]).toMatchObject({ + bindingId: heartbeatLease.bindingId, + connectorId: heartbeatLease.connectorId, + event: 'reject', + outcome: 'denied', + reason: 'policy_denied', + }); + const forgedReleaseAudits = await handle.db + .select() + .from(connectorLeaseAuditLog) + .where(eq(connectorLeaseAuditLog.correlationId, 'correlation-release-scope-forgery')); + expect(forgedReleaseAudits).toHaveLength(1); + expect(forgedReleaseAudits[0]).toMatchObject({ + bindingId: releaseLease.bindingId, + connectorId: releaseLease.connectorId, + event: 'reject', + outcome: 'denied', + reason: 'policy_denied', + }); + + authorize.mockImplementation(async (subject: ConnectorLeasePolicySubject) => { + return subject.requestedScopes.length === 1 && subject.requestedScopes[0] === 'runtime.send'; + }); + await expect( + service.heartbeat(heartbeatLease, 30_000, { + ...context, + correlationId: 'correlation-heartbeat-scope-canonical', + }), + ).resolves.toMatchObject({ scopes: ['runtime.send'] }); + await expect( + service.release(releaseLease, { + ...context, + correlationId: 'correlation-release-scope-canonical', + }), + ).resolves.toBeUndefined(); + }); +}); diff --git a/apps/gateway/src/agent/connector-lease.postgres.integration.test.ts b/apps/gateway/src/agent/connector-lease.postgres.integration.test.ts new file mode 100644 index 00000000..feebfc7c --- /dev/null +++ b/apps/gateway/src/agent/connector-lease.postgres.integration.test.ts @@ -0,0 +1,76 @@ +import { randomUUID } from 'node:crypto'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { + connectorLeaseAuditLog, + createDb, + eq, + logicalAgentConnectorLeases, + type DbHandle, +} from '@mosaicstack/db'; +import { ConnectorLeaseCoordinator } from '@mosaicstack/agent'; +import { ConnectorLeaseRepository } from './connector-lease.repository.js'; + +const hasPostgres = Boolean(process.env['DATABASE_URL']); +const tenantId = `lease-test-${randomUUID()}`; +const identity = { tenantId, logicalAgentId: 'mos' } as const; + +describe.skipIf(!hasPostgres)('ConnectorLeaseRepository real PostgreSQL integration', (): void => { + let handle: DbHandle; + + beforeAll((): void => { + handle = createDb(process.env['DATABASE_URL']); + }); + + afterAll(async (): Promise => { + if (!handle) return; + await handle.db + .delete(connectorLeaseAuditLog) + .where(eq(connectorLeaseAuditLog.tenantId, tenantId)); + await handle.db + .delete(logicalAgentConnectorLeases) + .where(eq(logicalAgentConnectorLeases.tenantId, tenantId)); + await handle.close(); + }); + + it('preserves the exclusive CAS fence across a real pool close/reopen', async (): Promise => { + const command = { + identity, + bindingId: 'operator-chat', + scopes: ['runtime.send'], + ttlMs: 60_000, + } as const; + const firstCoordinator = new ConnectorLeaseCoordinator(new ConnectorLeaseRepository(handle.db)); + const contenders = await Promise.allSettled([ + firstCoordinator.acquire({ + ...command, + connectorId: 'connector-a', + correlationId: 'postgres-acquire-a', + }), + firstCoordinator.acquire({ + ...command, + connectorId: 'connector-b', + correlationId: 'postgres-acquire-b', + }), + ]); + const acquired = contenders.find((result) => result.status === 'fulfilled'); + if (!acquired || acquired.status !== 'fulfilled') throw new Error('no lease contender won'); + expect(contenders.filter((result) => result.status === 'fulfilled')).toHaveLength(1); + + await handle.close(); + handle = createDb(process.env['DATABASE_URL']); + const reopened = new ConnectorLeaseCoordinator(new ConnectorLeaseRepository(handle.db)); + const persisted = await reopened.current({ identity, bindingId: 'operator-chat' }); + expect(persisted).toMatchObject({ + leaseId: acquired.value.leaseId, + leaseEpoch: '1', + }); + + const takeover = await reopened.takeover({ + ...command, + connectorId: 'connector-c', + correlationId: 'postgres-takeover', + expectedEpoch: acquired.value.leaseEpoch, + }); + expect(takeover).toMatchObject({ connectorId: 'connector-c', leaseEpoch: '2' }); + }); +}); diff --git a/apps/gateway/src/agent/connector-lease.repository.test.ts b/apps/gateway/src/agent/connector-lease.repository.test.ts new file mode 100644 index 00000000..999257be --- /dev/null +++ b/apps/gateway/src/agent/connector-lease.repository.test.ts @@ -0,0 +1,149 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + connectorLeaseAuditLog, + createPgliteDb, + eq, + runPgliteMigrations, + type DbHandle, +} from '@mosaicstack/db'; +import { ConnectorLeaseCoordinator, ConnectorLeaseError } from '@mosaicstack/agent'; +import { ConnectorLeaseRepository } from './connector-lease.repository.js'; + +const identity = { tenantId: 'tenant-a', logicalAgentId: 'mos' } as const; + +function acquireCommand(connectorId: string, correlationId: string) { + return { + identity, + bindingId: 'operator-chat', + connectorId, + scopes: ['runtime.send', 'tool.execute'], + ttlMs: 60_000, + correlationId, + }; +} + +describe('ConnectorLeaseRepository PostgreSQL semantics', (): void => { + let dataDir: string; + let handle: DbHandle; + let now: Date; + let coordinator: ConnectorLeaseCoordinator; + + beforeEach(async (): Promise => { + dataDir = await mkdtemp(join(tmpdir(), 'mosaic-connector-lease-')); + handle = createPgliteDb(dataDir); + await runPgliteMigrations(handle); + now = new Date('2026-07-14T17:00:00.000Z'); + coordinator = new ConnectorLeaseCoordinator(new ConnectorLeaseRepository(handle.db), { + now: (): Date => now, + }); + }); + + afterEach(async (): Promise => { + await handle.close(); + await rm(dataDir, { recursive: true, force: true }); + }); + + it('allows only one concurrent contender to acquire a binding', async (): Promise => { + const outcomes = await Promise.allSettled([ + coordinator.acquire(acquireCommand('connector-a', 'correlation-a')), + coordinator.acquire(acquireCommand('connector-b', 'correlation-b')), + ]); + + expect(outcomes.filter((result) => result.status === 'fulfilled')).toHaveLength(1); + const rejected = outcomes.find((result) => result.status === 'rejected'); + expect(rejected).toMatchObject({ + reason: { code: 'lease_held' } satisfies Partial, + }); + }); + + it('uses compare-and-swap takeover and increments the fencing epoch monotonically', async (): Promise => { + const acquired = await coordinator.acquire(acquireCommand('connector-a', 'correlation-a')); + const results = await Promise.allSettled([ + coordinator.takeover({ + ...acquireCommand('connector-b', 'correlation-b'), + expectedEpoch: acquired.leaseEpoch, + }), + coordinator.takeover({ + ...acquireCommand('connector-c', 'correlation-c'), + expectedEpoch: acquired.leaseEpoch, + }), + ]); + const winner = results.find((result) => result.status === 'fulfilled'); + + expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1); + expect(winner?.status === 'fulfilled' ? winner.value.leaseEpoch : null).toBe('2'); + expect(results.find((result) => result.status === 'rejected')).toMatchObject({ + reason: { code: 'cas_mismatch' } satisfies Partial, + }); + }); + + it('heartbeats and releases only the current connector epoch', async (): Promise => { + const acquired = await coordinator.acquire(acquireCommand('connector-a', 'correlation-a')); + now = new Date('2026-07-14T17:00:30.000Z'); + const renewed = await coordinator.heartbeat({ + lease: acquired, + ttlMs: 120_000, + correlationId: 'correlation-renew', + }); + expect(renewed.expiresAt).toBe('2026-07-14T17:02:30.000Z'); + + await coordinator.release({ lease: renewed, correlationId: 'correlation-release' }); + await expect( + coordinator.heartbeat({ + lease: renewed, + ttlMs: 120_000, + correlationId: 'correlation-stale', + }), + ).rejects.toMatchObject({ code: 'lease_released' } satisfies Partial); + }); + + it('survives close/reopen and requires CAS takeover to recover an expired lease', async (): Promise => { + const acquired = await coordinator.acquire(acquireCommand('connector-a', 'correlation-a')); + await handle.close(); + + now = new Date('2026-07-14T17:02:00.000Z'); + handle = createPgliteDb(dataDir); + await runPgliteMigrations(handle); + coordinator = new ConnectorLeaseCoordinator(new ConnectorLeaseRepository(handle.db), { + now: (): Date => now, + }); + + await expect( + coordinator.acquire(acquireCommand('connector-b', 'correlation-plain-acquire')), + ).rejects.toMatchObject({ code: 'takeover_required' } satisfies Partial); + const recovered = await coordinator.takeover({ + ...acquireCommand('connector-b', 'correlation-takeover'), + expectedEpoch: acquired.leaseEpoch, + }); + expect(recovered).toMatchObject({ connectorId: 'connector-b', leaseEpoch: '2' }); + }); + + it('writes credential-safe lifecycle and rejection audit records', async (): Promise => { + const acquired = await coordinator.acquire(acquireCommand('connector-a', 'correlation-a')); + await coordinator.heartbeat({ + lease: acquired, + ttlMs: 60_000, + correlationId: 'correlation-renew', + }); + await expect( + coordinator.acquire(acquireCommand('connector-b', 'correlation-reject')), + ).rejects.toBeInstanceOf(ConnectorLeaseError); + + const rows = await handle.db + .select() + .from(connectorLeaseAuditLog) + .where(eq(connectorLeaseAuditLog.tenantId, identity.tenantId)); + expect(rows.map((row) => row.event)).toEqual( + expect.arrayContaining(['acquire', 'renew', 'reject']), + ); + const serialized = JSON.stringify(rows, (_key: string, value: unknown): unknown => + typeof value === 'bigint' ? value.toString(10) : value, + ); + expect(serialized).not.toContain('tool.execute'); + expect(serialized).not.toContain('runtime.send'); + expect(serialized).not.toMatch(/token|secret|credential/i); + }); +}); diff --git a/apps/gateway/src/agent/connector-lease.repository.ts b/apps/gateway/src/agent/connector-lease.repository.ts new file mode 100644 index 00000000..fd450eae --- /dev/null +++ b/apps/gateway/src/agent/connector-lease.repository.ts @@ -0,0 +1,354 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { + and, + connectorLeaseAuditLog, + eq, + gt, + isNull, + logicalAgentConnectorLeases, + sql, + type Db, +} from '@mosaicstack/db'; +import { ConnectorLeaseError } from '@mosaicstack/agent'; +import type { + ConnectorLease, + ConnectorLeaseAcquireMutation, + ConnectorLeaseAuditEvent, + ConnectorLeaseHeartbeatMutation, + ConnectorLeaseRejectReason, + ConnectorLeaseReleaseMutation, + ConnectorLeaseStore, + ConnectorLeaseTakeoverMutation, + LogicalAgentBinding, +} from '@mosaicstack/types'; +import { DB } from '../database/database.module.js'; + +interface SuccessfulMutation { + readonly ok: true; + readonly lease: ConnectorLease; +} + +interface FailedMutation { + readonly ok: false; + readonly reason: ConnectorLeaseRejectReason; +} + +type MutationResult = SuccessfulMutation | FailedMutation; + +@Injectable() +export class ConnectorLeaseRepository implements ConnectorLeaseStore { + constructor(@Inject(DB) private readonly db: Db) {} + + async acquire(input: ConnectorLeaseAcquireMutation): Promise { + const result: MutationResult = await this.db.transaction( + async (tx): Promise => { + const inserted = await tx + .insert(logicalAgentConnectorLeases) + .values({ + leaseId: input.leaseId, + tenantId: input.identity.tenantId, + logicalAgentId: input.identity.logicalAgentId, + bindingId: input.bindingId, + connectorId: input.connectorId, + scopes: [...input.scopes], + leaseEpoch: 1n, + acquiredAt: new Date(input.now), + heartbeatAt: new Date(input.now), + expiresAt: new Date(input.expiresAt), + updatedAt: new Date(input.now), + }) + .onConflictDoNothing() + .returning(); + const row = inserted[0]; + if (row) { + const lease = toLease(row); + await insertAudit(tx, lifecycleAudit(input, lease, 'acquire')); + return { ok: true, lease }; + } + + const current = await findRow(tx, input); + if (current && current.expiresAt <= new Date(input.now) && !current.releasedAt) { + await insertAudit(tx, lifecycleAudit(input, toLease(current), 'expiry')); + } + const reason: ConnectorLeaseRejectReason = + current && (current.releasedAt || current.expiresAt <= new Date(input.now)) + ? 'takeover_required' + : 'lease_held'; + await insertAudit(tx, rejectionAudit(input, current ? toLease(current) : null, reason)); + return { ok: false, reason }; + }, + ); + return unwrap(result); + } + + async takeover(input: ConnectorLeaseTakeoverMutation): Promise { + const result: MutationResult = await this.db.transaction( + async (tx): Promise => { + const current = await findRow(tx, input); + if (!current || current.leaseEpoch.toString(10) !== input.expectedEpoch) { + await insertAudit( + tx, + rejectionAudit(input, current ? toLease(current) : null, 'cas_mismatch'), + ); + return { ok: false, reason: 'cas_mismatch' }; + } + if (current.expiresAt <= new Date(input.now) && !current.releasedAt) { + await insertAudit(tx, lifecycleAudit(input, toLease(current), 'expiry')); + } + const updated = await tx + .update(logicalAgentConnectorLeases) + .set({ + leaseId: input.leaseId, + connectorId: input.connectorId, + scopes: [...input.scopes], + leaseEpoch: sql`${logicalAgentConnectorLeases.leaseEpoch} + 1`, + acquiredAt: new Date(input.now), + heartbeatAt: new Date(input.now), + expiresAt: new Date(input.expiresAt), + releasedAt: null, + updatedAt: new Date(input.now), + }) + .where( + and( + bindingPredicate(input), + eq(logicalAgentConnectorLeases.leaseId, current.leaseId), + eq(logicalAgentConnectorLeases.leaseEpoch, BigInt(input.expectedEpoch)), + ), + ) + .returning(); + const row = updated[0]; + if (!row) { + await insertAudit(tx, rejectionAudit(input, toLease(current), 'cas_mismatch')); + return { ok: false, reason: 'cas_mismatch' }; + } + const lease = toLease(row); + await insertAudit(tx, lifecycleAudit(input, lease, 'takeover')); + return { ok: true, lease }; + }, + ); + return unwrap(result); + } + + async heartbeat(input: ConnectorLeaseHeartbeatMutation): Promise { + const result: MutationResult = await this.db.transaction( + async (tx): Promise => { + const updated = await tx + .update(logicalAgentConnectorLeases) + .set({ + heartbeatAt: new Date(input.now), + expiresAt: new Date(input.expiresAt), + updatedAt: new Date(input.now), + }) + .where( + and( + bindingPredicate(input.lease), + eq(logicalAgentConnectorLeases.leaseId, input.lease.leaseId), + eq(logicalAgentConnectorLeases.connectorId, input.lease.connectorId), + eq(logicalAgentConnectorLeases.leaseEpoch, BigInt(input.lease.leaseEpoch)), + isNull(logicalAgentConnectorLeases.releasedAt), + gt(logicalAgentConnectorLeases.expiresAt, new Date(input.now)), + ), + ) + .returning(); + const row = updated[0]; + if (row) { + const lease = toLease(row); + await insertAudit(tx, lifecycleAudit(input, lease, 'renew')); + return { ok: true, lease }; + } + const current = await findRow(tx, input.lease); + const reason = classifyAuthorityFailure( + current ? toLease(current) : null, + input.lease, + input.now, + ); + if (reason === 'lease_expired' && current) { + await insertAudit(tx, lifecycleAudit(input, toLease(current), 'expiry')); + } + await insertAudit( + tx, + rejectionAudit( + { ...input.lease, correlationId: input.correlationId, now: input.now }, + current ? toLease(current) : null, + reason, + ), + ); + return { ok: false, reason }; + }, + ); + return unwrap(result); + } + + async release(input: ConnectorLeaseReleaseMutation): Promise { + const result: MutationResult = await this.db.transaction( + async (tx): Promise => { + const updated = await tx + .update(logicalAgentConnectorLeases) + .set({ + releasedAt: new Date(input.now), + expiresAt: new Date(input.now), + updatedAt: new Date(input.now), + }) + .where( + and( + bindingPredicate(input.lease), + eq(logicalAgentConnectorLeases.leaseId, input.lease.leaseId), + eq(logicalAgentConnectorLeases.connectorId, input.lease.connectorId), + eq(logicalAgentConnectorLeases.leaseEpoch, BigInt(input.lease.leaseEpoch)), + isNull(logicalAgentConnectorLeases.releasedAt), + gt(logicalAgentConnectorLeases.expiresAt, new Date(input.now)), + ), + ) + .returning(); + const row = updated[0]; + if (row) { + const lease = toLease(row); + await insertAudit(tx, lifecycleAudit(input, lease, 'release')); + return { ok: true, lease }; + } + const current = await findRow(tx, input.lease); + const reason = classifyAuthorityFailure( + current ? toLease(current) : null, + input.lease, + input.now, + ); + if (reason === 'lease_expired' && current) { + await insertAudit(tx, lifecycleAudit(input, toLease(current), 'expiry')); + } + await insertAudit( + tx, + rejectionAudit( + { ...input.lease, correlationId: input.correlationId, now: input.now }, + current ? toLease(current) : null, + reason, + ), + ); + return { ok: false, reason }; + }, + ); + unwrap(result); + } + + async findCurrent(binding: LogicalAgentBinding): Promise { + const row = await findRow(this.db, binding); + return row ? toLease(row) : null; + } + + async recordAudit(event: ConnectorLeaseAuditEvent): Promise { + await insertAudit(this.db, event); + } +} + +function unwrap(result: MutationResult): ConnectorLease { + if (!result.ok) throw new ConnectorLeaseError(result.reason, safeErrorMessage(result.reason)); + return result.lease; +} + +function safeErrorMessage(reason: ConnectorLeaseRejectReason): string { + return `Connector lease mutation denied: ${reason}`; +} + +function bindingPredicate(binding: LogicalAgentBinding) { + return and( + eq(logicalAgentConnectorLeases.tenantId, binding.identity.tenantId), + eq(logicalAgentConnectorLeases.logicalAgentId, binding.identity.logicalAgentId), + eq(logicalAgentConnectorLeases.bindingId, binding.bindingId), + ); +} + +async function findRow( + db: Pick, + binding: LogicalAgentBinding, +): Promise { + const rows = await db + .select() + .from(logicalAgentConnectorLeases) + .where(bindingPredicate(binding)) + .limit(1); + return rows[0] ?? null; +} + +function toLease(row: typeof logicalAgentConnectorLeases.$inferSelect): ConnectorLease { + return Object.freeze({ + identity: Object.freeze({ tenantId: row.tenantId, logicalAgentId: row.logicalAgentId }), + bindingId: row.bindingId, + leaseId: row.leaseId, + connectorId: row.connectorId, + scopes: Object.freeze([...row.scopes]), + leaseEpoch: row.leaseEpoch.toString(10), + acquiredAt: row.acquiredAt.toISOString(), + heartbeatAt: row.heartbeatAt.toISOString(), + expiresAt: row.expiresAt.toISOString(), + ...(row.releasedAt ? { releasedAt: row.releasedAt.toISOString() } : {}), + }); +} + +function classifyAuthorityFailure( + current: ConnectorLease | null, + claimed: ConnectorLease, + now: string, +): ConnectorLeaseRejectReason { + if (!current) return 'lease_missing'; + if (current.releasedAt) return 'lease_released'; + if (new Date(current.expiresAt) <= new Date(now)) return 'lease_expired'; + if (current.leaseEpoch !== claimed.leaseEpoch) return 'stale_epoch'; + return 'connector_mismatch'; +} + +function lifecycleAudit( + input: { readonly correlationId: string; readonly now: string }, + lease: ConnectorLease, + event: Exclude, +): ConnectorLeaseAuditEvent { + return { + identity: lease.identity, + bindingId: lease.bindingId, + connectorId: lease.connectorId, + leaseId: lease.leaseId, + leaseEpoch: lease.leaseEpoch, + event, + outcome: 'succeeded', + correlationId: input.correlationId, + occurredAt: input.now, + }; +} + +function rejectionAudit( + input: { + readonly identity: ConnectorLease['identity']; + readonly bindingId: string; + readonly connectorId: string; + readonly correlationId: string; + readonly now: string; + }, + current: ConnectorLease | null, + reason: ConnectorLeaseRejectReason, +): ConnectorLeaseAuditEvent { + return { + identity: input.identity, + bindingId: input.bindingId, + connectorId: input.connectorId, + event: 'reject', + outcome: 'denied', + correlationId: input.correlationId, + occurredAt: input.now, + ...(current ? { leaseId: current.leaseId, leaseEpoch: current.leaseEpoch } : {}), + reason, + }; +} + +async function insertAudit(db: Pick, event: ConnectorLeaseAuditEvent): Promise { + await db.insert(connectorLeaseAuditLog).values({ + tenantId: event.identity.tenantId, + logicalAgentId: event.identity.logicalAgentId, + bindingId: event.bindingId, + connectorId: event.connectorId, + ...(event.leaseId ? { leaseId: event.leaseId } : {}), + ...(event.leaseEpoch ? { leaseEpoch: BigInt(event.leaseEpoch) } : {}), + event: event.event, + outcome: event.outcome, + ...(event.reason ? { reason: event.reason } : {}), + correlationId: event.correlationId, + occurredAt: new Date(event.occurredAt), + }); +} diff --git a/apps/gateway/src/agent/connector-lease.service.ts b/apps/gateway/src/agent/connector-lease.service.ts new file mode 100644 index 00000000..a6db3121 --- /dev/null +++ b/apps/gateway/src/agent/connector-lease.service.ts @@ -0,0 +1,285 @@ +import { ForbiddenException, Inject, Injectable } from '@nestjs/common'; +import { ConnectorLeaseCoordinator, normalizeConnectorLease } from '@mosaicstack/agent'; +import { + normalizeConnectorId, + normalizeConnectorScopes, + normalizeCorrelationId, + normalizeLogicalAgentIdentity, + normalizeLogicalBindingId, + type AcquireConnectorLeaseInput, + type ConnectorExecutionGrant, + type ConnectorLease, + type ConnectorLeaseAuditEvent, + type FencedConnectorAdapter, +} from '@mosaicstack/types'; +import type { ActorTenantScope } from '../auth/session-scope.js'; +import { ConnectorLeaseRepository } from './connector-lease.repository.js'; + +export const CONNECTOR_LEASE_POLICY = Symbol('CONNECTOR_LEASE_POLICY'); + +export type ConnectorLeasePolicyAction = + | 'lease.acquire' + | 'lease.takeover' + | 'lease.heartbeat' + | 'lease.release' + | 'lease.read' + | 'grant.issue'; + +export interface ConnectorLeaseRequestContext { + readonly actorScope: ActorTenantScope; + readonly correlationId: string; +} + +export interface GatewayConnectorLeaseRequest { + readonly logicalAgentId: string; + readonly bindingId: string; + readonly connectorId: string; + readonly scopes: readonly string[]; + readonly ttlMs: number; +} + +export interface GatewayConnectorLeaseTakeoverRequest extends GatewayConnectorLeaseRequest { + readonly expectedEpoch: string; +} + +export interface GatewayConnectorGrantRequest { + readonly lease: ConnectorLease; + readonly scopes: readonly string[]; + readonly ttlMs: number; +} + +export interface ConnectorLeasePolicySubject { + readonly action: ConnectorLeasePolicyAction; + readonly actorId: string; + readonly tenantId: string; + readonly logicalAgentId: string; + readonly bindingId: string; + readonly connectorId: string; + readonly requestedScopes: readonly string[]; + readonly requestedTtlMs: number | null; +} + +export interface ConnectorLeasePolicy { + authorize(subject: ConnectorLeasePolicySubject): Promise; +} + +/** M1 has no concrete cutover policy: unconfigured production use fails closed. */ +@Injectable() +export class DenyConnectorLeasePolicy implements ConnectorLeasePolicy { + async authorize(_subject: ConnectorLeasePolicySubject): Promise { + return false; + } +} + +/** Gateway-owned policy surface for durable connector authority and fenced effects. */ +@Injectable() +export class ConnectorLeaseService { + private readonly coordinator: ConnectorLeaseCoordinator; + + constructor( + @Inject(ConnectorLeaseRepository) private readonly repository: ConnectorLeaseRepository, + @Inject(CONNECTOR_LEASE_POLICY) private readonly policy: ConnectorLeasePolicy, + ) { + this.coordinator = new ConnectorLeaseCoordinator(repository); + } + + async acquire( + request: GatewayConnectorLeaseRequest, + context: ConnectorLeaseRequestContext, + ): Promise { + const command = this.command(request, context); + await this.assertPolicy('lease.acquire', command, context, command.scopes, command.ttlMs); + return this.coordinator.acquire({ ...command, correlationId: this.correlation(context) }); + } + + async takeover( + request: GatewayConnectorLeaseTakeoverRequest, + context: ConnectorLeaseRequestContext, + ): Promise { + const command = this.command(request, context); + await this.assertPolicy('lease.takeover', command, context, command.scopes, command.ttlMs); + return this.coordinator.takeover({ + ...command, + expectedEpoch: request.expectedEpoch, + correlationId: this.correlation(context), + }); + } + + async heartbeat( + lease: ConnectorLease, + ttlMs: number, + context: ConnectorLeaseRequestContext, + ): Promise { + const normalizedLease = normalizeConnectorLease(lease); + const durableLease = await this.durableLifecycleLease(normalizedLease, context); + await this.assertPolicy('lease.heartbeat', durableLease, context, durableLease.scopes, ttlMs); + return this.coordinator.heartbeat({ + lease: durableLease, + ttlMs, + correlationId: this.correlation(context), + }); + } + + async release(lease: ConnectorLease, context: ConnectorLeaseRequestContext): Promise { + const normalizedLease = normalizeConnectorLease(lease); + const durableLease = await this.durableLifecycleLease(normalizedLease, context); + await this.assertPolicy('lease.release', durableLease, context, durableLease.scopes, null); + await this.coordinator.release({ + lease: durableLease, + correlationId: this.correlation(context), + }); + } + + async current( + logicalAgentId: string, + bindingId: string, + context: ConnectorLeaseRequestContext, + ): Promise { + const binding = { + identity: normalizeLogicalAgentIdentity({ + tenantId: context.actorScope.tenantId, + logicalAgentId, + }), + bindingId: normalizeLogicalBindingId(bindingId), + connectorId: 'gateway', + }; + await this.assertPolicy('lease.read', binding, context, [], null); + return this.coordinator.current(binding); + } + + async issueGrant( + request: GatewayConnectorGrantRequest, + context: ConnectorLeaseRequestContext, + ): Promise { + const lease = normalizeConnectorLease(request.lease); + await this.assertTenant(lease, context); + const scopes = normalizeConnectorScopes(request.scopes); + await this.assertPolicy('grant.issue', lease, context, scopes, request.ttlMs); + return this.coordinator.issueGrant({ + lease, + scopes, + ttlMs: request.ttlMs, + correlationId: this.correlation(context), + }); + } + + async executeGrant( + grant: ConnectorExecutionGrant, + requiredScope: string, + input: TInput, + adapter: FencedConnectorAdapter, + ): Promise { + return this.coordinator.executeGrant(grant, requiredScope, input, adapter); + } + + private command( + request: GatewayConnectorLeaseRequest, + context: ConnectorLeaseRequestContext, + ): Omit { + return { + identity: normalizeLogicalAgentIdentity({ + tenantId: context.actorScope.tenantId, + logicalAgentId: request.logicalAgentId, + }), + bindingId: normalizeLogicalBindingId(request.bindingId), + connectorId: normalizeConnectorId(request.connectorId), + scopes: normalizeConnectorScopes(request.scopes), + ttlMs: request.ttlMs, + }; + } + + private async assertTenant( + lease: Pick, + context: ConnectorLeaseRequestContext, + ): Promise { + if (lease.identity.tenantId !== context.actorScope.tenantId) { + await this.recordPolicyDenial( + { + identity: { + tenantId: context.actorScope.tenantId, + logicalAgentId: 'untrusted', + }, + bindingId: 'untrusted', + connectorId: 'untrusted', + }, + context, + ); + throw new ForbiddenException('Connector authority tenant scope denied'); + } + } + + private async durableLifecycleLease( + submittedLease: ConnectorLease, + context: ConnectorLeaseRequestContext, + ): Promise { + await this.assertTenant(submittedLease, context); + const durableLease = await this.coordinator.current(submittedLease); + if (!durableLease || !hasSameLifecycleAuthority(submittedLease, durableLease)) { + await this.recordPolicyDenial(durableLease ?? submittedLease, context); + throw new ForbiddenException('Connector authority policy denied'); + } + return durableLease; + } + + private async assertPolicy( + action: ConnectorLeasePolicyAction, + subject: Pick, + context: ConnectorLeaseRequestContext, + requestedScopes: readonly string[], + requestedTtlMs: number | null, + ): Promise { + const allowed = await this.policy.authorize({ + action, + actorId: context.actorScope.userId, + tenantId: subject.identity.tenantId, + logicalAgentId: subject.identity.logicalAgentId, + bindingId: subject.bindingId, + connectorId: subject.connectorId, + requestedScopes: Object.freeze([...requestedScopes]), + requestedTtlMs, + }); + if (!allowed) { + await this.recordPolicyDenial(subject, context); + throw new ForbiddenException('Connector authority policy denied'); + } + } + + private async recordPolicyDenial( + subject: Pick, + context: ConnectorLeaseRequestContext, + ): Promise { + const event: ConnectorLeaseAuditEvent = { + identity: subject.identity, + bindingId: subject.bindingId, + connectorId: subject.connectorId, + event: 'reject', + outcome: 'denied', + reason: 'policy_denied', + correlationId: this.correlation(context), + occurredAt: new Date().toISOString(), + }; + await this.repository.recordAudit(event); + } + + private correlation(context: ConnectorLeaseRequestContext): string { + return normalizeCorrelationId(context.correlationId); + } +} + +function hasSameLifecycleAuthority( + submittedLease: ConnectorLease, + durableLease: ConnectorLease, +): boolean { + return ( + submittedLease.identity.tenantId === durableLease.identity.tenantId && + submittedLease.identity.logicalAgentId === durableLease.identity.logicalAgentId && + submittedLease.bindingId === durableLease.bindingId && + submittedLease.leaseId === durableLease.leaseId && + submittedLease.connectorId === durableLease.connectorId && + submittedLease.leaseEpoch === durableLease.leaseEpoch && + submittedLease.scopes.length === durableLease.scopes.length && + submittedLease.scopes.every((scope: string, index: number): boolean => { + return scope === durableLease.scopes[index]; + }) + ); +} diff --git a/docs/PRD.md b/docs/PRD.md index ddee2d70..7585ecc4 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -284,6 +284,37 @@ Use TDD for remote-ingress routing and permission boundaries. Required evidence --- +## Mos Runtime Portability Workstream (MOS-PORT) + +### Problem and Objective + +Mos is currently identified partly by a harness-native session and communication process. Replacement/rebinding exists, but no gateway-enforced logical identity or fencing prevents a stale harness from continuing to reply or execute effects after takeover. + +The objective is to make Mos a server-derived logical Mosaic identity whose authority can move safely among runtime connectors. The gateway owns identity, lease, policy, and audit; harnesses remain replaceable adapters. + +### M1 Requirements + +1. `MOS-PORT-ID-001`: Define a normalized logical-agent identity independent of Claude Code, Pi, Codex, tmux, Matrix, and provider-native session IDs. +2. `MOS-PORT-LEASE-001`: Persist one exclusive connector lease per tenant/logical-agent/binding with CAS acquisition, monotonic fencing epoch, TTL, heartbeat, explicit release, and takeover. +3. `MOS-PORT-FENCE-001`: Bind every connector dispatch/execution grant to the current server-derived tenant, logical identity, binding, connector, scopes, expiry, and lease epoch. +4. `MOS-PORT-FENCE-002`: Reject and audit stale, expired, forged, cross-tenant, cross-binding, and unauthorized grants before connector, channel, provider, or tool side effects. +5. `MOS-PORT-OBS-001`: Emit credential-safe correlation/audit events for lease acquire, renew, takeover, reject, release, and expiry. +6. `MOS-PORT-ARCH-001`: Runtime/provider adapters consume normalized lease context without adding harness-native schemas to Mosaic core. + +### M1 Acceptance Criteria + +1. `AC-MOS-PORT-01`: Two contenders for one binding cannot simultaneously hold current authority under concurrency. +2. `AC-MOS-PORT-02`: Successful takeover increments the fencing epoch and every operation from the old epoch fails closed before side effects. +3. `AC-MOS-PORT-03`: Gateway/database restart preserves lease and epoch state; expired leases can be recovered only through the authorized takeover path. +4. `AC-MOS-PORT-04`: Cross-tenant, cross-agent, cross-binding, forged, and expired lease/grant cases are denied and audited. +5. `AC-MOS-PORT-05`: Unit, migration, repository close/reopen, concurrency, abuse, gateway integration, independent security review, CI, and documentation gates pass. + +### Deferred to Later #754 Milestones + +Canonical checkpoint/handoff payloads, exactly-once connector receipts, concrete Claude/Pi/Codex adapters, channel cutover, and full cross-harness failover/rollback E2E are explicitly out of M1 scope. + +--- + ## Architecture ### High-Level System Diagram diff --git a/docs/SITEMAP.md b/docs/SITEMAP.md index 4644e2e9..487df036 100644 --- a/docs/SITEMAP.md +++ b/docs/SITEMAP.md @@ -56,3 +56,5 @@ - [Optional AI egress gateway ADR](architecture/ADR-MOS-EGRESS-GATEWAYS.md) — placement and gates for LiteLLM, Bifrost, and purpose-built translation proxies. - [Runtime-neutral Mos identity and failover mission](https://git.mosaicstack.dev/mosaicstack/stack/issues/754) - [Logical identity and connector lease/fencing implementation](https://git.mosaicstack.dev/mosaicstack/stack/issues/755) +- [M1 logical identity and fencing architecture](architecture/mos-runtime-portability-m1.md) +- [M1 connector lease operations](guides/mos-connector-lease-operations.md) diff --git a/docs/architecture/mos-runtime-portability-m1.md b/docs/architecture/mos-runtime-portability-m1.md new file mode 100644 index 00000000..932d10fd --- /dev/null +++ b/docs/architecture/mos-runtime-portability-m1.md @@ -0,0 +1,49 @@ +# Mos Runtime Portability M1 — Logical Identity and Fencing + +## Boundary + +M1 separates the logical Mosaic agent from any Claude, Pi, Codex, tmux, Matrix, or provider-native session. The normalized identity is: + +```text +(tenant_id, logical_agent_id, binding_id) +``` + +`logical_agent_id` is a server-owned stable identifier. A connector is a replaceable holder of a lease for one binding; it is not the agent identity. + +## Durable lease model + +PostgreSQL table `logical_agent_connector_leases` has one unique row per identity/binding tuple. The current row records: + +- an opaque lease UUID; +- connector ID and normalized allowed scopes; +- a positive decimal fencing epoch stored as PostgreSQL `bigint`; +- acquired, heartbeat, expiry, release, and update timestamps. + +Initial acquisition is insert-only. An existing active row causes `lease_held`. An expired or released row causes `takeover_required`; ordinary acquisition cannot recover it. Authorized takeover uses compare-and-swap against the expected epoch, rotates the lease UUID, and increments the epoch atomically. Heartbeat and release match the full identity, binding, connector, lease UUID, and epoch. + +The companion `connector_lease_audit_log` is append-only metadata. It stores lifecycle event, outcome/reason, identity/binding/connector, epoch, correlation ID, and timestamp. It deliberately excludes scopes, grant objects, payloads, approval references, tokens, and credentials. + +## Execution grants + +`ConnectorLeaseCoordinator` issues a short-lived internal grant only after rereading the durable current lease. Defense-in-depth caps leases at 5 minutes and grants at 30 seconds by default; constructor options may tighten these limits. A grant is bound to tenant, logical agent, binding, connector, lease UUID, scope subset, expiry, and epoch. + +Validation occurs immediately before adapter invocation and rereads PostgreSQL. The adapter receives only `ConnectorExecutionContext`; harness-native schemas remain behind the adapter. Validation denies: + +- grants not minted by the current gateway process (including cloned/forged objects); +- expired grants or leases; +- released leases; +- stale epochs or replaced connector/lease UUIDs; +- missing/cross-tenant/cross-agent/cross-binding leases; +- scopes not authorized by both grant and current lease. + +A gateway restart intentionally invalidates process-local grants. The durable lease and epoch survive, and a fresh grant may be issued only after current-lease and gateway-policy validation. + +## Concurrency and side-effect rule + +The database CAS determines the sole current holder. A successful takeover makes every old-epoch validation fail. Connector adapters must consume and propagate the normalized lease epoch/context so downstream effect boundaries can also fence races that occur after gateway validation. + +M1 does not provide exactly-once receipts or a side-effect journal. Those remain later #754 work; callers must not infer exactly-once delivery from lease fencing. + +## Extension boundary + +`ConnectorLeaseService` is the gateway-owned policy surface. Every policy decision receives the normalized requested scopes and TTL (or explicit `null` where no TTL applies), so a concrete policy can enforce least privilege and duration limits. Its production default policy denies every lease/grant operation until a server-configured connector policy is supplied. No M1 HTTP endpoint accepts caller-controlled tenant or logical identity, and no concrete Claude/Pi/Codex adapter or channel cutover is included. diff --git a/docs/guides/mos-connector-lease-operations.md b/docs/guides/mos-connector-lease-operations.md new file mode 100644 index 00000000..99ef63b8 --- /dev/null +++ b/docs/guides/mos-connector-lease-operations.md @@ -0,0 +1,43 @@ +# Mos Connector Lease Operations — M1 + +## Operational status + +M1 installs the durable schema and gateway policy/adapter boundary. It does **not** activate a connector, expose a lease administration endpoint, or cut over a channel. The default gateway connector-lease policy is deny-all until a later work package supplies an authorized server-side policy and concrete adapter. + +## Events to monitor + +Use correlation IDs to follow `connector_lease_audit_log` events: + +| Event | Meaning | +| ---------- | --------------------------------------------------------------------- | +| `acquire` | First holder inserted for an unused binding | +| `renew` | Current holder heartbeat extended the TTL | +| `takeover` | Authorized CAS replaced the holder and incremented epoch | +| `release` | Current holder explicitly relinquished authority | +| `expiry` | An expired current lease was observed | +| `reject` | Policy, CAS, expiry, scope, or fencing validation denied an operation | + +Audit data is metadata-only. Raw grant objects, connector payloads, scopes, tokens, approval references, and credentials must never be added to audit output. + +## Incident checks + +For suspected duplicate/stale connector effects: + +1. Correlate the attempted operation with its `reject`, `takeover`, or `expiry` event. +2. Compare the current row's connector ID, lease UUID, epoch, expiry, and release time with the adapter's normalized execution context. +3. Treat an old epoch, old lease UUID, expired lease, or released lease as non-authoritative. Do not retry it as the old holder. +4. Recovery uses the authorized takeover path with the observed expected epoch. Ordinary acquire is intentionally rejected for expired/released rows. +5. If an external effect may already have happened, preserve evidence and do not assume lease fencing provides exactly-once replay safety. + +## Migration and rollback safety + +Migration `0016_salty_morlocks.sql` is additive: it creates two new tables and indexes without modifying existing authorization/session tables. Before rollout, normal database backup and migration verification still apply. Rolling application code back leaves unused additive tables in place; dropping tables is not part of automated rollback because it would destroy lease/audit evidence. + +## Security constraints + +- Tenant comes from authenticated gateway context, never a connector request field. +- Logical agent, binding, connector, and scope identifiers use normalized constrained forms. +- Takeover requires explicit gateway policy authorization and an expected epoch. +- Default defense-in-depth TTL caps are 5 minutes for leases and 30 seconds for grants; policy may enforce stricter limits. +- Validation and rejection audit complete before adapter side effects. +- Existing authz and exact-action approval controls remain additional required gates; a valid connector lease does not bypass them. diff --git a/docs/scratchpads/755-mos-logical-identity-fencing.md b/docs/scratchpads/755-mos-logical-identity-fencing.md new file mode 100644 index 00000000..7e18c9fe --- /dev/null +++ b/docs/scratchpads/755-mos-logical-identity-fencing.md @@ -0,0 +1,148 @@ +# Issue #755 — Logical Mos identity and connector lease fencing + +- Task: `MOS-PORT-M1-001` +- Branch: `feat/mos-logical-identity-fencing` +- Base: `origin/main` +- Started: 2026-07-14 +- Working budget: 38K tokens (task ledger estimate); one implementation lane, bounded to M1. + +## Objective + +Implement the first runtime-portability security boundary: normalized logical-agent identity plus a PostgreSQL-durable exclusive connector lease and server-validated fencing grants. + +## Scope + +- Normalized identity contract independent of harness/provider-native session IDs. +- DB migration/schema/repository for one lease per tenant/logical-agent/binding. +- CAS acquire/takeover, monotonic epoch, TTL, heartbeat, release, expiry handling. +- Server-derived grants bound to tenant, logical agent, binding, connector, scopes, expiry, and lease epoch. +- Reject and credential-safely audit stale, expired, forged, unauthorized, cross-tenant, and cross-binding grants before adapter side effects. +- Runtime adapter boundary consumes normalized lease context. +- Unit, migration, close/reopen, concurrency, abuse, and gateway integration tests. +- Required developer/operations documentation for schema and security behavior. + +## Explicit exclusions + +No checkpoint/handoff payloads, exactly-once journal/receipts, concrete Claude/Pi/Codex harness adapter, channel cutover, or full cross-harness failover E2E. + +## Reconciliation baseline + +- Prospective remediation handoff: `web1:coder1`; PR [#757](https://git.mosaicstack.dev/mosaicstack/stack/pulls/757), issue [#755](https://git.mosaicstack.dev/mosaicstack/stack/issues/755). +- Before rebase: `dff8ce4f79ef90370c29d925002118a708010091`; required base: `origin/main` at `2e2280070ae67288be45f41743cf67052a8ca5a6`; original branch base: `d0771835542deab048ad8e79f271e3abdb6151f7`. +- Provider metadata: #757 is open, targets `main`, head is `feat/mos-logical-identity-fencing`, prior CI is green, and the provider reports it is not mergeable because of conflicts. +- Affected delivery paths: `apps/gateway/src/agent/agent.module.ts`; connector-lease gateway repository/service and three focused tests; `packages/agent/src/connector-lease.ts` plus test/export; `packages/types/src/agent/connector-lease.dto.ts` plus test/export; `packages/db/src/schema.ts`, migration `0016_salty_morlocks.sql`, Drizzle snapshot/journal; `docs/PRD.md`, `docs/SITEMAP.md`, MOS architecture/operations pages, this scratchpad, and `docs/tess/TASKS.md`. +- Read-only merge-tree inspection found only `docs/PRD.md` and `docs/SITEMAP.md` conflicts. Current-main #756 channel contracts, #758 roster-v2 structural compiler, and Native Kanban SOT use distinct contract domains; no substantive architecture collision was identified before mechanical reconciliation. +- Reconciliation constraints: retain all current-main #752/#756/#758/KBN content; add only nonduplicative #755 references; do not alter semantics or conflate connector leases/grants with Kanban task leases/fences, local Fleet leases, auth sessions, ResetSession generations, or federation grants. + +## Plan (TDD RED → GREEN → REFACTOR) + +1. Map existing contracts, DB/migration conventions, gateway authorization/audit boundaries, and test infrastructure. +2. Add failing contract/repository/concurrency/restart/abuse/gateway tests and capture RED evidence. +3. Implement the smallest normalized contracts, schema/migration/repository, grant validator, audit sink, and gateway service/adapter boundary needed to pass. +4. Refactor for clear invariants and credential-safe observability; rerun focused suites. +5. Run package/repo typecheck, lint, format, and appropriate tests. +6. Run independent code + security review, remediate, and re-review. +7. Inspect the final diff for security/scope drift; commit; queue guard; push; open PR with `Refs #755` and exact verification; stop without merge/issue closure. + +## Constraints and safety notes + +- `docs/tess/TASKS.md` is orchestrator-only and will not be edited. +- Existing dirty `.mosaic/orchestrator/mission.json` and `.mosaic/orchestrator/session.lock` are launcher/orchestrator state and will not be staged or altered intentionally. +- No client-supplied identity may confer authority. +- No credential, token, or raw grant material may be persisted to audit/log output. +- Existing authorization checks remain intact; fencing is an additional fail-closed layer. + +## Assumptions resolved from existing architecture + +- `ASSUMPTION:` M1 exposes no public lease endpoint. The gateway service is an internal policy surface with deny-all default policy because concrete connector activation/cutover is explicitly deferred. +- `ASSUMPTION:` Fencing epochs use PostgreSQL `bigint` and cross-module decimal strings, preserving JSON portability without JavaScript number precision loss. +- `ASSUMPTION:` Process-local grant provenance intentionally fails closed across restart; durable lease/epoch state survives and fresh grants require current policy + lease validation. + +## TDD evidence + +RED observed before implementation: + +- `corepack pnpm --filter @mosaicstack/types exec vitest run src/agent/connector-lease.dto.spec.ts` → failed to load missing `connector-lease.dto.js`. +- `corepack pnpm --filter @mosaicstack/agent exec vitest run src/connector-lease.test.ts` → failed to load missing `connector-lease.js`. +- Gateway focused tests failed before implementation because the new repository/service boundaries did not exist (workspace dependencies were then built before behavioral GREEN runs). + +GREEN to date: + +- Types contract: 6/6 passed. +- Agent grant/fencing unit suite: 5/5 passed. +- Gateway PGlite repository + policy/side-effect integration: 7/7 passed; 1 real-PostgreSQL test skipped when `DATABASE_URL` absent. +- Real PostgreSQL focused run with configured `DATABASE_URL`: 1/1 passed (credential value not emitted in reports). + +## Documentation checklist + +- [x] `docs/PRD.md` contains current MOS-PORT M1 scope and acceptance criteria. +- [x] Developer architecture: `docs/architecture/mos-runtime-portability-m1.md`. +- [x] Admin/operations guidance: `docs/guides/mos-connector-lease-operations.md`. +- [x] `docs/SITEMAP.md` links both pages. +- [x] No user-guide change: M1 exposes no user-facing flow or channel cutover. +- [x] No OpenAPI/endpoint-index change: M1 adds no HTTP endpoint. +- [x] Migration/restart/rollback safety and credential-safe audit constraints documented. +- [x] Canonical source remains in-repo; no external publishing action is in scope. +- [x] Independent review confirms documentation matches implementation; implementation-specific findings were remediated. + +## Independent review and remediation + +Codex code/security review ran in multiple rounds. Findings and root-cause remediations: + +1. Policy could not inspect requested scope/TTL → policy subject now receives normalized requested scopes and explicit requested TTL. +2. Unbounded authority lifetime → hard defaults cap leases at 5 minutes and grants at 30 seconds; overrides may only tighten; over-limit tests added. +3. Cross-tenant denial could audit under submitted tenant → mismatch audit uses authenticated tenant plus sanitized `untrusted` target metadata; integration assertion added. +4. Malformed forged grant could break the denial/audit path → runtime-safe shape validation with sanitized fallback audit; malformed-input test added. +5. Gateway integration test depended on prior test state → denial test now seeds a unique binding itself; isolated `-t` run passed. +6. Reviewer repeatedly identified launcher-generated `.mosaic/orchestrator/*` state; those files remain unstaged and excluded from the implementation commit. + +Latest independent security review: no critical/high/medium/low findings. Final commit-level code review remains to run after the intended diff is committed without launcher state. + +## Verification evidence + +- Focused contracts/fencing: types 6/6; agent 9/9. +- Gateway focused PGlite repository/policy integration: 7/7; isolated denial test 1/1. +- Real PostgreSQL close/reopen/CAS test: 1/1 with configured `DATABASE_URL`. +- Root `corepack pnpm typecheck`: 42/42 Turbo tasks passed. +- Root `corepack pnpm lint`: 23/23 Turbo tasks passed. +- Root `corepack pnpm format:check`: all matched files passed. +- Root `corepack pnpm test`: 42/42 Turbo tasks passed; gateway 616 passed / 12 environment-gated skipped; DB 19 passed / 7 environment-gated skipped; Mosaic 650 passed. + +## Known residual risks + +- Concrete connector policies and Claude/Pi/Codex adapters are intentionally deferred; production policy defaults deny-all. +- Gateway pre-side-effect validation cannot make an external system exactly-once. Adapters must propagate/enforce the epoch at downstream effect boundaries; receipts/journaling are later #754 scope. +- Migration rollback is additive-only; dropping lease/audit tables is intentionally manual to avoid destroying authority/audit evidence. + +## Commit-level review remediation + +- Commit-level Codex code review found one `should-fix`: heartbeat, release, and grant issuance authorized caller-supplied lease fields before canonical normalization. +- TDD RED: the isolated gateway policy-boundary test showed mixed-case/padded logical agent, binding, connector, scope, and epoch values reaching policy unchanged. +- Remediation: exported the coordinator's canonical lease normalizer and applied it at the gateway boundary before tenant/policy checks and coordinator dispatch for heartbeat, release, and grant issuance. +- GREEN: isolated policy test 1/1; focused types 6/6, agent 9/9, gateway 8/8; root typecheck 42/42, lint 23/23, format check passed, and root tests 42/42 (gateway 617 passed / 12 environment-gated skipped). +- Commit-level security review remained clean: no critical/high/medium/low findings. + +## Durable grant-expiry review remediation + +- Final commit review found a second `should-fix`: grant expiry was capped against submitted lease metadata after current-authority validation, rather than the durable lease row. +- TDD RED: a crafted same-authority lease with a later submitted expiry produced a grant expiring after the durable row. +- Remediation: grant authority fields and expiry now derive from the durable current lease; submitted scopes remain an additional narrowing constraint. +- GREEN: focused agent fencing suite 10/10. + +## Current-main reconciliation (2026-07-14) + +- Rebased the existing PR branch from `dff8ce4f79ef90370c29d925002118a708010091` (old base `d0771835542deab048ad8e79f271e3abdb6151f7`) onto `origin/main` `2e2280070ae67288be45f41743cf67052a8ca5a6`; current uncommitted reconciliation head is `d190732a550918161b91d3eb54640f4ea0e2e499`. +- Resolved only `docs/PRD.md` and `docs/SITEMAP.md`: preserved current-main #752 Native Kanban, #756 official-channel, and #758 FCM material; retained the nonduplicative #755 M1 workstream and placed its two documentation links in the existing Runtime-neutral Mos section. No #755 source semantics changed. +- Compatibility review confirmed separate authority domains: connector lease/epoch/grant remains distinct from KBN task leases/fences, local Fleet roster lifecycle, auth sessions, ResetSession context, and federation grants. No channel cutover, adapter activation, checkpoint/exactly-once behavior, UI convergence, or #754 expansion was added. +- Focused verification after building the required workspace dependencies: types contract 6/6; agent fencing/grant 10/10; gateway repository/PGlite and policy integration 8/8. The focused real-PostgreSQL test was skipped because `DATABASE_URL` was not configured; no credentials were inspected or emitted. +- Generated schema check: `pnpm --filter @mosaicstack/db db:generate` reported no schema changes; migration `0016_salty_morlocks`, snapshot, and journal were unchanged by generation. +- Full verification: `pnpm typecheck` 42/42; `pnpm lint` 23/23; `pnpm format:check` passed; `pnpm test` 42/42 (gateway 627 passed / 12 environment-gated skipped). Scoped diff check and local documentation-link scan passed. +- Pending only: commit this reconciliation record, queue guard, force-with-lease push of the rebased existing branch, then fresh independent DB/code/security review and Ultron. Do not claim merge or issue closure. + +## Durable lifecycle-authority remediation (2026-07-14) + +- Independent review found that heartbeat and release authorized the submitted lease, allowing forged lifecycle scope data to influence policy before the durable row was consulted. +- Remediation: lifecycle operations tenant-check the submitted lease, load the durable current lease, compare tenant, logical agent, binding, lease UUID, connector, epoch, and canonical ordered scopes, audit and deny any absent/mismatched authority, then run policy and coordinator lifecycle calls with the durable lease. Coordinator CAS/fencing checks remain unchanged. +- Adversarial gateway integration coverage proves forged `tool.execute` scopes on a durable `runtime.send` lease deny before policy or mutation, preserve heartbeat/release state, and write a denial audit for both lifecycle actions; canonical heartbeat and release remain accepted. +- Verification: focused types 6/6; agent 10/10; gateway PGlite integration/repository 9/9 with one `DATABASE_URL`-gated PostgreSQL test skipped; Drizzle check passed; root typecheck 42/42; lint 23/23; format check passed; root test 42/42 (gateway 628 passed / 12 environment-gated skipped). +- Pending: commit, queue-guard, force-with-lease push, then independent DB/code/security rereview and CI. Do not merge or close #755. diff --git a/docs/tess/TASKS.md b/docs/tess/TASKS.md index dd729ef9..6becd141 100644 --- a/docs/tess/TASKS.md +++ b/docs/tess/TASKS.md @@ -43,3 +43,4 @@ | TESS-M5-002 | done | Complete migration inventory, cutover, rollback, retention and deprecation evidence | #711 | coder3 | docs/tess | feat/tess-migration-docs | TESS-M4-V | 18K | TESS-MIG-001. **Mos-DISPATCHED to coder3 2026-07-13** ("M4 complete; advancing to M5") — dispatched AHEAD of TESS-M4-V passing; the M4-V-status-vs-#710-CLOSED dependency reconciliation is pending Mos ruling (tracked, not orchestrator-decided). **DELIVERED as PR #742** — 4 new files docs/tess/M5-MIGRATION-{INVENTORY,CUTOVER,ROLLBACK,RETENTION-DEPRECATION}.md, base main b7b0f508, frozen head b5e9d0e528a50aae2e916cc7202bacc2e8db67dd. Docs-only; tracking-control trio (MISSION-MANIFEST/TASKS/VERIFICATION-MATRIX) UNTOUCHED; command-authz byte-identical a9f829e7; no live creds. **CI pipeline 1773 SUCCESS** (repo 47, refs/pull/742/head, commit==head). **Independent non-author ROR COMPLETE: reviewer VERIFIED APPROVE at exact head b5e9d0e528a50aae2e916cc7202bacc2e8db67dd (Gitea comment 17108)** — evidence claims verified to trace to landed Hermes adapter / capability matrix, gateway registry/reachability, operator-memory scope path, Mos coordination boundary; docs do NOT over-claim transcript/profile import, schema migration, unsupported-capability enablement, production cutover, or deprecation completion. Head independently verified UNMOVED at b5e9d0e528a5 post-ROR (live Gitea), base main, mergeable=true. **#742 MERGED by Mos → main 5789711e. Deliverable docs LANDED. GATE: TESS-M4-V/M5-V verification-gate reconciliation remains Mos-owned/pending (this row was dispatched ahead of M4-V passing).** | | TESS-M5-003 | done | Complete OpenAPI, user/admin/developer/plugin/operations docs and checklist | #711 | codex | docs | feat/tess-docs | TESS-M5-001,TESS-M5-002 | 22K | Documentation hard gate. **DELIVERED as PR #746** (branch feat/tess-docs, base main, 7 docs-only files: docs/openapi-tess.yaml + docs/tess/{ADMIN,DEVELOPER,OPERATIONS,PLUGIN,USER}-GUIDE.md + M5-003-DOCUMENTATION-CHECKLIST.md). 4-round revise-loop (heads 470eb911→c9f69300→7aea94e2→25b9d642) converged: OpenAPI covers interaction routes + SSE /sessions/{sessionId}/stream + Mos coord (/api/coord/mos/handoff,/observe,/result) + memory preferences/insights/search; request-body schemas aligned to real DTOs (Send requires content+idempotencyKey, Stop requires approvalRef, Insight requires only content, MosHandoff body requires idempotencyKey+summary); checklist accurate. **CI pipeline 1786 SUCCESS** (repo 47, refs/pull/746/head, commit==head 25b9d642). **Independent non-author ROR COMPLETE: reviewer VERIFIED APPROVE at exact head 25b9d642b939014b3efd61826e7524cafc6ffc2e (Gitea comment 17170)** — docs-only, tracking-trio untouched, command-authz byte-identical a9f829e7, no false coverage claims. Head verified UNMOVED at 25b9d642 (live Gitea, not worker-reported), base main, mergeable=true. **#746 MERGED by Mos → main bc8016c8314ec3a4b6ebc2fec5d9f276fca3327a. Documentation gate LANDED.** GATE: TESS-M4-V/M5-V verification-gate reconciliation remains Mos-owned/pending. | | TESS-M5-V | not-started | Full baseline, contract, integration, Discord/CLI E2E, security review, recovery drill and rollback qualification | #711 | sonnet | apps/gateway, packages/agent, plugins/discord, packages/mosaic | review/tess-final | TESS-M5-003 | 35K | Maps AC-TESS-01..11 to evidence | +| MOS-PORT-M1-001 | in-progress | Implement logical Mos identity, PostgreSQL connector lease, monotonic fencing, server-bound execution grants, audit, migrations, concurrency/restart/abuse/integration tests | #755 | codex | packages/types, packages/agent, packages/db, apps/gateway | feat/mos-logical-identity-fencing | — | 38K | Requirements MOS-PORT-ID-001, MOS-PORT-LEASE-001, MOS-PORT-FENCE-001..002, MOS-PORT-OBS-001, MOS-PORT-ARCH-001. One Sol/Pi worker; TDD; PR-open STOP; worker must not edit this ledger. | diff --git a/packages/agent/src/connector-lease.test.ts b/packages/agent/src/connector-lease.test.ts new file mode 100644 index 00000000..2120eea7 --- /dev/null +++ b/packages/agent/src/connector-lease.test.ts @@ -0,0 +1,261 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { + ConnectorExecutionContext, + ConnectorExecutionGrant, + ConnectorLease, + ConnectorLeaseAuditEvent, + ConnectorLeaseStore, + FencedConnectorAdapter, + LogicalAgentBinding, +} from '@mosaicstack/types'; +import { + ConnectorLeaseCoordinator, + MAX_CONNECTOR_GRANT_TTL_MS, + MAX_CONNECTOR_LEASE_TTL_MS, +} from './connector-lease.js'; +import type { ConnectorLeaseError } from './connector-lease.js'; + +const identity = { tenantId: 'tenant-a', logicalAgentId: 'mos' } as const; +const binding: LogicalAgentBinding = { identity, bindingId: 'operator-chat' }; +const activeLease: ConnectorLease = { + ...binding, + leaseId: '00000000-0000-4000-8000-000000000001', + connectorId: 'connector-a', + scopes: ['runtime.send', 'tool.execute'], + leaseEpoch: '3', + acquiredAt: '2026-07-14T17:00:00.000Z', + heartbeatAt: '2026-07-14T17:00:00.000Z', + expiresAt: '2026-07-14T17:10:00.000Z', +}; + +class FakeLeaseStore implements ConnectorLeaseStore { + lease: ConnectorLease | null = activeLease; + readonly audits: ConnectorLeaseAuditEvent[] = []; + + async acquire(): Promise { + if (!this.lease) throw new Error('fixture has no lease'); + return this.lease; + } + + async takeover(): Promise { + if (!this.lease) throw new Error('fixture has no lease'); + return this.lease; + } + + async heartbeat(): Promise { + if (!this.lease) throw new Error('fixture has no lease'); + return this.lease; + } + + async release(): Promise {} + + async findCurrent(): Promise { + return this.lease; + } + + async recordAudit(event: ConnectorLeaseAuditEvent): Promise { + this.audits.push(event); + } +} + +describe('ConnectorLeaseCoordinator fencing', (): void => { + it('validates a server-minted grant immediately before invoking an adapter side effect', async (): Promise => { + const store = new FakeLeaseStore(); + const coordinator = new ConnectorLeaseCoordinator(store, { + now: (): Date => new Date('2026-07-14T17:01:00.000Z'), + }); + const grant = await coordinator.issueGrant({ + lease: activeLease, + scopes: ['runtime.send'], + ttlMs: 30_000, + correlationId: 'correlation-1', + }); + const execute = vi.fn(async (_input: string, context: ConnectorExecutionContext) => context); + const adapter: FencedConnectorAdapter = { execute }; + + const context = await coordinator.executeGrant(grant, 'runtime.send', 'hello', adapter); + + expect(execute).toHaveBeenCalledOnce(); + expect(context).toMatchObject({ + identity, + bindingId: 'operator-chat', + connectorId: 'connector-a', + leaseEpoch: '3', + scopes: ['runtime.send'], + }); + }); + + it('caps grant expiry to the durable current lease instead of submitted metadata', async (): Promise => { + const store = new FakeLeaseStore(); + store.lease = { ...activeLease, expiresAt: '2026-07-14T17:01:05.000Z' }; + const coordinator = new ConnectorLeaseCoordinator(store, { + now: (): Date => new Date('2026-07-14T17:01:00.000Z'), + }); + + const grant = await coordinator.issueGrant({ + lease: { ...activeLease, expiresAt: '2026-07-14T18:00:00.000Z' }, + scopes: ['runtime.send'], + ttlMs: 30_000, + correlationId: 'correlation-durable-expiry', + }); + + expect(grant.expiresAt).toBe('2026-07-14T17:01:05.000Z'); + }); + + it.each([ + ['forged clone', (grant: ConnectorExecutionGrant): ConnectorExecutionGrant => ({ ...grant })], + [ + 'cross-tenant clone', + (grant: ConnectorExecutionGrant): ConnectorExecutionGrant => ({ + ...grant, + identity: { ...grant.identity, tenantId: 'tenant-b' }, + }), + ], + [ + 'cross-agent clone', + (grant: ConnectorExecutionGrant): ConnectorExecutionGrant => ({ + ...grant, + identity: { ...grant.identity, logicalAgentId: 'other-agent' }, + }), + ], + [ + 'cross-binding clone', + (grant: ConnectorExecutionGrant): ConnectorExecutionGrant => ({ + ...grant, + bindingId: 'other-binding', + }), + ], + [ + 'cross-connector clone', + (grant: ConnectorExecutionGrant): ConnectorExecutionGrant => ({ + ...grant, + connectorId: 'connector-b', + }), + ], + ])('denies and audits a %s before adapter invocation', async (_label, forge): Promise => { + const store = new FakeLeaseStore(); + const coordinator = new ConnectorLeaseCoordinator(store, { + now: (): Date => new Date('2026-07-14T17:01:00.000Z'), + }); + const grant = await coordinator.issueGrant({ + lease: activeLease, + scopes: ['runtime.send'], + ttlMs: 30_000, + correlationId: 'correlation-forged', + }); + const adapter = { execute: vi.fn().mockResolvedValue(undefined) }; + + await expect( + coordinator.executeGrant(forge(grant), 'runtime.send', undefined, adapter), + ).rejects.toMatchObject({ code: 'forged_grant' } satisfies Partial); + expect(adapter.execute).not.toHaveBeenCalled(); + expect(store.audits.at(-1)).toMatchObject({ + event: 'reject', + outcome: 'denied', + reason: 'forged_grant', + correlationId: 'correlation-forged', + }); + }); + + it('denies malformed forged grants with sanitized audit metadata', async (): Promise => { + const store = new FakeLeaseStore(); + const coordinator = new ConnectorLeaseCoordinator(store, { + now: (): Date => new Date('2026-07-14T17:01:00.000Z'), + }); + const adapter = { execute: vi.fn().mockResolvedValue(undefined) }; + + await expect( + coordinator.executeGrant( + // @ts-expect-error Deliberately exercise malformed runtime input at the trust boundary. + {}, + 'runtime.send', + undefined, + adapter, + ), + ).rejects.toMatchObject({ code: 'forged_grant' } satisfies Partial); + expect(adapter.execute).not.toHaveBeenCalled(); + expect(store.audits).toContainEqual( + expect.objectContaining({ + identity: { tenantId: 'untrusted', logicalAgentId: 'untrusted' }, + bindingId: 'untrusted', + connectorId: 'untrusted', + correlationId: 'untrusted', + reason: 'forged_grant', + }), + ); + }); + + it('rejects lease and grant TTLs above server-side safety caps', async (): Promise => { + const store = new FakeLeaseStore(); + const coordinator = new ConnectorLeaseCoordinator(store, { + now: (): Date => new Date('2026-07-14T17:01:00.000Z'), + }); + expect( + () => + new ConnectorLeaseCoordinator(store, { + maxLeaseTtlMs: MAX_CONNECTOR_LEASE_TTL_MS + 1, + }), + ).toThrow(/no greater than/); + + await expect( + coordinator.acquire({ + identity, + bindingId: 'operator-chat', + connectorId: 'connector-a', + scopes: ['runtime.send'], + ttlMs: MAX_CONNECTOR_LEASE_TTL_MS + 1, + correlationId: 'correlation-ttl', + }), + ).rejects.toThrow(/no greater than/); + await expect( + coordinator.issueGrant({ + lease: activeLease, + scopes: ['runtime.send'], + ttlMs: MAX_CONNECTOR_GRANT_TTL_MS + 1, + correlationId: 'correlation-ttl', + }), + ).rejects.toThrow(/no greater than/); + }); + + it('denies stale epoch, expired grant, and unauthorized scope before side effects', async (): Promise => { + let now = new Date('2026-07-14T17:01:00.000Z'); + const store = new FakeLeaseStore(); + const coordinator = new ConnectorLeaseCoordinator(store, { now: (): Date => now }); + const stale = await coordinator.issueGrant({ + lease: activeLease, + scopes: ['runtime.send'], + ttlMs: 30_000, + correlationId: 'correlation-stale', + }); + store.lease = { ...activeLease, leaseEpoch: '4', connectorId: 'connector-b' }; + const adapter = { execute: vi.fn().mockResolvedValue(undefined) }; + + await expect( + coordinator.executeGrant(stale, 'runtime.send', undefined, adapter), + ).rejects.toMatchObject({ code: 'stale_epoch' } satisfies Partial); + + store.lease = activeLease; + const expiring = await coordinator.issueGrant({ + lease: activeLease, + scopes: ['runtime.send'], + ttlMs: 1_000, + correlationId: 'correlation-expired', + }); + now = new Date('2026-07-14T17:01:02.000Z'); + await expect( + coordinator.executeGrant(expiring, 'runtime.send', undefined, adapter), + ).rejects.toMatchObject({ code: 'grant_expired' } satisfies Partial); + + now = new Date('2026-07-14T17:01:00.000Z'); + const scoped = await coordinator.issueGrant({ + lease: activeLease, + scopes: ['runtime.send'], + ttlMs: 30_000, + correlationId: 'correlation-scope', + }); + await expect( + coordinator.executeGrant(scoped, 'tool.execute', undefined, adapter), + ).rejects.toMatchObject({ code: 'scope_denied' } satisfies Partial); + expect(adapter.execute).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/agent/src/connector-lease.ts b/packages/agent/src/connector-lease.ts new file mode 100644 index 00000000..7283bdb9 --- /dev/null +++ b/packages/agent/src/connector-lease.ts @@ -0,0 +1,381 @@ +import { randomUUID } from 'node:crypto'; +import { + normalizeConnectorId, + normalizeConnectorScope, + normalizeConnectorScopes, + normalizeCorrelationId, + normalizeLeaseEpoch, + normalizeLogicalAgentIdentity, + normalizeLogicalBindingId, + type AcquireConnectorLeaseInput, + type ConnectorExecutionContext, + type ConnectorExecutionGrant, + type ConnectorLease, + type ConnectorLeaseAuditEvent, + type ConnectorLeaseRejectReason, + type ConnectorLeaseStore, + type FencedConnectorAdapter, + type HeartbeatConnectorLeaseInput, + type IssueConnectorExecutionGrantInput, + type LogicalAgentBinding, + type ReleaseConnectorLeaseInput, + type TakeoverConnectorLeaseInput, +} from '@mosaicstack/types'; + +export const MAX_CONNECTOR_LEASE_TTL_MS = 5 * 60 * 1000; +export const MAX_CONNECTOR_GRANT_TTL_MS = 30 * 1000; + +export interface ConnectorLeaseCoordinatorOptions { + readonly now?: () => Date; + readonly maxLeaseTtlMs?: number; + readonly maxGrantTtlMs?: number; +} + +export class ConnectorLeaseError extends Error { + constructor( + readonly code: ConnectorLeaseRejectReason, + message: string, + ) { + super(message); + this.name = ConnectorLeaseError.name; + } +} + +/** + * Runtime-neutral lease coordinator. Durable CAS lives in the store adapter; + * grant provenance remains process-local so a restart fails closed and mints + * fresh grants from the durable current lease. + */ +export class ConnectorLeaseCoordinator { + private readonly issuedGrants = new WeakSet(); + private readonly now: () => Date; + private readonly maxLeaseTtlMs: number; + private readonly maxGrantTtlMs: number; + + constructor( + private readonly store: ConnectorLeaseStore, + options: ConnectorLeaseCoordinatorOptions = {}, + ) { + this.now = options.now ?? (() => new Date()); + this.maxLeaseTtlMs = normalizeTtlLimit( + options.maxLeaseTtlMs ?? MAX_CONNECTOR_LEASE_TTL_MS, + MAX_CONNECTOR_LEASE_TTL_MS, + 'lease', + ); + this.maxGrantTtlMs = normalizeTtlLimit( + options.maxGrantTtlMs ?? MAX_CONNECTOR_GRANT_TTL_MS, + MAX_CONNECTOR_GRANT_TTL_MS, + 'grant', + ); + } + + async acquire(input: AcquireConnectorLeaseInput): Promise { + const command = normalizeAcquireInput(input, this.maxLeaseTtlMs); + const now = this.now(); + return this.store.acquire({ + ...command, + leaseId: randomUUID(), + now: now.toISOString(), + expiresAt: expiresAt(now, command.ttlMs), + }); + } + + async takeover(input: TakeoverConnectorLeaseInput): Promise { + const command = normalizeAcquireInput(input, this.maxLeaseTtlMs); + const now = this.now(); + return this.store.takeover({ + ...command, + expectedEpoch: normalizeLeaseEpoch(input.expectedEpoch), + leaseId: randomUUID(), + now: now.toISOString(), + expiresAt: expiresAt(now, command.ttlMs), + }); + } + + async heartbeat(input: HeartbeatConnectorLeaseInput): Promise { + const now = this.now(); + const lease = normalizeConnectorLease(input.lease); + const ttlMs = normalizeTtl(input.ttlMs, this.maxLeaseTtlMs, 'lease'); + return this.store.heartbeat({ + lease, + ttlMs, + correlationId: normalizeCorrelationId(input.correlationId), + now: now.toISOString(), + expiresAt: expiresAt(now, ttlMs), + }); + } + + async release(input: ReleaseConnectorLeaseInput): Promise { + await this.store.release({ + lease: normalizeConnectorLease(input.lease), + correlationId: normalizeCorrelationId(input.correlationId), + now: this.now().toISOString(), + }); + } + + async current(binding: LogicalAgentBinding): Promise { + return this.store.findCurrent(normalizeBinding(binding)); + } + + async issueGrant(input: IssueConnectorExecutionGrantInput): Promise { + const now = this.now(); + const lease = normalizeConnectorLease(input.lease); + const scopes = normalizeConnectorScopes(input.scopes); + const correlationId = normalizeCorrelationId(input.correlationId); + const ttlMs = normalizeTtl(input.ttlMs, this.maxGrantTtlMs, 'grant'); + const current = await this.store.findCurrent(lease); + await this.assertCurrentLease(current, lease, now, correlationId); + if (!current) throw new ConnectorLeaseError('lease_missing', 'Connector lease is unavailable'); + if (!isScopeSubset(scopes, lease.scopes) || !isScopeSubset(scopes, current.scopes)) { + await this.reject(lease, correlationId, now, 'scope_denied'); + } + const requestedExpiry = new Date(now.getTime() + ttlMs); + const leaseExpiry = new Date(current.expiresAt); + const grantExpiry = requestedExpiry < leaseExpiry ? requestedExpiry : leaseExpiry; + const grant: ConnectorExecutionGrant = Object.freeze({ + identity: current.identity, + bindingId: current.bindingId, + leaseId: current.leaseId, + connectorId: current.connectorId, + scopes, + leaseEpoch: current.leaseEpoch, + issuedAt: now.toISOString(), + expiresAt: grantExpiry.toISOString(), + correlationId, + }); + this.issuedGrants.add(grant); + return grant; + } + + async executeGrant( + grant: ConnectorExecutionGrant, + requiredScope: string, + input: TInput, + adapter: FencedConnectorAdapter, + ): Promise { + const now = this.now(); + const normalizedScope = normalizeConnectorScope(requiredScope); + if (!this.issuedGrants.has(grant)) { + await this.rejectForgedGrant(grant, now); + } + if (new Date(grant.expiresAt) <= now) { + await this.rejectGrant(grant, now, 'grant_expired'); + } + const current = await this.store.findCurrent(normalizeBinding(grant)); + await this.assertCurrentLease(current, grant, now, grant.correlationId); + if (!grant.scopes.includes(normalizedScope) || !current?.scopes.includes(normalizedScope)) { + await this.rejectGrant(grant, now, 'scope_denied'); + } + if (!current) throw new ConnectorLeaseError('lease_missing', 'Connector lease is unavailable'); + const context: ConnectorExecutionContext = Object.freeze({ + identity: current.identity, + bindingId: current.bindingId, + leaseId: current.leaseId, + connectorId: current.connectorId, + scopes: Object.freeze([...grant.scopes]), + leaseEpoch: current.leaseEpoch, + correlationId: grant.correlationId, + grantExpiresAt: grant.expiresAt, + }); + return adapter.execute(input, context); + } + + private async assertCurrentLease( + current: ConnectorLease | null, + authority: ConnectorLease | ConnectorExecutionGrant, + now: Date, + correlationId: string, + ): Promise { + if (!current) await this.reject(authority, correlationId, now, 'lease_missing'); + if (!current) throw new ConnectorLeaseError('lease_missing', 'Connector lease is unavailable'); + if (current.releasedAt) await this.reject(authority, correlationId, now, 'lease_released'); + if (new Date(current.expiresAt) <= now) { + await this.store.recordAudit(auditEvent(current, correlationId, now, 'expiry', 'succeeded')); + await this.reject(authority, correlationId, now, 'lease_expired'); + } + if (current.leaseEpoch !== authority.leaseEpoch) { + await this.reject(authority, correlationId, now, 'stale_epoch'); + } + if (current.leaseId !== authority.leaseId || current.connectorId !== authority.connectorId) { + await this.reject(authority, correlationId, now, 'connector_mismatch'); + } + } + + private async rejectGrant( + grant: ConnectorExecutionGrant, + now: Date, + reason: ConnectorLeaseRejectReason, + ): Promise { + return this.reject(grant, grant.correlationId, now, reason); + } + + private async rejectForgedGrant(grant: unknown, now: Date): Promise { + const event = safeForgedGrantAudit(grant, now); + await this.store.recordAudit(event); + throw new ConnectorLeaseError('forged_grant', safeReasonMessage('forged_grant')); + } + + private async reject( + authority: LogicalAgentBinding & { + readonly connectorId: string; + readonly leaseId?: string; + readonly leaseEpoch?: string; + }, + correlationId: string, + now: Date, + reason: ConnectorLeaseRejectReason, + ): Promise { + await this.store.recordAudit( + auditEvent(authority, correlationId, now, 'reject', 'denied', reason), + ); + throw new ConnectorLeaseError(reason, safeReasonMessage(reason)); + } +} + +function normalizeAcquireInput( + input: AcquireConnectorLeaseInput, + maxLeaseTtlMs: number, +): AcquireConnectorLeaseInput { + return { + identity: normalizeLogicalAgentIdentity(input.identity), + bindingId: normalizeLogicalBindingId(input.bindingId), + connectorId: normalizeConnectorId(input.connectorId), + scopes: normalizeConnectorScopes(input.scopes), + ttlMs: normalizeTtl(input.ttlMs, maxLeaseTtlMs, 'lease'), + correlationId: normalizeCorrelationId(input.correlationId), + }; +} + +function normalizeBinding(input: LogicalAgentBinding): LogicalAgentBinding { + return { + identity: normalizeLogicalAgentIdentity(input.identity), + bindingId: normalizeLogicalBindingId(input.bindingId), + }; +} + +export function normalizeConnectorLease(lease: ConnectorLease): ConnectorLease { + const binding = normalizeBinding(lease); + return Object.freeze({ + ...binding, + leaseId: lease.leaseId, + connectorId: normalizeConnectorId(lease.connectorId), + scopes: normalizeConnectorScopes(lease.scopes), + leaseEpoch: normalizeLeaseEpoch(lease.leaseEpoch), + acquiredAt: normalizeTimestamp(lease.acquiredAt, 'lease acquisition'), + heartbeatAt: normalizeTimestamp(lease.heartbeatAt, 'lease heartbeat'), + expiresAt: normalizeTimestamp(lease.expiresAt, 'lease expiry'), + ...(lease.releasedAt + ? { releasedAt: normalizeTimestamp(lease.releasedAt, 'lease release') } + : {}), + }); +} + +function normalizeTtl(ttlMs: number, maximum: number, kind: 'lease' | 'grant'): number { + if (!Number.isSafeInteger(ttlMs) || ttlMs <= 0 || ttlMs > maximum) { + throw new Error( + `Connector ${kind} TTL must be a positive safe integer no greater than ${maximum}ms`, + ); + } + return ttlMs; +} + +function normalizeTtlLimit(ttlMs: number, hardMaximum: number, kind: 'lease' | 'grant'): number { + if (!Number.isSafeInteger(ttlMs) || ttlMs <= 0 || ttlMs > hardMaximum) { + throw new Error( + `Maximum connector ${kind} TTL must be a positive safe integer no greater than ${hardMaximum}ms`, + ); + } + return ttlMs; +} + +function normalizeTimestamp(value: string, label: string): string { + const date = new Date(value); + if (Number.isNaN(date.getTime())) throw new Error(`${label} timestamp is invalid`); + return date.toISOString(); +} + +function expiresAt(now: Date, ttlMs: number): string { + const expiry = new Date(now.getTime() + ttlMs); + if (Number.isNaN(expiry.getTime())) throw new Error('Connector lease TTL exceeds date range'); + return expiry.toISOString(); +} + +function isScopeSubset(requested: readonly string[], allowed: readonly string[]): boolean { + return requested.every((scope) => allowed.includes(scope)); +} + +function auditEvent( + authority: LogicalAgentBinding & { + readonly connectorId: string; + readonly leaseId?: string; + readonly leaseEpoch?: string; + }, + correlationId: string, + now: Date, + event: ConnectorLeaseAuditEvent['event'], + outcome: ConnectorLeaseAuditEvent['outcome'], + reason?: ConnectorLeaseRejectReason, +): ConnectorLeaseAuditEvent { + return { + identity: authority.identity, + bindingId: authority.bindingId, + connectorId: authority.connectorId, + correlationId, + occurredAt: now.toISOString(), + event, + outcome, + ...(authority.leaseId ? { leaseId: authority.leaseId } : {}), + ...(authority.leaseEpoch ? { leaseEpoch: authority.leaseEpoch } : {}), + ...(reason ? { reason } : {}), + }; +} + +function safeForgedGrantAudit(grant: unknown, now: Date): ConnectorLeaseAuditEvent { + const fallback: ConnectorLeaseAuditEvent = { + identity: { tenantId: 'untrusted', logicalAgentId: 'untrusted' }, + bindingId: 'untrusted', + connectorId: 'untrusted', + correlationId: 'untrusted', + occurredAt: now.toISOString(), + event: 'reject', + outcome: 'denied', + reason: 'forged_grant', + }; + if (typeof grant !== 'object' || grant === null || !('identity' in grant)) return fallback; + const identity = grant.identity; + if (typeof identity !== 'object' || identity === null) return fallback; + if (!('tenantId' in identity) || !('logicalAgentId' in identity)) return fallback; + if (!('bindingId' in grant) || !('connectorId' in grant) || !('correlationId' in grant)) { + return fallback; + } + if ( + typeof identity.tenantId !== 'string' || + typeof identity.logicalAgentId !== 'string' || + typeof grant.bindingId !== 'string' || + typeof grant.connectorId !== 'string' || + typeof grant.correlationId !== 'string' + ) { + return fallback; + } + try { + return { + identity: normalizeLogicalAgentIdentity({ + tenantId: identity.tenantId, + logicalAgentId: identity.logicalAgentId, + }), + bindingId: normalizeLogicalBindingId(grant.bindingId), + connectorId: normalizeConnectorId(grant.connectorId), + correlationId: normalizeCorrelationId(grant.correlationId), + occurredAt: now.toISOString(), + event: 'reject', + outcome: 'denied', + reason: 'forged_grant', + }; + } catch { + return fallback; + } +} + +function safeReasonMessage(reason: ConnectorLeaseRejectReason): string { + return `Connector authority denied: ${reason}`; +} diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 01237936..3eabafb4 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -5,3 +5,4 @@ export * from './tmux-fleet-runtime-provider.js'; export * from './hermes-runtime-provider.js'; export * from './matrix-native-runtime-provider.js'; export * from './durable-session.js'; +export * from './connector-lease.js'; diff --git a/packages/db/drizzle/0016_salty_morlocks.sql b/packages/db/drizzle/0016_salty_morlocks.sql new file mode 100644 index 00000000..fe8cecd6 --- /dev/null +++ b/packages/db/drizzle/0016_salty_morlocks.sql @@ -0,0 +1,36 @@ +CREATE TABLE "connector_lease_audit_log" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "tenant_id" text NOT NULL, + "logical_agent_id" text NOT NULL, + "binding_id" text NOT NULL, + "connector_id" text NOT NULL, + "lease_id" uuid, + "lease_epoch" bigint, + "event" text NOT NULL, + "outcome" text NOT NULL, + "reason" text, + "correlation_id" text NOT NULL, + "occurred_at" timestamp with time zone NOT NULL +); +--> statement-breakpoint +CREATE TABLE "logical_agent_connector_leases" ( + "lease_id" uuid PRIMARY KEY NOT NULL, + "tenant_id" text NOT NULL, + "logical_agent_id" text NOT NULL, + "binding_id" text NOT NULL, + "connector_id" text NOT NULL, + "scopes" jsonb NOT NULL, + "lease_epoch" bigint NOT NULL, + "acquired_at" timestamp with time zone NOT NULL, + "heartbeat_at" timestamp with time zone NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "released_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE INDEX "connector_lease_audit_binding_occurred_idx" ON "connector_lease_audit_log" USING btree ("tenant_id","logical_agent_id","binding_id","occurred_at" DESC NULLS LAST);--> statement-breakpoint +CREATE INDEX "connector_lease_audit_correlation_idx" ON "connector_lease_audit_log" USING btree ("correlation_id");--> statement-breakpoint +CREATE UNIQUE INDEX "logical_agent_connector_lease_binding_idx" ON "logical_agent_connector_leases" USING btree ("tenant_id","logical_agent_id","binding_id");--> statement-breakpoint +CREATE INDEX "logical_agent_connector_lease_expiry_idx" ON "logical_agent_connector_leases" USING btree ("expires_at");--> statement-breakpoint +CREATE INDEX "logical_agent_connector_lease_connector_idx" ON "logical_agent_connector_leases" USING btree ("connector_id"); \ No newline at end of file diff --git a/packages/db/drizzle/meta/0016_snapshot.json b/packages/db/drizzle/meta/0016_snapshot.json new file mode 100644 index 00000000..064efdf0 --- /dev/null +++ b/packages/db/drizzle/meta/0016_snapshot.json @@ -0,0 +1,4530 @@ +{ + "id": "77193fb2-b6e8-4a59-b611-c37441b49e1b", + "prevId": "1a0a53b1-2dd8-4ea9-8d59-3e92ccca6c6a", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "accounts_provider_account_idx": { + "name": "accounts_provider_account_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "accounts_user_id_idx": { + "name": "accounts_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.admin_tokens": { + "name": "admin_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'admin'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "admin_tokens_user_id_idx": { + "name": "admin_tokens_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "admin_tokens_hash_idx": { + "name": "admin_tokens_hash_idx", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "admin_tokens_user_id_users_id_fk": { + "name": "admin_tokens_user_id_users_id_fk", + "tableFrom": "admin_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_logs": { + "name": "agent_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'info'" + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'general'" + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "tier": { + "name": "tier", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'hot'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "summarized_at": { + "name": "summarized_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "agent_logs_session_tier_idx": { + "name": "agent_logs_session_tier_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_logs_user_id_idx": { + "name": "agent_logs_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_logs_tier_created_at_idx": { + "name": "agent_logs_tier_created_at_idx", + "columns": [ + { + "expression": "tier", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_logs_user_id_users_id_fk": { + "name": "agent_logs_user_id_users_id_fk", + "tableFrom": "agent_logs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agents": { + "name": "agents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "system_prompt": { + "name": "system_prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_tools": { + "name": "allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "skills": { + "name": "skills", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_system": { + "name": "is_system", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agents_project_id_idx": { + "name": "agents_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agents_owner_id_idx": { + "name": "agents_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agents_is_system_idx": { + "name": "agents_is_system_idx", + "columns": [ + { + "expression": "is_system", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agents_project_id_projects_id_fk": { + "name": "agents_project_id_projects_id_fk", + "tableFrom": "agents", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "agents_owner_id_users_id_fk": { + "name": "agents_owner_id_users_id_fk", + "tableFrom": "agents", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.appreciations": { + "name": "appreciations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "from_user": { + "name": "from_user", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "to_user": { + "name": "to_user", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.backlog": { + "name": "backlog", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "backlog_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'ready'" + }, + "depends_on": { + "name": "depends_on", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "claim_owner": { + "name": "claim_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "claim_ttl_seconds": { + "name": "claim_ttl_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "acceptance": { + "name": "acceptance", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "backlog_status_priority_idx": { + "name": "backlog_status_priority_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "backlog_status_claimed_at_idx": { + "name": "backlog_status_claimed_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "claimed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "backlog_idempotency_key_idx": { + "name": "backlog_idempotency_key_idx", + "columns": [ + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.connector_lease_audit_log": { + "name": "connector_lease_audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logical_agent_id": { + "name": "logical_agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "binding_id": { + "name": "binding_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lease_id": { + "name": "lease_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "lease_epoch": { + "name": "lease_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "correlation_id": { + "name": "correlation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "connector_lease_audit_binding_occurred_idx": { + "name": "connector_lease_audit_binding_occurred_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "logical_agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "binding_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "connector_lease_audit_correlation_idx": { + "name": "connector_lease_audit_correlation_idx", + "columns": [ + { + "expression": "correlation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.conversations": { + "name": "conversations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "conversations_user_archived_idx": { + "name": "conversations_user_archived_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversations_project_id_idx": { + "name": "conversations_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "conversations_agent_id_idx": { + "name": "conversations_agent_id_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "conversations_user_id_users_id_fk": { + "name": "conversations_user_id_users_id_fk", + "tableFrom": "conversations", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "conversations_project_id_projects_id_fk": { + "name": "conversations_project_id_projects_id_fk", + "tableFrom": "conversations", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "conversations_agent_id_agents_id_fk": { + "name": "conversations_agent_id_agents_id_fk", + "tableFrom": "conversations", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.events": { + "name": "events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "events_type_idx": { + "name": "events_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "events_date_idx": { + "name": "events_date_idx", + "columns": [ + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.federation_audit_log": { + "name": "federation_audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "peer_id": { + "name": "peer_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "subject_user_id": { + "name": "subject_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_id": { + "name": "grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "verb": { + "name": "verb", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "result_count": { + "name": "result_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "denied_reason": { + "name": "denied_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "query_hash": { + "name": "query_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bytes_out": { + "name": "bytes_out", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "federation_audit_log_peer_created_at_idx": { + "name": "federation_audit_log_peer_created_at_idx", + "columns": [ + { + "expression": "peer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "federation_audit_log_subject_created_at_idx": { + "name": "federation_audit_log_subject_created_at_idx", + "columns": [ + { + "expression": "subject_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "federation_audit_log_created_at_idx": { + "name": "federation_audit_log_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "federation_audit_log_peer_id_federation_peers_id_fk": { + "name": "federation_audit_log_peer_id_federation_peers_id_fk", + "tableFrom": "federation_audit_log", + "tableTo": "federation_peers", + "columnsFrom": [ + "peer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "federation_audit_log_subject_user_id_users_id_fk": { + "name": "federation_audit_log_subject_user_id_users_id_fk", + "tableFrom": "federation_audit_log", + "tableTo": "users", + "columnsFrom": [ + "subject_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "federation_audit_log_grant_id_federation_grants_id_fk": { + "name": "federation_audit_log_grant_id_federation_grants_id_fk", + "tableFrom": "federation_audit_log", + "tableTo": "federation_grants", + "columnsFrom": [ + "grant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.federation_enrollment_tokens": { + "name": "federation_enrollment_tokens", + "schema": "", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "grant_id": { + "name": "grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "peer_id": { + "name": "peer_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "used_at": { + "name": "used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "federation_enrollment_tokens_grant_id_federation_grants_id_fk": { + "name": "federation_enrollment_tokens_grant_id_federation_grants_id_fk", + "tableFrom": "federation_enrollment_tokens", + "tableTo": "federation_grants", + "columnsFrom": [ + "grant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "federation_enrollment_tokens_peer_id_federation_peers_id_fk": { + "name": "federation_enrollment_tokens_peer_id_federation_peers_id_fk", + "tableFrom": "federation_enrollment_tokens", + "tableTo": "federation_peers", + "columnsFrom": [ + "peer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.federation_grants": { + "name": "federation_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "subject_user_id": { + "name": "subject_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "peer_id": { + "name": "peer_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "grant_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_reason": { + "name": "revoked_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "federation_grants_subject_status_idx": { + "name": "federation_grants_subject_status_idx", + "columns": [ + { + "expression": "subject_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "federation_grants_peer_status_idx": { + "name": "federation_grants_peer_status_idx", + "columns": [ + { + "expression": "peer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "federation_grants_subject_user_id_users_id_fk": { + "name": "federation_grants_subject_user_id_users_id_fk", + "tableFrom": "federation_grants", + "tableTo": "users", + "columnsFrom": [ + "subject_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "federation_grants_peer_id_federation_peers_id_fk": { + "name": "federation_grants_peer_id_federation_peers_id_fk", + "tableFrom": "federation_grants", + "tableTo": "federation_peers", + "columnsFrom": [ + "peer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.federation_peers": { + "name": "federation_peers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "common_name": { + "name": "common_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cert_pem": { + "name": "cert_pem", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cert_serial": { + "name": "cert_serial", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cert_not_after": { + "name": "cert_not_after", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "client_key_pem": { + "name": "client_key_pem", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "peer_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "endpoint_url": { + "name": "endpoint_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "federation_peers_cert_serial_idx": { + "name": "federation_peers_cert_serial_idx", + "columns": [ + { + "expression": "cert_serial", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "federation_peers_state_idx": { + "name": "federation_peers_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "federation_peers_common_name_unique": { + "name": "federation_peers_common_name_unique", + "nullsNotDistinct": false, + "columns": [ + "common_name" + ] + }, + "federation_peers_cert_serial_unique": { + "name": "federation_peers_cert_serial_unique", + "nullsNotDistinct": false, + "columns": [ + "cert_serial" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.insights": { + "name": "insights", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'agent'" + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'general'" + }, + "relevance_score": { + "name": "relevance_score", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "decayed_at": { + "name": "decayed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "insights_user_id_idx": { + "name": "insights_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "insights_category_idx": { + "name": "insights_category_idx", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "insights_relevance_idx": { + "name": "insights_relevance_idx", + "columns": [ + { + "expression": "relevance_score", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "insights_user_id_users_id_fk": { + "name": "insights_user_id_users_id_fk", + "tableFrom": "insights", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.interaction_checkpoints": { + "name": "interaction_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_digest": { + "name": "content_digest", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "compaction_epoch": { + "name": "compaction_epoch", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "interaction_checkpoints_session_idempotency_idx": { + "name": "interaction_checkpoints_session_idempotency_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "checkpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "interaction_checkpoints_session_epoch_idx": { + "name": "interaction_checkpoints_session_epoch_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "compaction_epoch", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "interaction_checkpoints_session_id_interaction_sessions_id_fk": { + "name": "interaction_checkpoints_session_id_interaction_sessions_id_fk", + "tableFrom": "interaction_checkpoints", + "tableTo": "interaction_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.interaction_handoffs": { + "name": "interaction_handoffs", + "schema": "", + "columns": { + "handoff_id": { + "name": "handoff_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "destination": { + "name": "destination", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "correlation_id": { + "name": "correlation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "interaction_handoff_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "interaction_handoffs_session_status_idx": { + "name": "interaction_handoffs_session_status_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "interaction_handoffs_session_id_interaction_sessions_id_fk": { + "name": "interaction_handoffs_session_id_interaction_sessions_id_fk", + "tableFrom": "interaction_handoffs", + "tableTo": "interaction_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.interaction_inbox": { + "name": "interaction_inbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "correlation_id": { + "name": "correlation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_digest": { + "name": "content_digest", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "interaction_inbox_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "interaction_inbox_session_idempotency_idx": { + "name": "interaction_inbox_session_idempotency_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "interaction_inbox_session_status_created_idx": { + "name": "interaction_inbox_session_status_created_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "interaction_inbox_session_id_interaction_sessions_id_fk": { + "name": "interaction_inbox_session_id_interaction_sessions_id_fk", + "tableFrom": "interaction_inbox", + "tableTo": "interaction_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.interaction_outbox": { + "name": "interaction_outbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "correlation_id": { + "name": "correlation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_digest": { + "name": "content_digest", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "interaction_outbox_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "interaction_outbox_session_idempotency_idx": { + "name": "interaction_outbox_session_idempotency_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "interaction_outbox_session_status_created_idx": { + "name": "interaction_outbox_session_status_created_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "interaction_outbox_session_id_interaction_sessions_id_fk": { + "name": "interaction_outbox_session_id_interaction_sessions_id_fk", + "tableFrom": "interaction_outbox", + "tableTo": "interaction_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.interaction_sessions": { + "name": "interaction_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "agent_name": { + "name": "agent_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "runtime_session_id": { + "name": "runtime_session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "interaction_sessions_owner_id_users_id_fk": { + "name": "interaction_sessions_owner_id_users_id_fk", + "tableFrom": "interaction_sessions", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.logical_agent_connector_leases": { + "name": "logical_agent_connector_leases", + "schema": "", + "columns": { + "lease_id": { + "name": "lease_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logical_agent_id": { + "name": "logical_agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "binding_id": { + "name": "binding_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "lease_epoch": { + "name": "lease_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "acquired_at": { + "name": "acquired_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "heartbeat_at": { + "name": "heartbeat_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "released_at": { + "name": "released_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "logical_agent_connector_lease_binding_idx": { + "name": "logical_agent_connector_lease_binding_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "logical_agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "binding_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "logical_agent_connector_lease_expiry_idx": { + "name": "logical_agent_connector_lease_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "logical_agent_connector_lease_connector_idx": { + "name": "logical_agent_connector_lease_connector_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.messages": { + "name": "messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "conversation_id": { + "name": "conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "messages_conversation_id_idx": { + "name": "messages_conversation_id_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "messages_conversation_id_conversations_id_fk": { + "name": "messages_conversation_id_conversations_id_fk", + "tableFrom": "messages", + "tableTo": "conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mission_tasks": { + "name": "mission_tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "mission_id": { + "name": "mission_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'not-started'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pr": { + "name": "pr", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mission_tasks_mission_id_idx": { + "name": "mission_tasks_mission_id_idx", + "columns": [ + { + "expression": "mission_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mission_tasks_task_id_idx": { + "name": "mission_tasks_task_id_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mission_tasks_user_id_idx": { + "name": "mission_tasks_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mission_tasks_status_idx": { + "name": "mission_tasks_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mission_tasks_mission_id_missions_id_fk": { + "name": "mission_tasks_mission_id_missions_id_fk", + "tableFrom": "mission_tasks", + "tableTo": "missions", + "columnsFrom": [ + "mission_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mission_tasks_task_id_tasks_id_fk": { + "name": "mission_tasks_task_id_tasks_id_fk", + "tableFrom": "mission_tasks", + "tableTo": "tasks", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mission_tasks_user_id_users_id_fk": { + "name": "mission_tasks_user_id_users_id_fk", + "tableFrom": "mission_tasks", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.missions": { + "name": "missions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'planning'" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "milestones": { + "name": "milestones", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "missions_project_id_idx": { + "name": "missions_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "missions_user_id_idx": { + "name": "missions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "missions_project_id_projects_id_fk": { + "name": "missions_project_id_projects_id_fk", + "tableFrom": "missions", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "missions_user_id_users_id_fk": { + "name": "missions_user_id_users_id_fk", + "tableFrom": "missions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.preferences": { + "name": "preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'general'" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mutable": { + "name": "mutable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "preferences_user_id_idx": { + "name": "preferences_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "preferences_user_key_idx": { + "name": "preferences_user_key_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "preferences_user_id_users_id_fk": { + "name": "preferences_user_id_users_id_fk", + "tableFrom": "preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owner_type": { + "name": "owner_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "projects_owner_id_users_id_fk": { + "name": "projects_owner_id_users_id_fk", + "tableFrom": "projects", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "projects_team_id_teams_id_fk": { + "name": "projects_team_id_teams_id_fk", + "tableFrom": "projects", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_credentials": { + "name": "provider_credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_type": { + "name": "credential_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_value": { + "name": "encrypted_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "provider_credentials_user_provider_idx": { + "name": "provider_credentials_user_provider_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "provider_credentials_user_id_idx": { + "name": "provider_credentials_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "provider_credentials_user_id_users_id_fk": { + "name": "provider_credentials_user_id_users_id_fk", + "tableFrom": "provider_credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routing_rules": { + "name": "routing_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "conditions": { + "name": "conditions", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "routing_rules_scope_priority_idx": { + "name": "routing_rules_scope_priority_idx", + "columns": [ + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routing_rules_user_id_idx": { + "name": "routing_rules_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routing_rules_enabled_idx": { + "name": "routing_rules_enabled_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routing_rules_user_id_users_id_fk": { + "name": "routing_rules_user_id_users_id_fk", + "tableFrom": "routing_rules", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sessions_user_id_idx": { + "name": "sessions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_expires_at_idx": { + "name": "sessions_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skills": { + "name": "skills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'custom'" + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "installed_by": { + "name": "installed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skills_enabled_idx": { + "name": "skills_enabled_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skills_installed_by_users_id_fk": { + "name": "skills_installed_by_users_id_fk", + "tableFrom": "skills", + "tableTo": "users", + "columnsFrom": [ + "installed_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "skills_name_unique": { + "name": "skills_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.summarization_jobs": { + "name": "summarization_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "logs_processed": { + "name": "logs_processed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "insights_created": { + "name": "insights_created", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "summarization_jobs_status_idx": { + "name": "summarization_jobs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tasks": { + "name": "tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'not-started'" + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'medium'" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "mission_id": { + "name": "mission_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "assignee": { + "name": "assignee", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "due_date": { + "name": "due_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tasks_project_id_idx": { + "name": "tasks_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_mission_id_idx": { + "name": "tasks_mission_id_idx", + "columns": [ + { + "expression": "mission_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_status_idx": { + "name": "tasks_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tasks_project_id_projects_id_fk": { + "name": "tasks_project_id_projects_id_fk", + "tableFrom": "tasks", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tasks_mission_id_missions_id_fk": { + "name": "tasks_mission_id_missions_id_fk", + "tableFrom": "tasks", + "tableTo": "missions", + "columnsFrom": [ + "mission_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_members": { + "name": "team_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "team_members_team_user_idx": { + "name": "team_members_team_user_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "team_members_team_id_teams_id_fk": { + "name": "team_members_team_id_teams_id_fk", + "tableFrom": "team_members", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_members_user_id_users_id_fk": { + "name": "team_members_user_id_users_id_fk", + "tableFrom": "team_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_members_invited_by_users_id_fk": { + "name": "team_members_invited_by_users_id_fk", + "tableFrom": "team_members", + "tableTo": "users", + "columnsFrom": [ + "invited_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.teams": { + "name": "teams", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "manager_id": { + "name": "manager_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "teams_owner_id_users_id_fk": { + "name": "teams_owner_id_users_id_fk", + "tableFrom": "teams", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "teams_manager_id_users_id_fk": { + "name": "teams_manager_id_users_id_fk", + "tableFrom": "teams", + "tableTo": "users", + "columnsFrom": [ + "manager_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "teams_slug_unique": { + "name": "teams_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tickets": { + "name": "tickets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'medium'" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tickets_status_idx": { + "name": "tickets_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verifications": { + "name": "verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.backlog_status": { + "name": "backlog_status", + "schema": "public", + "values": [ + "ready", + "claimed", + "blocked", + "done" + ] + }, + "public.grant_status": { + "name": "grant_status", + "schema": "public", + "values": [ + "pending", + "active", + "revoked", + "expired" + ] + }, + "public.interaction_handoff_status": { + "name": "interaction_handoff_status", + "schema": "public", + "values": [ + "pending", + "accepted" + ] + }, + "public.interaction_inbox_status": { + "name": "interaction_inbox_status", + "schema": "public", + "values": [ + "pending", + "processing", + "processed" + ] + }, + "public.interaction_outbox_status": { + "name": "interaction_outbox_status", + "schema": "public", + "values": [ + "pending", + "processing", + "delivered" + ] + }, + "public.peer_state": { + "name": "peer_state", + "schema": "public", + "values": [ + "pending", + "active", + "suspended", + "revoked" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index dcdb944d..1f143f9e 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -113,6 +113,13 @@ "when": 1783942610000, "tag": "0015_interaction_checkpoint_payload_digest", "breakpoints": true + }, + { + "idx": 16, + "version": "7", + "when": 1784050648841, + "tag": "0016_salty_morlocks", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 76809d21..9735cb2c 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -15,6 +15,7 @@ import { uniqueIndex, real, integer, + bigint, customType, } from 'drizzle-orm/pg-core'; @@ -487,6 +488,68 @@ export const agentLogs = pgTable( ], ); +// ─── Logical agent connector authority ────────────────────────────────────── +// One durable row is the current authority for a tenant/logical-agent/binding. +// Runtime-native session identifiers never enter these core tables. + +export const logicalAgentConnectorLeases = pgTable( + 'logical_agent_connector_leases', + { + leaseId: uuid('lease_id').primaryKey(), + tenantId: text('tenant_id').notNull(), + logicalAgentId: text('logical_agent_id').notNull(), + bindingId: text('binding_id').notNull(), + connectorId: text('connector_id').notNull(), + scopes: jsonb('scopes').notNull().$type(), + leaseEpoch: bigint('lease_epoch', { mode: 'bigint' }).notNull(), + acquiredAt: timestamp('acquired_at', { withTimezone: true }).notNull(), + heartbeatAt: timestamp('heartbeat_at', { withTimezone: true }).notNull(), + expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(), + releasedAt: timestamp('released_at', { withTimezone: true }), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [ + uniqueIndex('logical_agent_connector_lease_binding_idx').on( + t.tenantId, + t.logicalAgentId, + t.bindingId, + ), + index('logical_agent_connector_lease_expiry_idx').on(t.expiresAt), + index('logical_agent_connector_lease_connector_idx').on(t.connectorId), + ], +); + +/** Append-only, credential-safe lease lifecycle and fencing denial metadata. */ +export const connectorLeaseAuditLog = pgTable( + 'connector_lease_audit_log', + { + id: uuid('id').primaryKey().defaultRandom(), + tenantId: text('tenant_id').notNull(), + logicalAgentId: text('logical_agent_id').notNull(), + bindingId: text('binding_id').notNull(), + connectorId: text('connector_id').notNull(), + leaseId: uuid('lease_id'), + leaseEpoch: bigint('lease_epoch', { mode: 'bigint' }), + event: text('event', { + enum: ['acquire', 'renew', 'takeover', 'reject', 'release', 'expiry'], + }).notNull(), + outcome: text('outcome', { enum: ['succeeded', 'denied'] }).notNull(), + reason: text('reason'), + correlationId: text('correlation_id').notNull(), + occurredAt: timestamp('occurred_at', { withTimezone: true }).notNull(), + }, + (t) => [ + index('connector_lease_audit_binding_occurred_idx').on( + t.tenantId, + t.logicalAgentId, + t.bindingId, + t.occurredAt.desc(), + ), + index('connector_lease_audit_correlation_idx').on(t.correlationId), + ], +); + // ─── Tess durable session state ───────────────────────────────────────────── // PostgreSQL is canonical for restart-safe Tess session recovery. The state // machine lives in @mosaicstack/agent; these records are its durable adapter. diff --git a/packages/types/src/agent/connector-lease.dto.spec.ts b/packages/types/src/agent/connector-lease.dto.spec.ts new file mode 100644 index 00000000..727b044d --- /dev/null +++ b/packages/types/src/agent/connector-lease.dto.spec.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; +import { + normalizeConnectorId, + normalizeConnectorScopes, + normalizeLeaseEpoch, + normalizeLogicalAgentIdentity, + normalizeLogicalBindingId, + type ConnectorExecutionContext, + type LogicalAgentIdentity, +} from './connector-lease.dto.js'; + +describe('logical agent connector lease contract', (): void => { + it('normalizes a runtime-neutral logical identity and binding vocabulary', (): void => { + const identity = normalizeLogicalAgentIdentity({ + tenantId: ' tenant-01 ', + logicalAgentId: ' MOS.Primary ', + }); + + expect(identity).toEqual({ tenantId: 'tenant-01', logicalAgentId: 'mos.primary' }); + expect(normalizeLogicalBindingId(' Discord:Operations ')).toBe('discord:operations'); + expect(normalizeConnectorId(' PI.Worker-01 ')).toBe('pi.worker-01'); + expect(Object.isFrozen(identity)).toBe(true); + expect(Object.keys(identity).sort()).toEqual(['logicalAgentId', 'tenantId']); + }); + + it('canonicalizes scopes and decimal fencing epochs', (): void => { + expect(normalizeConnectorScopes([' Runtime.Send ', 'tool.execute', 'runtime.send'])).toEqual([ + 'runtime.send', + 'tool.execute', + ]); + expect(normalizeLeaseEpoch('00042')).toBe('42'); + }); + + it.each([ + ['', 'mos'], + ['tenant', ''], + ['tenant', 'claude session/123'], + ])('rejects ambiguous identity values tenant=%j agent=%j', (tenantId, logicalAgentId): void => { + expect(() => normalizeLogicalAgentIdentity({ tenantId, logicalAgentId })).toThrow(); + }); + + it('defines an adapter context without harness-native identity fields', (): void => { + const identity: LogicalAgentIdentity = { tenantId: 'tenant-01', logicalAgentId: 'mos' }; + const context: ConnectorExecutionContext = { + identity, + bindingId: 'operator-chat', + connectorId: 'connector-a', + leaseId: '00000000-0000-4000-8000-000000000001', + leaseEpoch: '7', + scopes: ['runtime.send'], + correlationId: 'correlation-1', + grantExpiresAt: '2026-07-14T18:00:00.000Z', + }; + + expect(context).not.toHaveProperty('sessionId'); + expect(context).not.toHaveProperty('tmuxSession'); + expect(context).not.toHaveProperty('providerSessionId'); + }); +}); diff --git a/packages/types/src/agent/connector-lease.dto.ts b/packages/types/src/agent/connector-lease.dto.ts new file mode 100644 index 00000000..fc5aed68 --- /dev/null +++ b/packages/types/src/agent/connector-lease.dto.ts @@ -0,0 +1,199 @@ +const ID_PATTERN = /^[a-z0-9][a-z0-9._:@-]{0,127}$/; +const TENANT_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:@-]{0,127}$/; +const SCOPE_PATTERN = /^[a-z][a-z0-9._:-]{0,127}$/; + +/** Stable Mosaic identity. It intentionally contains no runtime/provider session identifier. */ +export interface LogicalAgentIdentity { + readonly tenantId: string; + readonly logicalAgentId: string; +} + +export interface LogicalAgentBinding { + readonly identity: LogicalAgentIdentity; + readonly bindingId: string; +} + +export interface ConnectorLease extends LogicalAgentBinding { + readonly leaseId: string; + readonly connectorId: string; + readonly scopes: readonly string[]; + readonly leaseEpoch: string; + readonly acquiredAt: string; + readonly heartbeatAt: string; + readonly expiresAt: string; + readonly releasedAt?: string; +} + +export interface AcquireConnectorLeaseInput { + readonly identity: LogicalAgentIdentity; + readonly bindingId: string; + readonly connectorId: string; + readonly scopes: readonly string[]; + readonly ttlMs: number; + readonly correlationId: string; +} + +export interface TakeoverConnectorLeaseInput extends AcquireConnectorLeaseInput { + readonly expectedEpoch: string; +} + +export interface HeartbeatConnectorLeaseInput { + readonly lease: ConnectorLease; + readonly ttlMs: number; + readonly correlationId: string; +} + +export interface ReleaseConnectorLeaseInput { + readonly lease: ConnectorLease; + readonly correlationId: string; +} + +export interface IssueConnectorExecutionGrantInput { + readonly lease: ConnectorLease; + readonly scopes: readonly string[]; + readonly ttlMs: number; + readonly correlationId: string; +} + +/** Internal server grant. Object provenance is checked in addition to these fields. */ +export interface ConnectorExecutionGrant extends LogicalAgentBinding { + readonly leaseId: string; + readonly connectorId: string; + readonly scopes: readonly string[]; + readonly leaseEpoch: string; + readonly issuedAt: string; + readonly expiresAt: string; + readonly correlationId: string; +} + +/** Normalized context passed to an adapter only after current-lease validation. */ +export interface ConnectorExecutionContext extends LogicalAgentBinding { + readonly leaseId: string; + readonly connectorId: string; + readonly scopes: readonly string[]; + readonly leaseEpoch: string; + readonly correlationId: string; + readonly grantExpiresAt: string; +} + +export interface FencedConnectorAdapter { + execute(input: TInput, context: ConnectorExecutionContext): Promise; +} + +export type ConnectorLeaseAuditEventType = + | 'acquire' + | 'renew' + | 'takeover' + | 'reject' + | 'release' + | 'expiry'; +export type ConnectorLeaseAuditOutcome = 'succeeded' | 'denied'; +export type ConnectorLeaseRejectReason = + | 'policy_denied' + | 'lease_held' + | 'takeover_required' + | 'cas_mismatch' + | 'lease_missing' + | 'lease_released' + | 'lease_expired' + | 'stale_epoch' + | 'connector_mismatch' + | 'scope_denied' + | 'forged_grant' + | 'grant_expired'; + +/** Credential-safe metadata only: no grant object, scope set, payload, token, or approval ref. */ +export interface ConnectorLeaseAuditEvent extends LogicalAgentBinding { + readonly event: ConnectorLeaseAuditEventType; + readonly outcome: ConnectorLeaseAuditOutcome; + readonly connectorId: string; + readonly correlationId: string; + readonly occurredAt: string; + readonly leaseId?: string; + readonly leaseEpoch?: string; + readonly reason?: ConnectorLeaseRejectReason; +} + +export interface ConnectorLeaseAcquireMutation extends AcquireConnectorLeaseInput { + readonly leaseId: string; + readonly now: string; + readonly expiresAt: string; +} + +export interface ConnectorLeaseTakeoverMutation extends TakeoverConnectorLeaseInput { + readonly leaseId: string; + readonly now: string; + readonly expiresAt: string; +} + +export interface ConnectorLeaseHeartbeatMutation extends HeartbeatConnectorLeaseInput { + readonly now: string; + readonly expiresAt: string; +} + +export interface ConnectorLeaseReleaseMutation extends ReleaseConnectorLeaseInput { + readonly now: string; +} + +export interface ConnectorLeaseStore { + acquire(input: ConnectorLeaseAcquireMutation): Promise; + takeover(input: ConnectorLeaseTakeoverMutation): Promise; + heartbeat(input: ConnectorLeaseHeartbeatMutation): Promise; + release(input: ConnectorLeaseReleaseMutation): Promise; + findCurrent(binding: LogicalAgentBinding): Promise; + recordAudit(event: ConnectorLeaseAuditEvent): Promise; +} + +export function normalizeLogicalAgentIdentity(input: LogicalAgentIdentity): LogicalAgentIdentity { + const tenantId = requiredIdentifier(input.tenantId, 'tenant ID', TENANT_PATTERN, false); + const logicalAgentId = requiredIdentifier( + input.logicalAgentId, + 'logical agent ID', + ID_PATTERN, + true, + ); + return Object.freeze({ tenantId, logicalAgentId }); +} + +export function normalizeLogicalBindingId(value: string): string { + return requiredIdentifier(value, 'logical binding ID', ID_PATTERN, true); +} + +export function normalizeConnectorId(value: string): string { + return requiredIdentifier(value, 'connector ID', ID_PATTERN, true); +} + +export function normalizeCorrelationId(value: string): string { + return requiredIdentifier(value, 'correlation ID', TENANT_PATTERN, false); +} + +export function normalizeConnectorScope(value: string): string { + return requiredIdentifier(value, 'connector scope', SCOPE_PATTERN, true); +} + +export function normalizeConnectorScopes(values: readonly string[]): readonly string[] { + if (values.length === 0) throw new Error('At least one connector scope is required'); + return Object.freeze(Array.from(new Set(values.map(normalizeConnectorScope))).sort()); +} + +export function normalizeLeaseEpoch(value: string): string { + const trimmed = value.trim(); + if (!/^\d+$/.test(trimmed)) throw new Error('Lease epoch must be a positive decimal integer'); + const epoch = BigInt(trimmed); + if (epoch < 1n) throw new Error('Lease epoch must be a positive decimal integer'); + return epoch.toString(10); +} + +function requiredIdentifier( + value: string, + label: string, + pattern: RegExp, + lowerCase: boolean, +): string { + const trimmed = value.trim(); + const normalized = lowerCase ? trimmed.toLowerCase() : trimmed; + if (!pattern.test(normalized)) { + throw new Error(`${label} has an invalid normalized format`); + } + return normalized; +} diff --git a/packages/types/src/agent/index.ts b/packages/types/src/agent/index.ts index 1803cd30..d701b4c9 100644 --- a/packages/types/src/agent/index.ts +++ b/packages/types/src/agent/index.ts @@ -4,3 +4,4 @@ export interface AgentSessionHandle { } export * from './agent-runtime-provider.js'; +export * from './connector-lease.dto.js'; From a5e8e554012f27898e035d2882a8e47e1a02fe97 Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Wed, 15 Jul 2026 00:53:47 +0000 Subject: [PATCH 050/152] feat(fleet): add shared role semantics (#768) --- docs/TASKS.md | 28 +- docs/fleet/how-to/customize-roles.md | 54 ++ docs/fleet/migration/legacy-class-aliases.md | 40 ++ docs/fleet/reference/role-classes.md | 45 ++ docs/fleet/reference/roster-v2-fields.md | 29 + .../758-fcm-m1-002-shared-role-resolution.md | 148 +++++ .../mosaic/framework/fleet/roles/LIBRARY.md | 8 +- .../framework/fleet/roles/interaction.md | 16 + .../framework/fleet/roles/team-leader.md | 16 + .../mosaic/framework/fleet/roles/validator.md | 16 + .../src/commands/fleet-personas.spec.ts | 437 ++++++++++++++- .../mosaic/src/commands/fleet-personas.ts | 524 +++++++++++++----- .../src/commands/fleet-profiles.spec.ts | 85 +++ .../mosaic/src/commands/fleet-profiles.ts | 73 ++- .../src/commands/fleet-provision.spec.ts | 40 ++ .../mosaic/src/commands/fleet-provision.ts | 36 +- .../mosaic/src/fleet/persona-contract.spec.ts | 19 +- packages/mosaic/src/fleet/roster-v2.spec.ts | 128 ++++- packages/mosaic/src/fleet/roster-v2.ts | 85 +++ 19 files changed, 1650 insertions(+), 177 deletions(-) create mode 100644 docs/fleet/how-to/customize-roles.md create mode 100644 docs/fleet/migration/legacy-class-aliases.md create mode 100644 docs/fleet/reference/role-classes.md create mode 100644 docs/scratchpads/758-fcm-m1-002-shared-role-resolution.md create mode 100644 packages/mosaic/framework/fleet/roles/interaction.md create mode 100644 packages/mosaic/framework/fleet/roles/team-leader.md create mode 100644 packages/mosaic/framework/fleet/roles/validator.md diff --git a/docs/TASKS.md b/docs/TASKS.md index 40973731..58ea16bf 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -52,20 +52,20 @@ Active workstream is **W1 — Federation v1**. Workers should: > the repository quality gates, independent code and security review, terminal-green CI, and > the applicable acceptance evidence before merge. Issue #758 remains open until M5 closes. -| id | status | description | issue | agent | repo | branch | depends_on | estimate | notes | -| ---------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ------ | ----------------- | --------------------------------------- | ---------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------- | -| FCM-M0-001 | in-progress | Publish normative PRD requirements/acceptance criteria, this M0–M5 DAG, docs-IA checklist, and legacy example/profile disposition inventory; no implementation changes | #758 | sonnet | mosaicstack/stack | `docs/758-fleet-config-management` | — | 18K | M0 exit: approved docs; every shipped example/profile/service preset classified; docs-only PR | -| FCM-M1-001 | not-started | Implement narrow local-tmux v2 roster structural contract/compiler with YAML/JSON canonicalization and schema/parser parity tests | #758 | codex | mosaicstack/stack | `feat/758-roster-v2-compiler` | FCM-M0-001 | 30K | No lifecycle, remote, connector, secret, channel, or gateway work | -| FCM-M1-002 | not-started | Reuse existing profile/persona/provision resolver for roster semantics; add canonical class/authority validation and approved aliases | #758 | codex | mosaicstack/stack | `feat/758-shared-role-resolution` | FCM-M0-001 | 25K | Validator is certificate-only; merge-gate remains sole merge authority | -| FCM-M1-003 | not-started | Convert the M0 legacy inventory into executable example/profile/service-preset validation and explicit v1-version/retirement checks | #758 | codex | mosaicstack/stack | `test/758-example-profile-dispositions` | FCM-M1-001, FCM-M1-002 | 20K | Every shipped artifact must validate, be versioned v1, or be retired with replacement | -| FCM-M2-001 | not-started | Migrate generic launch chain to deterministic `.env.generated` plus strict data-only `.env.local`; quarantine forbidden legacy keys | #758 | codex | mosaicstack/stack | `feat/758-generated-env-boundary` | FCM-M1-001, FCM-M1-002 | 30K | No arbitrary command compatibility path; diagnostics expose key names/hashes only | -| FCM-M2-002 | not-started | Add generation-guarded local fleet agent create/get/update/delete mutations with plan/dry-run, atomic roster writes, and recovery output | #758 | codex | mosaicstack/stack | `feat/758-fleet-agent-crud` | FCM-M1-001, FCM-M2-001 | 30K | Fresh create persists stopped unless explicit persisted start | -| FCM-M3-001 | not-started | Implement local roster-owned reconcile/apply plus lifecycle/status/verify/doctor contracts and stable JSON/exit codes | #758 | codex | mosaicstack/stack | `feat/758-local-reconciler` | FCM-M2-001, FCM-M2-002 | 35K | Exact systemd/tmux ownership; remote/schema-only entries are inventory only | -| FCM-M3-002 | not-started | Add isolated systemd/tmux lifecycle, drift, socket, unmanaged-session, crash, and rollback acceptance coverage | #758 | sonnet | mosaicstack/stack | `test/758-reconciler-lifecycle-gates` | FCM-M3-001 | 25K | Proves stopped-state preservation and zero fuzzy destructive targeting | -| FCM-M4-001 | not-started | Implement field-complete v1-to-v2 inventory/preview/migrator with alias, lifecycle, env-quarantine, and remote/connector disposition evidence | #758 | codex | mosaicstack/stack | `feat/758-v1-v2-migrator` | FCM-M1-003, FCM-M3-001 | 35K | Preview first; no unreviewed lifecycle inference | -| FCM-M4-002 | not-started | Add reversible canary migration, rollback, stale-projection/orphan classification, and current-host 9-managed/3-unmanaged fixture coverage | #758 | sonnet | mosaicstack/stack | `test/758-migration-rollback-gates` | FCM-M4-001, FCM-M3-002 | 25K | Never starts a previously stopped agent or kills an unproven unmanaged session | -| FCM-M5-001 | not-started | Deliver the accepted fleet documentation IA, how-to/operations/migration references, and link/example validation | #758 | haiku | mosaicstack/stack | `docs/758-fleet-config-operator-docs` | FCM-M1-003, FCM-M2-002, FCM-M3-001, FCM-M4-001 | 24K | Must close every checklist item or record an approved deferral | -| FCM-M5-002 | not-started | Package/update asset-drift checks, rolling local canary, independent validation certificate, and release evidence | #758 | sonnet | mosaicstack/stack | `feat/758-fleet-config-release-gate` | FCM-M3-002, FCM-M4-002, FCM-M5-001 | 30K | Final #758 gate: quality, independent code/security review, validator certificate, merge-gate approval, green CI | +| id | status | description | issue | agent | repo | branch | depends_on | estimate | notes | +| ---------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ------------- | ----------------- | --------------------------------------- | ---------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------ | +| FCM-M0-001 | done | Publish normative PRD requirements/acceptance criteria, this M0–M5 DAG, docs-IA checklist, and legacy example/profile disposition inventory; no implementation changes | #758 | sonnet | mosaicstack/stack | `docs/758-fleet-config-management` | — | 18K | Merged via #760 (`c32d85a`); parent #758 intentionally remains open through M5 | +| FCM-M1-001 | done | Implement narrow local-tmux v2 roster structural contract/compiler with YAML/JSON canonicalization and schema/parser parity tests | #758 | coder0 | mosaicstack/stack | `feat/758-roster-v2-compiler` | FCM-M0-001 | 30K | #764 squash `aa5b43b`; exact-head RoR and PR/main terminal-green CI; no lifecycle or live mutation | +| FCM-M1-002 | in-progress | Reuse existing profile/persona/provision resolver for roster semantics; add canonical class/authority validation and approved aliases | #758 | native-sonnet | mosaicstack/stack | `feat/758-shared-role-resolution` | FCM-M0-001 | 25K | Started 2026-07-14 from `aa5b43b`; one shared resolver only; validator certificate-only; merge-gate sole merge authority | +| FCM-M1-003 | not-started | Convert the M0 legacy inventory into executable example/profile/service-preset validation and explicit v1-version/retirement checks | #758 | codex | mosaicstack/stack | `test/758-example-profile-dispositions` | FCM-M1-001, FCM-M1-002 | 20K | Every shipped artifact must validate, be versioned v1, or be retired with replacement | +| FCM-M2-001 | not-started | Migrate generic launch chain to deterministic `.env.generated` plus strict data-only `.env.local`; quarantine forbidden legacy keys | #758 | codex | mosaicstack/stack | `feat/758-generated-env-boundary` | FCM-M1-001, FCM-M1-002 | 30K | No arbitrary command compatibility path; diagnostics expose key names/hashes only | +| FCM-M2-002 | not-started | Add generation-guarded local fleet agent create/get/update/delete mutations with plan/dry-run, atomic roster writes, and recovery output | #758 | codex | mosaicstack/stack | `feat/758-fleet-agent-crud` | FCM-M1-001, FCM-M2-001 | 30K | Fresh create persists stopped unless explicit persisted start | +| FCM-M3-001 | not-started | Implement local roster-owned reconcile/apply plus lifecycle/status/verify/doctor contracts and stable JSON/exit codes | #758 | codex | mosaicstack/stack | `feat/758-local-reconciler` | FCM-M2-001, FCM-M2-002 | 35K | Exact systemd/tmux ownership; remote/schema-only entries are inventory only | +| FCM-M3-002 | not-started | Add isolated systemd/tmux lifecycle, drift, socket, unmanaged-session, crash, and rollback acceptance coverage | #758 | sonnet | mosaicstack/stack | `test/758-reconciler-lifecycle-gates` | FCM-M3-001 | 25K | Proves stopped-state preservation and zero fuzzy destructive targeting | +| FCM-M4-001 | not-started | Implement field-complete v1-to-v2 inventory/preview/migrator with alias, lifecycle, env-quarantine, and remote/connector disposition evidence | #758 | codex | mosaicstack/stack | `feat/758-v1-v2-migrator` | FCM-M1-003, FCM-M3-001 | 35K | Preview first; no unreviewed lifecycle inference | +| FCM-M4-002 | not-started | Add reversible canary migration, rollback, stale-projection/orphan classification, and current-host 9-managed/3-unmanaged fixture coverage | #758 | sonnet | mosaicstack/stack | `test/758-migration-rollback-gates` | FCM-M4-001, FCM-M3-002 | 25K | Never starts a previously stopped agent or kills an unproven unmanaged session | +| FCM-M5-001 | not-started | Deliver the accepted fleet documentation IA, how-to/operations/migration references, and link/example validation | #758 | haiku | mosaicstack/stack | `docs/758-fleet-config-operator-docs` | FCM-M1-003, FCM-M2-002, FCM-M3-001, FCM-M4-001 | 24K | Must close every checklist item or record an approved deferral | +| FCM-M5-002 | not-started | Package/update asset-drift checks, rolling local canary, independent validation certificate, and release evidence | #758 | sonnet | mosaicstack/stack | `feat/758-fleet-config-release-gate` | FCM-M3-002, FCM-M4-002, FCM-M5-001 | 30K | Final #758 gate: quality, independent code/security review, validator certificate, merge-gate approval, green CI | ## Thin-core prompt diet (#528) — feat/contract-thin-core diff --git a/docs/fleet/how-to/customize-roles.md b/docs/fleet/how-to/customize-roles.md new file mode 100644 index 00000000..21238980 --- /dev/null +++ b/docs/fleet/how-to/customize-roles.md @@ -0,0 +1,54 @@ +# Customize Fleet Roles + +Mosaic resolves persona contracts through two layers: + +1. `fleet/roles/.md` — seeded baseline contract. +2. `fleet/roles.local/.md` — operator override or custom role; this layer wins. + +The same shared resolver is used by profile validation, provisioning, roster-v2 semantic validation, +and launch-time persona injection. + +## Override a baseline role + +Create a readable Markdown contract under `roles.local` with the canonical filename and class marker: + +```markdown +# Code — local role definition + +The local code role (`class: code`) follows the operator's repository conventions. +``` + +Save it as `fleet/roles.local/code.md`. Do not edit generated or seeded baseline assets when the goal +is a durable local customization. + +Legacy aliases canonicalize before lookup. Therefore `roles.local/implementer.md` does not override +`code`; use `roles.local/code.md`. See [Legacy Fleet Class Aliases](../migration/legacy-class-aliases.md). + +## Add a custom class + +A custom class remains supported when a readable contract exists for the exact identifier: + +```markdown +# Release notes — local role definition + +The release-notes role (`class: release-notes`) prepares operator-reviewed release copy. +``` + +Save it as `fleet/roles.local/release-notes.md`, then reference `class: release-notes` and a matching +`tool_policy: release-notes` in roster v2. Adding only a `LIBRARY.md` row is insufficient. + +Names such as `worker`, `analyst`, and `canary` are not built-in aliases; they need genuine custom +contracts. `agents[].alias`, Tess, and Ultron are display names and cannot select a class. + +## Validation and authority boundaries + +Semantic validation reads the winning contract and rejects missing, unreadable, or empty files. +Protected authority is derived from canonical class metadata in code, never from role prose. A custom +contract cannot claim merge, validation-certificate, orchestration, lease, or interaction authority. + +Roster v2 also fails closed when a protected class and tool policy do not match after canonicalization, +or when an unprotected class claims a protected tool policy. The legacy `operator-interaction` policy +canonicalizes to `interaction`. + +Role customization does not issue leases, store validation certificates, mutate credentials, or +change lifecycle state. diff --git a/docs/fleet/migration/legacy-class-aliases.md b/docs/fleet/migration/legacy-class-aliases.md new file mode 100644 index 00000000..be9bb3e2 --- /dev/null +++ b/docs/fleet/migration/legacy-class-aliases.md @@ -0,0 +1,40 @@ +# Legacy Fleet Class Aliases + +Fleet class compatibility is intentionally narrow. The shared resolver accepts exactly three legacy +class names and converts them to canonical classes before persona lookup: + +| Legacy value | Canonical value | Migration action | +| ---------------------- | --------------- | ---------------------------------------------------------------------------------------------------------------------- | +| `implementer` | `code` | Replace class and tool-policy references with `code`. | +| `reviewer` | `review` | Replace class and tool-policy references with `review`. | +| `operator-interaction` | `interaction` | Replace class and roster-v2 tool-policy references with `interaction`. The legacy service artifact remains compatible. | + +Alias support preserves existing inputs while provisioning and typed semantic output use canonical +identities. Requested and canonical class values remain separately observable during semantic +validation. + +## Lookup and override behavior + +Canonicalization precedes baseline and `roles.local` lookup. A legacy-named override such as +`roles.local/implementer.md` is not a separate authority and is not selected for an `implementer` +request. Customize the canonical role instead, for example `roles.local/code.md`. + +The compatibility file `operator-interaction.md` remains shipped, but `interaction` is the canonical +role class. Tess is an example display name only. + +## Unresolved and custom classes + +No names are inferred from historical usage, instance names, or similar wording. `worker`, `analyst`, +`canary`, Tess, and Ultron are not aliases. An otherwise unknown class is accepted only if the shared +resolver can read an actual baseline or `roles.local` contract for that exact class. A `LIBRARY.md` +row without a readable contract fails semantic validation. + +Custom classes receive no protected authority implicitly. Protected class/tool-policy mismatches +fail closed. + +## Retirement guidance + +New configuration should emit canonical values. Existing inputs may use the three aliases during the +compatibility period, but operators should migrate class and tool-policy fields together. Do not +create new legacy-named role overrides; move their intended content to the canonical filename and +validate the roster/profile before removing the old artifact. diff --git a/docs/fleet/reference/role-classes.md b/docs/fleet/reference/role-classes.md new file mode 100644 index 00000000..50a59ec8 --- /dev/null +++ b/docs/fleet/reference/role-classes.md @@ -0,0 +1,45 @@ +# Fleet Role Classes and Authority + +A fleet role class is a machine identity resolved from the persona library. Resolution uses the +canonical class before consulting the baseline `fleet/roles/` and operator `fleet/roles.local/` +layers. A readable role contract is required; an index entry alone is not semantic success. + +## Canonicalization + +Only these legacy class aliases are recognized: + +| Requested class | Canonical class | +| ---------------------- | --------------- | +| `implementer` | `code` | +| `reviewer` | `review` | +| `operator-interaction` | `interaction` | + +No other alias is inferred. In particular, `worker`, `analyst`, and `canary` are custom classes only +when an operator supplies a readable contract for that exact class. Tess and Ultron are instance +names, not classes. `agents[].alias` is display-only and cannot grant authority. + +Canonicalization happens before role lookup. For example, requesting `implementer` resolves +`code.md`; a separate `roles.local/implementer.md` cannot redefine the legacy alias. A canonical +`roles.local/code.md` still overrides the baseline `roles/code.md` contract. + +## Protected authority + +Protected authority is immutable metadata derived only from canonical class. Role prose, instance +name, display alias, tool policy, runtime, and custom role files cannot grant it. + +| Canonical class | Granted authority | Explicit limits | +| ----------------- | -------------------------------------------------- | --------------------------------------------------------------------------------- | +| `merge-gate` | Sole approve-to-land and merge authority | No authority is inferred by similarly named custom roles or policies. | +| `validator` | May issue a validation certificate | Cannot approve-to-land or merge. | +| `orchestrator` | May orchestrate, manage topology, and issue leases | Cannot approve-to-land or merge. | +| `team-leader` | May use orchestrator-leased capacity | Cannot issue leases or mutate roster, configuration, credentials, or merge state. | +| `interaction` | Request and status surface | Cannot orchestrate, issue leases, mutate roster/configuration, or merge. | +| all other classes | No protected authority implicitly | Custom contracts do not acquire protected powers from prose. | + +Roster-v2 semantic validation requires a protected class and its canonical tool policy to match. It +also rejects an unprotected class paired with a protected tool policy. The legacy tool-policy name +`operator-interaction` canonicalizes to `interaction`. + +This mapping describes authority metadata only. Lease issuance, validation-certificate storage or +workflow, lifecycle reconciliation, credentials, roster mutation, and merge execution are outside +this resolver contract. diff --git a/docs/fleet/reference/roster-v2-fields.md b/docs/fleet/reference/roster-v2-fields.md index 0e92d6cb..13179ae5 100644 --- a/docs/fleet/reference/roster-v2-fields.md +++ b/docs/fleet/reference/roster-v2-fields.md @@ -81,6 +81,35 @@ agents: | `agents[].lifecycle.desired_state` | yes | `running` or `stopped` | | `agents[].launch.yolo` | yes | boolean; structured data only, not an arbitrary command escape hatch | +## Semantic handoff + +`parseRosterV2` and `normalizeRosterV2` remain synchronous and structural. After structural success, +call the asynchronous `validateRosterV2Semantics` handoff before using persona identity or authority. +That validator batches the baseline `fleet/roles/` and operator `fleet/roles.local/` scans, then +delegates every agent to the shared persona resolver. + +Semantic validation: + +- requires the winning role contract to be readable and non-empty; `LIBRARY.md` membership alone does + not resolve a class; +- retains `requestedClass` separately from `canonicalClass` in typed output; +- canonicalizes only `implementer` to `code`, `reviewer` to `review`, and + `operator-interaction` to `interaction`; +- canonicalizes `tool_policy` with the same exact alias table; +- rejects protected class/tool-policy mismatches in either direction, while accepting + `class: operator-interaction` with `tool_policy: operator-interaction` as canonical + `interaction`; +- derives immutable protected authority only from canonical class; and +- accepts custom baseline or `roles.local` classes without granting protected authority. + +`agents[].alias` remains display-only. Tess and Ultron are instance names, never semantic classes. +Canonicalization happens before role-layer lookup, so a legacy-named override cannot redefine an +alias as separate authority. See [Role Classes and Authority](./role-classes.md) and +[Customize Fleet Roles](../how-to/customize-roles.md). + +This handoff performs no filesystem, systemd, tmux, roster, credential, lease, certificate, or +lifecycle mutation. + ## Fail-closed boundary Every object is `additionalProperties: false`. The compiler rejects unknown, missing, malformed, diff --git a/docs/scratchpads/758-fcm-m1-002-shared-role-resolution.md b/docs/scratchpads/758-fcm-m1-002-shared-role-resolution.md new file mode 100644 index 00000000..5c6943a1 --- /dev/null +++ b/docs/scratchpads/758-fcm-m1-002-shared-role-resolution.md @@ -0,0 +1,148 @@ +# FCM-M1-002 — Shared role resolution + +- **Task:** `FCM-M1-002` +- **Issue:** `mosaicstack/stack#758` +- **Branch:** `feat/758-shared-role-resolution` +- **Starting head:** `32e75c67b094de443d37fe7d5ff8d25cdfc8b39d` +- **Role:** implementation worker; independent review and merge remain outside this worker + +## Objective + +Reuse the existing baseline-plus-`roles.local` persona resolver as the sole class authority for roster-v2 semantics, profile validation, provisioning, and launch/persona resolution. Add exact approved alias canonicalization, fail-closed semantic validation, immutable canonical-class authority contracts, required baseline roles, and operator documentation without implementing lifecycle, mutation, credentials, certificate workflow, or later FCM cards. + +## Budget + +- Soft budget: **25K tokens**. +- Strategy: inspect once, implement in small TDD units, run focused suites before the full package gate, and avoid unrelated refactors or M1-003/M2/M4 scope. + +## Plan + +1. Map the existing persona resolver, roster-v2 compiler, profile/provision consumers, launch resolution, role library, and focused tests. +2. Write denial/invariant tests first for aliases, canonicalization-before-override, unreadable roles, authority boundaries, policy mismatch, canonical provision output, and resolver parity. +3. Run the focused suites and record the expected red evidence. +4. Implement one shared canonical resolution and authority contract in/through `fleet-personas.ts`; delegate roster semantic validation and profile/provision paths to it. +5. Add baseline `validator`, `team-leader`, and `interaction` role contracts plus `LIBRARY.md` entries while retaining `operator-interaction` compatibility. +6. Add the required role reference, alias migration, customization guide, and roster-v2 semantic handoff documentation. +7. Run focused tests, the full `@mosaicstack/mosaic` suite, typecheck, lint, Prettier, `git diff --check`, situational verification, independent code/security review, and remediation. +8. Commit with the required co-author trailer, run the CI queue guard, push the existing branch, and create/update exactly one PR to `main` with `Refs #758`. + +## TDD evidence + +### Red + +After installing worktree-local dependencies and building `@mosaicstack/db`, the pre-implementation +focused run collected the intended tests and failed as expected: + +```text +2 test files failed; 32 tests failed; 32 tests passed +``` + +Expected failures named the missing `canonicalizeRoleClass`, +`authorityForCanonicalClass`, and `validateRosterV2Semantics` APIs, absent requested/canonical typed +output, and unresolved required canonical role contracts. An earlier run that failed before test +collection on an unresolved `yaml` dependency was treated as environment setup, not TDD evidence. + +### Green + +Focused role-resolution and affected service fixtures: + +```text +6 test files passed; 109 tests passed +``` + +The focused set covers personas, profiles, provision, launch persona contract, roster-v2 semantics, +and the operator-interaction service fixture. The final profile tests also cover readable lead/floor +compatibility and canonical collision denial. + +## Tests and gates + +- Focused suites: pass, **6 files / 109 tests**. +- Full `@mosaicstack/mosaic` suite: pass, **50 files / 713 tests**. Workspace package build outputs + were prepared first because a clean worktree has no dependency `dist` entries. +- `pnpm --filter @mosaicstack/mosaic typecheck`: pass. +- `pnpm --filter @mosaicstack/mosaic lint`: pass. +- Prettier check over every changed file: pass. +- `git diff --check`: pass. +- Runtime/file-boundary evidence: real role library, profile/provision filesystem integration, + launch-time synchronous contract injection, v1 roster parser round-trip, roster-v2 semantic + filesystem checks, and operator-interaction service fixtures all pass without live mutation. +- Independent code review: **APPROVE**, no blocking or non-blocking findings; reviewed complete + tracked/untracked delta including the canonical collision guard. Residual: roster-v2 semantic + validation is an explicit async handoff with production caller wiring owned by later work. +- Independent security review: **APPROVE**, no verified authority/security findings on the final + delta. + +### Post-PR fail-closed remediation + +Independent rereview found resolver fail-open edges that the original green PR head did not cover. The +remediation remained uncommitted until every finding was reproduced red-first and the same reviewer +approved the complete two-file delta. + +Final regression evidence: + +```text +persona resolver: 47/47 +focused affected suites: 6 files / 138 tests +root-container resolver suites: 86/86 +full canonical run: 42/42 Turbo tasks; Mosaic 50 files / 733 tests +``` + +DB migration, typecheck, lint, Prettier, and `git diff --check` also passed. Coverage now proves: + +- unreadable, unscannable, direct-dangling, ancestor-dangling, and literal `..` traversal override paths + fail closed across async, sync, listing, and status APIs; +- genuinely missing override directories still permit baseline fallback; +- cached missing scans are revalidated before fallback; +- marker-defined identity and domain metadata are revalidated on the second read; +- `LIBRARY.md` rows and incidental later markers cannot define, shadow, or advertise personas. + +Exact-head pipeline `1819` passed for rebased head `4d990eee…`, but the independent reviewer-of-record +returned **REQUEST CHANGES** after reproducing three additional edge failures: a canonical filename could +inherit protected authority despite a conflicting explicit first marker, cached `scanned` absence could +miss an override created before baseline fallback, and inherited plain-object names such as `constructor` +could corrupt alias/authority lookup. Merge remained held. + +Each failure was reproduced red-first in the persona suite (4 failing assertions), then remediated without +expanding card scope. Explicit first markers now own identity and filename fallback applies only to +markerless contracts; every second read rejects a newly introduced conflicting marker regardless of +cached classification; cached async resolution re-scans the override layer immediately before every +baseline fallback; alias and authority registries require own-property matches. Current uncommitted +evidence is persona **52/52**, focused affected suites **6 files / 143 tests**, and full Mosaic package +**50 files / 738 tests**, plus typecheck, lint, Prettier, and `git diff --check`. Independent +finding-specific rereview **APPROVED** the complete uncommitted three-file remediation after direct +adversarial reproduction of all three findings and the follow-up markerless TOCTOU. All post-commit +exact-head gates remain required. + +## Risks and boundaries + +- **Security-sensitive authority:** authority must derive only from canonical class, never role prose, aliases, display names, or tool-policy text. +- **Resolver divergence:** no second regex, registry, scanner, or prose parser may be introduced. +- **Alias capture:** aliases must canonicalize before baseline/`roles.local` lookup so local files cannot redefine legacy aliases as separate authority. +- **Readable persona requirement:** semantic success requires a resolved readable persona, not class-set membership. +- **Scope control:** no roster mutation, lifecycle, lease issuance, certificate workflow/storage, credentials, remote reconciliation, provision-v2 conversion, or shipped-example disposition execution. +- **Coordination:** `docs/TASKS.md` is read-only and remains orchestrator-owned. + +## Acceptance-evidence mapping + +| Requirement / criterion | Verification evidence | +| --- | --- | +| `FCM-REQ-02` shared semantic resolver | Async/sync resolver parity; roster-v2 delegates batched scans and resolution; profiles/provision and launch reuse `fleet-personas.ts`; no second scanner or class-marker regex added. | +| `FCM-REQ-07` canonical classes and authority boundaries | Exact alias and non-alias tests; immutable authority invariant tests; all required canonical contracts resolve through the real role library. | +| `AC-FCM-01` structural + semantic roster validation | Synchronous parser/normalizer tests remain intact; async semantic tests cover aliases, custom roles, unreadable/unresolved roles, `LIBRARY`-only rejection, and bidirectional protected policy mismatch. | +| `AC-FCM-07` protected authority invariants | Denial tests prove merge-gate-only merge, validator certificate-only, orchestrator/team-leader/interaction limits, no implicit custom-role authority, and canonical tool-policy matching. | + +## Documentation + +- `docs/fleet/reference/role-classes.md` +- `docs/fleet/migration/legacy-class-aliases.md` +- `docs/fleet/how-to/customize-roles.md` +- `docs/fleet/reference/roster-v2-fields.md` semantic handoff +- Baseline role contracts and `LIBRARY.md` rows for `validator`, `team-leader`, and `interaction` + +## Residual risks + +- Provisioning remains intentionally v1 and does not emit `reports_to`; canonical topology is retained + in its typed seat/summary path only, matching the existing v1 parser boundary. +- Alias support remains for compatibility; new configuration should emit canonical identities. +- This card defines authority metadata and validation only. Enforcement workflows for leases, + certificates, lifecycle, and mutation remain owned by later FCM cards. diff --git a/packages/mosaic/framework/fleet/roles/LIBRARY.md b/packages/mosaic/framework/fleet/roles/LIBRARY.md index b605909e..59d2440b 100644 --- a/packages/mosaic/framework/fleet/roles/LIBRARY.md +++ b/packages/mosaic/framework/fleet/roles/LIBRARY.md @@ -12,19 +12,22 @@ on demand. Engineering personas have no explicit `domain:` marker (they are the implicit `engineering` domain); cross-domain personas carry a `domain:` key in their intro so tooling can group them. -> This file is an index only — no code imports it. To add a persona, drop a new -> `*.md` next to the others (mirroring the existing structure) and add a row here. +> This file is an index, not an authority source. The fleet persona resolver reads +> its rows for discovery compatibility, then requires a readable `*.md` contract; +> authority is derived from canonical class metadata in code, never from this prose. ## engineering | Persona | Purpose | | --------------- | ------------------------------------------------------------------------------ | | orchestrator | Always-on coordinator — runs the supervisor loop, dispatches ready work | +| team-leader | Coordinates only orchestrator-leased capacity for one bounded project | | board | Multi-lens deliberation panel; owns the mission's direction, not its execution | | planner | Turns ratified objectives into a phased FR plan wired into a `depends_on` DAG | | decomposition | Splits FRs into one-PR-each cards wired with `depends_on` edges | | code | Primary executor — one card, one branch, one PR to green CI | | review | Correctness reviewer — judges an open PR on correctness, scope, and coverage | +| validator | Independent final evidence certificate; never approves-to-land or merges | | security-review | Second line of review — secrets, auth, and forbidden-path safety | | site-tester | Runtime verifier — runs the change and checks behavior vs. acceptance criteria | | documentation | Prose maintainer — keeps human-facing docs and projections in sync | @@ -33,6 +36,7 @@ their intro so tooling can group them. | operator | Escalation and control surface — owns exceptions and the fleet pause switch | | session-review | Post-task retrospective — turns finished work into improvement signals | | enhancer | Continuous-improvement loop — upgrades the fleet's tools, skills, and harness | +| interaction | Operator request/status surface; routes orchestration and merge decisions | ## executive diff --git a/packages/mosaic/framework/fleet/roles/interaction.md b/packages/mosaic/framework/fleet/roles/interaction.md new file mode 100644 index 00000000..8346ee0f --- /dev/null +++ b/packages/mosaic/framework/fleet/roles/interaction.md @@ -0,0 +1,16 @@ +# Interaction — fleet role definition + +The **interaction** role (`class: interaction`) is the operator-facing request and status surface for Mosaic. + +## Mandate + +1. Receive operator requests and present observable fleet or runtime status. +2. Route orchestration requests to the orchestrator and merge decisions to the merge-gate. +3. Report supported actions and their outcomes without claiming another role's authority. + +## Boundaries + +- Request/status only; it does not orchestrate, issue leases, approve-to-land, or merge. +- It does not mutate roster configuration, role authority, or credentials. +- A configured instance name such as Tess is display data, never a class or authority source. +- `operator-interaction` remains a compatibility alias for this canonical class. diff --git a/packages/mosaic/framework/fleet/roles/team-leader.md b/packages/mosaic/framework/fleet/roles/team-leader.md new file mode 100644 index 00000000..c4ccfcc8 --- /dev/null +++ b/packages/mosaic/framework/fleet/roles/team-leader.md @@ -0,0 +1,16 @@ +# Team leader — fleet role definition + +The **team-leader** (`class: team-leader`) coordinates a bounded project team using only capacity granted by an orchestrator-issued lease. + +## Mandate + +1. Direct the leased coder, reviewer, and validator capacity for the assigned project scope. +2. Track delivery status and return results or blockers to the orchestrator. +3. Stop using capacity when the lease or assignment ends. + +## Boundaries + +- Leased capacity only; this role does not issue or expand its own lease. +- It cannot change fleet roster membership, role authority, fleet configuration, or credentials. +- It cannot approve-to-land or merge. +- It does not displace the orchestrator's topology and lease authority. diff --git a/packages/mosaic/framework/fleet/roles/validator.md b/packages/mosaic/framework/fleet/roles/validator.md new file mode 100644 index 00000000..08a092a5 --- /dev/null +++ b/packages/mosaic/framework/fleet/roles/validator.md @@ -0,0 +1,16 @@ +# Validator — fleet role definition + +The **validator** (`class: validator`) is the independent final evidence seat. It examines the accepted requirements, test evidence, review record, and candidate head and may issue a validation certificate for that exact evidence set. + +## Mandate + +1. Validate acceptance evidence independently from the implementation author. +2. Issue or withhold a final validation certificate for the reviewed candidate. +3. Report missing, stale, or contradictory evidence without altering it. + +## Boundaries + +- **Certificate only:** the validator does not approve-to-land or merge. +- It does not replace correctness or security review. +- It does not write product code, mutate the roster, issue leases, or access credentials. +- A configured instance name such as Ultron is display data, never a class or authority source. diff --git a/packages/mosaic/src/commands/fleet-personas.spec.ts b/packages/mosaic/src/commands/fleet-personas.spec.ts index 7421d685..2b3eadcd 100644 --- a/packages/mosaic/src/commands/fleet-personas.spec.ts +++ b/packages/mosaic/src/commands/fleet-personas.spec.ts @@ -1,13 +1,18 @@ -import { cp, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { cp, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { dirname, join, resolve } from 'node:path'; +import { dirname, join, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { + authorityForCanonicalClass, + canonicalizeRoleClass, extractClassesFromDir, + extractClassesFromDirSync, listPersonaClasses, personaStatus, resolvePersona, + resolvePersonaFrom, + resolvePersonaSync, } from './fleet-personas.js'; import { loadProfiles, validateProfile, type FleetProfile } from './fleet-profiles.js'; @@ -54,13 +59,156 @@ afterEach(async () => { await rm(tmp, { recursive: true, force: true }); }); +describe('FCM class canonicalization and authority', () => { + it.each([ + ['implementer', 'code'], + ['reviewer', 'review'], + ['operator-interaction', 'interaction'], + ])('canonicalizes the approved alias %s to %s', (requested: string, canonical: string) => { + expect(canonicalizeRoleClass(requested)).toEqual({ + requestedClass: requested, + canonicalClass: canonical, + }); + }); + + it.each(['worker', 'analyst', 'canary', 'tess', 'ultron', 'constructor'])( + 'does not infer an alias for %s', + (requested: string) => { + expect(canonicalizeRoleClass(requested)).toEqual({ + requestedClass: requested, + canonicalClass: requested, + }); + }, + ); + + it('resolves inherited-property class names without granting protected authority', async () => { + await mkdir(overrideDir, { recursive: true }); + await writeFile( + join(overrideDir, 'constructor.md'), + overridePersona('constructor', 'custom'), + 'utf8', + ); + + const resolved = await resolvePersona('constructor', { rolesDir, overrideDir }); + expect(resolved).toMatchObject({ + requestedClass: 'constructor', + canonicalClass: 'constructor', + klass: 'constructor', + layer: 'override', + }); + expect(authorityForCanonicalClass('constructor')).toMatchObject({ + mayMerge: false, + mayIssueValidationCertificate: false, + mayOrchestrate: false, + }); + }); + + it('canonicalizes before override lookup and lets the canonical override win', async () => { + await mkdir(overrideDir, { recursive: true }); + await writeFile( + join(overrideDir, 'implementer.md'), + overridePersona('implementer', 'legacy'), + 'utf8', + ); + await writeFile( + join(overrideDir, 'code.md'), + overridePersona('code', 'engineering', 'CANONICAL-OVERRIDE'), + 'utf8', + ); + + const resolved = await resolvePersona('implementer', { rolesDir, overrideDir }); + expect(resolved).toMatchObject({ + requestedClass: 'implementer', + canonicalClass: 'code', + klass: 'code', + layer: 'override', + }); + expect(resolved?.content).toContain('CANONICAL-OVERRIDE'); + expect(resolved?.content).not.toContain('legacy'); + }); + + it('keeps sync and async resolution equivalent for aliases and canonical overrides', async () => { + await mkdir(overrideDir, { recursive: true }); + await writeFile( + join(overrideDir, 'code.md'), + overridePersona('code', 'engineering', 'CANONICAL-OVERRIDE'), + 'utf8', + ); + + const asyncResolution = await resolvePersona('implementer', { rolesDir, overrideDir }); + const syncResolution = resolvePersonaSync('implementer', { rolesDir, overrideDir }); + expect(syncResolution).toEqual(asyncResolution); + }); + + it('derives immutable protected authority only from the canonical class', () => { + expect(authorityForCanonicalClass('merge-gate')).toMatchObject({ mayMerge: true }); + for (const klass of [ + 'validator', + 'orchestrator', + 'team-leader', + 'interaction', + 'code', + 'custom-role', + ]) { + expect(authorityForCanonicalClass(klass).mayMerge).toBe(false); + } + expect(authorityForCanonicalClass('validator')).toMatchObject({ + mayIssueValidationCertificate: true, + mayMerge: false, + }); + expect(authorityForCanonicalClass('orchestrator')).toMatchObject({ + mayOrchestrate: true, + mayIssueLeases: true, + mayMerge: false, + }); + expect(authorityForCanonicalClass('team-leader')).toMatchObject({ + leasedCapacityOnly: true, + mayMutateRoster: false, + mayAccessCredentials: false, + mayMerge: false, + }); + expect(authorityForCanonicalClass('interaction')).toMatchObject({ + requestStatusOnly: true, + mayOrchestrate: false, + mayMerge: false, + }); + expect(Object.isFrozen(authorityForCanonicalClass('merge-gate'))).toBe(true); + }); +}); + +describe('required FCM role library', () => { + it.each([ + 'code', + 'review', + 'validator', + 'orchestrator', + 'team-leader', + 'enhancer', + 'interaction', + 'merge-gate', + ])('resolves %s through the real framework role library', async (klass: string) => { + const resolved = await resolvePersona(klass, { + rolesDir: realRolesDir, + overrideDir: join(tmp, 'none'), + }); + expect(resolved).not.toBeNull(); + expect(resolved?.canonicalClass).toBe(klass); + expect(resolved?.content.trim()).not.toBe(''); + }); +}); + describe('extractClassesFromDir (shared extraction)', () => { - it('records class + domain from inline markers and degrades on missing dir', async () => { + it('records class + domain and distinguishes scanned from missing directories', async () => { const base = await extractClassesFromDir(rolesDir); + expect(base.scanState).toBe('scanned'); expect(base.classes.has('ceo')).toBe(true); expect(base.byClass.get('ceo')?.domain).toBe('executive'); - const missing = await extractClassesFromDir(join(tmp, 'nope')); + + const missingDir = join(tmp, 'nope'); + const missing = await extractClassesFromDir(missingDir); + expect(missing.scanState).toBe('missing'); expect(missing.classes.size).toBe(0); + expect(extractClassesFromDirSync(missingDir).scanState).toBe('missing'); }); }); @@ -81,6 +229,287 @@ describe('resolvePersona — override wins', () => { expect(resolved?.content).toContain('BASELINE'); }); + it('does not let a LIBRARY-only row shadow a readable baseline persona', async () => { + await mkdir(overrideDir, { recursive: true }); + await writeFile( + join(overrideDir, 'LIBRARY.md'), + '| Persona | Purpose |\n| --- | --- |\n| code | Index only |\n', + 'utf8', + ); + + const resolved = await resolvePersona('code', { rolesDir, overrideDir }); + expect(resolved?.layer).toBe('baseline'); + expect(resolved?.content).toContain('BASELINE'); + expect((await listPersonaClasses({ rolesDir, overrideDir })).has('code')).toBe(true); + expect( + (await personaStatus({ rolesDir, overrideDir })).find(({ klass }) => klass === 'code') + ?.status, + ).toBe('baseline'); + }); + + it('does not let an incidental later class marker shadow a readable baseline persona', async () => { + await mkdir(overrideDir, { recursive: true }); + await writeFile( + join(overrideDir, 'mascot.md'), + '# mascot\n\n(`class: mascot`)\n\nSee also (`class: code`).\n', + 'utf8', + ); + + const resolved = await resolvePersona('code', { rolesDir, overrideDir }); + expect(resolved?.layer).toBe('baseline'); + expect(resolved?.content).toContain('BASELINE'); + expect((await listPersonaClasses({ rolesDir, overrideDir })).has('code')).toBe(true); + expect((await listPersonaClasses({ rolesDir, overrideDir })).has('mascot')).toBe(true); + expect( + (await personaStatus({ rolesDir, overrideDir })).find(({ klass }) => klass === 'code') + ?.status, + ).toBe('baseline'); + }); + + it('does not advertise an incidental-only class through listing or status APIs', async () => { + await mkdir(overrideDir, { recursive: true }); + await writeFile( + join(overrideDir, 'mascot.md'), + '# mascot\n\n(`class: mascot`)\n\nSee also (`class: phantom`).\n', + 'utf8', + ); + + expect((await listPersonaClasses({ rolesDir, overrideDir })).has('phantom')).toBe(false); + expect( + (await personaStatus({ rolesDir, overrideDir })).some(({ klass }) => klass === 'phantom'), + ).toBe(false); + }); + + it('rejects a canonical filename whose explicit marker names another class', async () => { + await mkdir(overrideDir, { recursive: true }); + await writeFile(join(overrideDir, 'merge-gate.md'), overridePersona('mascot', 'fun'), 'utf8'); + await writeFile( + join(rolesDir, 'merge-gate.md'), + baselinePersona('merge-gate', 'governance'), + 'utf8', + ); + + expect(await resolvePersona('merge-gate', { rolesDir, overrideDir })).toBeNull(); + expect(resolvePersonaSync('merge-gate', { rolesDir, overrideDir })).toBeNull(); + expect((await listPersonaClasses({ rolesDir, overrideDir })).has('merge-gate')).toBe(false); + expect( + (await personaStatus({ rolesDir, overrideDir })).some(({ klass }) => klass === 'merge-gate'), + ).toBe(false); + }); + + it('fails closed if a marker-defined override becomes unreadable after extraction', async () => { + await mkdir(overrideDir, { recursive: true }); + const overrideFile = join(overrideDir, 'engineering.md'); + await writeFile(overrideFile, overridePersona('code', 'engineering'), 'utf8'); + const [base, over] = await Promise.all([ + extractClassesFromDir(rolesDir), + extractClassesFromDir(overrideDir), + ]); + await rm(overrideFile); + await mkdir(overrideFile); + + expect(await resolvePersonaFrom('code', { rolesDir, overrideDir, base, over })).toBeNull(); + }); + + it('fails closed if a marker-defined override changes identity after extraction', async () => { + await mkdir(overrideDir, { recursive: true }); + const overrideFile = join(overrideDir, 'engineering.md'); + await writeFile(overrideFile, overridePersona('code', 'engineering'), 'utf8'); + const [base, over] = await Promise.all([ + extractClassesFromDir(rolesDir), + extractClassesFromDir(overrideDir), + ]); + await writeFile(overrideFile, overridePersona('mascot', 'fun'), 'utf8'); + + expect(await resolvePersonaFrom('code', { rolesDir, overrideDir, base, over })).toBeNull(); + + const classes = await listPersonaClasses({ rolesDir, overrideDir }); + expect(classes.has('code')).toBe(true); + expect(classes.has('mascot')).toBe(true); + const status = new Map( + (await personaStatus({ rolesDir, overrideDir })).map((entry) => [entry.klass, entry]), + ); + expect(status.get('code')?.status).toBe('baseline'); + expect(status.get('mascot')?.status).toBe('custom'); + }); + + it('fails closed if a markerless cached override gains a conflicting marker', async () => { + await mkdir(overrideDir, { recursive: true }); + const overrideFile = join(overrideDir, 'merge-gate.md'); + await writeFile(overrideFile, '# markerless merge gate\n', 'utf8'); + await writeFile( + join(rolesDir, 'merge-gate.md'), + baselinePersona('merge-gate', 'governance'), + 'utf8', + ); + const [base, over] = await Promise.all([ + extractClassesFromDir(rolesDir), + extractClassesFromDir(overrideDir), + ]); + await writeFile(overrideFile, overridePersona('mascot', 'fun'), 'utf8'); + + expect( + await resolvePersonaFrom('merge-gate', { rolesDir, overrideDir, base, over }), + ).toBeNull(); + }); + + it('fails closed asynchronously when the override directory cannot be scanned', async () => { + await writeFile(overrideDir, 'not a directory', 'utf8'); + expect(await resolvePersona('code', { rolesDir, overrideDir })).toBeNull(); + }); + + it('fails closed synchronously when the override directory cannot be scanned', async () => { + await writeFile(overrideDir, 'not a directory', 'utf8'); + expect(resolvePersonaSync('code', { rolesDir, overrideDir })).toBeNull(); + }); + + it('fails closed asynchronously and synchronously for a dangling override-directory symlink', async () => { + await symlink(join(tmp, 'missing-override-target'), overrideDir, 'dir'); + + expect(await resolvePersona('code', { rolesDir, overrideDir })).toBeNull(); + expect(resolvePersonaSync('code', { rolesDir, overrideDir })).toBeNull(); + }); + + it('fails closed asynchronously and synchronously when an override ancestor is a dangling symlink', async () => { + const danglingAncestor = join(tmp, 'dangling-ancestor'); + await symlink(join(tmp, 'missing-ancestor-target'), danglingAncestor, 'dir'); + overrideDir = join(danglingAncestor, 'nested', 'roles.local'); + + expect(await resolvePersona('code', { rolesDir, overrideDir })).toBeNull(); + expect(resolvePersonaSync('code', { rolesDir, overrideDir })).toBeNull(); + expect(await listPersonaClasses({ rolesDir, overrideDir })).toEqual(new Set()); + expect(await personaStatus({ rolesDir, overrideDir })).toEqual([]); + }); + + it('fails closed when dot-dot traversal crosses a dangling override ancestor', async () => { + const danglingAncestor = join(tmp, 'dangling-dotdot-ancestor'); + await symlink(join(tmp, 'missing-dotdot-target'), danglingAncestor, 'dir'); + overrideDir = `${danglingAncestor}/../roles.local`; + + expect(await resolvePersona('code', { rolesDir, overrideDir })).toBeNull(); + expect(resolvePersonaSync('code', { rolesDir, overrideDir })).toBeNull(); + expect(await listPersonaClasses({ rolesDir, overrideDir })).toEqual(new Set()); + expect(await personaStatus({ rolesDir, overrideDir })).toEqual([]); + }); + + it('fails closed when relative dot-dot traversal crosses a dangling override ancestor', async () => { + const danglingAncestor = join(tmp, 'relative-dangling-ancestor'); + await symlink(join(tmp, 'missing-relative-target'), danglingAncestor, 'dir'); + overrideDir = `${relative(process.cwd(), danglingAncestor)}/../roles.local`; + expect(overrideDir.startsWith('/')).toBe(false); + + expect(await resolvePersona('code', { rolesDir, overrideDir })).toBeNull(); + expect(resolvePersonaSync('code', { rolesDir, overrideDir })).toBeNull(); + expect(await listPersonaClasses({ rolesDir, overrideDir })).toEqual(new Set()); + expect(await personaStatus({ rolesDir, overrideDir })).toEqual([]); + }); + + it('revalidates a cached missing override before baseline fallback', async () => { + const cachedOverrideDir = join(tmp, 'mutable-ancestor', 'roles.local'); + const [base, over] = await Promise.all([ + extractClassesFromDir(rolesDir), + extractClassesFromDir(cachedOverrideDir), + ]); + expect(over.scanState).toBe('missing'); + await symlink(join(tmp, 'missing-mutable-target'), join(tmp, 'mutable-ancestor'), 'dir'); + + expect( + await resolvePersonaFrom('code', { + rolesDir, + overrideDir: cachedOverrideDir, + base, + over, + }), + ).toBeNull(); + }); + + it('revalidates a cached scanned override before baseline fallback', async () => { + await mkdir(overrideDir, { recursive: true }); + const [base, over] = await Promise.all([ + extractClassesFromDir(rolesDir), + extractClassesFromDir(overrideDir), + ]); + expect(over.scanState).toBe('scanned'); + expect(over.classes.size).toBe(0); + + await writeFile(join(overrideDir, 'engineering.md'), overridePersona('code', 'engineering')); + + const resolved = await resolvePersonaFrom('code', { + rolesDir, + overrideDir, + base, + over, + }); + expect(resolved?.layer).toBe('override'); + expect(resolved?.file).toBe(join(overrideDir, 'engineering.md')); + }); + + it('falls back to baseline asynchronously and synchronously when override directory is missing', async () => { + const asyncResolution = await resolvePersona('code', { rolesDir, overrideDir }); + const syncResolution = resolvePersonaSync('code', { rolesDir, overrideDir }); + expect(asyncResolution?.layer).toBe('baseline'); + expect(syncResolution?.layer).toBe('baseline'); + }); + + it('fails closed asynchronously when the canonical override exists but is unreadable', async () => { + await mkdir(overrideDir, { recursive: true }); + await mkdir(join(overrideDir, 'code.md')); + + expect(await resolvePersona('code', { rolesDir, overrideDir })).toBeNull(); + }); + + it('does not advertise a shadowed baseline whose canonical override is unreadable', async () => { + await mkdir(overrideDir, { recursive: true }); + await mkdir(join(overrideDir, 'code.md')); + + expect((await listPersonaClasses({ rolesDir, overrideDir })).has('code')).toBe(false); + expect( + (await personaStatus({ rolesDir, overrideDir })).some(({ klass }) => klass === 'code'), + ).toBe(false); + }); + + it('does not advertise baseline personas when the override directory is unscannable', async () => { + await writeFile(overrideDir, 'not a directory', 'utf8'); + + expect(await listPersonaClasses({ rolesDir, overrideDir })).toEqual(new Set()); + expect(await personaStatus({ rolesDir, overrideDir })).toEqual([]); + }); + + it('does not advertise baseline personas through a dangling override-directory symlink', async () => { + await symlink(join(tmp, 'missing-override-target'), overrideDir, 'dir'); + + expect(await listPersonaClasses({ rolesDir, overrideDir })).toEqual(new Set()); + expect(await personaStatus({ rolesDir, overrideDir })).toEqual([]); + }); + + it('preserves baseline listings when the override directory is missing', async () => { + expect((await listPersonaClasses({ rolesDir, overrideDir })).has('code')).toBe(true); + expect( + (await personaStatus({ rolesDir, overrideDir })).find(({ klass }) => klass === 'code') + ?.status, + ).toBe('baseline'); + }); + + it('does not advertise an unreadable custom override as a valid persona class or status', async () => { + await mkdir(overrideDir, { recursive: true }); + await mkdir(join(overrideDir, 'ghost.md')); + + const extracted = await extractClassesFromDir(overrideDir); + expect(extracted.fileStems.has('ghost')).toBe(true); + expect(extracted.classes.has('ghost')).toBe(false); + expect((await listPersonaClasses({ rolesDir, overrideDir })).has('ghost')).toBe(false); + expect( + (await personaStatus({ rolesDir, overrideDir })).some(({ klass }) => klass === 'ghost'), + ).toBe(false); + }); + + it('fails closed synchronously when the canonical override exists but is unreadable', async () => { + await mkdir(overrideDir, { recursive: true }); + await mkdir(join(overrideDir, 'code.md')); + + expect(resolvePersonaSync('code', { rolesDir, overrideDir })).toBeNull(); + }); + it('returns null for an unknown class', async () => { expect(await resolvePersona('does-not-exist', { rolesDir, overrideDir })).toBeNull(); }); diff --git a/packages/mosaic/src/commands/fleet-personas.ts b/packages/mosaic/src/commands/fleet-personas.ts index a65d5fc9..c3fe3fee 100644 --- a/packages/mosaic/src/commands/fleet-personas.ts +++ b/packages/mosaic/src/commands/fleet-personas.ts @@ -25,10 +25,10 @@ * can reference a customized or user-added persona. */ -import { readFileSync, readdirSync } from 'node:fs'; -import { readFile, readdir } from 'node:fs/promises'; +import { lstatSync, readFileSync, readdirSync, statSync } from 'node:fs'; +import { lstat, readFile, readdir, stat } from 'node:fs/promises'; import { homedir } from 'node:os'; -import { basename, join } from 'node:path'; +import { basename, isAbsolute, join, sep } from 'node:path'; import type { Command } from 'commander'; function defaultMosaicHome(): string { @@ -53,52 +53,136 @@ export function defaultOverrideDir(mosaicHome = defaultMosaicHome()): string { const CLASS_MARKER = /`?class:\s*\n?\s*([a-z][a-z0-9-]*)`?/g; /** Optional `domain: Y` marker that travels alongside the class in the prose. */ const DOMAIN_MARKER = /`?domain:\s*\n?\s*([a-z][a-z0-9-]*)`?/; -/** LIBRARY.md persona rows: the first table cell is the persona name. */ -const LIBRARY_ROW = /^\|\s*([a-z][a-z0-9-]*)\s*\|/gm; /** Where a resolved persona's definition came from. */ export type PersonaLayer = 'baseline' | 'override'; +export const ROLE_CLASS_ALIASES = Object.freeze({ + implementer: 'code', + reviewer: 'review', + 'operator-interaction': 'interaction', +} as const); + +export interface CanonicalRoleClass { + readonly requestedClass: string; + readonly canonicalClass: string; +} + +/** Canonicalize only the three explicitly approved compatibility aliases. */ +export function canonicalizeRoleClass(requestedClass: string): CanonicalRoleClass { + const requested = requestedClass.trim(); + const canonical = Object.hasOwn(ROLE_CLASS_ALIASES, requested) + ? ROLE_CLASS_ALIASES[requested as keyof typeof ROLE_CLASS_ALIASES] + : requested; + return Object.freeze({ requestedClass: requested, canonicalClass: canonical }); +} + +export interface RoleAuthority { + readonly mayMerge: boolean; + readonly mayIssueValidationCertificate: boolean; + readonly mayOrchestrate: boolean; + readonly mayManageTopology: boolean; + readonly mayIssueLeases: boolean; + readonly leasedCapacityOnly: boolean; + readonly requestStatusOnly: boolean; + readonly mayMutateRoster: boolean; + readonly mayMutateConfiguration: boolean; + readonly mayAccessCredentials: boolean; +} + +const NO_PROTECTED_AUTHORITY: RoleAuthority = Object.freeze({ + mayMerge: false, + mayIssueValidationCertificate: false, + mayOrchestrate: false, + mayManageTopology: false, + mayIssueLeases: false, + leasedCapacityOnly: false, + requestStatusOnly: false, + mayMutateRoster: false, + mayMutateConfiguration: false, + mayAccessCredentials: false, +}); + +/** Immutable authority contracts keyed only by canonical class identity. */ +export const ROLE_AUTHORITY_BY_CANONICAL_CLASS: Readonly> = + Object.freeze({ + 'merge-gate': Object.freeze({ ...NO_PROTECTED_AUTHORITY, mayMerge: true }), + validator: Object.freeze({ + ...NO_PROTECTED_AUTHORITY, + mayIssueValidationCertificate: true, + }), + orchestrator: Object.freeze({ + ...NO_PROTECTED_AUTHORITY, + mayOrchestrate: true, + mayManageTopology: true, + mayIssueLeases: true, + }), + 'team-leader': Object.freeze({ ...NO_PROTECTED_AUTHORITY, leasedCapacityOnly: true }), + interaction: Object.freeze({ ...NO_PROTECTED_AUTHORITY, requestStatusOnly: true }), + }); + +/** Return protected authority for an already-canonical class; custom classes get none. */ +export function authorityForCanonicalClass(canonicalClass: string): RoleAuthority { + return Object.hasOwn(ROLE_AUTHORITY_BY_CANONICAL_CLASS, canonicalClass) + ? ROLE_AUTHORITY_BY_CANONICAL_CLASS[canonicalClass]! + : NO_PROTECTED_AUTHORITY; +} + /** One discovered persona file (a single role contract on disk). */ export interface PersonaFile { klass: string; /** The markdown file the class was found in. */ file: string; + /** True when the first class marker, rather than filename, defined this mapping. */ + markerDefined?: boolean; domain?: string; } +export type DirScanState = 'scanned' | 'missing' | 'error'; + /** The set of persona classes a directory of role contracts defines. */ export interface DirClasses { - /** Every class name the dir contributes (markers + filenames + LIBRARY rows). */ + /** Whether the directory was scanned, absent, or present-but-unscannable. */ + scanState: DirScanState; + /** Every readable class name the directory contributes by filename or first marker. */ classes: Set; + /** Filename stems present on disk, retained even when a markdown entry is unreadable. */ + fileStems: Set; /** For classes whose file carries a marker, the file + domain that defined it. */ byClass: Map; } /** - * Scan one directory of role contracts and extract the persona classes it - * defines. THIS is the shared extraction both fleet-personas and fleet-profiles - * rely on. Sources, unioned (each needed — see module doc): - * 1. inline `class: X` markers in roles/*.md (primary; may wrap a newline), - * 2. persona-name cells from LIBRARY.md index tables, - * 3. the role filename stem (covers marker-less alias docs like planner). + * Scan one directory of role contracts and extract readable persona identities. + * Valid identities come only from a successfully read non-LIBRARY filename and + * that file's first `class:` marker. LIBRARY rows and later prose mentions are + * index/reference data, not independently readable role contracts. * - * Missing dir / unreadable files degrade gracefully to whatever was found. - * `byClass` records the defining file+domain for marker-bearing classes so the - * resolver can map a class back to its file; filename-only and LIBRARY-only - * classes still appear in `classes` for membership checks. + * Missing directories are distinguished from present-but-unscannable paths. + * `byClass` records the first marker-defined file+domain so the resolver can map + * a class back to its contract; marker-less readable files map by filename. */ export async function extractClassesFromDir(dir: string): Promise { - const acc: DirClasses = { classes: new Set(), byClass: new Map() }; + const acc: DirClasses = { + scanState: 'scanned', + classes: new Set(), + fileStems: new Set(), + byClass: new Map(), + }; let entries: string[]; try { entries = await readdir(dir); - } catch { + } catch (error) { + acc.scanState = await classifyScanFailure(dir, error); return acc; } for (const entry of entries) { if (!entry.endsWith('.md')) continue; + // Preserve entry presence separately from valid readable classes. Resolvers + // use it to fail closed on an explicit unreadable canonical override without + // advertising that override through class listing/status APIs. + if (entry !== 'LIBRARY.md') acc.fileStems.add(basename(entry, '.md')); let text: string; try { text = await readFile(join(dir, entry), 'utf8'); @@ -117,16 +201,25 @@ export async function extractClassesFromDir(dir: string): Promise { * cannot await. Missing dir / unreadable files degrade gracefully. */ export function extractClassesFromDirSync(dir: string): DirClasses { - const acc: DirClasses = { classes: new Set(), byClass: new Map() }; + const acc: DirClasses = { + scanState: 'scanned', + classes: new Set(), + fileStems: new Set(), + byClass: new Map(), + }; let entries: string[]; try { entries = readdirSync(dir); - } catch { + } catch (error) { + acc.scanState = classifyScanFailureSync(dir, error); return acc; } for (const entry of entries) { if (!entry.endsWith('.md')) continue; + // Keep sync extraction equivalent to the async scanner, including unreadable + // filename presence kept separate from valid readable classes. + if (entry !== 'LIBRARY.md') acc.fileStems.add(basename(entry, '.md')); let text: string; try { text = readFileSync(join(dir, entry), 'utf8'); @@ -138,6 +231,74 @@ export function extractClassesFromDirSync(dir: string): DirClasses { return acc; } +async function classifyScanFailure(dir: string, error: unknown): Promise { + if (!isMissingPathError(error)) return 'error'; + return (await hasBrokenSymlinkInPath(dir)) ? 'error' : 'missing'; +} + +function classifyScanFailureSync(dir: string, error: unknown): DirScanState { + if (!isMissingPathError(error)) return 'error'; + return hasBrokenSymlinkInPathSync(dir) ? 'error' : 'missing'; +} + +function traversalPrefixes(path: string): string[] { + const absolute = isAbsolute(path) ? path : `${process.cwd()}${sep}${path}`; + const parts = absolute.split(sep); + const prefixes: string[] = []; + let current: string = sep; + for (const part of parts) { + if (!part) continue; + current = current === sep ? `${sep}${part}` : `${current}${sep}${part}`; + prefixes.push(current); + } + return prefixes; +} + +async function hasBrokenSymlinkInPath(path: string): Promise { + for (const current of traversalPrefixes(path)) { + try { + const entry = await lstat(current); + if (entry.isSymbolicLink()) { + try { + await stat(current); + } catch { + return true; + } + } + } catch (error) { + if (!isMissingPathError(error)) return true; + } + } + return false; +} + +function hasBrokenSymlinkInPathSync(path: string): boolean { + for (const current of traversalPrefixes(path)) { + try { + const entry = lstatSync(current); + if (entry.isSymbolicLink()) { + try { + statSync(current); + } catch { + return true; + } + } + } catch (error) { + if (!isMissingPathError(error)) return true; + } + } + return false; +} + +function isMissingPathError(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as { code?: unknown }).code === 'ENOENT' + ); +} + /** * Apply the class-extraction rules for ONE role file's text into `acc`. Pure * over already-read content, so the async and sync directory scanners share a @@ -146,33 +307,26 @@ export function extractClassesFromDirSync(dir: string): DirClasses { */ function accumulateEntry(acc: DirClasses, dir: string, entry: string, text: string): void { const { classes, byClass } = acc; - if (entry === 'LIBRARY.md') { - for (const m of text.matchAll(LIBRARY_ROW)) { - const name = m[1]; - if (name && name !== 'persona') classes.add(name); - } - return; - } - // The filename stem is itself a valid class (covers marker-less alias docs). + if (entry === 'LIBRARY.md') return; + + // An explicit first marker owns identity. Filename identity is a fallback only + // for markerless compatibility contracts such as planner.md. const stem = basename(entry, '.md'); - classes.add(stem); const domainMatch = DOMAIN_MARKER.exec(text); const domain = domainMatch?.[1]; - let markedClassForFile: string | undefined; - for (const m of text.matchAll(CLASS_MARKER)) { - const klass = m[1]; - if (!klass) continue; - classes.add(klass); - // Record the FIRST marker as the file's defining class (the prose names - // the persona's own class up top; later mentions reference siblings). - if (!markedClassForFile) { - markedClassForFile = klass; - byClass.set(klass, { klass, file: join(dir, entry), ...(domain ? { domain } : {}) }); - } - } - // A marker-less file still maps its stem to itself (no domain known). - if (!markedClassForFile && !byClass.has(stem)) { - byClass.set(stem, { klass: stem, file: join(dir, entry) }); + const firstMarker = text.matchAll(CLASS_MARKER).next().value as RegExpExecArray | undefined; + const markedClassForFile = firstMarker?.[1]; + if (markedClassForFile) { + classes.add(markedClassForFile); + byClass.set(markedClassForFile, { + klass: markedClassForFile, + file: join(dir, entry), + markerDefined: true, + ...(domain ? { domain } : {}), + }); + } else { + classes.add(stem); + if (!byClass.has(stem)) byClass.set(stem, { klass: stem, file: join(dir, entry) }); } } @@ -193,24 +347,19 @@ function resolveDirs(opts: PersonaDirs): { rolesDir: string; overrideDir: string } /** - * UNION of baseline classes and override classes. Overrides may ADD entirely new - * classes not present in the baseline, so callers (e.g. profile roster - * validation) treat a user-added persona as a real class. + * Every class with an actually readable winning contract. Discovery applies the + * same override shadow/fail-closed semantics as resolution, so callers never + * advertise a class that cannot be used. */ export async function listPersonaClasses(opts: PersonaDirs = {}): Promise> { - const { rolesDir, overrideDir } = resolveDirs(opts); - const [base, over] = await Promise.all([ - extractClassesFromDir(rolesDir), - extractClassesFromDir(overrideDir), - ]); - const union = new Set(base.classes); - for (const c of over.classes) union.add(c); - return union; + const { personas } = await collectUsablePersonas(opts); + return new Set(personas.keys()); } export type PersonaStatus = 'baseline' | 'overridden' | 'custom'; -export interface PersonaResolution { +export interface PersonaResolution extends CanonicalRoleClass { + /** Compatibility name for the canonical class. */ klass: string; layer: PersonaLayer; /** The file the resolved persona was read from (override wins). */ @@ -219,6 +368,118 @@ export interface PersonaResolution { domain?: string; } +async function readPersonaFromLayer( + requestedClass: string, + canonicalClass: string, + dir: string, + extracted: DirClasses, + layer: PersonaLayer, +): Promise { + const pf = extracted.byClass.get(canonicalClass); + if (!pf) { + if (!extracted.classes.has(canonicalClass)) return null; + const byName = join(dir, `${canonicalClass}.md`); + try { + const content = await readFile(byName, 'utf8'); + const currentMarker = content.matchAll(CLASS_MARKER).next().value as + | RegExpExecArray + | undefined; + if (currentMarker && currentMarker[1] !== canonicalClass) return null; + const dm = DOMAIN_MARKER.exec(content); + return { + requestedClass, + canonicalClass, + klass: canonicalClass, + layer, + file: byName, + content, + ...(dm?.[1] ? { domain: dm[1] } : {}), + }; + } catch { + return null; + } + } + try { + const content = await readFile(pf.file, 'utf8'); + const currentMarker = content.matchAll(CLASS_MARKER).next().value as + | RegExpExecArray + | undefined; + if (currentMarker && currentMarker[1] !== canonicalClass) return null; + const dm = DOMAIN_MARKER.exec(content); + return { + requestedClass, + canonicalClass, + klass: canonicalClass, + layer, + file: pf.file, + content, + ...(dm?.[1] ? { domain: dm[1] } : {}), + }; + } catch { + return null; + } +} + +function overrideShadowsBaseline(over: DirClasses, canonicalClass: string): boolean { + return ( + over.scanState === 'error' || + over.fileStems.has(canonicalClass) || + over.byClass.has(canonicalClass) + ); +} + +function readPersonaFromLayerSync( + requestedClass: string, + canonicalClass: string, + dir: string, + extracted: DirClasses, + layer: PersonaLayer, +): PersonaResolution | null { + const pf = extracted.byClass.get(canonicalClass); + if (!pf) { + if (!extracted.classes.has(canonicalClass)) return null; + const byName = join(dir, `${canonicalClass}.md`); + try { + const content = readFileSync(byName, 'utf8'); + const currentMarker = content.matchAll(CLASS_MARKER).next().value as + | RegExpExecArray + | undefined; + if (currentMarker && currentMarker[1] !== canonicalClass) return null; + const dm = DOMAIN_MARKER.exec(content); + return { + requestedClass, + canonicalClass, + klass: canonicalClass, + layer, + file: byName, + content, + ...(dm?.[1] ? { domain: dm[1] } : {}), + }; + } catch { + return null; + } + } + try { + const content = readFileSync(pf.file, 'utf8'); + const currentMarker = content.matchAll(CLASS_MARKER).next().value as + | RegExpExecArray + | undefined; + if (currentMarker && currentMarker[1] !== canonicalClass) return null; + const dm = DOMAIN_MARKER.exec(content); + return { + requestedClass, + canonicalClass, + klass: canonicalClass, + layer, + file: pf.file, + content, + ...(dm?.[1] ? { domain: dm[1] } : {}), + }; + } catch { + return null; + } +} + /** * Resolve a persona class to its winning definition: the override file if * roles.local/ defines that class, else the baseline. Match by inline `class:` @@ -245,42 +506,32 @@ export async function resolvePersona( * is identical to {@link resolvePersona}: override layer wins, then baseline. */ export async function resolvePersonaFrom( - klass: string, + requestedClass: string, layers: { rolesDir: string; overrideDir: string; base: DirClasses; over: DirClasses }, ): Promise { + const { requestedClass: requested, canonicalClass: klass } = + canonicalizeRoleClass(requestedClass); const { rolesDir, overrideDir, base, over } = layers; - const fromLayer = async ( - dir: string, - extracted: DirClasses, - layer: PersonaLayer, - ): Promise => { - // Prefer the marker-defined file; fall back to the filename stem. - let pf = extracted.byClass.get(klass); - if (!pf) { - const byName = join(dir, `${klass}.md`); - if (!extracted.classes.has(klass)) return null; - // Class known only via filename/LIBRARY: read the stem file if present. - try { - const content = await readFile(byName, 'utf8'); - const dm = DOMAIN_MARKER.exec(content); - return { klass, layer, file: byName, content, ...(dm?.[1] ? { domain: dm[1] } : {}) }; - } catch { - return null; - } - } - try { - const content = await readFile(pf.file, 'utf8'); - return { klass, layer, file: pf.file, content, ...(pf.domain ? { domain: pf.domain } : {}) }; - } catch { - return null; - } - }; + const override = await readPersonaFromLayer(requested, klass, overrideDir, over, 'override'); + if (override) return override; + // An observed explicit override that cannot resolve/read shadows the baseline. + if (overrideShadowsBaseline(over, klass)) return null; - return ( - (await fromLayer(overrideDir, over, 'override')) ?? - (await fromLayer(rolesDir, base, 'baseline')) + // Cached scans are only snapshots. Re-scan immediately before baseline fallback + // so a newly created canonical or marker-defined override cannot be skipped. + const currentOver = await extractClassesFromDir(overrideDir); + const currentOverride = await readPersonaFromLayer( + requested, + klass, + overrideDir, + currentOver, + 'override', ); + if (currentOverride) return currentOverride; + if (overrideShadowsBaseline(currentOver, klass)) return null; + if (currentOver.scanState === 'error') return null; + return readPersonaFromLayer(requested, klass, rolesDir, base, 'baseline'); } /** @@ -292,40 +543,55 @@ export async function resolvePersonaFrom( * one module so the launch-time and command-time resolutions never diverge. */ export function resolvePersonaSync( - klass: string, + requestedClass: string, opts: PersonaDirs = {}, ): PersonaResolution | null { + const { requestedClass: requested, canonicalClass: klass } = + canonicalizeRoleClass(requestedClass); const { rolesDir, overrideDir } = resolveDirs(opts); const base = extractClassesFromDirSync(rolesDir); const over = extractClassesFromDirSync(overrideDir); - const fromLayer = ( - dir: string, - extracted: DirClasses, - layer: PersonaLayer, - ): PersonaResolution | null => { - // Prefer the marker-defined file; fall back to the filename stem. - const pf = extracted.byClass.get(klass); - if (!pf) { - if (!extracted.classes.has(klass)) return null; - const byName = join(dir, `${klass}.md`); - try { - const content = readFileSync(byName, 'utf8'); - const dm = DOMAIN_MARKER.exec(content); - return { klass, layer, file: byName, content, ...(dm?.[1] ? { domain: dm[1] } : {}) }; - } catch { - return null; - } - } - try { - const content = readFileSync(pf.file, 'utf8'); - return { klass, layer, file: pf.file, content, ...(pf.domain ? { domain: pf.domain } : {}) }; - } catch { - return null; - } - }; + const override = readPersonaFromLayerSync(requested, klass, overrideDir, over, 'override'); + if (override) return override; + if (overrideShadowsBaseline(over, klass)) return null; + return readPersonaFromLayerSync(requested, klass, rolesDir, base, 'baseline'); +} - return fromLayer(overrideDir, over, 'override') ?? fromLayer(rolesDir, base, 'baseline'); +interface UsablePersonaIndex { + personas: Map; + readableBaseline: Set; +} + +async function collectUsablePersonas(opts: PersonaDirs): Promise { + const { rolesDir, overrideDir } = resolveDirs(opts); + const [base, over] = await Promise.all([ + extractClassesFromDir(rolesDir), + extractClassesFromDir(overrideDir), + ]); + if (over.scanState === 'error') { + return { personas: new Map(), readableBaseline: new Set() }; + } + + const candidates = new Set([...base.classes, ...over.classes]); + const checked = await Promise.all( + [...candidates].map(async (requestedClass) => { + const { canonicalClass } = canonicalizeRoleClass(requestedClass); + const [persona, baseline] = await Promise.all([ + resolvePersonaFrom(requestedClass, { rolesDir, overrideDir, base, over }), + readPersonaFromLayer(requestedClass, canonicalClass, rolesDir, base, 'baseline'), + ]); + return { requestedClass, persona, baseline }; + }), + ); + + const personas = new Map(); + const readableBaseline = new Set(); + for (const { requestedClass, persona, baseline } of checked) { + if (persona) personas.set(requestedClass, persona); + if (baseline) readableBaseline.add(requestedClass); + } + return { personas, readableBaseline }; } export interface PersonaStatusEntry { @@ -342,24 +608,20 @@ export interface PersonaStatusEntry { * Domain is taken from the WINNING layer (override domain wins if present). */ export async function personaStatus(opts: PersonaDirs = {}): Promise { - const { rolesDir, overrideDir } = resolveDirs(opts); - const [base, over] = await Promise.all([ - extractClassesFromDir(rolesDir), - extractClassesFromDir(overrideDir), - ]); - - const all = new Set([...base.classes, ...over.classes]); - const domainOf = (extracted: DirClasses, klass: string): string | undefined => - extracted.byClass.get(klass)?.domain; - - const entries: PersonaStatusEntry[] = []; - for (const klass of all) { - const inBase = base.classes.has(klass); - const inOver = over.classes.has(klass); - const status: PersonaStatus = inOver ? (inBase ? 'overridden' : 'custom') : 'baseline'; - const domain = (inOver ? domainOf(over, klass) : undefined) ?? domainOf(base, klass); - entries.push({ klass, status, ...(domain ? { domain } : {}) }); - } + const { personas, readableBaseline } = await collectUsablePersonas(opts); + const entries = [...personas.entries()].map(([klass, persona]): PersonaStatusEntry => { + const status: PersonaStatus = + persona.layer === 'baseline' + ? 'baseline' + : readableBaseline.has(klass) + ? 'overridden' + : 'custom'; + return { + klass, + status, + ...(persona.domain ? { domain: persona.domain } : {}), + }; + }); entries.sort((a, b) => a.klass.localeCompare(b.klass)); return entries; } diff --git a/packages/mosaic/src/commands/fleet-profiles.spec.ts b/packages/mosaic/src/commands/fleet-profiles.spec.ts index f44322db..2173b0fa 100644 --- a/packages/mosaic/src/commands/fleet-profiles.spec.ts +++ b/packages/mosaic/src/commands/fleet-profiles.spec.ts @@ -203,6 +203,91 @@ describe('loadProfiles with a temp override dir', () => { await rm(dir, { recursive: true, force: true }); }); + it('canonicalizes approved aliases across lead, floor, roster, and topology', async () => { + await writeFile( + join(dir, 'aliases.yaml'), + [ + 'id: aliases', + 'title: Aliases', + 'description: legacy class compatibility', + 'lead: operator-interaction', + 'floor: [operator-interaction]', + 'roster:', + ' - class: operator-interaction', + ' - class: implementer', + ' reports_to: operator-interaction', + ' - class: reviewer', + ' reports_to: operator-interaction', + '', + ].join('\n'), + ); + + const profile = await loadProfile('aliases', { + profilesDir: dir, + rolesDir, + overrideDir: join(dir, 'roles.local'), + }); + expect(profile.lead).toBe('interaction'); + expect(profile.floor).toEqual(['interaction']); + expect(profile.roster).toEqual([ + { class: 'interaction', multiplicity: 1 }, + { class: 'code', reportsTo: 'interaction', multiplicity: 1 }, + { class: 'review', reportsTo: 'interaction', multiplicity: 1 }, + ]); + }); + + it('rejects roster entries that collapse to the same canonical class', async () => { + await writeFile( + join(dir, 'alias-collision.yaml'), + [ + 'id: alias-collision', + 'title: Alias collision', + 'description: ambiguous canonical topology', + 'lead: orchestrator', + 'floor: [orchestrator]', + 'roster:', + ' - class: orchestrator', + ' - class: code', + ' reports_to: orchestrator', + ' - class: implementer', + ' reports_to: orchestrator', + '', + ].join('\n'), + ); + + await expect( + loadProfile('alias-collision', { + profilesDir: dir, + rolesDir, + overrideDir: join(dir, 'roles.local'), + }), + ).rejects.toThrow(/duplicate classes after canonicalization/); + }); + + it('allows a readable lead or floor persona that is not itself a roster seat', async () => { + await writeFile( + join(dir, 'external-topology.yaml'), + [ + 'id: external-topology', + 'title: External topology', + 'description: structural compatibility', + 'lead: orchestrator', + 'floor: [enhancer]', + 'roster:', + ' - class: code', + '', + ].join('\n'), + ); + + const profile = await loadProfile('external-topology', { + profilesDir: dir, + rolesDir, + overrideDir: join(dir, 'roles.local'), + }); + expect(profile.lead).toBe('orchestrator'); + expect(profile.floor).toEqual(['enhancer']); + }); + it('throws when a profile references an unknown class (validated against real roles)', async () => { await writeFile( join(dir, 'bad.yaml'), diff --git a/packages/mosaic/src/commands/fleet-profiles.ts b/packages/mosaic/src/commands/fleet-profiles.ts index 3a9ecb4f..96899d9a 100644 --- a/packages/mosaic/src/commands/fleet-profiles.ts +++ b/packages/mosaic/src/commands/fleet-profiles.ts @@ -29,6 +29,7 @@ import { defaultOverrideDir, extractClassesFromDir, listPersonaClasses as listOverrideAwarePersonaClasses, + resolvePersonaFrom, } from './fleet-personas.js'; function defaultMosaicHome(): string { @@ -199,6 +200,61 @@ export function validateProfile(profile: FleetProfile, validClasses: Set return problems; } +export async function resolveProfilePersonas( + profile: FleetProfile, + rolesDir: string, + overrideDir: string, +): Promise { + const [base, over] = await Promise.all([ + extractClassesFromDir(rolesDir), + extractClassesFromDir(overrideDir), + ]); + const requestedClasses = new Set([ + profile.lead, + ...profile.floor, + ...profile.roster.flatMap((entry: ProfileRosterEntry): string[] => + entry.reportsTo ? [entry.class, entry.reportsTo] : [entry.class], + ), + ]); + const canonicalByRequested = new Map(); + for (const requestedClass of requestedClasses) { + const resolved = await resolvePersonaFrom(requestedClass, { + rolesDir, + overrideDir, + base, + over, + }); + if (!resolved || resolved.content.trim() === '') { + throw new Error(`persona class "${requestedClass}" does not resolve to a readable persona`); + } + canonicalByRequested.set(requestedClass, resolved.canonicalClass); + } + const canonical = (requestedClass: string): string => + canonicalByRequested.get(requestedClass) ?? requestedClass; + const roster = profile.roster.map( + (entry: ProfileRosterEntry): ProfileRosterEntry => ({ + class: canonical(entry.class), + multiplicity: entry.multiplicity, + ...(entry.reportsTo ? { reportsTo: canonical(entry.reportsTo) } : {}), + }), + ); + const canonicalRosterClasses = roster.map((entry: ProfileRosterEntry): string => entry.class); + if (new Set(canonicalRosterClasses).size !== canonicalRosterClasses.length) { + throw new Error('profile roster contains duplicate classes after canonicalization'); + } + const resolvedProfile: FleetProfile = { + ...profile, + lead: canonical(profile.lead), + floor: profile.floor.map(canonical), + roster, + }; + const problems = validateProfile(resolvedProfile, new Set(canonicalByRequested.values())); + if (problems.length > 0) { + throw new Error(problems.join('\n - ')); + } + return resolvedProfile; +} + export interface LoadProfilesOptions { /** Override the profiles dir (tests). Defaults to /fleet/profiles. */ profilesDir?: string; @@ -237,10 +293,8 @@ export async function loadProfiles(opts: LoadProfilesOptions = {}): Promise(); @@ -255,11 +309,14 @@ export async function loadProfiles(opts: LoadProfilesOptions = {}): Promise 0) { - throw new Error(`Profile ${file} is invalid:\n - ${problems.join('\n - ')}`); + let resolvedProfile: FleetProfile; + try { + resolvedProfile = await resolveProfilePersonas(profile, rolesDir, overrideDir); + } catch (error: unknown) { + const detail = error instanceof Error ? error.message : String(error); + throw new Error(`Profile ${file} is invalid:\n - ${detail}`); } - profiles.push(profile); + profiles.push(resolvedProfile); } return profiles; } diff --git a/packages/mosaic/src/commands/fleet-provision.spec.ts b/packages/mosaic/src/commands/fleet-provision.spec.ts index 18799b09..5b1a947d 100644 --- a/packages/mosaic/src/commands/fleet-provision.spec.ts +++ b/packages/mosaic/src/commands/fleet-provision.spec.ts @@ -172,6 +172,46 @@ describe('override-aware persona validation', () => { expect(result.summary).toContain('persona=override'); }); + it('canonicalizes aliased profile classes and topology identities before emission', async () => { + const overrideDir = join(dir, 'roles.local'); + const customProfilesDir = join(dir, 'profiles'); + await mkdir(overrideDir, { recursive: true }); + await mkdir(customProfilesDir, { recursive: true }); + await writeFile( + join(customProfilesDir, 'aliases.yaml'), + [ + 'id: aliases', + 'title: Aliases', + 'description: legacy aliases', + 'lead: operator-interaction', + 'floor: [operator-interaction]', + 'roster:', + ' - class: operator-interaction', + ' - class: implementer', + ' reports_to: operator-interaction', + ' - class: reviewer', + ' reports_to: operator-interaction', + '', + ].join('\n'), + ); + + const result = await runProvision('aliases', { + mosaicHome: dir, + profilesDir: customProfilesDir, + rolesDir, + overrideDir, + full: true, + }); + expect(result.yaml).toContain('name: interaction'); + expect(result.yaml).toContain('class: interaction'); + expect(result.yaml).toContain('name: code'); + expect(result.yaml).toContain('class: code'); + expect(result.yaml).toContain('name: review'); + expect(result.yaml).toContain('class: review'); + expect(result.yaml).not.toContain('class: implementer'); + expect(result.summary).toContain('reports_to=interaction'); + }); + it('FAILS with a clear message when a profile references a bogus class', async () => { const customProfilesDir = join(dir, 'profiles'); await mkdir(customProfilesDir, { recursive: true }); diff --git a/packages/mosaic/src/commands/fleet-provision.ts b/packages/mosaic/src/commands/fleet-provision.ts index b2f64a1a..aeff50ee 100644 --- a/packages/mosaic/src/commands/fleet-provision.ts +++ b/packages/mosaic/src/commands/fleet-provision.ts @@ -26,12 +26,11 @@ import type { Command } from 'commander'; import YAML from 'yaml'; import { loadProfile, - validateProfile, type FleetProfile, type ProfileRosterEntry, defaultProfilesDir, defaultRolesDir, - listPersonaClassesWithOverrides, + resolveProfilePersonas, } from './fleet-profiles.js'; import { defaultOverrideDir, @@ -201,18 +200,35 @@ export async function generateRoster( ); } - const runtimeChoice = resolveSeatRuntime(entry.class, isFloor, isLead); - for (const name of seatNames(entry)) { + const canonicalEntry: ProfileRosterEntry = { + class: resolved.canonicalClass, + multiplicity: entry.multiplicity, + ...(entry.reportsTo + ? { + reportsTo: + ( + await resolvePersonaFrom(entry.reportsTo, { + rolesDir, + overrideDir, + base, + over, + }) + )?.canonicalClass ?? entry.reportsTo, + } + : {}), + }; + const runtimeChoice = resolveSeatRuntime(resolved.canonicalClass, isFloor, isLead); + for (const name of seatNames(canonicalEntry)) { const seat: GeneratedSeat = { name, - className: entry.class, + className: resolved.canonicalClass, runtime: runtimeChoice.runtime, personaLayer: resolved.layer, }; if (runtimeChoice.modelHint) seat.modelHint = runtimeChoice.modelHint; if (isFloor || isLead) seat.persistentPersona = true; if (!isFloor && !isLead) seat.resetBetweenTasks = true; - if (entry.reportsTo) seat.reportsTo = entry.reportsTo; + if (canonicalEntry.reportsTo) seat.reportsTo = canonicalEntry.reportsTo; seats.push(seat); } } @@ -279,13 +295,7 @@ export async function validateProfileForProvision( opts: ProvisionOptions, ): Promise { const { rolesDir, overrideDir } = resolveDirs(opts); - const validClasses = await listPersonaClassesWithOverrides(rolesDir, overrideDir); - const problems = validateProfile(profile, validClasses); - if (problems.length > 0) { - throw new Error( - `Profile "${profile.id}" is invalid; cannot provision:\n - ${problems.join('\n - ')}`, - ); - } + await resolveProfilePersonas(profile, rolesDir, overrideDir); } // --------------------------------------------------------------------------- diff --git a/packages/mosaic/src/fleet/persona-contract.spec.ts b/packages/mosaic/src/fleet/persona-contract.spec.ts index c4c14212..bd723831 100644 --- a/packages/mosaic/src/fleet/persona-contract.spec.ts +++ b/packages/mosaic/src/fleet/persona-contract.spec.ts @@ -77,12 +77,25 @@ describe('readPersonaContractBlock — launch-time persona injection (A3b)', () }); it('injects an override-only (user-added) persona with no baseline at all', () => { - seedOverride(home, 'reviewer', '# Reviewer\n\n(`class: reviewer`)\n\nCUSTOM-ROLE.\n'); - const block = readPersonaContractBlock(home, 'reviewer'); - expect(block).toContain('# Persona Contract (reviewer)'); + seedOverride(home, 'mascot', '# Mascot\n\n(`class: mascot`)\n\nCUSTOM-ROLE.\n'); + const block = readPersonaContractBlock(home, 'mascot'); + expect(block).toContain('# Persona Contract (mascot)'); expect(block).toContain('CUSTOM-ROLE'); }); + it('canonicalizes an approved alias before launch-time override lookup', () => { + seedBaseline(home, 'code', '# Code\n\n(`class: code`)\n\nCANONICAL-CODE.\n'); + seedOverride( + home, + 'implementer', + '# Legacy implementer\n\n(`class: implementer`)\n\nLEGACY-OVERRIDE.\n', + ); + const block = readPersonaContractBlock(home, 'implementer'); + expect(block).toContain('# Persona Contract (code)'); + expect(block).toContain('CANONICAL-CODE'); + expect(block).not.toContain('LEGACY-OVERRIDE'); + }); + it('no-ops (empty string) when the class is undefined', () => { seedBaseline(home, 'coder', BASELINE_CODER); expect(readPersonaContractBlock(home, undefined)).toBe(''); diff --git a/packages/mosaic/src/fleet/roster-v2.spec.ts b/packages/mosaic/src/fleet/roster-v2.spec.ts index 358aebc7..1fc0b0b4 100644 --- a/packages/mosaic/src/fleet/roster-v2.spec.ts +++ b/packages/mosaic/src/fleet/roster-v2.spec.ts @@ -1,11 +1,14 @@ -import { readFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it } from 'vitest'; import { ROSTER_V2_JSON_SCHEMA, RosterV2ValidationError, parseRosterV2, renderRosterV2Yaml, + validateRosterV2Semantics, } from './roster-v2.js'; const validRoster = ` @@ -40,6 +43,127 @@ agents: yolo: true `; +let semanticTmp: string | undefined; + +afterEach(async (): Promise => { + if (semanticTmp) await rm(semanticTmp, { recursive: true, force: true }); + semanticTmp = undefined; +}); + +async function semanticDirs(): Promise<{ rolesDir: string; overrideDir: string }> { + semanticTmp = await mkdtemp(join(tmpdir(), 'roster-v2-semantics-')); + const rolesDir = join(semanticTmp, 'roles'); + const overrideDir = join(semanticTmp, 'roles.local'); + await mkdir(rolesDir, { recursive: true }); + await mkdir(overrideDir, { recursive: true }); + for (const klass of [ + 'code', + 'review', + 'interaction', + 'orchestrator', + 'merge-gate', + 'validator', + 'team-leader', + ]) { + await writeFile(join(rolesDir, `${klass}.md`), `# ${klass}\n\n(\`class: ${klass}\`)\n`, 'utf8'); + } + return { rolesDir, overrideDir }; +} + +function rosterWithClass(klass: string, toolPolicy = klass): string { + return validRoster + .replace('class: code', `class: ${klass}`) + .replace('tool_policy: code', `tool_policy: ${toolPolicy}`); +} + +describe('roster v2 semantic validation', (): void => { + it.each([ + ['implementer', 'code'], + ['reviewer', 'review'], + ['operator-interaction', 'interaction'], + ])( + 'canonicalizes requested alias %s while retaining requested and canonical class', + async (requested: string, canonical: string) => { + const dirs = await semanticDirs(); + const roster = parseRosterV2(rosterWithClass(requested, requested), 'yaml'); + const validated = await validateRosterV2Semantics(roster, dirs); + expect(validated.agents[0]).toMatchObject({ + requestedClass: requested, + canonicalClass: canonical, + canonicalToolPolicy: canonical, + }); + }, + ); + + it.each(['worker', 'analyst', 'canary'])( + 'rejects %s when no genuine custom role exists', + async (klass: string) => { + const dirs = await semanticDirs(); + await expect( + validateRosterV2Semantics(parseRosterV2(rosterWithClass(klass), 'yaml'), dirs), + ).rejects.toThrow(/unresolved|readable persona/i); + }, + ); + + it('accepts a genuine custom roles.local class without protected authority', async () => { + const dirs = await semanticDirs(); + await writeFile(join(dirs.overrideDir, 'worker.md'), '# worker\n\n(`class: worker`)\n', 'utf8'); + const validated = await validateRosterV2Semantics( + parseRosterV2(rosterWithClass('worker'), 'yaml'), + dirs, + ); + expect(validated.agents[0]?.authority).toMatchObject({ + mayMerge: false, + mayOrchestrate: false, + }); + }); + + it('rejects a LIBRARY-only class with no readable resolved persona', async () => { + const dirs = await semanticDirs(); + await writeFile( + join(dirs.rolesDir, 'LIBRARY.md'), + '| Persona | Purpose |\n| --- | --- |\n| phantom | Missing |\n', + 'utf8', + ); + await expect( + validateRosterV2Semantics(parseRosterV2(rosterWithClass('phantom'), 'yaml'), dirs), + ).rejects.toThrow(/readable persona/i); + }); + + it('rejects an unreadable resolved persona', async () => { + const dirs = await semanticDirs(); + await mkdir(join(dirs.overrideDir, 'worker.md')); + await expect( + validateRosterV2Semantics(parseRosterV2(rosterWithClass('worker'), 'yaml'), dirs), + ).rejects.toThrow(/readable persona/i); + }); + + it.each([ + ['merge-gate', 'code'], + ['validator', 'merge-gate'], + ['orchestrator', 'interaction'], + ['team-leader', 'orchestrator'], + ['interaction', 'orchestrator'], + ['code', 'merge-gate'], + ['worker', 'validator'], + ])( + 'denies protected class/tool-policy mismatch %s with %s', + async (klass: string, toolPolicy: string) => { + const dirs = await semanticDirs(); + if (klass === 'worker') { + await writeFile( + join(dirs.overrideDir, 'worker.md'), + '# worker\n\n(`class: worker`)\n', + 'utf8', + ); + } + await expect( + validateRosterV2Semantics(parseRosterV2(rosterWithClass(klass, toolPolicy), 'yaml'), dirs), + ).rejects.toThrow(/tool policy.*must match|mismatch/i); + }, + ); +}); + describe('roster v2 structural compiler', (): void => { it('parses YAML into a normalized typed model and renders canonical YAML', (): void => { const roster = parseRosterV2(validRoster, 'yaml'); diff --git a/packages/mosaic/src/fleet/roster-v2.ts b/packages/mosaic/src/fleet/roster-v2.ts index 87df60c1..f0056f11 100644 --- a/packages/mosaic/src/fleet/roster-v2.ts +++ b/packages/mosaic/src/fleet/roster-v2.ts @@ -1,4 +1,15 @@ import YAML from 'yaml'; +import { + authorityForCanonicalClass, + canonicalizeRoleClass, + defaultOverrideDir, + defaultRolesDir, + extractClassesFromDir, + resolvePersonaFrom, + type PersonaDirs, + type PersonaResolution, + type RoleAuthority, +} from '../commands/fleet-personas.js'; export const ROSTER_V2_SUPPORTED_RUNTIMES = ['claude', 'codex', 'opencode', 'pi'] as const; export const ROSTER_V2_REASONING_LEVELS = ['low', 'medium', 'high'] as const; @@ -58,6 +69,80 @@ export interface FleetRosterV2 { readonly agents: readonly FleetRosterV2Agent[]; } +export interface SemanticallyValidatedRosterV2Agent extends FleetRosterV2Agent { + readonly requestedClass: string; + readonly canonicalClass: string; + readonly canonicalToolPolicy: string; + readonly persona: PersonaResolution; + readonly authority: RoleAuthority; +} + +export interface SemanticallyValidatedRosterV2 extends Omit { + readonly agents: readonly SemanticallyValidatedRosterV2Agent[]; +} + +const PROTECTED_TOOL_POLICY_CLASSES = new Set([ + 'merge-gate', + 'validator', + 'orchestrator', + 'team-leader', + 'interaction', +]); + +/** + * Validate filesystem-backed roster semantics after synchronous structural parsing. + * Directory scans are batched once and every class must resolve to readable content. + */ +export async function validateRosterV2Semantics( + roster: FleetRosterV2, + opts: PersonaDirs = {}, +): Promise { + const rolesDir = opts.rolesDir ?? defaultRolesDir(opts.mosaicHome); + const overrideDir = opts.overrideDir ?? defaultOverrideDir(opts.mosaicHome); + const [base, over] = await Promise.all([ + extractClassesFromDir(rolesDir), + extractClassesFromDir(overrideDir), + ]); + + const agents: SemanticallyValidatedRosterV2Agent[] = []; + for (const agent of roster.agents) { + const requestedClass = agent.className; + const { canonicalClass } = canonicalizeRoleClass(requestedClass); + const canonicalToolPolicy = canonicalizeRoleClass(agent.toolPolicy).canonicalClass; + const persona = await resolvePersonaFrom(requestedClass, { + rolesDir, + overrideDir, + base, + over, + }); + if (!persona || persona.content.trim() === '') { + throw new RosterV2ValidationError( + `Roster v2 agent "${agent.name}" class "${requestedClass}" does not resolve to a readable persona.`, + ); + } + if ( + (PROTECTED_TOOL_POLICY_CLASSES.has(canonicalClass) || + PROTECTED_TOOL_POLICY_CLASSES.has(canonicalToolPolicy)) && + canonicalToolPolicy !== canonicalClass + ) { + throw new RosterV2ValidationError( + `Roster v2 agent "${agent.name}" protected class "${canonicalClass}" tool policy must match its canonical class; received "${agent.toolPolicy}".`, + ); + } + agents.push( + Object.freeze({ + ...agent, + requestedClass, + canonicalClass, + canonicalToolPolicy, + persona, + authority: authorityForCanonicalClass(canonicalClass), + }), + ); + } + return Object.freeze({ ...roster, agents: Object.freeze(agents) }); +} + export class RosterV2ValidationError extends Error { constructor(message: string) { super(message); From e9c4aa3e8b3780719cd5a43c0ef3f37fc70de666 Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Wed, 15 Jul 2026 01:37:12 +0000 Subject: [PATCH 051/152] test(fleet): validate shipped artifact dispositions (#770) --- ...Y-EXAMPLE-PROFILE-DISPOSITION-INVENTORY.md | 3 +- .../migration/example-profile-disposition.md | 52 ++++++++ ...fcm-m1-003-example-profile-dispositions.md | 37 ++++++ .../example-profile-dispositions.spec.ts | 90 +++++++++++++ .../src/fleet/example-profile-dispositions.ts | 121 ++++++++++++++++++ 5 files changed, 302 insertions(+), 1 deletion(-) create mode 100644 docs/fleet/migration/example-profile-disposition.md create mode 100644 docs/scratchpads/758-fcm-m1-003-example-profile-dispositions.md create mode 100644 packages/mosaic/src/fleet/example-profile-dispositions.spec.ts create mode 100644 packages/mosaic/src/fleet/example-profile-dispositions.ts diff --git a/docs/fleet/LEGACY-EXAMPLE-PROFILE-DISPOSITION-INVENTORY.md b/docs/fleet/LEGACY-EXAMPLE-PROFILE-DISPOSITION-INVENTORY.md index 318b4e9a..d1ccf6ec 100644 --- a/docs/fleet/LEGACY-EXAMPLE-PROFILE-DISPOSITION-INVENTORY.md +++ b/docs/fleet/LEGACY-EXAMPLE-PROFILE-DISPOSITION-INVENTORY.md @@ -7,7 +7,8 @@ The v2 compiler may not silently accept an unresolved class. Before M1 exits, ev below must be either migrated and executable, retained as an explicitly versioned v1 fixture, or retired with a replacement/deprecation note. Class resolution must use the existing profile/persona/provision baseline-plus-`roles.local` resolver; this inventory does not create a -parallel resolver. +parallel resolver. The current executable implementation and per-artifact outcomes are recorded in +[the disposition evidence](./migration/example-profile-disposition.md). ## Examples diff --git a/docs/fleet/migration/example-profile-disposition.md b/docs/fleet/migration/example-profile-disposition.md new file mode 100644 index 00000000..dd7ba44c --- /dev/null +++ b/docs/fleet/migration/example-profile-disposition.md @@ -0,0 +1,52 @@ +# Executable Fleet Example, Profile, and Service-Preset Dispositions + +**Issue:** #758 · **Card:** FCM-M1-003 · **Status:** M1 executable disposition evidence + +This document records the executable disposition for every currently shipped fleet YAML artifact. +The authoritative baseline classification remains the +[legacy inventory](../LEGACY-EXAMPLE-PROFILE-DISPOSITION-INVENTORY.md). The executable guard is +`packages/mosaic/src/fleet/example-profile-dispositions.ts`; its test fails if a shipped YAML +artifact is added, removed, or left without one of the dispositions below. + +## Disposition rules + +- **Explicit v1 fixture:** the artifact is loaded through the existing v1 roster parser and must + declare `version: 1`. It remains a compatibility fixture; it is not silently treated as a v2 + roster or given inferred aliases. +- **Canonical profile:** the artifact is loaded through `loadProfiles`, which uses the shared + baseline-plus-`roles.local` persona resolver and rejects unreadable or unresolved classes. +- **Canonical service policy:** the artifact is loaded through the operator-interaction service + policy reader and provisioned with a generic supplied identity. It validates its runtime, model, + reasoning, and legacy tool-policy compatibility without hardcoding a product identity. + +No artifact is retired in this card. A later retirement requires both a replacement link and a +visible deprecation note; the executable guard must then record the new disposition before the +artifact can be removed. + +## Shipped artifacts + +| Artifact | Disposition | Executable path | Compatibility notes | +| ------------------------------------ | ------------------------ | --------------------------------- | ------------------------------------------------------------------------------------------------ | +| `examples/coding.yaml` | Explicit v1 fixture | v1 roster parser | Retains approved `implementer` and `reviewer` compatibility inputs. | +| `examples/general.yaml` | Explicit v1 fixture | v1 roster parser | Retains unresolved `worker` without an inferred canonical role. | +| `examples/hybrid.yaml` | Explicit v1 fixture | v1 roster parser | Retains `implementer`, `reviewer`, and resolver-dependent `researcher`. | +| `examples/local-canary.yaml` | Explicit v1 fixture | v1 roster parser | Retains the local-tmux canary topology. | +| `examples/minimal.yaml` | Explicit v1 fixture | v1 roster parser | Retains `canary` without an inferred canonical role. | +| `examples/operator-interaction.yaml` | Explicit v1 fixture | v1 roster parser | Keeps Tess only as an example instance name; `operator-interaction` remains compatibility input. | +| `examples/research.yaml` | Explicit v1 fixture | v1 roster parser | Retains resolver-dependent `researcher` and `analyst`. | +| `profiles/business.yaml` | Canonical profile | shared profile/persona resolver | Every referenced business class must resolve to a readable contract. | +| `profiles/marketing.yaml` | Canonical profile | shared profile/persona resolver | Every referenced marketing class must resolve to a readable contract. | +| `profiles/personal-assistant.yaml` | Canonical profile | shared profile/persona resolver | No interaction equivalence is inferred. | +| `profiles/research.yaml` | Canonical profile | shared profile/persona resolver | Every research class must resolve to a readable contract. | +| `profiles/software-delivery.yaml` | Canonical profile | shared profile/persona resolver | Retains the governance profile; authority validation remains FCM-M1-002 evidence. | +| `services/operator-interaction.yaml` | Canonical service policy | service-policy reader/provisioner | Generic provisioning supplies the instance name; the policy itself never names Tess. | + +## Running the guard + +```bash +pnpm --filter @mosaicstack/mosaic test -- example-profile-dispositions.spec.ts +``` + +The guard is intentionally limited to shipped assets and validation. It does not generate +environment files, mutate a roster, reconcile a fleet, migrate an installed roster, or launch an +agent. diff --git a/docs/scratchpads/758-fcm-m1-003-example-profile-dispositions.md b/docs/scratchpads/758-fcm-m1-003-example-profile-dispositions.md new file mode 100644 index 00000000..0ad76545 --- /dev/null +++ b/docs/scratchpads/758-fcm-m1-003-example-profile-dispositions.md @@ -0,0 +1,37 @@ +# FCM-M1-003 — Executable example/profile/service-preset dispositions + +- **Task / issue:** FCM-M1-003 / #758 +- **Branch / base:** `test/758-example-profile-dispositions` from `origin/main` `a5e8e554012f27898e035d2882a8e47e1a02fe97` +- **Objective:** Make every artifact in the M0 legacy disposition inventory executable evidence: it must validate canonically, be explicitly retained as a v1 fixture, or be retired with a replacement/deprecation link. +- **Scope:** Validation and explicit version/retirement metadata only for shipped examples, profiles, and the operator-interaction service preset. Reuse the central resolver and existing v2 roster compiler. +- **Out of scope:** Generated environment boundaries, CRUD, reconciliation/apply, migration, live fleet mutation, and `docs/TASKS.md`. +- **Budget:** 20K card allocation; use focused package tests before full package validation. + +## Plan + +1. Inventory exact shipped artifacts and existing compiler/resolver/profile tests. +2. Add failing behavior tests covering all listed artifacts and their documented disposition. +3. Implement minimal declarative fixture/disposition validation; do not add a role/class resolver. +4. Run focused and package quality gates; obtain independent code and security review. +5. Commit, queue-guard, push, open one `main` PR with `Refs #758`. + +## Progress + +- Intake complete: verified no branch, worktree, or open PR for this card before creating this isolated worktree. +- Requirements read: FCM PRD, FCM-M1-003 task row, M0 disposition inventory, delivery/QA/documentation guides. +- TDD: RED recorded with `pnpm --filter @mosaicstack/mosaic test -- example-profile-dispositions.spec.ts` failing because the new module did not exist; GREEN recorded after the minimal guard implementation. The focused suite now has 4 passing tests, including undeclared-artifact and missing-explicit-v1-version denials. +- Independent review: initial code review found the service policy path was hardcoded; remediation iterates declared `canonical-service-policy` artifacts. Exact-head code review approved and exact-head security review found no issues. + +## Risks / decisions + +- The M0 inventory permits unresolved legacy roles only when explicitly v1-versioned or retired. Do not infer aliases beyond the three approved by FCM-M1-002. +- `docs/TASKS.md` is orchestrator-owned and will not be edited. + +## Verification evidence + +- Focused TDD guard: `pnpm --filter @mosaicstack/mosaic test -- example-profile-dispositions.spec.ts` — PASS (4 tests). +- Full package suite: `pnpm --filter @mosaicstack/mosaic test` — PASS (51 files, 742 tests). +- Static gates: `pnpm --filter @mosaicstack/mosaic typecheck`, `pnpm --filter @mosaicstack/mosaic lint`, and `pnpm format:check` — PASS. +- Diff gate: `git diff --check` — PASS. +- Exact-head reviews: `codex-code-review.sh --uncommitted` — APPROVE; `codex-security-review.sh --uncommitted` — no findings. +- Delivery: committed as `9a9ad1a`, pushed after `ci-queue-wait.sh --purpose push`, and opened PR [#770](https://git.mosaicstack.dev/mosaicstack/stack/pulls/770) to `main` with `Refs #758`. `pr-ci-wait.sh -n 770` reported terminal-green Woodpecker pipeline [#1823](https://ci.mosaicstack.dev/repos/47/pipeline/1823/1). diff --git a/packages/mosaic/src/fleet/example-profile-dispositions.spec.ts b/packages/mosaic/src/fleet/example-profile-dispositions.spec.ts new file mode 100644 index 00000000..d2595d28 --- /dev/null +++ b/packages/mosaic/src/fleet/example-profile-dispositions.spec.ts @@ -0,0 +1,90 @@ +import { cp, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + SHIPPED_FLEET_ARTIFACT_DISPOSITIONS, + validateShippedFleetArtifactDispositions, +} from './example-profile-dispositions.js'; + +const frameworkFleet = resolve( + dirname(fileURLToPath(import.meta.url)), + '..', + '..', + 'framework', + 'fleet', +); + +const EXPECTED_DISPOSITIONS = [ + 'examples/coding.yaml:v1-fixture', + 'examples/general.yaml:v1-fixture', + 'examples/hybrid.yaml:v1-fixture', + 'examples/local-canary.yaml:v1-fixture', + 'examples/minimal.yaml:v1-fixture', + 'examples/operator-interaction.yaml:v1-fixture', + 'examples/research.yaml:v1-fixture', + 'profiles/business.yaml:canonical-profile', + 'profiles/marketing.yaml:canonical-profile', + 'profiles/personal-assistant.yaml:canonical-profile', + 'profiles/research.yaml:canonical-profile', + 'profiles/software-delivery.yaml:canonical-profile', + 'services/operator-interaction.yaml:canonical-service-policy', +]; + +const declared = SHIPPED_FLEET_ARTIFACT_DISPOSITIONS.map( + ({ path, disposition }): string => `${path}:${disposition}`, +); + +describe('shipped fleet example/profile/service disposition validation', (): void => { + it('enumerates every inventory artifact with an explicit executable disposition', (): void => { + expect(declared).toEqual(EXPECTED_DISPOSITIONS); + }); + + it('validates every shipped artifact through its declared v1 or canonical path', async (): Promise => { + const results = await validateShippedFleetArtifactDispositions({ frameworkFleet }); + + expect(results.map(({ path, disposition }): string => `${path}:${disposition}`)).toEqual( + EXPECTED_DISPOSITIONS, + ); + expect(results.filter(({ disposition }): boolean => disposition === 'v1-fixture')).toHaveLength( + 7, + ); + expect( + results.filter(({ disposition }): boolean => disposition === 'canonical-profile'), + ).toHaveLength(5); + expect( + results.find(({ path }): boolean => path === 'services/operator-interaction.yaml'), + ).toMatchObject({ + disposition: 'canonical-service-policy', + }); + }); + + let temporaryFleet: string | undefined; + afterEach(async (): Promise => { + if (temporaryFleet) await rm(temporaryFleet, { recursive: true, force: true }); + temporaryFleet = undefined; + }); + + it('fails closed when a shipped artifact lacks a declared disposition', async (): Promise => { + temporaryFleet = await mkdtemp(join(tmpdir(), 'mosaic-dispositions-')); + await cp(frameworkFleet, temporaryFleet, { recursive: true }); + await writeFile(join(temporaryFleet, 'examples', 'undeclared.yaml'), 'version: 1\n'); + + await expect( + validateShippedFleetArtifactDispositions({ frameworkFleet: temporaryFleet }), + ).rejects.toThrow(/undeclared shipped fleet artifact.*examples\/undeclared.yaml/i); + }); + + it('rejects a v1 fixture when its explicit version declaration is removed', async (): Promise => { + temporaryFleet = await mkdtemp(join(tmpdir(), 'mosaic-dispositions-')); + await cp(frameworkFleet, temporaryFleet, { recursive: true }); + const fixturePath = join(temporaryFleet, 'examples', 'coding.yaml'); + const fixture = await readFile(fixturePath, 'utf8'); + await writeFile(fixturePath, fixture.replace(/^version: 1\n/, '')); + + await expect( + validateShippedFleetArtifactDispositions({ frameworkFleet: temporaryFleet }), + ).rejects.toThrow(/Fleet roster version must be 1/); + }); +}); diff --git a/packages/mosaic/src/fleet/example-profile-dispositions.ts b/packages/mosaic/src/fleet/example-profile-dispositions.ts new file mode 100644 index 00000000..c87fb338 --- /dev/null +++ b/packages/mosaic/src/fleet/example-profile-dispositions.ts @@ -0,0 +1,121 @@ +import { readdir } from 'node:fs/promises'; +import { basename, join } from 'node:path'; +import { loadFleetRoster } from '../commands/fleet.js'; +import { loadProfiles } from '../commands/fleet-profiles.js'; +import { + provisionInteractionService, + readInteractionServiceProfile, +} from './interaction-service-profile.js'; + +export type FleetArtifactDisposition = + | 'v1-fixture' + | 'canonical-profile' + | 'canonical-service-policy'; + +export interface ShippedFleetArtifactDisposition { + readonly path: string; + readonly disposition: FleetArtifactDisposition; +} + +export interface ValidateShippedFleetArtifactDispositionsOptions { + readonly frameworkFleet: string; + readonly rolesDir?: string; + readonly overrideDir?: string; +} + +/** + * The M0 inventory in executable form. Every shipped fleet YAML asset is either + * a deliberately retained v1 fixture or validated through its canonical loader. + */ +export const SHIPPED_FLEET_ARTIFACT_DISPOSITIONS: readonly ShippedFleetArtifactDisposition[] = [ + { path: 'examples/coding.yaml', disposition: 'v1-fixture' }, + { path: 'examples/general.yaml', disposition: 'v1-fixture' }, + { path: 'examples/hybrid.yaml', disposition: 'v1-fixture' }, + { path: 'examples/local-canary.yaml', disposition: 'v1-fixture' }, + { path: 'examples/minimal.yaml', disposition: 'v1-fixture' }, + { path: 'examples/operator-interaction.yaml', disposition: 'v1-fixture' }, + { path: 'examples/research.yaml', disposition: 'v1-fixture' }, + { path: 'profiles/business.yaml', disposition: 'canonical-profile' }, + { path: 'profiles/marketing.yaml', disposition: 'canonical-profile' }, + { path: 'profiles/personal-assistant.yaml', disposition: 'canonical-profile' }, + { path: 'profiles/research.yaml', disposition: 'canonical-profile' }, + { path: 'profiles/software-delivery.yaml', disposition: 'canonical-profile' }, + { path: 'services/operator-interaction.yaml', disposition: 'canonical-service-policy' }, +]; + +/** + * Fail closed when a fleet YAML asset is added or removed without a disposition. + * This keeps legacy v1 compatibility explicit instead of silently accepting new + * unresolved classes outside the shared resolver. + */ +export async function validateShippedFleetArtifactDispositions( + options: ValidateShippedFleetArtifactDispositionsOptions, +): Promise { + await assertEveryShippedArtifactIsDeclared(options.frameworkFleet); + + const examples = SHIPPED_FLEET_ARTIFACT_DISPOSITIONS.filter( + ({ disposition }): boolean => disposition === 'v1-fixture', + ); + for (const artifact of examples) { + const roster = await loadFleetRoster(join(options.frameworkFleet, artifact.path)); + if (roster.version !== 1) { + throw new Error(`v1 fixture ${artifact.path} must declare version: 1`); + } + } + + const profilesDir = join(options.frameworkFleet, 'profiles'); + const profiles = await loadProfiles({ + profilesDir, + rolesDir: options.rolesDir ?? join(options.frameworkFleet, 'roles'), + overrideDir: options.overrideDir ?? join(options.frameworkFleet, 'roles.local'), + }); + const declaredProfiles = SHIPPED_FLEET_ARTIFACT_DISPOSITIONS.filter( + ({ disposition }): boolean => disposition === 'canonical-profile', + ).map(({ path }): string => basename(path, '.yaml')); + const resolvedProfiles = new Set(profiles.map(({ id }): string => id)); + for (const profileId of declaredProfiles) { + if (!resolvedProfiles.has(profileId)) { + throw new Error(`declared canonical profile ${profileId} did not resolve`); + } + } + + const servicePolicies = SHIPPED_FLEET_ARTIFACT_DISPOSITIONS.filter( + ({ disposition }): boolean => disposition === 'canonical-service-policy', + ); + for (const artifact of servicePolicies) { + const serviceProfile = await readInteractionServiceProfile( + join(options.frameworkFleet, artifact.path), + ); + provisionInteractionService(serviceProfile, { agentName: 'interaction-example' }); + } + + return SHIPPED_FLEET_ARTIFACT_DISPOSITIONS; +} + +async function assertEveryShippedArtifactIsDeclared(frameworkFleet: string): Promise { + const declared = new Set(SHIPPED_FLEET_ARTIFACT_DISPOSITIONS.map(({ path }): string => path)); + const shipped = await listShippedFleetArtifactPaths(frameworkFleet); + + for (const path of shipped) { + if (!declared.has(path)) { + throw new Error(`undeclared shipped fleet artifact: ${path}`); + } + } + for (const path of declared) { + if (!shipped.has(path)) { + throw new Error(`declared shipped fleet artifact is missing: ${path}`); + } + } +} + +async function listShippedFleetArtifactPaths(frameworkFleet: string): Promise> { + const directories = ['examples', 'profiles', 'services']; + const paths = new Set(); + for (const directory of directories) { + const files = await readdir(join(frameworkFleet, directory)); + for (const file of files) { + if (file.endsWith('.yaml') || file.endsWith('.yml')) paths.add(`${directory}/${file}`); + } + } + return paths; +} From 191efaefeb5c0c6bb218c1292d12ce8e73ace12b Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Wed, 15 Jul 2026 08:40:32 +0000 Subject: [PATCH 052/152] feat(fleet): enforce generated environment boundary (#772) --- docs/SITEMAP.md | 7 + docs/fleet/FLEET-LAUNCH.md | 149 ++-- .../fleet/reference/generated-env-boundary.md | 96 +++ .../fcm-m2-001-generated-env-boundary.md | 110 +++ packages/mosaic/framework/fleet/README.md | 14 +- .../mosaic/framework/systemd/user/README.md | 65 +- .../systemd/user/mosaic-agent@.service | 15 +- .../user/mosaic-interaction-agent@.service | 10 +- .../systemd/user/mosaic-tmux-holder.service | 9 +- .../systemd/user/test-fleet-units.sh | 130 +++- .../tools/fleet/start-agent-session.sh | 456 +++++++----- .../tools/fleet/start-interaction-service.sh | 10 +- .../tools/fleet/start-tmux-holder.sh | 64 ++ .../tools/fleet/test-start-agent-session.sh | 703 +++++++++--------- packages/mosaic/src/commands/fleet.spec.ts | 294 ++++++-- packages/mosaic/src/commands/fleet.ts | 147 ++-- .../src/fleet/generated-env-boundary.spec.ts | 279 +++++++ .../src/fleet/generated-env-boundary.ts | 509 +++++++++++++ 18 files changed, 2282 insertions(+), 785 deletions(-) create mode 100644 docs/fleet/reference/generated-env-boundary.md create mode 100644 docs/scratchpads/fcm-m2-001-generated-env-boundary.md create mode 100755 packages/mosaic/framework/tools/fleet/start-tmux-holder.sh create mode 100644 packages/mosaic/src/fleet/generated-env-boundary.spec.ts create mode 100644 packages/mosaic/src/fleet/generated-env-boundary.ts diff --git a/docs/SITEMAP.md b/docs/SITEMAP.md index 487df036..1ac19d5f 100644 --- a/docs/SITEMAP.md +++ b/docs/SITEMAP.md @@ -1,5 +1,12 @@ # Documentation Sitemap +## Fleet configuration management + +- [Generated environment boundary](fleet/reference/generated-env-boundary.md) — roster-derived launch projection, strict local data, legacy quarantine, and downstream interface evidence. +- [Roster v2 structural contract](fleet/reference/roster-v2-fields.md) — local-tmux schema v2 parsing and structural validation. +- [Role classes and authority](fleet/reference/role-classes.md) — canonical role resolver and protected authority boundaries. +- [Executable asset dispositions](fleet/migration/example-profile-disposition.md) — shipped v1 fixture/profile/service validation posture. + ## Official channel plugins - [Channel protocol architecture](architecture/channel-protocol.md) — shared lifecycle, message, stable-route, authorization, and response-target contracts. diff --git a/docs/fleet/FLEET-LAUNCH.md b/docs/fleet/FLEET-LAUNCH.md index 515aabe6..8b685c7d 100644 --- a/docs/fleet/FLEET-LAUNCH.md +++ b/docs/fleet/FLEET-LAUNCH.md @@ -1,114 +1,77 @@ # Fleet Launch Runbook -How every Mosaic fleet agent — workers **and** the orchestrator — is launched, and how to -configure each one. The guiding principle: **one roster-driven launcher**. There is no bespoke -per-agent launch script; the roster plus per-agent `.env` files are the single source of launch -config. +The local fleet roster is the sole writable desired-state authority for membership and launch policy. +Generated environment files are rebuildable projections, not an operator-editable command surface. -## The launch chain +## Launch chain -| Layer | File | Responsibility | -| ---------------- | ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | -| systemd unit | `mosaic-agent@.service` | One templated unit per role; `ExecStart` runs the session launcher with the instance name `%i`. Defaults `MOSAIC_AGENT_RUNTIME=pi`, `MOSAIC_AGENT_NAME=%i`. | -| session launcher | `tools/fleet/start-agent-session.sh ` | Builds the launch command, opens the tmux pane, wires the heartbeat. | -| launch command | `mosaic yolo ` (or a per-agent override) | Replaces the pane's foreground process with the runtime, fully seeded. | -| seeding | `mosaic`'s `composeContract()` | Injects the Constitution/USER/TOOLS/runtime contract, `*.local` overlays, **and** the Fleet-Comms cheat-sheet — all via `--append-system-prompt`. | +| Layer | Responsibility | +| ------------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| Roster | `fleet/roster.yaml` supplies the agent name, class, supported runtime, model, reasoning, tool policy, workdir, and tmux socket. | +| Projection writer | Renders deterministic `fleet/agents/.env.generated` from the roster. | +| Optional local data | Reads a strict, data-only `fleet/agents/.env.local`; it cannot shadow generated keys. | +| systemd | Starts the launcher with `env -i` and fixed bootstrap data. It does not preload either environment file. | +| session launcher | Validates generated and local data before it queries, creates, or stops an exact tmux session. | +| runtime launch | Derives the fixed `mosaic yolo ` argument array from validated roster data, then seeds the runtime contract. | -Per-agent overrides live in `fleet/agents/.env`, generated from `roster.yaml` by -`generateAgentEnv` (`packages/mosaic/src/commands/fleet.ts`) and consumed by the launcher. +The launcher never `source`s or `eval`s an environment file and never accepts an environment-supplied +command. `MOSAIC_AGENT_COMMAND`, command/channel overrides, unknown keys, generated-key shadowing, +secret-like key names, duplicate keys, comments, quoted/export syntax, and unsafe values are rejected. -## Worker launch path (default) +## Generated and local files -1. `roster.yaml` carries each agent's `runtime` and optional `model_hint`. -2. `generateAgentEnv` emits `fleet/agents/.env` with `MOSAIC_AGENT_NAME`, - `MOSAIC_AGENT_RUNTIME`, and `MOSAIC_AGENT_MODEL`. -3. `start-agent-session.sh` has no `MOSAIC_AGENT_COMMAND` set, so it falls through to the default - (line ~44): - ```sh - MOSAIC_AGENT_COMMAND="mosaic yolo $MOSAIC_AGENT_RUNTIME${MOSAIC_AGENT_MODEL:+ --model $MOSAIC_AGENT_MODEL}" - ``` -4. The launcher bakes `MOSAIC_AGENT_NAME` into the pane command (line ~118), so `composeContract` - can inject the Fleet-Comms cheat-sheet for that role. +`.env.generated` is complete, deterministic, and written only by Mosaic. Its ordered keys are: -That is the whole worker path: roster → `.env` → `mosaic yolo ` → seeded pane. - -## Orchestrator fold (PATH A — ships today) - -The orchestrator is **just another roster agent** launched through the canonical path — not a -snowflake script. - -| Piece | Value | -| ------------------ | ----------------------------------- | -| host-side launcher | `orchestrator-launch.sh` | -| systemd unit | `mosaic-fleet-orchestrator.service` | -| tmux session | `orchestrator` (role-named) | - -Set its launch command via `fleet/agents/orchestrator.env`: - -```sh -MOSAIC_AGENT_COMMAND='mosaic yolo claude --channels plugin:discord@' +```dotenv +MOSAIC_AGENT_NAME= +MOSAIC_AGENT_CLASS= +MOSAIC_AGENT_RUNTIME= +MOSAIC_AGENT_MODEL= +MOSAIC_AGENT_REASONING= +MOSAIC_AGENT_TOOL_POLICY= +MOSAIC_AGENT_WORKDIR= +MOSAIC_TMUX_SOCKET= ``` -When `MOSAIC_AGENT_COMMAND` is set, `start-agent-session.sh`'s `if [ -z "$MOSAIC_AGENT_COMMAND" ]` -guard (line ~41) is false, so the line-44 default — **including its hardcoded `yolo`** — is skipped -entirely. The override fully controls the runtime and flags. Routing through `mosaic yolo claude` -(rather than a raw `claude` invocation) is what gives the orchestrator the same full -`composeContract` seeding + Fleet-Comms cheat-sheet as every worker, with `--channels` and any -other flags passed straight through to the `claude` binary. +The generated launch contract supports `claude`, `codex`, `opencode`, and `pi`. `mosaic fleet add` +rejects another runtime before it writes the roster or modifies generated, local, or quarantine state. +The legacy dogfood stub remains an observability-only canary on its separate `mosaic-factory` socket; +it has no generated-launch adapter and cannot be added through this path. -## Launch gotchas +`.env.local` is optional and may contain only non-secret machine data: -1. **Flag conflict.** `mosaic yolo claude` already injects `--dangerously-skip-permissions`. Do - **not** also pass `--permission-mode bypassPermissions` — the `claude` binary would receive both. - Use `mosaic yolo claude …` alone (yolo covers the unattended posture), **or** non-yolo - `mosaic claude --permission-mode bypassPermissions …`. Never mix the two. -2. **`MOSAIC_AGENT_NAME` must reach the pane.** The launcher bakes it from the instance name, and - `composeContract` gates the Fleet-Comms block on it (`launch.ts`, in `composeContract`) — **and** - the role must be a member of `roster.yaml`, or the block resolves empty. -3. **`launchRuntime` guards.** `mosaic yolo claude` runs `checkSoul` / `checkRuntime` / - `checkSequentialThinking`. The host needs `SOUL.md` and the sequential-thinking MCP, or the - launch aborts (a raw `claude` invocation skipped these checks). Dry-run the composed command in a - throwaway tmux session before swapping a live launcher. +- `MOSAIC_RUNTIME_BIN` +- `MOSAIC_HEARTBEAT_RUN_DIR` +- `MOSAIC_HEARTBEAT_INTERVAL` +- `MOSAIC_CLAUDE_JSON` +- `CLAUDE_CONFIG_DIR` -## Why per-agent `.env` survives upgrades (#632) +Paths must be safe absolute paths and the heartbeat interval must be a positive integer. Projection, +local, and quarantine files must be private regular files; the managed directories must be real, +private, non-symlink paths. Violations fail closed before tmux interaction. -`install.sh` `PRESERVE_PATHS` includes `fleet/*.yaml`, `fleet/agents`, and `fleet/run`, so -`mosaic update`'s framework re-seed **preserves** your roster and per-agent `.env` overrides -(glob-aware `cp` fallback; matching TS parity in `file-adapter.ts`). Before #632, an auto re-seed -could wipe them — which is exactly why PATH A's `.env` override is safe to rely on now. +## Legacy input and diagnostics -## Inspecting the comms wiring +A legacy `.env` is input only during projection generation. Roster-owned keys are regenerated; +valid allowed local data can move to `.env.local`; invalid legacy input is privately retained at +`.env.quarantine`. Neither legacy nor quarantine files are launch authority. -- `mosaic fleet comms-block ` prints the Fleet-Comms cheat-sheet a given role receives at - launch — its `[host:session]` identity, the exact `agent-send.sh` command for each peer, and the - FLIP / `--verify` conventions. `--host ` previews a cross-host view. An unknown role or missing - roster **fails loud** (stderr + non-zero exit), so a typo is never a silent no-op. -- Versus `mosaic compose-contract `: that emits the **whole** system prompt and reads the - role from `MOSAIC_AGENT_NAME` (a full-prompt smoke test). `comms-block` is the targeted, - explicit-arg, comms-only view — e.g. `mosaic fleet comms-block coder0-0` to preview a peer. +Diagnostics expose only rule code, key name, and a SHA-256 content hash. They do not reveal command +text, credentials, or other values. -## North Star / future direction +## Launch and stop behavior -**Vision:** a webUI lets the user edit each agent's launch config — switch **harness** -(claude / pi / codex / opencode), toggle **yolo**, pick a **model**, set a **command/channels** -override — with no terminal. +The launcher obtains the agent's socket only from the validated generated projection. It creates or +checks the exact `=` tmux target; it never uses an ambient socket or fuzzy session match. +The same strict parser runs before exact-stop behavior. A fresh native Pi heartbeat remains authoritative; +the shell sidecar only provides fallback state when the native marker is stale or absent. -**Continuity — this is not a new launch path.** It is a data-model + UI-binding layer over the -existing roster-driven launcher. Field-by-field status today: +`mosaic fleet comms-block ` can inspect the role's resolved Fleet-Comms block. It is a read-only +inspection tool and fails loudly for an unknown role or missing roster. -| Launch-config field | Roster-native today? | Mechanism / gap | -| ------------------------ | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **harness** (`runtime`) | ✅ end-to-end | `roster.runtime` → `generateAgentEnv` emits `MOSAIC_AGENT_RUNTIME` → launcher line 44. UI just writes the field. | -| **model** (`model_hint`) | ✅ end-to-end | `roster.model_hint` → `MOSAIC_AGENT_MODEL` → launcher line 44 `--model`. UI just writes the field. | -| **yolo** | ❌ new | Launcher line 44 **hardcodes** `mosaic yolo`. A non-yolo toggle needs a roster `yolo` field → emit `MOSAIC_AGENT_YOLO` → make line 44 conditional. | -| **command / channels** | ❌ new | `MOSAIC_AGENT_COMMAND` is **consumed** (launcher line ~12) but `generateAgentEnv` does not emit it. Needs a roster `command`/`channels` field → emitted. | +## Current M2 boundary -**The arc:** - -- **A** — `.env` `MOSAIC_AGENT_COMMAND` hatch: manual, ships now, kept safe across upgrades by #632. -- **B** — roster-native launch-config: harness + model are already there; add the **yolo** toggle - (line-44 conditional) and **command/channels** emission to complete the data model. -- **webUI** — binds dropdowns/toggles directly to those four roster fields. - -PATH A's `.env` override is the **manual form** of exactly what PATH B makes roster-native and the -webUI edits — one continuous arc, not three separate features. PATH B is tracked as #636. +FCM-M2-001 supplies generated/local parsing, validation, projection, quarantine, and launch-boundary +evidence only. It does not authorize roster CRUD expansion, reconciliation, lifecycle changes, remote +or connector mutation, site canaries, or migration. M3 must establish the local reconcile/lifecycle +path; M4 separately provides migration preview, canary, and rollback gates. diff --git a/docs/fleet/reference/generated-env-boundary.md b/docs/fleet/reference/generated-env-boundary.md new file mode 100644 index 00000000..19d85c8c --- /dev/null +++ b/docs/fleet/reference/generated-env-boundary.md @@ -0,0 +1,96 @@ +# Fleet Generated Environment Boundary + +**Card:** FCM-M2-001 · **Issue:** #758 · **Status:** unreleased/card-local + +The local fleet roster is the desired-state authority. A launch reads a deterministic, +roster-derived generated projection and an optional strictly data-only local file; neither file is +a second roster or a command configuration surface. + +## Paths and ownership + +For agent `` under `/fleet/agents/`: + +| Path | Owner | Purpose | +| ----------------------- | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------- | +| `.env.generated` | Mosaic projection writer | Complete deterministic launch data rendered from the authoritative roster. | +| `.env.local` | Operator | Optional, constrained local machine data. It cannot shadow generated keys. | +| `.env` | Legacy input only | Read once during projection generation, then regenerated/relocated or privately quarantined. It is never a launch authority. | +| `.env.quarantine` | Mosaic quarantine | Mode-`0600` private record of forbidden legacy input; it is never read by the launcher. | + +The systemd templates do not load either environment file. They invoke Bash with a fixed, cleared +bootstrap environment; the launcher reads and validates `.env.generated` and `.env.local` itself before +it queries, creates, or stops an exact tmux session. It does not `source`, `eval`, or execute an +environment-supplied command. Exact stop derives its socket from the same validated generated projection, +not from systemd or ambient environment data. + +All projection, local, and quarantine files must be regular files with no group or world permissions. +The agent environment directory must also be a real, non-symlink private directory; it is validated +before either environment file is read or tmux is queried. Unsafe paths, symlinks, or permissions fail +closed. Diagnostics identify only a rule code, key name, and SHA-256 content hash; they never print +values, credential material, or command text. + +## Allowed data + +`.env.generated` is complete and ordered exactly as follows: + +```dotenv +MOSAIC_AGENT_NAME= +MOSAIC_AGENT_CLASS= +MOSAIC_AGENT_RUNTIME= +MOSAIC_AGENT_MODEL= +MOSAIC_AGENT_REASONING= +MOSAIC_AGENT_TOOL_POLICY= +MOSAIC_AGENT_WORKDIR= +MOSAIC_TMUX_SOCKET= +``` + +The generated launch contract supports only `claude`, `codex`, `opencode`, and `pi`. `fleet add` +uses that same runtime authority and rejects any other runtime before it writes the roster or changes +projection, local, or quarantine files. The legacy dogfood stub on its separate `mosaic-factory` +socket remains an observability canary; it has no generated-launch adapter and cannot be added through +this projection path. + +`.env.local` may contain only these non-secret data keys: + +- `MOSAIC_RUNTIME_BIN` +- `MOSAIC_HEARTBEAT_RUN_DIR` +- `MOSAIC_HEARTBEAT_INTERVAL` +- `MOSAIC_CLAUDE_JSON` +- `CLAUDE_CONFIG_DIR` + +Local paths must be safe absolute paths and the interval must be a positive integer. Comments, +quoted/export syntax, duplicate keys, unknown keys, generated-key shadowing, sensitive key names, +and `MOSAIC_AGENT_COMMAND` are rejected. The launcher derives the only executable command from the +validated runtime, model, and reasoning data; no arbitrary command compatibility path exists. When a +Pi runtime writes a fresh `.hb.native` marker, its native heartbeat remains authoritative; the +shell sidecar resumes its `status=ok` fallback only after that marker is stale or absent. + +## Legacy disposition + +During projection generation, legacy roster-derived keys are regenerated from the roster. A valid +allowed local value is relocated to `.env.local`; forbidden, malformed, duplicate, sensitive, and +unknown legacy entries cause the legacy file to be moved to `.env.quarantine` and are represented by +sanitized diagnostics. This is deterministic and idempotent after the legacy file has been consumed. + +## USC interface packet + +This card does not add a USC site file, write a USC roster, or run a site canary. The following is the +consolidated downstream interface packet. Status is deliberately separated from checkout presence: no +product release version has been evidenced for this interface set. + +| Interface | Canonical public path and version | Tracker/release status | Downstream limit | +| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | +| M1 structural compiler | `parseRosterV2` in `packages/mosaic/src/fleet/roster-v2.ts`; schema `docs/fleet/reference/roster-v2.schema.json`; roster `version: 2` | FCM-M1-001 is recorded done, merged as #764 (`aa5b43b`); no released product version is asserted here. | Parse YAML/JSON and canonicalize a supplied v2 site roster without writes. | +| M1 semantic resolver | `validateRosterV2Semantics` in `packages/mosaic/src/fleet/roster-v2.ts`; baseline `framework/fleet/roles/` plus `roles.local/` | FCM-M1-002 remains `in-progress` in `docs/TASKS.md`; unreleased. | Reuse the shared resolver only; no parallel role resolver or lifecycle action. | +| M1 disposition evidence | `packages/mosaic/src/fleet/example-profile-dispositions.ts`; `docs/fleet/migration/example-profile-disposition.md`; retained fixture `version: 1` | FCM-M1-003 remains `not-started` in `docs/TASKS.md`; unreleased even though these checkout artifacts are inspectable. | Inspect fixture/profile/service disposition evidence only; it is not migration authorization. | +| M2 generated boundary | `packages/mosaic/src/fleet/generated-env-boundary.ts`; generated projection contract in this document | FCM-M2-001 card-local and uncommitted; unreleased. | Render/write a roster-derived projection; local input is never authority. | + +The canonical source remains `/fleet/roster.yaml` for the current local fleet path. +Generated environment data is a rebuildable projection, not an operator-editable source of membership, +runtime policy, or lifecycle state. + +**Corrected downstream gates:** M2 supplies only parse/validation/projection evidence and does not +permit a USC site canary, reconciliation, or lifecycle mutation. M3 must first define and validate the +canonical local reconcile/lifecycle path. M4 then supplies preview/migration and its separate +canary/rollback gates; only after those M3 and M4 gates may a site migration or canary be considered. +This card authorizes none of those actions. diff --git a/docs/scratchpads/fcm-m2-001-generated-env-boundary.md b/docs/scratchpads/fcm-m2-001-generated-env-boundary.md new file mode 100644 index 00000000..f71c46dd --- /dev/null +++ b/docs/scratchpads/fcm-m2-001-generated-env-boundary.md @@ -0,0 +1,110 @@ +# FCM-M2-001 — Generated Environment Boundary + +- **Issue/card:** #758 / FCM-M2-001 +- **Branch/base:** `feat/758-generated-env-boundary` from `origin/main` `e9c4aa3e8b3780719cd5a43c0ef3f37fc70de666` +- **Budget assumption:** 30K-card budget; implement only the deterministic generated/local environment boundary and its launch-chain/docs/tests. + +## Objective + +Replace the generic fleet agent `.env` authority/merge path with a deterministic roster-derived `.env.generated` projection and strict, data-only `.env.local`. The roster remains the desired-state authority. Reject bad input before the launcher creates a tmux session; never print sensitive or privileged-command values. + +## Scope and non-goals + +- In scope: deterministic render/write, strict generated/local parse rules, legacy `.env` disposition/quarantine, systemd/launcher boundary, permission/path checks, focused fail-closed tests, operator/reference documentation, USC interface evidence. +- Excluded: roster CRUD/mutation, v2 roster schema changes, lifecycle/reconcile/apply behavior, migration/canary rollout, connectors, remote surfaces, live-fleet actions, and M2-002. + +## Plan + +1. Add red tests for generated-key shadowing, malformed/duplicate/unknown/command/sensitive input, no-value diagnostics, deterministic/idempotent projection, secure file modes, and legacy disposition. +2. Implement a pure strict environment contract plus atomic projection/quarantine helper. +3. Replace the generic `.env` writer/merge path and systemd reference with `.env.generated` + `.env.local` ownership. +4. Make the shell launcher parse the files without `source`/`eval`, reject unsafe input before tmux creation, and construct only the roster-derived runtime command. +5. Add operator/reference documentation with the requested USC M1 interface evidence and M2–M4 gate statement. +6. Run focused/package/root gates and audit the USC interface packet. Per continuation scope, stop before review, commit, push, PR, or live mutation. + +## Initial evidence + +- No existing owner: target worktree path absent; no target local/remote branch; `pr-list.sh -s open` returned no open PRs. +- M1 compiler/API/docs and executable disposition evidence are present at the assigned base. +- Existing launch chain writes `fleet/agents/.env`, preserves arbitrary legacy lines via `mergeAgentEnv`, sources `MOSAIC_AGENT_COMMAND`, and executes it through `bash -c`; all are M2 remediation targets. +- `~/.config/mosaic/guides/SECURITY.md` is absent. Read the available security-review role contract and the vault/secrets guide instead. + +## Verification log + +### Continuation (2026-07-14) + +- Preserved the inherited 14-file delta; no reset, stash, rebase, roster mutation, lifecycle action, + live-fleet action, commit, push, or PR action was performed. +- Focused gates passed: + - `pnpm --dir packages/mosaic test -- src/fleet/generated-env-boundary.spec.ts` — 1 file, 10 tests passed. + - `bash packages/mosaic/framework/tools/fleet/test-start-agent-session.sh` — passed. + - `bash packages/mosaic/framework/systemd/user/test-fleet-units.sh` — passed. + - `pnpm --dir packages/mosaic test -- src/commands/fleet.spec.ts` — 1 file, 192 tests passed. +- Package gates passed before final documentation/format follow-up: + - `pnpm --dir packages/mosaic typecheck` — passed. + - `pnpm --dir packages/mosaic lint` — passed. + - `pnpm --dir packages/mosaic test` — 52 files, 752 tests passed. +- `pnpm format:check` initially failed only for the new boundary reference and generated-boundary + TypeScript files; targeted Prettier normalization was applied. A final `pnpm format:check` passed. +- USC packet audit: the M1 structural compiler is `parseRosterV2` with roster `version: 2`; the + semantic resolver is `validateRosterV2Semantics`; disposition artifacts retain `version: 1` fixture + evidence. `docs/TASKS.md` records M1-001 done, M1-002 in-progress, and M1-003 not-started; no + product release version is claimed. The packet now distinguishes these statuses from checkout + artifact presence and states the M2 → M3 → M4 downstream gates. + +## Review remediation (2026-07-14) + +- **Blocker 1 red-first:** Added a launcher reproducer with a `0777` `fleet/agents` parent and a private generated file. Before implementation, `bash packages/mosaic/framework/tools/fleet/test-start-agent-session.sh` failed: `FAIL: generated file under a world-writable parent was accepted`. The failure occurred after the launch path reached fake tmux, proving the parent was not validated. +- **Blocker 2 red-first:** Added a `symlink()` projection-directory reproducer that preloads generated/local/quarantine/legacy target files and asserts no target mutation. Before implementation, `pnpm --dir packages/mosaic test -- src/fleet/generated-env-boundary.spec.ts` failed the new test because the existing writer followed the `agentEnvDir` symlink and parsed its target legacy input (`expected /unsafe-directory/i`, received `code=malformed-line`). The initial test-only missing `mkdir` import was corrected before recording this behavior failure. +- **Blocker 3 red-first:** Added fresh/stale/absent native-heartbeat regression coverage. Before implementation, an isolated fake-tmux launcher reproducer with a fresh `.hb.native` marker failed `FAIL: fresh native heartbeat was overwritten`; the existing sidecar immediately replaced native `status=busy`/`model` content. +- **Remediation result:** The launcher now rejects a group/world-accessible or symlinked `fleet/agents` parent before an environment read or tmux call. The projection writer uses `lstat` before chmod/write processing and rejects a symlinked directory without creating generated/local/quarantine files or deleting legacy input. The heartbeat sidecar defers to a fresh non-symlink native marker and falls back when stale/absent. Focused green evidence before independent review: `bash packages/mosaic/framework/tools/fleet/test-start-agent-session.sh` and `pnpm --dir packages/mosaic test -- src/fleet/generated-env-boundary.spec.ts` (11 tests) passed. +- **Independent-review follow-up red-first:** Codex code review returned one blocker and security review one medium CWE-732 finding: the writer repaired an already `0777` directory with `chmod` before trusting its contents. Added a reproducer with a safe local file beneath an existing `0777` directory. Before the follow-up fix, `pnpm --dir packages/mosaic test -- src/fleet/generated-env-boundary.spec.ts` failed because the promise resolved and wrote `coder0.env.generated` instead of rejecting. +- **Independent-review remediation:** Existing directories now pass non-following private-directory validation before any read or chmod; only a directory created in this call is normalized to `0700`. The fleet-add test fixture now creates its simulated trusted `fleet/agents` boundary at `0700`; this corrects fixture setup to match the new required contract rather than weakening the rejection assertion. Focused reruns passed: generated-boundary 12 tests, launcher boundary suite, and fleet suite 192 tests. +- **Final verification before re-review:** Launcher + systemd suites passed; package suite passed (52 files, 754 tests); package lint/typecheck, root typecheck (42 tasks), format check, and diff check passed. The rerun code review still reports a tmux command-arity blocker, and the security rerun reports systemd `EnvironmentFile` pre-validation injection findings for both agent units. These were discovered after the specified three-remediation scope; no additional source changes were made. Independent review therefore remains `REQUEST CHANGES` despite the requested three fixes passing their behavioral suites. + +## Systemd pre-validation remediation (2026-07-14) + +- **Red-first:** Updated the fleet unit contract to reject any `EnvironmentFile=` projection preload, require a cleared bootstrap environment, and require a validated exact-stop path. Before implementation, `bash packages/mosaic/framework/systemd/user/test-fleet-units.sh` failed: `FAIL: agent units must not preload projections before strict parsing`. +- **Red-first parser/stop coverage:** Added interaction-wrapper and exact-stop cases to the launcher boundary suite. Before implementation, `bash packages/mosaic/framework/tools/fleet/test-start-agent-session.sh` failed: `FAIL: interaction did not use shared strict parser first`, because the interaction wrapper consumed inherited environment before projection validation. +- **Focused green:** Both unit templates now use `env -i` with fixed `HOME`, agent instance, and PATH; neither has `Environment=`/`EnvironmentFile=`. The interaction wrapper delegates to `start-agent-session.sh --interaction`, so strict generated/local parsing precedes pinned Pi profile checks. `--stop` reuses the strict generated parser before exact `=` socket/session termination. Passed: systemd unit suite, launcher boundary suite (including malformed interaction, pinned profile, and ambient-socket stop cases), and 210 focused TypeScript tests. +- **Final verification:** `pnpm --dir packages/mosaic test` passed (52 files, 754 tests); package lint/typecheck, root typecheck (42 tasks), format/diff, and shell syntax checks passed. Security review passed with no findings. Code review repeated the previously refuted tmux argv concern and a pre-existing Claude trust-lock suggestion; per the assigned narrow follow-up, no tmux or unrelated trust-path change was made. + +## Risks and next review + +- This card is uncommitted and unreleased. The canonical tracker still records its dependencies as + M1-002 in progress and M1-003 not started; this continuation does not reinterpret those task states. +- Final post-documentation checks passed: `pnpm --dir packages/mosaic typecheck`, + `pnpm --dir packages/mosaic lint`, `pnpm --dir packages/mosaic test` (52 files, 752 tests), + `pnpm typecheck` (42 Turbo tasks), and `pnpm format:check`. +- Obtain independent code and security review of the complete delta next. Do not run commit, push, + PR, or live-fleet commands in this continuation. + +## Fresh-install directory remediation (2026-07-14) + +- **Objective:** Remediate only the fresh-install path where `installFleet` created `fleet/agents` + with host-umask permissions before the boundary writer correctly rejected it. +- **Plan:** Add a real `fleet install --no-enable` integration reproducer; prove red; let the + existing boundary writer own directory creation; run focused and full gates. No commit, push, + PR, review disposition, or live-fleet action. +- **Red evidence:** Before the one-line remediation, + `pnpm --dir packages/mosaic test -- src/commands/fleet.spec.ts` failed the new test with + `AgentEnvBoundaryError: code=unsafe-permissions` at `ensurePrivateProjectionDirectory`, after + `installFleet` pre-created the directory. +- **Change:** Removed only the recursive `mkdir(activePaths.agentEnvDir)` in `installFleet`. + `writeAgentEnvironmentProjection` remains the sole creator and retains its existing `lstat`, + private-directory, symlink, and existing-unsafe-directory fail-closed checks. +- **Focused green:** `pnpm --dir packages/mosaic test -- src/commands/fleet.spec.ts` — 193 tests + passed. The new integration executes a fresh `fleet install --no-enable`, asserts a real + non-symlink `0700` directory and a `0600` generated projection. Existing unsafe-directory + coverage remains in `generated-env-boundary.spec.ts` and asserts no chmod repair/no generated + file write. +- **Full gates green:** generated-boundary 12 tests; launcher and systemd suites; package + typecheck/lint and 52 files / 755 tests; root typecheck (42 tasks), lint, format, diff check, + and root test (42 tasks) all passed. +- **Independent review:** The complete inherited uncommitted delta still has Codex `REQUEST CHANGES` + findings outside this narrow fix (tmux command arity and Claude trust-lock regression), plus a + security-review medium finding on unvalidated writable ancestor directories. No out-of-scope + source changes were made. +- **Risk:** The writer's existing create-then-validate sequence is relied on for the creation + boundary; a concurrent substitution causes fail-closed validation rather than repair. The + review findings above remain residual risks for the complete card delta. diff --git a/packages/mosaic/framework/fleet/README.md b/packages/mosaic/framework/fleet/README.md index 36cfb1e1..df42327f 100644 --- a/packages/mosaic/framework/fleet/README.md +++ b/packages/mosaic/framework/fleet/README.md @@ -9,7 +9,8 @@ package, normally at: ``` The default tmux socket is `mosaic-fleet` so fleet commands do not touch the -default tmux server. +default tmux server. The roster is the desired-state authority; generated environment files are +rebuildable projections, never a second source of configuration. ## Examples @@ -31,6 +32,17 @@ The installed `tools/fleet/print-interaction-effective-policy.sh` prints only the resolved name, runtime, model, reasoning, and tool policy. It never reads or prints credential variables. +## Generated agent environment boundary + +`mosaic fleet install` writes a private deterministic projection at +`~/.config/mosaic/fleet/agents/.env.generated`. It may relocate only approved local machine +data to `.env.local`; generated keys, arbitrary commands, secret-like keys, duplicate keys, +unknown keys, and unsafe permissions fail before a tmux session is created. Legacy `.env` input is +regenerated, relocated, or quarantined and is not a launch authority. + +See [`docs/fleet/reference/generated-env-boundary.md`](../../../../docs/fleet/reference/generated-env-boundary.md) +for allowed local keys and the USC downstream interface evidence. + Initialize a roster: ```bash diff --git a/packages/mosaic/framework/systemd/user/README.md b/packages/mosaic/framework/systemd/user/README.md index 1811ead4..e0df756f 100644 --- a/packages/mosaic/framework/systemd/user/README.md +++ b/packages/mosaic/framework/systemd/user/README.md @@ -24,45 +24,56 @@ The agent template calls: which starts or reuses a tmux session on `MOSAIC_TMUX_SOCKET`. -## Local customization +## Generated environment and local data -Per-agent overrides live outside the package in: +The roster-derived projection is written outside the package at: ```text -~/.config/mosaic/fleet/agents/.env +~/.config/mosaic/fleet/agents/.env.generated ``` -Example: +Systemd does not read either environment file. It starts the launcher with a fixed cleared bootstrap +environment; before it creates, queries, or stops an exact agent tmux session, `start-agent-session.sh` +strictly parses the generated projection and the optional local data file: -```dotenv -MOSAIC_TMUX_SOCKET=mosaic-fleet -MOSAIC_AGENT_RUNTIME=claude -MOSAIC_AGENT_WORKDIR=$HOME/src/your-project -# Optional escape hatch for PoC/canary agents: -# MOSAIC_AGENT_COMMAND=mosaic yolo claude +```text +~/.config/mosaic/fleet/agents/.env.local ``` +The local file may contain only safe machine-specific data (`MOSAIC_RUNTIME_BIN`, heartbeat paths or +interval, and Claude configuration paths). It cannot override roster-derived keys, carry a command, +or contain secret-like/unknown keys. Both files must be private regular files. Do not hand-edit the +generated projection; update the roster and regenerate it instead. A legacy `.env` is +consumed only for regeneration, strict relocation, or private quarantine and is never launch input. + +See `docs/fleet/reference/generated-env-boundary.md` for the full contract. + ## Manual canary sequence -```bash -mkdir -p ~/.config/systemd/user ~/.config/mosaic/tools/fleet ~/.config/mosaic/fleet/agents -cp packages/mosaic/framework/systemd/user/mosaic-*.service ~/.config/systemd/user/ -cp packages/mosaic/framework/tools/fleet/*.sh ~/.config/mosaic/tools/fleet/ -chmod +x ~/.config/mosaic/tools/fleet/*.sh -systemctl --user daemon-reload -systemctl --user start mosaic-tmux-holder.service -systemctl --user start mosaic-agent@canary.service -tmux -L mosaic-fleet ls +Use the roster and the supported installer; do not pre-create the agent environment directory or +edit a generated projection. `mosaic fleet install` validates the roster, installs the units and +helpers, and writes private roster-derived projections before any service is started. -# For an operator-interaction service, the roster/env identity selects the -# generic unit instance; no service source is renamed for an instance. +```bash +# Create a site-owned canary roster. Inspect an existing roster before using --force. +mosaic fleet init --profile minimal --write +mosaic fleet install +systemctl --user daemon-reload +mosaic fleet start canary-pi +tmux -L mosaic-fleet ls +``` + +For an operator-interaction service, first put `` in the roster with the pinned Pi +runtime, model, reasoning, and `operator-interaction` tool policy. Re-run `mosaic fleet install` after +that roster change so it writes `.env.generated`; ambient `MOSAIC_AGENT_*` values are not +launch authority. The generic unit instance uses that generated identity, and no service source is +renamed for an instance: + +```bash +mosaic fleet install +systemctl --user daemon-reload systemctl --user start mosaic-interaction-agent@.service -MOSAIC_AGENT_NAME= \ -MOSAIC_AGENT_RUNTIME=pi \ -MOSAIC_AGENT_MODEL=openai/gpt-5.6-sol \ -MOSAIC_AGENT_REASONING=high \ -MOSAIC_AGENT_TOOL_POLICY=operator-interaction \ - ~/.config/mosaic/tools/fleet/print-interaction-effective-policy.sh +~/.config/mosaic/tools/fleet/print-interaction-effective-policy.sh ``` Do not use `tmux kill-server` without `-L mosaic-fleet`; this pattern is meant diff --git a/packages/mosaic/framework/systemd/user/mosaic-agent@.service b/packages/mosaic/framework/systemd/user/mosaic-agent@.service index 0ebdec9c..f4d4a985 100644 --- a/packages/mosaic/framework/systemd/user/mosaic-agent@.service +++ b/packages/mosaic/framework/systemd/user/mosaic-agent@.service @@ -7,16 +7,13 @@ PartOf=mosaic-tmux-holder.service [Service] Type=oneshot +# Remove loader and noninteractive-shell controls before ExecStart loads env. +UnsetEnvironment=LD_PRELOAD BASH_ENV ENV RemainAfterExit=yes -# No default MOSAIC_TMUX_SOCKET: an absent roster socket means the literal -# default tmux socket (no -L). The per-agent .env sets it when the roster names -# one; otherwise it stays unset and start-agent-session.sh uses the default socket. -Environment=MOSAIC_AGENT_NAME=%i -Environment=MOSAIC_AGENT_RUNTIME=pi -Environment=MOSAIC_AGENT_WORKDIR=%h -EnvironmentFile=-%h/.config/mosaic/fleet/agents/%i.env -ExecStart=/bin/bash %h/.config/mosaic/tools/fleet/start-agent-session.sh %i -ExecStop=-/bin/bash -lc 'if [ -n "${MOSAIC_TMUX_SOCKET:-}" ]; then tmux -L "$MOSAIC_TMUX_SOCKET" kill-session -t "=%i"; else tmux kill-session -t "=%i"; fi' +# Never preload the projection. The launcher starts from a fixed minimal +# environment and strictly validates generated/local data before tmux effects. +ExecStart=/usr/bin/env -i HOME=%h MOSAIC_AGENT_NAME=%i PATH=/usr/bin:/bin /bin/bash --noprofile --norc %h/.config/mosaic/tools/fleet/start-agent-session.sh %i +ExecStop=-/usr/bin/env -i HOME=%h MOSAIC_AGENT_NAME=%i PATH=/usr/bin:/bin /bin/bash --noprofile --norc %h/.config/mosaic/tools/fleet/start-agent-session.sh --stop %i [Install] WantedBy=default.target diff --git a/packages/mosaic/framework/systemd/user/mosaic-interaction-agent@.service b/packages/mosaic/framework/systemd/user/mosaic-interaction-agent@.service index 346dbd80..9bc5831d 100644 --- a/packages/mosaic/framework/systemd/user/mosaic-interaction-agent@.service +++ b/packages/mosaic/framework/systemd/user/mosaic-interaction-agent@.service @@ -7,11 +7,13 @@ PartOf=mosaic-tmux-holder.service [Service] Type=oneshot +# Remove loader and noninteractive-shell controls before ExecStart loads env. +UnsetEnvironment=LD_PRELOAD BASH_ENV ENV RemainAfterExit=yes -Environment=MOSAIC_AGENT_NAME=%i -EnvironmentFile=%h/.config/mosaic/fleet/agents/%i.env -ExecStart=/bin/bash %h/.config/mosaic/tools/fleet/start-interaction-service.sh %i -ExecStop=-/bin/bash -lc 'if [ -n "${MOSAIC_TMUX_SOCKET:-}" ]; then tmux -L "$MOSAIC_TMUX_SOCKET" kill-session -t "=%i"; else tmux kill-session -t "=%i"; fi' +# The interaction wrapper delegates to the shared strict parser before pinned +# profile checks; no projection data reaches Bash through systemd. +ExecStart=/usr/bin/env -i HOME=%h MOSAIC_AGENT_NAME=%i PATH=/usr/bin:/bin /bin/bash --noprofile --norc %h/.config/mosaic/tools/fleet/start-interaction-service.sh %i +ExecStop=-/usr/bin/env -i HOME=%h MOSAIC_AGENT_NAME=%i PATH=/usr/bin:/bin /bin/bash --noprofile --norc %h/.config/mosaic/tools/fleet/start-agent-session.sh --stop %i [Install] WantedBy=default.target diff --git a/packages/mosaic/framework/systemd/user/mosaic-tmux-holder.service b/packages/mosaic/framework/systemd/user/mosaic-tmux-holder.service index a4ae3aed..7795efdf 100644 --- a/packages/mosaic/framework/systemd/user/mosaic-tmux-holder.service +++ b/packages/mosaic/framework/systemd/user/mosaic-tmux-holder.service @@ -6,10 +6,11 @@ After=default.target [Service] Type=oneshot RemainAfterExit=yes -Environment=MOSAIC_TMUX_SOCKET=mosaic-fleet -Environment=MOSAIC_TMUX_HOLDER=_holder -ExecStart=/bin/bash -lc 'tmux -L "$MOSAIC_TMUX_SOCKET" has-session -t "=${MOSAIC_TMUX_HOLDER}:0.0" 2>/dev/null || tmux -L "$MOSAIC_TMUX_SOCKET" new-session -d -s "$MOSAIC_TMUX_HOLDER" "while true; do sleep 3600; done"' -ExecStop=-/bin/bash -lc 'tmux -L "$MOSAIC_TMUX_SOCKET" kill-server' +# The holder owns the tmux server, so clear loader, shell-control, and stale +# manager/session variables before the server process starts. +UnsetEnvironment=LD_PRELOAD BASH_ENV ENV +ExecStart=/usr/bin/env -i HOME=%h PATH=/usr/bin:/bin MOSAIC_TMUX_SOCKET=mosaic-fleet MOSAIC_TMUX_HOLDER=_holder /bin/bash --noprofile --norc %h/.config/mosaic/tools/fleet/start-tmux-holder.sh +ExecStop=-/usr/bin/env -i HOME=%h PATH=/usr/bin:/bin MOSAIC_TMUX_SOCKET=mosaic-fleet /bin/bash --noprofile --norc -c 'tmux -L "$MOSAIC_TMUX_SOCKET" kill-server' [Install] WantedBy=default.target diff --git a/packages/mosaic/framework/systemd/user/test-fleet-units.sh b/packages/mosaic/framework/systemd/user/test-fleet-units.sh index 041a3251..6973a9ce 100755 --- a/packages/mosaic/framework/systemd/user/test-fleet-units.sh +++ b/packages/mosaic/framework/systemd/user/test-fleet-units.sh @@ -5,6 +5,8 @@ SCRIPT_DIR=$(cd -- "$(dirname -- "$0")" && pwd) HOLDER="$SCRIPT_DIR/mosaic-tmux-holder.service" AGENT="$SCRIPT_DIR/mosaic-agent@.service" INTERACTION="$SCRIPT_DIR/mosaic-interaction-agent@.service" +HOLDER_START="$SCRIPT_DIR/../../tools/fleet/start-tmux-holder.sh" +START_AGENT="$SCRIPT_DIR/../../tools/fleet/start-agent-session.sh" fail() { echo "FAIL: $*" >&2 @@ -14,16 +16,40 @@ fail() { [ -f "$HOLDER" ] || fail "missing mosaic-tmux-holder.service" [ -f "$AGENT" ] || fail "missing mosaic-agent@.service" [ -f "$INTERACTION" ] || fail "missing mosaic-interaction-agent@.service" +[ -x "$HOLDER_START" ] || fail "missing executable start-tmux-holder.sh" +[ -x "$START_AGENT" ] || fail "missing executable start-agent-session.sh" grep -qF 'ExecStart=' "$HOLDER" || fail "holder has no ExecStart" grep -qF 'tmux -L' "$HOLDER" || fail "holder does not use named tmux socket" grep -qF '_holder' "$HOLDER" || fail "holder session is not explicit" +grep -qF 'UnsetEnvironment=LD_PRELOAD BASH_ENV ENV' "$HOLDER" || \ + fail "holder does not remove loader and shell-control variables" +grep -qF 'ExecStart=/usr/bin/env -i HOME=%h PATH=/usr/bin:/bin MOSAIC_TMUX_SOCKET=mosaic-fleet MOSAIC_TMUX_HOLDER=_holder /bin/bash --noprofile --norc %h/.config/mosaic/tools/fleet/start-tmux-holder.sh' "$HOLDER" || \ + fail "holder does not clear manager environment before starting tmux" +grep -qF 'ExecStop=-/usr/bin/env -i HOME=%h PATH=/usr/bin:/bin MOSAIC_TMUX_SOCKET=mosaic-fleet /bin/bash --noprofile --norc -c' "$HOLDER" || \ + fail "holder stop does not clear manager environment" +if grep -qF -- '/bin/bash -lc' "$HOLDER"; then + fail "holder must not start tmux through a login shell" +fi grep -qF 'Requires=mosaic-tmux-holder.service' "$AGENT" || fail "agent does not require holder" grep -qF 'start-agent-session.sh' "$AGENT" || fail "agent unit does not call start-agent-session.sh" -grep -qF 'kill-session -t "=%i"' "$AGENT" || fail "agent stop does not exact-match its session" +if grep -qE '^Environment(File)?=' "$AGENT" "$INTERACTION"; then + fail "agent units must not accept ambient or projection environment before strict parsing" +fi +grep -qF 'UnsetEnvironment=LD_PRELOAD BASH_ENV ENV' "$AGENT" || \ + fail "agent unit does not remove loader and shell-control variables" +grep -qF 'UnsetEnvironment=LD_PRELOAD BASH_ENV ENV' "$INTERACTION" || \ + fail "interaction unit does not remove loader and shell-control variables" +grep -qF 'ExecStart=/usr/bin/env -i HOME=%h MOSAIC_AGENT_NAME=%i PATH=/usr/bin:/bin /bin/bash --noprofile --norc' "$AGENT" || \ + fail "agent unit does not clear bootstrap environment before strict parsing" +grep -qF 'start-agent-session.sh --stop %i' "$AGENT" || \ + fail "agent stop does not use the validated exact-stop path" grep -qF 'Requires=mosaic-tmux-holder.service' "$INTERACTION" || fail "interaction service does not require holder" -grep -qF 'EnvironmentFile=%h/.config/mosaic/fleet/agents/%i.env' "$INTERACTION" || fail "interaction service does not require per-agent config" -grep -qF 'start-interaction-service.sh %i' "$INTERACTION" || fail "interaction service does not validate before startup" +grep -qF 'ExecStart=/usr/bin/env -i HOME=%h MOSAIC_AGENT_NAME=%i PATH=/usr/bin:/bin /bin/bash --noprofile --norc' "$INTERACTION" || \ + fail "interaction unit does not clear bootstrap environment before strict parsing" +grep -qF 'start-interaction-service.sh %i' "$INTERACTION" || fail "interaction service does not use shared strict parsing" +grep -qF 'start-agent-session.sh --stop %i' "$INTERACTION" || \ + fail "interaction stop does not use the validated exact-stop path" if command -v systemd-analyze >/dev/null 2>&1; then systemd-analyze verify --user "$HOLDER" "$AGENT" "$INTERACTION" >/tmp/mosaic-fleet-systemd-verify.log 2>&1 || { @@ -32,4 +58,102 @@ if command -v systemd-analyze >/dev/null 2>&1; then } fi +# Real isolated socket regression: a preexisting server with an LD_PRELOAD +# constructor marker must fail closed, while a fresh named server is created. +if command -v tmux >/dev/null 2>&1 && command -v cc >/dev/null 2>&1; then + TEST_ROOT=$(mktemp -d) + TEST_SOCKET="mosaic-holder-test-$$" + trap 'tmux -L "$TEST_SOCKET" kill-server >/dev/null 2>&1 || true; rm -rf "$TEST_ROOT"' EXIT + MARKER="$TEST_ROOT/loader-marker" + LIBRARY="$TEST_ROOT/marker.so" + HOLDER_HOME="$TEST_ROOT/holder-home" + mkdir -p "$HOLDER_HOME/.config/mosaic/fleet/run" + chmod 700 "$HOLDER_HOME/.config" "$HOLDER_HOME/.config/mosaic" \ + "$HOLDER_HOME/.config/mosaic/fleet" "$HOLDER_HOME/.config/mosaic/fleet/run" + printf '123e4567-e89b-12d3-a456-426614174000\n' > \ + "$HOLDER_HOME/.config/mosaic/fleet/run/holder-owner" + chmod 600 "$HOLDER_HOME/.config/mosaic/fleet/run/holder-owner" + cat > "$TEST_ROOT/marker.c" <<'EOF' +#include +#include +#include +__attribute__((constructor)) static void mark_loader(void) { + const char *path = getenv("MOSAIC_LOADER_MARKER"); + if (path != NULL) { + int fd = open(path, O_WRONLY | O_CREAT | O_APPEND, 0600); + if (fd >= 0) { write(fd, "loaded\\n", 7); close(fd); } + } +} +EOF + cc -shared -fPIC -o "$LIBRARY" "$TEST_ROOT/marker.c" + MOSAIC_LOADER_MARKER="$MARKER" LD_PRELOAD="$LIBRARY" \ + tmux -L "$TEST_SOCKET" new-session -d -s _holder 'sleep 60' + [ -s "$MARKER" ] || fail "contaminated fixture did not execute loader constructor" + server_pid=$(tmux -L "$TEST_SOCKET" display-message -p '#{pid}') + : > "$MARKER" + if /usr/bin/env -i HOME="$HOLDER_HOME" PATH=/usr/bin:/bin \ + MOSAIC_TMUX_SOCKET="$TEST_SOCKET" MOSAIC_TMUX_HOLDER=_holder "$HOLDER_START" \ + >"$TEST_ROOT/holder.out" 2>&1; then + fail "holder adopted contaminated named server" + fi + grep -qF 'global environment does not match the owned-server contract' "$TEST_ROOT/holder.out" || \ + fail "holder did not report contaminated server environment" + [ "$(tmux -L "$TEST_SOCKET" display-message -p '#{pid}')" = "$server_pid" ] || \ + fail "holder replaced a contaminated server instead of failing closed" + [ ! -s "$MARKER" ] || fail "holder execution triggered a contaminated loader" + + # Agent validation must reject the same unmanaged server without cleaning its + # global environment or adding a managed session. + AGENT_HOME="$HOLDER_HOME/.config/mosaic" + AGENT_NAME=loader-safe + AGENT_WORKDIR="$AGENT_HOME/work" + AGENT_BIN="$TEST_ROOT/agent-bin" + mkdir -p "$AGENT_HOME/fleet/agents" "$AGENT_WORKDIR" "$AGENT_BIN" + chmod 700 "$AGENT_HOME/fleet/agents" + cat > "$AGENT_HOME/fleet/agents/$AGENT_NAME.env.generated" < "$AGENT_HOME/fleet/agents/$AGENT_NAME.env.local" + chmod 600 "$AGENT_HOME/fleet/agents/$AGENT_NAME.env.generated" \ + "$AGENT_HOME/fleet/agents/$AGENT_NAME.env.local" + cat > "$AGENT_BIN/mosaic" <<'EOF' +#!/bin/sh +sleep 30 +EOF + chmod 700 "$AGENT_BIN/mosaic" + server_environment_before=$(tmux -L "$TEST_SOCKET" show-environment -g | sort) + server_sessions_before=$(tmux -L "$TEST_SOCKET" list-sessions | sort) + if /usr/bin/env -i HOME="$HOLDER_HOME" PATH=/usr/bin:/bin MOSAIC_HOME="$AGENT_HOME" \ + "$START_AGENT" "$AGENT_NAME" >"$TEST_ROOT/agent.out" 2>&1; then + fail "agent launcher adopted contaminated named server" + fi + [ "$(tmux -L "$TEST_SOCKET" display-message -p '#{pid}')" = "$server_pid" ] || \ + fail "agent launcher changed unmanaged server PID" + [ "$(tmux -L "$TEST_SOCKET" show-environment -g | sort)" = "$server_environment_before" ] || \ + fail "agent launcher changed unmanaged global environment" + [ "$(tmux -L "$TEST_SOCKET" list-sessions | sort)" = "$server_sessions_before" ] || \ + fail "agent launcher changed unmanaged sessions" + tmux -L "$TEST_SOCKET" kill-server + /usr/bin/env -i HOME="$HOLDER_HOME" PATH=/usr/bin:/bin \ + MOSAIC_TMUX_SOCKET="$TEST_SOCKET" MOSAIC_TMUX_HOLDER=_holder "$HOLDER_START" + tmux -L "$TEST_SOCKET" has-session -t '=_holder:0.0' || fail "fresh holder was not created" + if tmux -L "$TEST_SOCKET" show-environment -g LD_PRELOAD 2>/dev/null | grep -q '^LD_PRELOAD='; then + fail "fresh holder retained LD_PRELOAD" + fi + /usr/bin/env -i HOME="$HOLDER_HOME" PATH=/usr/bin:/bin MOSAIC_HOME="$AGENT_HOME" \ + "$START_AGENT" "$AGENT_NAME" + tmux -L "$TEST_SOCKET" has-session -t "=$AGENT_NAME:0.0" || \ + fail "agent did not launch on a valid owned server" + tmux -L "$TEST_SOCKET" kill-server + trap - EXIT + rm -rf "$TEST_ROOT" +fi + echo "ok - fleet systemd unit templates" diff --git a/packages/mosaic/framework/tools/fleet/start-agent-session.sh b/packages/mosaic/framework/tools/fleet/start-agent-session.sh index e5c9f2c5..60e73d78 100755 --- a/packages/mosaic/framework/tools/fleet/start-agent-session.sh +++ b/packages/mosaic/framework/tools/fleet/start-agent-session.sh @@ -1,39 +1,207 @@ #!/usr/bin/env bash set -euo pipefail -AGENT_NAME=${1:-${MOSAIC_AGENT_NAME:-}} -# Absent socket ⇒ the LITERAL default tmux socket (no -L). The roster's -# socket_name is honored when set; absent never silently becomes mosaic-fleet -# (spawn stays consistent with the onboarding cheat-sheet + fleet ps observe). -MOSAIC_TMUX_SOCKET=${MOSAIC_TMUX_SOCKET:-} -MOSAIC_AGENT_RUNTIME=${MOSAIC_AGENT_RUNTIME:-pi} -MOSAIC_AGENT_MODEL=${MOSAIC_AGENT_MODEL:-} -MOSAIC_AGENT_REASONING=${MOSAIC_AGENT_REASONING:-} -MOSAIC_AGENT_WORKDIR=${MOSAIC_AGENT_WORKDIR:-$HOME} -MOSAIC_AGENT_COMMAND=${MOSAIC_AGENT_COMMAND:-} -MOSAIC_HEARTBEAT_RUN_DIR=${MOSAIC_HEARTBEAT_RUN_DIR:-${MOSAIC_HOME:-$HOME/.config/mosaic}/fleet/run} -MOSAIC_HEARTBEAT_INTERVAL=${MOSAIC_HEARTBEAT_INTERVAL:-15} +# FCM-M2-001 boundary: only a roster-derived .env.generated projection and a +# separately parsed data-only .env.local can influence launch. Never source an +# environment file and never accept a command string from either file. -if [ -z "$AGENT_NAME" ]; then - echo "ERROR: agent name argument or MOSAIC_AGENT_NAME is required" >&2 - exit 64 -fi - -case "$MOSAIC_AGENT_REASONING" in - ''|low|medium|high) ;; - *) - echo "ERROR: MOSAIC_AGENT_REASONING must be low, medium, or high" >&2 - exit 64 +MODE=launch +case "${1:-}" in + --stop) + MODE=stop + AGENT_NAME=${2:-} ;; + --interaction) + MODE=interaction + AGENT_NAME=${2:-} + ;; + *) AGENT_NAME=${1:-${MOSAIC_AGENT_NAME:-}} ;; esac +MOSAIC_HOME=${MOSAIC_HOME:-$HOME/.config/mosaic} + +fail() { + echo "ERROR: $*" >&2 + exit 64 +} + +hash_value() { + printf '%s' "$1" | sha256sum | awk '{print $1}' +} + +fail_env() { + local code="$1" + local key="$2" + local value="$3" + echo "ERROR: agent environment rejected: code=${code} key=${key} sha256=$(hash_value "$value")" >&2 + exit 64 +} + +safe_agent_name() { + [[ "$1" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]*$ ]] +} + +safe_policy_name() { + [[ "$1" =~ ^[a-z][a-z0-9-]*$ ]] +} + +safe_path() { + [[ "$1" == /* ]] || return 1 + [[ "$1" != *".."* ]] || return 1 + [[ ! "$1" =~ [[:space:]\"\'\`\$\\\;\|\&\<\>\(\)\{\}] ]] +} + +assert_private_regular_file() { + local file="$1" + [ -f "$file" ] && [ ! -L "$file" ] || fail_env unsafe-file '(file)' "$file" + local mode + mode=$(stat -c '%a' -- "$file") || fail_env unsafe-file '(file)' "$file" + (( (8#$mode & 8#077) == 0 )) || fail_env unsafe-permissions '(file)' "$file" +} + +assert_managed_directory() { + local directory="$1" + [ -d "$directory" ] && [ ! -L "$directory" ] || fail_env unsafe-directory '(directory)' "$directory" + local mode + mode=$(stat -c '%a' -- "$directory") || fail_env unsafe-directory '(directory)' "$directory" + (( (8#$mode & 8#022) == 0 )) || fail_env unsafe-permissions '(directory)' "$directory" +} + +assert_private_directory() { + local directory="$1" + assert_managed_directory "$directory" + local mode + mode=$(stat -c '%a' -- "$directory") || fail_env unsafe-directory '(directory)' "$directory" + (( (8#$mode & 8#077) == 0 )) || fail_env unsafe-permissions '(directory)' "$directory" +} + +[ -n "$AGENT_NAME" ] || fail "agent name argument or MOSAIC_AGENT_NAME is required" +safe_agent_name "$AGENT_NAME" || fail_env unsafe-agent-name MOSAIC_AGENT_NAME "$AGENT_NAME" +safe_path "$MOSAIC_HOME" || fail_env unsafe-path MOSAIC_HOME "$MOSAIC_HOME" + +FLEET_DIR="$MOSAIC_HOME/fleet" +AGENT_ENV_DIR="$FLEET_DIR/agents" +assert_managed_directory "$MOSAIC_HOME" +assert_managed_directory "$FLEET_DIR" +assert_private_directory "$AGENT_ENV_DIR" + +GENERATED_ENV="$AGENT_ENV_DIR/$AGENT_NAME.env.generated" +LOCAL_ENV="$AGENT_ENV_DIR/$AGENT_NAME.env.local" + +declare -A GENERATED_VALUES=() +declare -A LOCAL_VALUES=() +declare -A SEEN_KEYS=() + +is_sensitive_key() { + [[ "$1" =~ (API[_-]?KEY|AUTH|CREDENTIAL|PASSWORD|PRIVATE|SECRET|TOKEN) ]] +} + +is_generated_key() { + case "$1" in + MOSAIC_AGENT_NAME|MOSAIC_AGENT_CLASS|MOSAIC_AGENT_RUNTIME|MOSAIC_AGENT_MODEL|MOSAIC_AGENT_REASONING|MOSAIC_AGENT_TOOL_POLICY|MOSAIC_AGENT_WORKDIR|MOSAIC_TMUX_SOCKET) return 0 ;; + *) return 1 ;; + esac +} + +is_local_key() { + case "$1" in + MOSAIC_RUNTIME_BIN|MOSAIC_HEARTBEAT_RUN_DIR|MOSAIC_HEARTBEAT_INTERVAL|MOSAIC_CLAUDE_JSON|CLAUDE_CONFIG_DIR) return 0 ;; + *) return 1 ;; + esac +} + +validate_generated_value() { + local key="$1" + local value="$2" + case "$key" in + MOSAIC_AGENT_NAME) safe_agent_name "$value" || fail_env unsafe-agent-name "$key" "$value" ;; + MOSAIC_AGENT_CLASS) safe_policy_name "$value" || fail_env unsafe-class "$key" "$value" ;; + MOSAIC_AGENT_RUNTIME) + case "$value" in claude|codex|opencode|pi) ;; *) fail_env unsupported-runtime "$key" "$value" ;; esac + ;; + MOSAIC_AGENT_MODEL) [[ "$value" =~ ^[A-Za-z0-9._/:+-]*$ ]] || fail_env unsafe-model "$key" "$value" ;; + MOSAIC_AGENT_REASONING) + case "$value" in ''|low|medium|high) ;; *) fail_env unsupported-reasoning "$key" "$value" ;; esac + ;; + MOSAIC_AGENT_TOOL_POLICY) [ -z "$value" ] || safe_policy_name "$value" || fail_env unsafe-tool-policy "$key" "$value" ;; + MOSAIC_AGENT_WORKDIR) safe_path "$value" || fail_env unsafe-path "$key" "$value" ;; + MOSAIC_TMUX_SOCKET) [[ "$value" =~ ^[A-Za-z0-9_.-]*$ ]] || fail_env unsafe-socket "$key" "$value" ;; + esac +} + +validate_local_value() { + local key="$1" + local value="$2" + if [ "$key" = MOSAIC_HEARTBEAT_INTERVAL ]; then + [[ "$value" =~ ^[1-9][0-9]*$ ]] || fail_env invalid-interval "$key" "$value" + else + safe_path "$value" || fail_env unsafe-path "$key" "$value" + fi +} + +load_environment_file() { + local file="$1" + local kind="$2" + [ -e "$file" ] || { + [ "$kind" = generated ] && fail_env missing-file '(generated)' "$file" + return 0 + } + assert_private_regular_file "$file" + SEEN_KEYS=() + + local line key value + while IFS= read -r line || [ -n "$line" ]; do + [ -z "$line" ] && continue + if [[ ! "$line" =~ ^([A-Z][A-Z0-9_]*)=(.*)$ ]]; then + fail_env malformed-line '(malformed)' "$line" + fi + key=${BASH_REMATCH[1]} + value=${BASH_REMATCH[2]} + [ -z "${SEEN_KEYS[$key]+set}" ] || fail_env duplicate-key "$key" "$value" + SEEN_KEYS[$key]=1 + is_sensitive_key "$key" && fail_env sensitive-key "$key" "$value" + + if [ "$kind" = generated ]; then + is_generated_key "$key" || fail_env unknown-key "$key" "$value" + validate_generated_value "$key" "$value" + GENERATED_VALUES[$key]=$value + else + is_generated_key "$key" && fail_env generated-key-shadow "$key" "$value" + is_local_key "$key" || fail_env unknown-key "$key" "$value" + validate_local_value "$key" "$value" + LOCAL_VALUES[$key]=$value + fi + done < "$file" +} + +load_environment_file "$GENERATED_ENV" generated +for required_key in \ + MOSAIC_AGENT_NAME MOSAIC_AGENT_CLASS MOSAIC_AGENT_RUNTIME MOSAIC_AGENT_MODEL \ + MOSAIC_AGENT_REASONING MOSAIC_AGENT_TOOL_POLICY MOSAIC_AGENT_WORKDIR MOSAIC_TMUX_SOCKET; do + [ -n "${GENERATED_VALUES[$required_key]+set}" ] || fail_env missing-key "$required_key" '' +done +load_environment_file "$LOCAL_ENV" local + +[ "${GENERATED_VALUES[MOSAIC_AGENT_NAME]}" = "$AGENT_NAME" ] || \ + fail_env agent-name-mismatch MOSAIC_AGENT_NAME "${GENERATED_VALUES[MOSAIC_AGENT_NAME]}" + +MOSAIC_TMUX_SOCKET=${GENERATED_VALUES[MOSAIC_TMUX_SOCKET]} +MOSAIC_AGENT_RUNTIME=${GENERATED_VALUES[MOSAIC_AGENT_RUNTIME]} +MOSAIC_AGENT_MODEL=${GENERATED_VALUES[MOSAIC_AGENT_MODEL]} +MOSAIC_AGENT_REASONING=${GENERATED_VALUES[MOSAIC_AGENT_REASONING]} +MOSAIC_AGENT_WORKDIR=${GENERATED_VALUES[MOSAIC_AGENT_WORKDIR]} +MOSAIC_AGENT_CLASS=${GENERATED_VALUES[MOSAIC_AGENT_CLASS]} +MOSAIC_AGENT_TOOL_POLICY=${GENERATED_VALUES[MOSAIC_AGENT_TOOL_POLICY]} +MOSAIC_RUNTIME_BIN=${LOCAL_VALUES[MOSAIC_RUNTIME_BIN]:-} +MOSAIC_HEARTBEAT_RUN_DIR=${LOCAL_VALUES[MOSAIC_HEARTBEAT_RUN_DIR]:-$MOSAIC_HOME/fleet/run} +MOSAIC_HEARTBEAT_INTERVAL=${LOCAL_VALUES[MOSAIC_HEARTBEAT_INTERVAL]:-15} +MOSAIC_CLAUDE_JSON=${LOCAL_VALUES[MOSAIC_CLAUDE_JSON]:-} +CLAUDE_CONFIG_DIR=${LOCAL_VALUES[CLAUDE_CONFIG_DIR]:-} if ! command -v tmux >/dev/null 2>&1; then echo "ERROR: tmux is required" >&2 exit 69 fi -# tmux wrapper: pass -L only when a socket is configured. An absent/empty socket -# means the default tmux socket (no -L), keeping spawn == observe == cheat-sheet. _tmux() { if [ -n "$MOSAIC_TMUX_SOCKET" ]; then tmux -L "$MOSAIC_TMUX_SOCKET" "$@" @@ -42,140 +210,90 @@ _tmux() { fi } +assert_owned_tmux_server() { + local owner_file="$MOSAIC_HOME/fleet/run/holder-owner" + [ -f "$owner_file" ] && [ ! -L "$owner_file" ] || fail "private tmux ownership identity is missing" + local owner_mode + owner_mode=$(stat -c '%a' -- "$owner_file") || fail "private tmux ownership identity is unreadable" + (( (8#$owner_mode & 8#077) == 0 )) || fail "private tmux ownership identity has unsafe permissions" + local owner + owner=$(tr -d '\n' < "$owner_file") + [[ "$owner" =~ ^[a-f0-9-]{36}$ ]] || fail "private tmux ownership identity is malformed" + _tmux has-session -t '=_holder:0.0' 2>/dev/null || fail "owned tmux holder session is absent" + local environment expected + environment=$(_tmux show-environment -g 2>/dev/null) || fail "owned tmux global environment is unreadable" + expected=$(printf '%s\n' \ + "HOME=$HOME" \ + 'PATH=/usr/bin:/bin' \ + "PWD=$HOME" \ + "MOSAIC_FLEET_OWNER=$owner" \ + 'MOSAIC_TMUX_HOLDER=_holder' \ + "MOSAIC_TMUX_SOCKET=$MOSAIC_TMUX_SOCKET" | sort) + [ "$(printf '%s\n' "$environment" | sort)" = "$expected" ] || \ + fail "tmux server ownership or environment validation failed" +} + +# Validate exact server ownership before querying, cleaning, or creating any +# managed session. An unmanaged or contaminated named socket is never repaired. +assert_owned_tmux_server + +if [ "$MODE" = interaction ]; then + [ "$MOSAIC_AGENT_RUNTIME" = pi ] || fail "operator interaction service requires runtime pi" + [ "$MOSAIC_AGENT_MODEL" = openai/gpt-5.6-sol ] || \ + fail "operator interaction service requires the pinned model" + [ "$MOSAIC_AGENT_REASONING" = high ] || \ + fail "operator interaction service requires high reasoning" + [ "$MOSAIC_AGENT_TOOL_POLICY" = operator-interaction ] || \ + fail "operator interaction service requires the operator-interaction tool policy" +fi + +if [ "$MODE" = stop ]; then + _tmux kill-session -t "=${AGENT_NAME}" >/dev/null 2>&1 || true + exit 0 +fi + if _tmux has-session -t "=${AGENT_NAME}:0.0" 2>/dev/null; then echo "Mosaic agent session already running: $AGENT_NAME on socket ${MOSAIC_TMUX_SOCKET:-(default)}" exit 0 fi -if [ -z "$MOSAIC_AGENT_COMMAND" ]; then - # Map the roster's per-agent model_hint to `--model` so workers launch on the - # configured model (e.g. pi on openai-codex/gpt-5.5:high). Omitted when unset. - MOSAIC_AGENT_COMMAND="mosaic yolo $MOSAIC_AGENT_RUNTIME${MOSAIC_AGENT_MODEL:+ --model $MOSAIC_AGENT_MODEL}${MOSAIC_AGENT_REASONING:+ --thinking $MOSAIC_AGENT_REASONING}" -fi +# Systemd passes HOME as %h, and the installed service fixes MOSAIC_HOME under +# that home. Derive the pane home from the canonical path when available so an +# inherited pane/session HOME cannot become runtime authority. +PANE_HOME=$HOME +case "$MOSAIC_HOME" in + */.config/mosaic) PANE_HOME=${MOSAIC_HOME%/.config/mosaic} ;; +esac -# ── Derive a runtime-bin PATH prefix ───────────────────────────────────────── -# Precedence: -# 1. $MOSAIC_RUNTIME_BIN (explicit override) -# 2. $(npm config get prefix)/bin (if npm is on PATH) -# 3. Fallbacks: $HOME/.npm-global/bin and $HOME/.local/bin -# -# Only directories that already exist are included. The prefix is baked into -# the pane command regardless of what the LAUNCHER process's $PATH contains, -# because the tmux pane inherits the tmux SERVER environment (not this script's -# environment). A dir on the launcher's PATH may be absent from the server PATH, -# so every existing candidate must always be included. Dedup within the -# constructed prefix avoids listing the same dir twice. _build_runtime_bin_prefix() { local candidates=() - - if [ -n "${MOSAIC_RUNTIME_BIN:-}" ]; then - candidates+=("$MOSAIC_RUNTIME_BIN") - fi - + if [ -n "$MOSAIC_RUNTIME_BIN" ]; then candidates+=("$MOSAIC_RUNTIME_BIN"); fi if command -v npm >/dev/null 2>&1; then local npm_prefix npm_prefix=$(npm config get prefix 2>/dev/null) || true - if [ -n "$npm_prefix" ]; then - candidates+=("${npm_prefix}/bin") - fi + if [ -n "$npm_prefix" ]; then candidates+=("${npm_prefix}/bin"); fi fi + candidates+=("$PANE_HOME/.npm-global/bin" "$PANE_HOME/.local/bin") - candidates+=("$HOME/.npm-global/bin") - candidates+=("$HOME/.local/bin") - - local prefix="" + local prefix="" dir for dir in "${candidates[@]}"; do [ -d "$dir" ] || continue - if [ -z "$prefix" ]; then - prefix="$dir" - else - case ":${prefix}:" in - *":${dir}:"*) ;; # already in our prefix — skip - *) prefix="${prefix}:${dir}" ;; - esac - fi + case ":${prefix}:" in *":${dir}:"*) ;; *) prefix="${prefix:+$prefix:}$dir" ;; esac done - printf '%s' "$prefix" } MOSAIC_RUNTIME_BIN_PREFIX=$(_build_runtime_bin_prefix) +PANE_PATH=${MOSAIC_RUNTIME_BIN_PREFIX:+${MOSAIC_RUNTIME_BIN_PREFIX}:}/usr/local/bin:/usr/bin:/bin -# ── Build the pane command ──────────────────────────────────────────────────── -# The pane command must: -# - Export the augmented PATH so the runtime binary is found. -# - exec the agent command so the runtime is the pane's foreground process -# (makes `fleet ps` pane_current_command check reliable; no DRIFT false-positive). -# -# Quoting strategy: single-quote the inner shell snippet so that variable -# references in MOSAIC_AGENT_COMMAND are NOT expanded here — they expand inside -# the pane shell. However, MOSAIC_RUNTIME_BIN_PREFIX and PATH must be expanded -# NOW (in this script) because the pane shell inherits the tmux server -# environment, not this script's env. -# -# We build the snippet as a double-quoted here-string embedded in a printf call -# to avoid nested quoting problems. -# -# MOSAIC_AGENT_NAME must also be exported INTO the pane: panes inherit the tmux -# server environment (not this script's, and not the systemd unit's), so the -# name would otherwise be empty in-pane and the runtime's native heartbeat -# (which gates on MOSAIC_AGENT_NAME) would never fire. %q-quote it so it is a -# safe single bash token regardless of the name's characters. -AGENT_NAME_Q=$(printf '%q' "$AGENT_NAME") - -# MOSAIC_AGENT_CLASS must ALSO be exported INTO the pane, for the same reason as -# MOSAIC_AGENT_NAME above: the pane inherits the tmux SERVER environment (not this -# script's env, and not the systemd unit's EnvironmentFile), so the per-agent class -# written to agents/.env would otherwise be invisible in-pane. The launcher -# composes the persona contract from process.env.MOSAIC_AGENT_CLASS at launch -# (compose-contract -> readPersonaContractBlock); without this export it sees an -# undefined class and silently injects NO persona contract. %q-quote it so it is a -# safe single bash token; an empty/unset class %q-quotes to '' and is a harmless -# no-op downstream (readPersonaContractBlock returns '' for an empty class). -AGENT_CLASS_Q=$(printf '%q' "${MOSAIC_AGENT_CLASS:-}") -AGENT_TOOL_POLICY_Q=$(printf '%q' "${MOSAIC_AGENT_TOOL_POLICY:-}") - -if [ -n "$MOSAIC_RUNTIME_BIN_PREFIX" ]; then - PANE_SHELL_SNIPPET="export MOSAIC_AGENT_NAME=${AGENT_NAME_Q}; export MOSAIC_AGENT_CLASS=${AGENT_CLASS_Q}; export MOSAIC_AGENT_TOOL_POLICY=${AGENT_TOOL_POLICY_Q}; export PATH=\"${MOSAIC_RUNTIME_BIN_PREFIX}:\${PATH}\"; exec ${MOSAIC_AGENT_COMMAND}" -else - PANE_SHELL_SNIPPET="export MOSAIC_AGENT_NAME=${AGENT_NAME_Q}; export MOSAIC_AGENT_CLASS=${AGENT_CLASS_Q}; export MOSAIC_AGENT_TOOL_POLICY=${AGENT_TOOL_POLICY_Q}; exec ${MOSAIC_AGENT_COMMAND}" -fi - -mkdir -p "$MOSAIC_AGENT_WORKDIR" - -# ── Pre-trust the workdir for the Claude runtime ───────────────────────────── -# Claude Code shows a one-time "Is this a project you trust?" folder-trust gate -# the first time it opens a directory. A fleet-launched agent has no human to -# answer it, so the pane stalls forever at the prompt while its heartbeat keeps -# reporting "healthy" (the pane process IS alive — it's just blocked). -# -# IMPORTANT: --dangerously-skip-permissions does NOT bypass this gate, and -# neither does `trustedProjectDirectories` in settings.json (verified empirically -# 2026-06-24). The ONLY thing the gate honors is the per-project record in -# ~/.claude.json: projects[""].hasTrustDialogAccepted == true (exactly what -# answering the prompt writes). So we pre-seed that record here. -# -# Idempotent, atomic, best-effort: any failure is non-fatal (the agent still -# launches — worst case it stalls on the gate, i.e. the pre-fix status quo). -# Only the claude runtime needs this; codex/pi have no such gate. _ensure_claude_workdir_trusted() { local workdir="$1" - # The path claude keys on is the resolved cwd it is launched in. - local rp - rp=$(cd "$workdir" 2>/dev/null && pwd -P) || rp="$workdir" - # ~/.claude.json lives next to the claude config dir; honor CLAUDE_CONFIG_DIR. + local resolved + resolved=$(cd "$workdir" 2>/dev/null && pwd -P) || resolved="$workdir" local claude_json="${MOSAIC_CLAUDE_JSON:-${CLAUDE_CONFIG_DIR:+$CLAUDE_CONFIG_DIR/.claude.json}}" claude_json="${claude_json:-$HOME/.claude.json}" - - if ! command -v python3 >/dev/null 2>&1; then - echo "WARNING: python3 not found; cannot pre-trust '$rp' for claude (agent may stall on the folder-trust gate)" >&2 - return 1 - fi - - # Serialize concurrent agent launches that share ~/.claude.json (flock if available). - local lock="${claude_json}.mosaic-lock" - _seed() { - MOSAIC_CJ="$claude_json" MOSAIC_TRUST_DIR="$rp" python3 - <<'PY' + command -v python3 >/dev/null 2>&1 || return 1 + MOSAIC_CJ="$claude_json" MOSAIC_TRUST_DIR="$resolved" python3 - <<'PY' import json, os, sys, tempfile cj = os.environ["MOSAIC_CJ"] d = os.environ["MOSAIC_TRUST_DIR"] @@ -184,22 +302,19 @@ try: if not isinstance(data, dict): data = {} except Exception: - # Never corrupt an unreadable/partial file — bail without writing. sys.exit(2) projects = data.setdefault("projects", {}) entry = projects.get(d) if not isinstance(entry, dict): entry = {} projects[d] = entry -if entry.get("hasTrustDialogAccepted") is True: - sys.exit(0) # already trusted — nothing to do entry["hasTrustDialogAccepted"] = True tmp_dir = os.path.dirname(cj) or "." fd, tmp = tempfile.mkstemp(dir=tmp_dir, prefix=".claude.json.mosaic.") try: with os.fdopen(fd, "w") as f: json.dump(data, f, indent=2) - os.replace(tmp, cj) # atomic + os.replace(tmp, cj) except Exception: try: os.unlink(tmp) @@ -207,56 +322,56 @@ except Exception: pass sys.exit(3) PY - } - if command -v flock >/dev/null 2>&1; then - ( flock 9; _seed ) 9>"$lock" 2>/dev/null || _seed - else - _seed - fi } -case "$MOSAIC_AGENT_RUNTIME" in - claude) - _ensure_claude_workdir_trusted "$MOSAIC_AGENT_WORKDIR" \ - || echo "WARNING: could not pre-trust workdir for claude agent $AGENT_NAME" >&2 - ;; -esac +if [ "$MOSAIC_AGENT_RUNTIME" = claude ]; then + _ensure_claude_workdir_trusted "$MOSAIC_AGENT_WORKDIR" || \ + echo "WARNING: could not pre-trust workdir for claude agent $AGENT_NAME" >&2 +fi -# ── Launch the tmux session (no exec — we continue to wire the heartbeat) ──── +LAUNCH_COMMAND=(mosaic yolo "$MOSAIC_AGENT_RUNTIME") +if [ -n "$MOSAIC_AGENT_MODEL" ]; then LAUNCH_COMMAND+=(--model "$MOSAIC_AGENT_MODEL"); fi +if [ -n "$MOSAIC_AGENT_REASONING" ]; then LAUNCH_COMMAND+=(--thinking "$MOSAIC_AGENT_REASONING"); fi + +# The tmux holder owns a named server. Explicitly clear the pane environment +# so server/session variables cannot cross the launch boundary; retain only +# trusted bootstrap, generated, and approved local data as argv assignments. +LAUNCH_ENV=( + /usr/bin/env + -i + "HOME=$PANE_HOME" + "PATH=$PANE_PATH" + "MOSAIC_HOME=$MOSAIC_HOME" + "MOSAIC_AGENT_NAME=$AGENT_NAME" + "MOSAIC_AGENT_CLASS=$MOSAIC_AGENT_CLASS" + "MOSAIC_AGENT_RUNTIME=$MOSAIC_AGENT_RUNTIME" + "MOSAIC_AGENT_MODEL=$MOSAIC_AGENT_MODEL" + "MOSAIC_AGENT_REASONING=$MOSAIC_AGENT_REASONING" + "MOSAIC_AGENT_TOOL_POLICY=$MOSAIC_AGENT_TOOL_POLICY" + "MOSAIC_AGENT_WORKDIR=$MOSAIC_AGENT_WORKDIR" + "MOSAIC_TMUX_SOCKET=$MOSAIC_TMUX_SOCKET" + "MOSAIC_HEARTBEAT_RUN_DIR=$MOSAIC_HEARTBEAT_RUN_DIR" +) + +mkdir -p "$MOSAIC_AGENT_WORKDIR" _tmux new-session -d -s "$AGENT_NAME" -c "$MOSAIC_AGENT_WORKDIR" \ - bash -c "$PANE_SHELL_SNIPPET" + "${LAUNCH_ENV[@]}" "${LAUNCH_COMMAND[@]}" -# ── Resolve the pane PID (retry briefly to let the session initialise) ──────── PANE_PID="" for _retry in 1 2 3 4 5; do - PANE_PID=$(_tmux list-panes \ - -t "=${AGENT_NAME}:0.0" -F '#{pane_pid}' 2>/dev/null || true) + PANE_PID=$(_tmux list-panes -t "=${AGENT_NAME}:0.0" -F '#{pane_pid}' 2>/dev/null || true) [ -n "$PANE_PID" ] && break sleep 0.2 done -# ── Spawn the heartbeat sidecar (detached, best-effort) ────────────────────── -# The sidecar writes ~/.config/mosaic/fleet/run/.hb atomically while the -# pane process is alive, then exits so the file goes stale (fleet ps shows stale -# then PANE=dead). It is runtime-agnostic: it only cares about the pane PID. _start_heartbeat_sidecar() { - local agent="$1" - local pane_pid="$2" - local run_dir="$3" - local interval="$4" + local agent="$1" pane_pid="$2" run_dir="$3" interval="$4" local hb_file="${run_dir}/${agent}.hb" - mkdir -p "$run_dir" - - # Write the sidecar as a self-contained bash one-liner so it carries no - # references to any variables from this script's environment. local sidecar_script sidecar_script=$(printf \ - 'hb=%q; pid=%q; iv=%q; mkdir -p "$(dirname "$hb")"; while kill -0 "$pid" 2>/dev/null; do nat="$hb.native"; if [ -f "$nat" ] && [ "$(( $(date +%%s) - $(stat -c %%Y "$nat" 2>/dev/null || echo 0) ))" -lt "$(( iv * 2 ))" ]; then sleep "$iv"; continue; fi; tmp="$hb.tmp.$$"; printf "ts=%%s\npid=%%s\nstatus=ok\n" "$(date +%%Y-%%m-%%dT%%H:%%M:%%S%%z)" "$pid" > "$tmp" && mv "$tmp" "$hb"; sleep "$iv"; done' \ + 'hb=%q; pid=%q; iv=%q; native="$hb.native"; mkdir -p "$(dirname "$hb")"; while kill -0 "$pid" 2>/dev/null; do now=$(date +%%s); marker=$(stat -c %%Y -- "$native" 2>/dev/null || true); if [ -z "$marker" ] || [ -L "$native" ] || (( now - marker > iv * 2 + 1 )); then tmp="$hb.tmp.$$"; printf "ts=%%s\npid=%%s\nstatus=ok\n" "$(date +%%Y-%%m-%%dT%%H:%%M:%%S%%z)" "$pid" > "$tmp" && mv "$tmp" "$hb"; fi; sleep "$iv"; done' \ "$hb_file" "$pane_pid" "$interval") - - # setsid + disown ensures the sidecar survives this script exiting. - # stderr/stdout go to /dev/null; failures are non-fatal. if command -v setsid >/dev/null 2>&1; then setsid bash -c "$sidecar_script" /dev/null 2>&1 & else @@ -266,7 +381,6 @@ _start_heartbeat_sidecar() { } if [ -n "$PANE_PID" ]; then - # Guard: do not let sidecar startup failures abort the launcher (set -e). _start_heartbeat_sidecar "$AGENT_NAME" "$PANE_PID" \ "$MOSAIC_HEARTBEAT_RUN_DIR" "$MOSAIC_HEARTBEAT_INTERVAL" || \ echo "WARNING: heartbeat sidecar could not be started for $AGENT_NAME" >&2 diff --git a/packages/mosaic/framework/tools/fleet/start-interaction-service.sh b/packages/mosaic/framework/tools/fleet/start-interaction-service.sh index 4efc5397..b972b6a8 100755 --- a/packages/mosaic/framework/tools/fleet/start-interaction-service.sh +++ b/packages/mosaic/framework/tools/fleet/start-interaction-service.sh @@ -9,11 +9,7 @@ fail() { } [ -n "$AGENT_NAME" ] || fail "agent name argument is required" -[[ "$AGENT_NAME" =~ ^[A-Za-z0-9_.-]+$ ]] || fail "agent name contains unsupported characters" -[ "${MOSAIC_AGENT_NAME:-}" = "$AGENT_NAME" ] || fail "configured agent name must exactly match the service instance" -[ "${MOSAIC_AGENT_RUNTIME:-}" = "pi" ] || fail "operator interaction service requires runtime pi" -[ "${MOSAIC_AGENT_MODEL:-}" = "openai/gpt-5.6-sol" ] || fail "operator interaction service requires the pinned model" -[ "${MOSAIC_AGENT_REASONING:-}" = "high" ] || fail "operator interaction service requires high reasoning" -[ "${MOSAIC_AGENT_TOOL_POLICY:-}" = "operator-interaction" ] || fail "operator interaction service requires the operator-interaction tool policy" -exec "$(cd -- "$(dirname -- "$0")" && pwd)/start-agent-session.sh" "$AGENT_NAME" +# The shared launcher strictly validates the generated/local data boundary +# before it applies this interaction service's pinned profile checks. +exec "$(cd -- "$(dirname -- "$0")" && pwd)/start-agent-session.sh" --interaction "$AGENT_NAME" diff --git a/packages/mosaic/framework/tools/fleet/start-tmux-holder.sh b/packages/mosaic/framework/tools/fleet/start-tmux-holder.sh new file mode 100755 index 00000000..1422647e --- /dev/null +++ b/packages/mosaic/framework/tools/fleet/start-tmux-holder.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +set -euo pipefail + +# A holder may create only the configured named socket. Existing servers are +# accepted only when their private install-derived ownership identity, exact +# holder session, and complete approved global environment all match. + +MOSAIC_HOME=${MOSAIC_HOME:-$HOME/.config/mosaic} +MOSAIC_TMUX_SOCKET=${MOSAIC_TMUX_SOCKET:-mosaic-fleet} +MOSAIC_TMUX_HOLDER=${MOSAIC_TMUX_HOLDER:-_holder} +OWNER_FILE="$MOSAIC_HOME/fleet/run/holder-owner" +TMUX_BIN=/usr/bin/tmux + +fail() { + echo "ERROR: refusing unmanaged Mosaic tmux server on socket ${MOSAIC_TMUX_SOCKET}: $1" >&2 + exit 64 +} + +[ -x "$TMUX_BIN" ] || fail "tmux binary is unavailable" +[ -f "$OWNER_FILE" ] && [ ! -L "$OWNER_FILE" ] || fail "private ownership identity is missing" +owner_mode=$(stat -c '%a' -- "$OWNER_FILE") || fail "private ownership identity is unreadable" +(( (8#$owner_mode & 8#077) == 0 )) || fail "private ownership identity has unsafe permissions" +MOSAIC_FLEET_OWNER=$(tr -d '\n' < "$OWNER_FILE") +[[ "$MOSAIC_FLEET_OWNER" =~ ^[a-f0-9-]{36}$ ]] || fail "private ownership identity is malformed" + +_tmux() { + "$TMUX_BIN" -L "$MOSAIC_TMUX_SOCKET" "$@" +} + +server_running() { + _tmux list-sessions >/dev/null 2>&1 +} + +assert_owned_server() { + _tmux has-session -t "=${MOSAIC_TMUX_HOLDER}:0.0" 2>/dev/null || fail "exact holder session is absent" + local environment + environment=$(_tmux show-environment -g 2>/dev/null) || fail "global environment is unreadable" + local expected + expected=$(printf '%s\n' \ + "HOME=$HOME" \ + 'PATH=/usr/bin:/bin' \ + "PWD=$HOME" \ + "MOSAIC_FLEET_OWNER=$MOSAIC_FLEET_OWNER" \ + "MOSAIC_TMUX_HOLDER=$MOSAIC_TMUX_HOLDER" \ + "MOSAIC_TMUX_SOCKET=$MOSAIC_TMUX_SOCKET" | sort) + [ "$(printf '%s\n' "$environment" | sort)" = "$expected" ] || \ + fail "global environment does not match the owned-server contract" +} + +if server_running; then + assert_owned_server +else + cd "$HOME" || fail "trusted home is unavailable" + # Start the tmux server itself under the approved environment. The holder pane + # receives the same closed environment rather than arbitrary server globals. + /usr/bin/env -i \ + "HOME=$HOME" \ + PATH=/usr/bin:/bin \ + "MOSAIC_FLEET_OWNER=$MOSAIC_FLEET_OWNER" \ + "MOSAIC_TMUX_HOLDER=$MOSAIC_TMUX_HOLDER" \ + "MOSAIC_TMUX_SOCKET=$MOSAIC_TMUX_SOCKET" \ + "$TMUX_BIN" -L "$MOSAIC_TMUX_SOCKET" new-session -d -s "$MOSAIC_TMUX_HOLDER" \ + /usr/bin/env -i "HOME=$HOME" PATH=/usr/bin:/bin /bin/sh -c 'while true; do sleep 3600; done' +fi diff --git a/packages/mosaic/framework/tools/fleet/test-start-agent-session.sh b/packages/mosaic/framework/tools/fleet/test-start-agent-session.sh index 0e83ecc1..5d1c27d6 100755 --- a/packages/mosaic/framework/tools/fleet/test-start-agent-session.sh +++ b/packages/mosaic/framework/tools/fleet/test-start-agent-session.sh @@ -3,373 +3,410 @@ set -euo pipefail SCRIPT_DIR=$(cd -- "$(dirname -- "$0")" && pwd) START="$SCRIPT_DIR/start-agent-session.sh" -SOCKET="mosaic-agent-test-$RANDOM-$$" -AGENT="agent-$RANDOM" -WORKDIR=$(mktemp -d) - -# Keep a single cleanup trap that accumulates resources. -CLEANUP_DIRS=("$WORKDIR") -CLEANUP_SOCKETS=("$SOCKET") -trap '_cleanup' EXIT -_cleanup() { - for s in "${CLEANUP_SOCKETS[@]:-}"; do - tmux -L "$s" kill-server >/dev/null 2>&1 || true - done - for d in "${CLEANUP_DIRS[@]:-}"; do - rm -rf "$d" - done -} +INTERACTION_START="$SCRIPT_DIR/start-interaction-service.sh" +ROOT=$(mktemp -d) +FAKE_BIN=$(mktemp -d) +TMUX_CALLS=$(mktemp) +trap 'rm -rf "$ROOT" "$FAKE_BIN" "$TMUX_CALLS"' EXIT fail() { echo "FAIL: $*" >&2 exit 1 } -# ── Test 1: basic session creation with workdir check ───────────────────────── -MOSAIC_TMUX_SOCKET="$SOCKET" \ -MOSAIC_AGENT_WORKDIR="$WORKDIR" \ -MOSAIC_AGENT_COMMAND='bash --noprofile --norc -i' \ - "$START" "$AGENT" - -tmux -L "$SOCKET" has-session -t "=$AGENT:0.0" || fail "agent session was not created" -# Retry: pane_current_path briefly reflects the tmux server's cwd until the pane -# process establishes its own cwd (the -c start dir). Poll until it settles. -actual_dir="" -for _ in $(seq 1 30); do - actual_dir=$(tmux -L "$SOCKET" display-message -p -t "=$AGENT:0.0" '#{pane_current_path}') - [ "$actual_dir" = "$WORKDIR" ] && break - sleep 0.1 -done -[ "$actual_dir" = "$WORKDIR" ] || fail "agent workdir mismatch: $actual_dir (expected $WORKDIR)" - -# ── Test 2: idempotency (duplicate start prints 'already running') ───────────── -MOSAIC_TMUX_SOCKET="$SOCKET" \ -MOSAIC_AGENT_WORKDIR="$WORKDIR" \ -MOSAIC_AGENT_COMMAND='bash --noprofile --norc -i' \ - "$START" "$AGENT" >/tmp/mosaic-start-agent-idempotent.out - -grep -qF 'already running' /tmp/mosaic-start-agent-idempotent.out || fail "duplicate start was not idempotent" - -# ── Test 3: runtime-bin PATH prefix is baked into the pane command ──────────── -# -# We capture the command the script would hand to tmux by injecting a fake -# 'tmux' shim into PATH. The shim: -# - Intercepts 'new-session' calls and records its arguments to a file. -# - For 'has-session' calls, exits 1 (session does not exist) so the script -# proceeds to launch instead of printing "already running". -# - For 'list-panes' calls, returns empty so PANE_PID stays unset and the -# heartbeat sidecar is NOT spawned (heartbeat is not the focus of this test; -# test 6 and 7 cover that path). This prevents any real-filesystem side -# effects or leaked background processes. -# - For all other subcommands, exits 0. -# -# Assertions: -# a) 'export PATH=' with the synthetic MOSAIC_RUNTIME_BIN prefix appears. -# b) 'exec' appears so the runtime replaces the wrapper shell. -# c) MOSAIC_AGENT_COMMAND with flags is forwarded intact. - -FAKE_BIN=$(mktemp -d) -FAKE_RUNTIME_BIN=$(mktemp -d) -TMUX_ARGS_FILE=$(mktemp) -HB_RUN_DIR3=$(mktemp -d) -CLEANUP_DIRS+=("$FAKE_BIN" "$FAKE_RUNTIME_BIN" "$HB_RUN_DIR3") - -# Write the fake tmux shim (uses only positional args, no sourced vars). -cat > "$FAKE_BIN/tmux" < "$FAKE_BIN/tmux" <<'SHIM' #!/usr/bin/env bash -# Fake tmux: record new-session args; report has-session as missing. -subcmd="\$3" # argv: tmux -L ... -if [ "\$subcmd" = "has-session" ]; then - exit 1 # session not found → script will attempt new-session -fi -if [ "\$subcmd" = "new-session" ]; then - printf '%s\n' "\$@" > "$TMUX_ARGS_FILE" - exit 0 -fi -if [ "\$subcmd" = "list-panes" ]; then - # Return empty: no sidecar spawned (heartbeat is not the focus of this test). - echo "" - exit 0 -fi -exit 0 +set -euo pipefail +printf '%s\0' "$@" >> "${MOSAIC_TEST_TMUX_CALLS:?}" +args=("$@") +index=0 +if [ "${args[0]:-}" = -L ]; then index=2; fi +case "${args[$index]:-}" in + has-session) + for argument in "${args[@]}"; do + [ "$argument" = '=_holder:0.0' ] && exit 0 + done + exit 1 + ;; + show-environment) + printf '%s\n' \ + "HOME=${MOSAIC_TEST_HOME:?}" \ + 'PATH=/usr/bin:/bin' \ + "PWD=${MOSAIC_TEST_HOME:?}" \ + "MOSAIC_FLEET_OWNER=${MOSAIC_TEST_FLEET_OWNER:?}" \ + 'MOSAIC_TMUX_HOLDER=_holder' \ + 'MOSAIC_TMUX_SOCKET=mosaic-test' + exit 0 + ;; + list-panes) printf '%s\n' "${MOSAIC_TEST_PANE_PID:-}"; exit 0 ;; + new-session) + if [ "${MOSAIC_TEST_EXECUTE_PANE:-}" = 1 ]; then + for ((index = 0; index < ${#args[@]}; index++)); do + if [ "${args[$index]}" = /usr/bin/env ]; then + "${args[@]:$index}" + break + fi + done + fi + exit 0 + ;; + *) exit 0 ;; +esac SHIM chmod +x "$FAKE_BIN/tmux" -SOCKET3="mosaic-agent-test3-$RANDOM-$$" -AGENT3="agent3-$RANDOM" -WORKDIR3=$(mktemp -d) -CLEANUP_DIRS+=("$WORKDIR3") - -PATH="$FAKE_BIN:$PATH" \ -MOSAIC_TMUX_SOCKET="$SOCKET3" \ -MOSAIC_AGENT_WORKDIR="$WORKDIR3" \ -MOSAIC_AGENT_RUNTIME="pi" \ -MOSAIC_AGENT_CLASS="code" \ -MOSAIC_AGENT_TOOL_POLICY="operator-interaction" \ -MOSAIC_RUNTIME_BIN="$FAKE_RUNTIME_BIN" \ -MOSAIC_AGENT_COMMAND="mosaic yolo pi --model openai-codex/gpt-5.5:high" \ -MOSAIC_HEARTBEAT_RUN_DIR="$HB_RUN_DIR3" \ - "$START" "$AGENT3" - -all_args=$(cat "$TMUX_ARGS_FILE" 2>/dev/null || true) -rm -f "$TMUX_ARGS_FILE" - -echo "--- captured tmux new-session args ---" -echo "$all_args" -echo "--- end args ---" - -# a) PATH prefix containing FAKE_RUNTIME_BIN must appear. -echo "$all_args" | grep -qF "export PATH=" || fail "pane command does not export PATH" -echo "$all_args" | grep -qF "$FAKE_RUNTIME_BIN" || fail "pane command does not include MOSAIC_RUNTIME_BIN in PATH prefix" - -# b) exec must appear so the runtime replaces the wrapper shell. -echo "$all_args" | grep -qF "exec " || fail "pane command does not use exec" - -# c) Full MOSAIC_AGENT_COMMAND (with flags) must be forwarded. -echo "$all_args" | grep -qF "mosaic yolo pi --model openai-codex/gpt-5.5:high" || \ - fail "pane command does not forward MOSAIC_AGENT_COMMAND with flags intact" - -# d) MOSAIC_AGENT_NAME and the per-agent MOSAIC_AGENT_CLASS must BOTH be exported -# INTO the pane. The pane inherits the tmux SERVER environment (not this -# script's env, nor the systemd unit's EnvironmentFile), so any per-agent var -# the launcher needs in-pane must be re-exported in the snippet. CLASS is -# load-bearing: the launcher composes the persona contract from -# process.env.MOSAIC_AGENT_CLASS, so a missing export silently drops the -# persona (regression guard for the A3a pane-propagation gap). -echo "$all_args" | grep -qF "export MOSAIC_AGENT_NAME=" || \ - fail "pane command does not export MOSAIC_AGENT_NAME into the pane" -echo "$all_args" | grep -qF "export MOSAIC_AGENT_CLASS=code" || \ - fail "pane command does not export MOSAIC_AGENT_CLASS into the pane (persona would silently drop)" -echo "$all_args" | grep -qF "export MOSAIC_AGENT_TOOL_POLICY=operator-interaction" || \ - fail "pane command does not export MOSAIC_AGENT_TOOL_POLICY into the pane" - -# ── Test 4: when no extra runtime-bin dirs exist, exec still appears ─────────── -TMUX_ARGS_FILE2=$(mktemp) -FAKE_BIN2=$(mktemp -d) -HB_RUN_DIR4=$(mktemp -d) -CLEANUP_DIRS+=("$FAKE_BIN2" "$HB_RUN_DIR4") - -cat > "$FAKE_BIN2/tmux" < "$FAKE_BIN/mosaic" <<'SHIM' #!/usr/bin/env bash -subcmd="\$3" -if [ "\$subcmd" = "has-session" ]; then exit 1; fi -if [ "\$subcmd" = "new-session" ]; then - printf '%s\n' "\$@" > "$TMUX_ARGS_FILE2" - exit 0 +set -euo pipefail +env -0 > "${MOSAIC_HOME:?}/fleet/pane-environment" +SHIM +chmod +x "$FAKE_BIN/mosaic" + +write_generated() { + local home="$1" + local agent="$2" + mkdir -p "$home/fleet/agents" "$home/fleet/run" + chmod 700 "$home" "$home/fleet" "$home/fleet/agents" "$home/fleet/run" + printf '123e4567-e89b-12d3-a456-426614174000\n' > "$home/fleet/run/holder-owner" + chmod 600 "$home/fleet/run/holder-owner" + cat > "$home/fleet/agents/$agent.env.generated" < "$TMUX_CALLS" +HOME_UNSAFE_PARENT="$ROOT/unsafe-parent" +write_generated "$HOME_UNSAFE_PARENT" "coder-parent" +chmod 777 "$HOME_UNSAFE_PARENT/fleet/agents" +if output=$(run_start "$HOME_UNSAFE_PARENT" coder-parent 2>&1); then + fail "generated file under a world-writable parent was accepted" fi -exit 0 -SHIM2 -chmod +x "$FAKE_BIN2/tmux" +[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before unsafe parent rejection" +echo "$output" | grep -qF 'code=unsafe-permissions' || fail "unsafe parent diagnostic missing" -SOCKET4="mosaic-agent-test4-$RANDOM-$$" -AGENT4="agent4-$RANDOM" -WORKDIR4=$(mktemp -d) -CLEANUP_DIRS+=("$WORKDIR4") - -# MOSAIC_RUNTIME_BIN points to a non-existent dir so prefix will be empty; -# .npm-global/bin and .local/bin may or may not exist but we just want exec. -PATH="$FAKE_BIN2:$PATH" \ -MOSAIC_TMUX_SOCKET="$SOCKET4" \ -MOSAIC_AGENT_WORKDIR="$WORKDIR4" \ -MOSAIC_AGENT_RUNTIME="pi" \ -MOSAIC_RUNTIME_BIN="/nonexistent-dir-$$" \ -MOSAIC_AGENT_COMMAND="mosaic yolo pi" \ -MOSAIC_HEARTBEAT_RUN_DIR="$HB_RUN_DIR4" \ - "$START" "$AGENT4" - -all_args4=$(cat "$TMUX_ARGS_FILE2" 2>/dev/null || true) -rm -f "$TMUX_ARGS_FILE2" -rm -rf "$WORKDIR4" - -echo "$all_args4" | grep -qF "exec " || fail "pane command (no prefix dirs) does not use exec" -echo "$all_args4" | grep -qF "mosaic yolo pi" || fail "pane command does not include agent command when no prefix" - -# ── Test 5: candidate dir already in LAUNCHER $PATH is still baked into pane ── -# -# Regression guard for the bug where _build_runtime_bin_prefix() used to skip -# a candidate because it was already present in the launcher process's $PATH. -# That check was wrong: the pane inherits the tmux SERVER environment, not the -# launcher's env. Even if a dir is on the launcher's PATH it must always be -# baked into the pane's PATH export. -# -# We prove this by setting PATH to include FAKE_RUNTIME_BIN5 (the candidate), -# then asserting the generated new-session command still exports it. -TMUX_ARGS_FILE5=$(mktemp) -FAKE_BIN5=$(mktemp -d) -FAKE_RUNTIME_BIN5=$(mktemp -d) # this dir IS on the launcher's PATH below -HB_RUN_DIR5=$(mktemp -d) -CLEANUP_DIRS+=("$FAKE_BIN5" "$FAKE_RUNTIME_BIN5" "$HB_RUN_DIR5") - -cat > "$FAKE_BIN5/tmux" < "$TMUX_ARGS_FILE5" - exit 0 +: > "$TMUX_CALLS" +HOME_SYMLINK_PARENT="$ROOT/symlink-parent" +write_generated "$HOME_SYMLINK_PARENT" "coder-symlink-parent" +mv "$HOME_SYMLINK_PARENT/fleet/agents" "$HOME_SYMLINK_PARENT/private-agents" +ln -s "$HOME_SYMLINK_PARENT/private-agents" "$HOME_SYMLINK_PARENT/fleet/agents" +if output=$(run_start "$HOME_SYMLINK_PARENT" coder-symlink-parent 2>&1); then + fail "generated file under a symlinked parent was accepted" fi -if [ "\$subcmd" = "list-panes" ]; then - # Return empty: no sidecar spawned (heartbeat is not the focus of this test). - echo "" - exit 0 -fi -exit 0 -SHIM5 -chmod +x "$FAKE_BIN5/tmux" +[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before symlinked parent rejection" +echo "$output" | grep -qF 'code=unsafe-directory' || fail "symlinked parent diagnostic missing" -SOCKET5="mosaic-agent-test5-$RANDOM-$$" -AGENT5="agent5-$RANDOM" -WORKDIR5=$(mktemp -d) -CLEANUP_DIRS+=("$WORKDIR5") -CLEANUP_SOCKETS+=("$SOCKET5") +# Every managed ancestor is a boundary: MOSAIC_HOME, fleet, and agents. A +# symlink or group/world-writable ancestor must fail before environment parsing, +# workdir creation, or tmux effects. The malformed local input proves parsing +# was not reached when the ancestor rejection is reported. +assert_managed_ancestor_rejected() { + local ancestor="$1" + local hazard="$2" + local home="$ROOT/managed-${ancestor//\//-}-${hazard}" + local agent="coder-managed-${ancestor//\//-}-${hazard}" + local node + write_generated "$home" "$agent" + printf 'MOSAIC_AGENT_COMMAND=must-not-be-parsed\n' > "$home/fleet/agents/$agent.env.local" + chmod 600 "$home/fleet/agents/$agent.env.local" + rm -rf "$home/work" -# FAKE_RUNTIME_BIN5 is deliberately placed on the LAUNCHER PATH so that the -# old (buggy) code would have skipped it. The correct code must still include -# it in the pane PATH export. -PATH="$FAKE_BIN5:$FAKE_RUNTIME_BIN5:$PATH" \ -MOSAIC_TMUX_SOCKET="$SOCKET5" \ -MOSAIC_AGENT_WORKDIR="$WORKDIR5" \ -MOSAIC_AGENT_RUNTIME="pi" \ -MOSAIC_RUNTIME_BIN="$FAKE_RUNTIME_BIN5" \ -MOSAIC_AGENT_COMMAND="mosaic yolo pi" \ -MOSAIC_HEARTBEAT_RUN_DIR="$HB_RUN_DIR5" \ - "$START" "$AGENT5" + case "$ancestor" in + MOSAIC_HOME) node="$home" ;; + MOSAIC_HOME/fleet) node="$home/fleet" ;; + MOSAIC_HOME/fleet/agents) node="$home/fleet/agents" ;; + *) fail "unknown managed ancestor: $ancestor" ;; + esac -all_args5=$(cat "$TMUX_ARGS_FILE5" 2>/dev/null || true) -rm -f "$TMUX_ARGS_FILE5" -rm -rf "$WORKDIR5" + if [ "$hazard" = symlink ]; then + local target="${node}-target" + mv "$node" "$target" + ln -s "$target" "$node" + else + chmod 777 "$node" + fi -echo "--- test 5: launcher-PATH candidate must still appear in pane export ---" -echo "$all_args5" -echo "--- end test 5 args ---" + : > "$TMUX_CALLS" + if output=$(run_start "$home" "$agent" 2>&1); then + fail "${hazard} $ancestor was accepted" + fi + [ ! -s "$TMUX_CALLS" ] || fail "tmux ran before $hazard $ancestor rejection" + [ ! -e "$home/work" ] || fail "workdir was created before $hazard $ancestor rejection" + echo "$output" | grep -qF "code=unsafe-" || fail "managed ancestor diagnostic missing" + if echo "$output" | grep -qF 'key=MOSAIC_AGENT_COMMAND'; then + fail "environment parsing ran before $hazard $ancestor rejection" + fi +} -echo "$all_args5" | grep -qF "export PATH=" || \ - fail "test5: pane command does not export PATH when candidate is on launcher PATH" -echo "$all_args5" | grep -qF "$FAKE_RUNTIME_BIN5" || \ - fail "test5: candidate dir (already on launcher PATH) was NOT baked into pane PATH — regression" - -# ── Test 6: heartbeat sidecar — pane PID resolved + .hb file written ────────── -# -# Uses a real tmux session (same socket as test 1 which already has $AGENT) so -# list-panes returns a real pane PID. We override MOSAIC_HEARTBEAT_RUN_DIR to -# a temp dir and set a 1-second interval, then wait up to 3 s for the .hb file -# to appear and check its content. - -HB_RUN_DIR=$(mktemp -d) -CLEANUP_DIRS+=("$HB_RUN_DIR") - -# Re-use the session+agent created in Test 1 (still alive on $SOCKET / $AGENT). -# We need to invoke the script for a NEW agent on the same socket to exercise -# the heartbeat path with a real pane PID. -AGENT6="agent6-$RANDOM" -MOSAIC_TMUX_SOCKET="$SOCKET" \ -MOSAIC_AGENT_WORKDIR="$WORKDIR" \ -MOSAIC_AGENT_COMMAND='bash --noprofile --norc -i' \ -MOSAIC_HEARTBEAT_RUN_DIR="$HB_RUN_DIR" \ -MOSAIC_HEARTBEAT_INTERVAL="1" \ - "$START" "$AGENT6" - -HB_FILE="$HB_RUN_DIR/${AGENT6}.hb" - -# Wait up to 5 seconds for the heartbeat file to appear. -_waited=0 -until [ -f "$HB_FILE" ] || [ "$_waited" -ge 5 ]; do - sleep 0.5 - _waited=$((_waited + 1)) +for managed_ancestor in MOSAIC_HOME MOSAIC_HOME/fleet MOSAIC_HOME/fleet/agents; do + assert_managed_ancestor_rejected "$managed_ancestor" symlink + assert_managed_ancestor_rejected "$managed_ancestor" group-world-writable done -[ -f "$HB_FILE" ] || fail "test6: heartbeat file not written at $HB_FILE within 5s" +# A local file cannot shadow any roster-derived generated key. Validation must +# happen before fake tmux receives even a has-session call. +: > "$TMUX_CALLS" +HOME_SHADOW="$ROOT/shadow" +write_generated "$HOME_SHADOW" "coder1" +printf 'MOSAIC_AGENT_RUNTIME=codex\n' > "$HOME_SHADOW/fleet/agents/coder1.env.local" +chmod 600 "$HOME_SHADOW/fleet/agents/coder1.env.local" +if output=$(run_start "$HOME_SHADOW" coder1 2>&1); then + fail "generated-key shadow was accepted" +fi +[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before generated-key shadow rejection" +echo "$output" | grep -qF 'key=MOSAIC_AGENT_RUNTIME' || fail "shadow diagnostic omitted key" +echo "$output" | grep -qF 'sha256=' || fail "shadow diagnostic omitted hash" +if echo "$output" | grep -qF 'codex'; then + fail "shadow diagnostic leaked value" +fi -hb_content=$(cat "$HB_FILE") -echo "--- test 6: heartbeat file content ---" -echo "$hb_content" -echo "--- end test 6 ---" +# Arbitrary command compatibility is quarantined/rejected as data. Diagnostics +# may name the key and hash but must never echo the privileged command text. +: > "$TMUX_CALLS" +HOME_COMMAND="$ROOT/command" +write_generated "$HOME_COMMAND" "coder2" +COMMAND_VALUE='mosaic yolo codex --dangerous' +printf 'MOSAIC_AGENT_COMMAND=%s\n' "$COMMAND_VALUE" > "$HOME_COMMAND/fleet/agents/coder2.env.local" +chmod 600 "$HOME_COMMAND/fleet/agents/coder2.env.local" +if output=$(run_start "$HOME_COMMAND" coder2 2>&1); then + fail "arbitrary command override was accepted" +fi +[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before command rejection" +echo "$output" | grep -qF 'key=MOSAIC_AGENT_COMMAND' || fail "command diagnostic omitted key" +echo "$output" | grep -qF 'sha256=' || fail "command diagnostic omitted hash" +if echo "$output" | grep -qF "$COMMAND_VALUE"; then + fail "command diagnostic leaked command value" +fi -# Verify required fields are present. -echo "$hb_content" | grep -qE '^ts=[0-9]{4}-[0-9]{2}-[0-9]{2}T' || \ - fail "test6: heartbeat ts field missing or malformed" -echo "$hb_content" | grep -qE '^pid=[0-9]+' || \ - fail "test6: heartbeat pid field missing or malformed" -echo "$hb_content" | grep -qF 'status=ok' || \ - fail "test6: heartbeat status=ok missing" +# Group/world-readable local input is not trusted even when its syntax is safe. +: > "$TMUX_CALLS" +HOME_PERMS="$ROOT/perms" +write_generated "$HOME_PERMS" "coder3" +printf 'MOSAIC_RUNTIME_BIN=/opt/mosaic/bin\n' > "$HOME_PERMS/fleet/agents/coder3.env.local" +chmod 644 "$HOME_PERMS/fleet/agents/coder3.env.local" +if output=$(run_start "$HOME_PERMS" coder3 2>&1); then + fail "world-readable local input was accepted" +fi +[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before permissions rejection" +echo "$output" | grep -qF 'code=unsafe-permissions' || fail "permission diagnostic missing" -# ── Test 7: heartbeat sidecar — targets correct .hb path per agent name ──────── -# -# Uses the fake-tmux shim approach (like tests 3-5) to capture the sidecar -# invocation without needing a real session. A fake setsid shim records its -# arguments so we can assert the sidecar script targets the expected .hb path -# and uses the configured interval. +# A unit/holder-like clean bootstrap must yield a pane with trusted HOME and +# computed PATH only. The pane command itself must not carry loader, shell +# control, arbitrary sentinel, or stale bootstrap variables. +: > "$TMUX_CALLS" +HOME_PANE_BOUNDARY="$ROOT/pane-boundary/.config/mosaic" +write_generated "$HOME_PANE_BOUNDARY" "coder-pane-boundary" +PANE_TRUSTED_HOME="${HOME_PANE_BOUNDARY%/.config/mosaic}" +PANE_STALE_HOME="$ROOT/stale-home" +PANE_STALE_PATH="$ROOT/stale-bin" +PANE_BASH_ENV="$ROOT/pane-boundary.bash-env" +printf 'MOSAIC_RUNTIME_BIN=%s\n' "$FAKE_BIN" > \ + "$HOME_PANE_BOUNDARY/fleet/agents/coder-pane-boundary.env.local" +chmod 600 "$HOME_PANE_BOUNDARY/fleet/agents/coder-pane-boundary.env.local" +LD_PRELOAD='/not/loaded/by-clean-bootstrap.so' \ +BASH_ENV="$PANE_BASH_ENV" \ +MOSAIC_UNTRUSTED_SENTINEL='must-not-reach-pane' \ +HOME="$PANE_STALE_HOME" \ +PATH="$PANE_STALE_PATH" \ + /usr/bin/env -i \ + "HOME=$PANE_TRUSTED_HOME" \ + "PATH=$FAKE_BIN:/usr/bin:/bin" \ + "MOSAIC_HOME=$HOME_PANE_BOUNDARY" \ + "MOSAIC_TEST_TMUX_CALLS=$TMUX_CALLS" \ + "MOSAIC_TEST_HOME=$PANE_TRUSTED_HOME" \ + MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \ + MOSAIC_TEST_EXECUTE_PANE=1 \ + "$START" coder-pane-boundary +pane_args=$(tr '\0' '\n' < "$TMUX_CALLS") +echo "$pane_args" | grep -qxF "HOME=$PANE_TRUSTED_HOME" || \ + fail "pane did not restore trusted HOME" +echo "$pane_args" | grep -qF "HOME=$PANE_STALE_HOME" && \ + fail "pane inherited stale HOME" +echo "$pane_args" | grep -qF "$PANE_STALE_PATH" && fail "pane inherited stale PATH" +for blocked in LD_PRELOAD= BASH_ENV= MOSAIC_UNTRUSTED_SENTINEL=; do + echo "$pane_args" | grep -qF "$blocked" && fail "pane inherited $blocked" +done -FAKE_BIN7=$(mktemp -d) -FAKE_RUNTIME_BIN7=$(mktemp -d) -SETSID_ARGS_FILE=$(mktemp) -HB_RUN_DIR7=$(mktemp -d) -CLEANUP_DIRS+=("$FAKE_BIN7" "$FAKE_RUNTIME_BIN7" "$HB_RUN_DIR7") +after_pane_env=$(printf '%s\n' "$pane_args" | grep -n -m1 -F '/usr/bin/env' | cut -d: -f1) +[ -n "$after_pane_env" ] || fail "pane command did not use absolute env" +printf '%s\n' "$pane_args" | tail -n +"$after_pane_env" | grep -qxF -- '-i' || \ + fail "pane command did not clear its environment" +pane_environment=$(tr '\0' '\n' < "$HOME_PANE_BOUNDARY/fleet/pane-environment") +echo "$pane_environment" | grep -qxF "HOME=$PANE_TRUSTED_HOME" || \ + fail "runtime pane did not receive trusted HOME" +echo "$pane_environment" | grep -qF "$PANE_STALE_PATH" && fail "runtime pane received stale PATH" +for blocked in LD_PRELOAD= BASH_ENV= MOSAIC_UNTRUSTED_SENTINEL=; do + echo "$pane_environment" | grep -qF "$blocked" && fail "runtime pane received $blocked" +done -AGENT7="my-fleet-agent-$RANDOM" -INTERVAL7="42" +write_interaction_generated() { + local home="$1" + local agent="$2" + mkdir -p "$home/fleet/agents" "$home/fleet/run" "$home/work" + chmod 700 "$home" "$home/fleet" "$home/fleet/agents" "$home/fleet/run" + printf '123e4567-e89b-12d3-a456-426614174000\n' > "$home/fleet/run/holder-owner" + chmod 600 "$home/fleet/run/holder-owner" + cat > "$home/fleet/agents/$agent.env.generated" < "$FAKE_BIN7/tmux" < argument for inspection, then -# background an actual bash subshell so disown succeeds in the caller. -cat > "$FAKE_BIN7/setsid" <<'SETSID_SHIM' -#!/usr/bin/env bash -# argv: setsid bash -c -# Record the full argument list to the capture file, then exit cleanly. -printf '%s\0' "$@" > __SETSID_ARGS_FILE__ -exit 0 -SETSID_SHIM -# Patch the placeholder with the real capture-file path (avoids heredoc expansion issues). -sed -i "s|__SETSID_ARGS_FILE__|${SETSID_ARGS_FILE}|g" "$FAKE_BIN7/setsid" -chmod +x "$FAKE_BIN7/setsid" +write_heartbeat_local() { + local home="$1" + local agent="$2" + mkdir -p "$home/run" + cat > "$home/fleet/agents/$agent.env.local" </dev/null && return 0 + sleep 0.1 + done + fail "heartbeat sidecar did not resume after native marker became stale or absent" +} -PATH="$FAKE_BIN7:$PATH" \ -MOSAIC_TMUX_SOCKET="$SOCKET7" \ -MOSAIC_AGENT_WORKDIR="$WORKDIR7" \ -MOSAIC_AGENT_RUNTIME="pi" \ -MOSAIC_RUNTIME_BIN="$FAKE_RUNTIME_BIN7" \ -MOSAIC_AGENT_COMMAND="mosaic yolo pi" \ -MOSAIC_HEARTBEAT_RUN_DIR="$HB_RUN_DIR7" \ -MOSAIC_HEARTBEAT_INTERVAL="$INTERVAL7" \ - "$START" "$AGENT7" +# A fresh Pi-native marker is authoritative: the shell sidecar may start but +# must not overwrite Pi's busy/ok/model heartbeat. It must resume only when +# the marker is stale or absent. +HOME_NATIVE_FRESH="$ROOT/native-fresh" +write_generated "$HOME_NATIVE_FRESH" "coder-native-fresh" +write_heartbeat_local "$HOME_NATIVE_FRESH" "coder-native-fresh" +FRESH_HB="$HOME_NATIVE_FRESH/run/coder-native-fresh.hb" +printf 'ts=native\npid=1\nstatus=busy\nmodel=authoritative-model\n' > "$FRESH_HB" +touch "$FRESH_HB.native" +MOSAIC_TEST_PANE_PID=$$ run_start "$HOME_NATIVE_FRESH" coder-native-fresh +sleep 0.3 +fresh_content=$(cat "$FRESH_HB") +[ "$fresh_content" = 'ts=native +pid=1 +status=busy +model=authoritative-model' ] || fail "fresh native heartbeat was overwritten" -# Give the background setsid shim a moment to finish writing the capture file. -sleep 0.5 +HOME_NATIVE_STALE="$ROOT/native-stale" +write_generated "$HOME_NATIVE_STALE" "coder-native-stale" +write_heartbeat_local "$HOME_NATIVE_STALE" "coder-native-stale" +STALE_HB="$HOME_NATIVE_STALE/run/coder-native-stale.hb" +printf 'ts=native\npid=1\nstatus=busy\nmodel=stale-model\n' > "$STALE_HB" +touch -d '10 seconds ago' "$STALE_HB.native" +MOSAIC_TEST_PANE_PID=$$ run_start "$HOME_NATIVE_STALE" coder-native-stale +wait_for_sidecar_status "$STALE_HB" -setsid_args=$(cat "$SETSID_ARGS_FILE" 2>/dev/null | tr '\0' '\n' || true) -rm -f "$SETSID_ARGS_FILE" -rm -rf "$WORKDIR7" +HOME_NATIVE_ABSENT="$ROOT/native-absent" +write_generated "$HOME_NATIVE_ABSENT" "coder-native-absent" +write_heartbeat_local "$HOME_NATIVE_ABSENT" "coder-native-absent" +ABSENT_HB="$HOME_NATIVE_ABSENT/run/coder-native-absent.hb" +printf 'ts=native\npid=1\nstatus=busy\nmodel=absent-model\n' > "$ABSENT_HB" +MOSAIC_TEST_PANE_PID=$$ run_start "$HOME_NATIVE_ABSENT" coder-native-absent +wait_for_sidecar_status "$ABSENT_HB" -echo "--- test 7: captured setsid args ---" -echo "$setsid_args" -echo "--- end test 7 ---" +# The interaction wrapper delegates to the shared strict parser before applying +# its pinned policy, so malformed projection data wins over profile diagnostics. +: > "$TMUX_CALLS" +HOME_INTERACTION_MALFORMED="$ROOT/interaction-malformed" +write_interaction_generated "$HOME_INTERACTION_MALFORMED" "interaction-malformed" +printf 'UNTRUSTED_BOOTSTRAP=value\n' >> "$HOME_INTERACTION_MALFORMED/fleet/agents/interaction-malformed.env.generated" +if output=$(run_interaction "$HOME_INTERACTION_MALFORMED" interaction-malformed 2>&1); then + fail "interaction wrapper accepted malformed generated data" +fi +[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before interaction strict-parser rejection" +echo "$output" | grep -qF 'code=unknown-key' || fail "interaction did not use shared strict parser first" -# The sidecar script (bash -c