From ac7286f83b83daf233622ed17fc4eb7c1d20f9c2 Mon Sep 17 00:00:00 2001 From: Jeffrey Smith Date: Fri, 3 Apr 2026 18:00:33 +0000 Subject: [PATCH] Feat v0.9.6 stage mode collapse (#80) Co-authored-by: Jeffrey Smith Co-committed-by: Jeffrey Smith --- CHANGELOG.md | 30 + ROADMAP.md | 44 +- VERSION | 2 +- ci/e2e-workflow-handoff.sh | 2 +- ci/e2e-workflow-nochat.sh | 2 +- docs/DESIGN-sidecar-v014x.md | 922 ++++++++++++++++++ docs/ROADMAP-v010x-v100.md | 157 +++ packages/bug-report-triage/manifest.json | 7 +- packages/content-approval/manifest.json | 6 +- packages/employee-onboarding/manifest.json | 7 +- .../icd-test-runner/js/crud/team-workflows.js | 6 +- packages/icd-test-runner/js/crud/workflows.js | 22 +- packages/webhook-notifier/manifest.json | 5 +- .../postgres/018_stage_mode_collapse.sql | 13 + .../sqlite/018_stage_mode_collapse.sql | 5 + server/handlers/workflow_engine_test.go | 16 +- server/handlers/workflow_packages.go | 4 +- server/handlers/workflow_store_test.go | 2 +- server/handlers/workflows.go | 22 +- server/models/workflow.go | 26 +- server/pages/pages.go | 4 +- server/pages/templates/workflow-landing.html | 2 +- server/pages/templates/workflow.html | 119 +-- server/sandbox/workflow_module.go | 2 +- server/static/openapi.yaml | 13 +- server/store/sqlite/workflows_test.go | 6 +- src/js/sw/components/stage-form.js | 13 +- 27 files changed, 1231 insertions(+), 228 deletions(-) create mode 100644 docs/DESIGN-sidecar-v014x.md create mode 100644 docs/ROADMAP-v010x-v100.md create mode 100644 server/database/migrations/postgres/018_stage_mode_collapse.sql create mode 100644 server/database/migrations/sqlite/018_stage_mode_collapse.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index fd20ded..028d170 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,36 @@ All notable changes to Armature are documented here. +## v0.9.6 — Deprecate stage_type, Collapse stage_mode + +Simplifies the workflow stage classification model by removing +redundant fields. + +**stage_type deprecated** + +- No longer validated on input; any value accepted, defaults to "simple" +- Existing manifests parsed for backward compatibility +- DB column retained; export no longer includes the field +- `starlark_hook` presence (not `stage_type`) determines automation + +**stage_mode collapsed (4 → 3 values)** + +- "review" removed as valid mode; mapped to "form" on input +- Existing DB rows migrated: review → form +- Review surface removed from workflow.html (~110 lines); signoff system + in `stage_config.validation` handles review behavior +- Valid modes: form, delegated, automated + +**DB migration 018** + +- Postgres: UPDATE + CHECK constraint replacement +- SQLite: UPDATE only (CHECK stays broad) + +**Package manifests updated** + +- bug-report-triage, content-approval, employee-onboarding, + webhook-notifier: review → form, stage_type removed + ## v0.9.5 — Typed Forms → SDK Primitive Promotes the typed form system from a workflow-only model to a reusable diff --git a/ROADMAP.md b/ROADMAP.md index b0b69aa..7dda8db 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -122,11 +122,12 @@ Extracted `TypedFormTemplate`, `FormField`, `FormFieldset`, etc. from FE SDK: `sw.forms.render()`, `sw.forms.validate()`, `sw.forms.validateRemote()`. Manifest `form_template` accepted at package level. 16 new tests. -**v0.9.6 — Deprecate `stage_type`, Collapse `stage_mode`** +**v0.9.6 — Deprecate `stage_type`, Collapse `stage_mode`** *(completed)* -`stage_type` (simple/dynamic/automated) is redundant with `starlark_hook` -presence. Remove from new manifests, keep parsing for backward compat. -Collapse `stage_mode` from 4 to 3 values: form / delegated / automated. +`stage_type` deprecated (no longer validated, defaults to "simple"). +`stage_mode` collapsed from 4→3 values: form / delegated / automated. +"review" mapped to "form" on input; review surface removed (~110 lines). +Migration 018. 4 package manifests updated. **v0.9.7 — Full Read/Write Workflow Starlark Module** @@ -232,13 +233,31 @@ capability. Zero configuration. --- -### v0.14.x — Sidecar Tier + Polish +### v0.14.x — Sidecar Tier + +Connect-inward model: sidecars connect TO the kernel (no K8s RBAC, no +service mesh, no DNS discovery). Instance sidecars (shared infrastructure) +and user sidecars (personal local tools). Design doc: +`docs/DESIGN-sidecar-v014x.md`. | Version | Title | |---------|-------| -| v0.14.0 | Sidecar Tier | -| v0.14.1 | Native Dialog Audit | -| v0.14.2 | Stability + Migration Tooling | +| v0.14.0 | Sidecar Registry + Auth | +| v0.14.1 | Capability Registration + Execution | +| v0.14.2 | Kernel API Access + Event Bus | +| v0.14.3 | Manifest Integration + Admin Polish | +| v0.14.4 | Reference Sidecar (`armature-embed`) + Instance Gate | +| v0.14.5 | User Sidecars + Reference (`user-bridge`) + User Gate | + +--- + +### v0.15.x — Polish + Stability + +| Version | Title | +|---------|-------| +| v0.15.0 | Native Dialog Audit | +| v0.15.1 | Versioned Migrations + `armature migrate` CLI | +| v0.15.2 | Pre-1.0 Schema Freeze + Upgrade Path Validation | --- @@ -265,6 +284,10 @@ Gate criteria: - Headless E2E green on PG + SQLite - `armature-ca.sh` + mTLS deployment guide - Single-binary + Docker + K8s deployment paths documented +- Instance sidecar (`armature-embed`) deployed and functional +- User sidecar (`user-bridge`) functional on macOS/Linux/Windows +- Token + mTLS sidecar auth both tested +- `armature migrate` CLI functional --- @@ -328,3 +351,8 @@ These are candidates, not commitments. Each requires a design doc. | Layered prompt architecture (6 layers) | Admin safety not overridable. Clear separation: platform → space → character → context → tools → user. | | Personas in llm-bridge, not chat | Chat sees AI as just another participant_type. Persona identity is llm-bridge's concern. | | llm-bridge after notes + chat (v0.13.x) | Proves the composability hooks work without being designed for a specific consumer. | +| Sidecar connect-inward | No K8s RBAC, no service mesh, no DNS discovery. Works on any deployment target. | +| Sidecar HTTP/JSON, not gRPC | KISS. 4-endpoint contract. Every language has HTTP. | +| User sidecars in v0.14.5 | Prove instance contract first, extend to users with same mechanism. | +| Three-layer user sidecar RBAC | Admin controls who + what, user controls visibility. Defense in depth. | +| Separate polish (v0.15.x) | Security-critical sidecar infra and quality-of-life polish shouldn't share focus. | diff --git a/VERSION b/VERSION index b0bb878..85b7c69 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.5 +0.9.6 diff --git a/ci/e2e-workflow-handoff.sh b/ci/e2e-workflow-handoff.sh index 4c08cd4..dfa23a9 100755 --- a/ci/e2e-workflow-handoff.sh +++ b/ci/e2e-workflow-handoff.sh @@ -127,7 +127,7 @@ STAGE2_RESP=$(curl -sf -X POST "${SERVER_URL}/api/v1/workflows/${WF_ID}/stages" -H "$AUTH" -H "Content-Type: application/json" \ -d "{ \"name\": \"Team Review\", - \"stage_mode\": \"review\", + \"stage_mode\": \"form\", \"audience\": \"team\", \"ordinal\": 1, \"assignment_team_id\": \"${TEAM_ID}\" diff --git a/ci/e2e-workflow-nochat.sh b/ci/e2e-workflow-nochat.sh index da2e611..124e59a 100755 --- a/ci/e2e-workflow-nochat.sh +++ b/ci/e2e-workflow-nochat.sh @@ -125,7 +125,7 @@ REVIEW_RESP=$(curl -sf -X POST "${SERVER_URL}/api/v1/workflows/${WF_ID}/stages" -H "$AUTH" -H "Content-Type: application/json" \ -d '{ "name": "Manager Review", - "stage_mode": "review", + "stage_mode": "form", "ordinal": 1 }' 2>/dev/null || echo '{"error":"failed"}') diff --git a/docs/DESIGN-sidecar-v014x.md b/docs/DESIGN-sidecar-v014x.md new file mode 100644 index 0000000..80b0f05 --- /dev/null +++ b/docs/DESIGN-sidecar-v014x.md @@ -0,0 +1,922 @@ +# DESIGN: Sidecar Tier — v0.14.x + +## Status: Proposed + +## Problem + +Starlark is the right sandbox for most extension logic: declarative, +safe, fast to start, no deployment pipeline. But some workloads cannot +run in Starlark: + +- **ML inference** — Python + PyTorch/TF model loading, GPU access. +- **Media transcoding** — ffmpeg, ImageMagick, Whisper. +- **Language servers** — LSP processes for code-workspace. +- **Local git** — clone, diff, merge operations on workspace directories. +- **Custom runtimes** — anything needing system libraries, native code, + or long-running processes. +- **Personal local bridges** — a user's laptop exposing filesystem, + terminal, clipboard, or local compute to the Armature instance. + +Today, the `http` module lets Starlark call external APIs (`api.http` +permission, domain allowlists, SSRF protection). This covers cloud +services (OpenAI, S3, external webhooks) but not **co-located processes** +that need access to Armature's data, events, and workspace filesystem, +nor **personal processes** on a user's own machine. + +The sidecar tier bridges this gap: out-of-process extensions that +connect inward to the kernel, authenticate, register capabilities, and +participate in the extension ecosystem as first-class citizens — at both +the **instance level** (shared infrastructure) and the **user level** +(personal local tools). + +**If done wrong, the consequences are severe:** + +- Wrong auth model → arbitrary processes access kernel APIs. +- Wrong lifecycle → resource leaks, zombie processes, orphaned + registrations. +- Wrong API surface → every sidecar extension inherits the debt. +- Wrong isolation → a runaway sidecar takes down the cluster. +- Wrong discovery → fragile deployments that break on restart. +- Wrong scoping → user sidecars see other users' data. + +This is invisible infrastructure. Users never see it. But every +extension that needs native code depends on the contract being right. + +## Non-Goals + +- **Kernel manages sidecar processes.** The kernel does not start, stop, + or restart sidecars. That's the deployment layer's job (K8s, Docker + Compose, systemd, or a human running a binary). The kernel discovers + sidecars that connect to it. +- **Sidecar marketplace.** Sidecar packages are installed like any other + `.pkg` file. The sidecar binary ships separately (container image, + standalone binary, pip package). The manifest declares the sidecar + endpoint; the operator deploys it. +- **Arbitrary bidirectional streaming.** Sidecars communicate via + HTTP/JSON request-response and WebSocket events. No gRPC, no custom + binary protocols. KISS. +- **Hot code reload.** Sidecar updates require process restart. The + kernel detects the reconnection and re-registers capabilities. +- **Multi-tenant sidecar isolation.** Instance-level sidecars run at the + instance level, not per-user. User-level isolation is handled by + user sidecars (v0.14.5), which are scoped to the owning user's + identity. + +--- + +## Architecture + +### Connect-Inward Model + +The defining property: **sidecars connect TO the kernel, not the other +way around.** The kernel never reaches outward to discover or contact +a sidecar. This eliminates: + +- K8s RBAC for the kernel to discover pods. +- Service mesh configuration. +- DNS-based service discovery. +- Any notion of the kernel "managing" external processes. + +A sidecar is just a process that knows the kernel's address and has +credentials to authenticate. It could be a container in the same pod, a +separate deployment, a systemd service on the same machine, or a process +on a developer's laptop connected via tunnel. + +``` +┌─────────────────────────────────────────────────────┐ +│ Deployment Layer (K8s / Docker / systemd / manual) │ +│ │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ +│ │ instance │ │ instance │ │ instance │ │ +│ │ sidecar: │ │ sidecar: │ │ sidecar: │ │ +│ │ ml-infer │ │ ffmpeg │ │ git-ops │ │ +│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │ +│ │ │ │ │ +│ │ connect inward │ │ +│ ▼ ▼ ▼ │ +│ ┌──────────────────────────────────────────┐ │ +│ │ Armature Kernel │ │ +│ │ ┌─────────────────────────────────────┐ │ │ +│ │ │ Sidecar Registry (PG) │ │ │ +│ │ │ ml-infer: instance, 3 caps │ │ │ +│ │ │ ffmpeg: instance, 2 caps │ │ │ +│ │ │ git-ops: instance, 4 caps │ │ │ +│ │ │ jeff-local: user:jeff, 3 caps │ │ │ +│ │ └─────────────────────────────────────┘ │ │ +│ └────────────────────────────────▲─────────┘ │ +│ │ │ +└───────────────────────────────────┼──────────────────┘ + │ connect inward + ┌─────┴──────┐ + │ user │ + │ sidecar: │ + │ jeff-local │ + │ (laptop) │ + └────────────┘ +``` + +### Two Scopes + +**Instance sidecars** — shared infrastructure. Deployed by the operator. +Any user's requests can invoke their capabilities. An ML inference +sidecar, a transcoding service, a git operations worker. + +**User sidecars** — personal processes. Run by an individual user on +their own machine. Only the owning user's requests can invoke their +capabilities. A local file bridge, a personal Ollama instance, a +script that reacts to Armature events. + +Both use the same connect-inward mechanism, same registration flow, +same capability contract. The difference is the identity binding and +access scoping. + +### Two Authentication Modes + +#### Mode A: Registration Token + +For simple deployments (Docker Compose, single machine, dev/test). + +1. Admin generates an instance sidecar token: + `armature sidecar token create --package ml-inference` + User generates a personal sidecar token (from settings UI or CLI): + `armature sidecar token create --personal` +2. Token is a signed JWT with claims: `{package_id, scope, user_id, exp}`. + Instance tokens: `scope: "instance"`, `user_id: null`. + User tokens: `scope: "user"`, `user_id: ""`. +3. Sidecar starts with the token as env: `ARMATURE_TOKEN=ey...` +4. Sidecar calls `POST /api/v1/sidecar/register`. +5. Kernel validates JWT, creates registry entry with appropriate scope. + +#### Mode B: mTLS + +For production deployments with certificate infrastructure. + +1. Certificate CN encodes identity: + Instance: `sidecar:ml-inference` + User: `sidecar-user:jeff:local-bridge` +2. Kernel's `MTLSNativeProvider` parses the CN prefix and resolves to + the appropriate sidecar identity. + +Both modes produce the same internal identity: a `SidecarIdentity` +struct with `{package_id, scope, user_id, node_id, registered_at}`. + +### Sidecar Registry + +```sql +CREATE TABLE IF NOT EXISTS sidecar_registry ( + sidecar_id TEXT PRIMARY KEY, + package_id TEXT NOT NULL, + scope TEXT NOT NULL DEFAULT 'instance', + user_id TEXT, + endpoint TEXT NOT NULL, + heartbeat TIMESTAMPTZ DEFAULT now(), + capabilities JSONB DEFAULT '[]', + stats JSONB DEFAULT '{}', + registered_at TIMESTAMPTZ DEFAULT now() +); + +CREATE INDEX idx_sidecar_pkg ON sidecar_registry (package_id); +CREATE INDEX idx_sidecar_user ON sidecar_registry (user_id) + WHERE user_id IS NOT NULL; +``` + +`scope` is `'instance'` or `'user'`. `user_id` is NULL for instance +sidecars, set for user sidecars. + +**Lifecycle (mirrors cluster registry):** + +- **Register:** `POST /api/v1/sidecar/register` with capability + manifest. Kernel creates/updates registry row. +- **Heartbeat:** `POST /api/v1/sidecar/heartbeat` every N seconds. +- **Sweep:** Kernel's heartbeat loop sweeps stale sidecar entries + alongside node entries. 3× heartbeat interval = stale. +- **Self-eviction:** Heartbeat returns 404 → re-register or exit. +- **Deregister:** `DELETE /api/v1/sidecar/deregister` on graceful + shutdown. + +### Capability Registration + +On registration, the sidecar declares capabilities: + +```json +POST /api/v1/sidecar/register +{ + "package_id": "ml-inference", + "endpoint": "http://ml-inference:8080", + "capabilities": [ + { + "name": "embed", + "description": "Generate text embeddings", + "input_schema": { + "type": "object", + "properties": { + "text": { "type": "string" }, + "model": { "type": "string", "default": "all-MiniLM-L6-v2" } + }, + "required": ["text"] + }, + "timeout_seconds": 30 + }, + { + "name": "classify", + "description": "Zero-shot text classification", + "input_schema": { ... }, + "timeout_seconds": 60 + } + ] +} +``` + +### Sidecar SDK (Starlark) + +New `sidecar` Starlark module. Permission: `sidecar.call`. + +```python +# Call an instance sidecar capability +result = sidecar.call("ml-inference", "embed", {"text": "Hello"}) + +# Check availability +available = sidecar.available("ml-inference") + +# List capabilities +caps = sidecar.capabilities("ml-inference") +``` + +User sidecar calls are routed automatically. When a request originates +from user Jeff, `sidecar.call("user-bridge", "fs.search", {...})` hits +Jeff's personal sidecar. The same call from user Alice would hit Alice's +personal `user-bridge` sidecar (or fail if she doesn't have one). The +Starlark caller doesn't specify the user — the kernel resolves it from +the request context. + +### Sidecar SDK (Frontend) + +```js +const result = await sw.sidecar.call('ml-inference', 'embed', { + text: 'Hello, world' +}); +const available = await sw.sidecar.available('user-bridge'); +``` + +Frontend calls go through the kernel API. The kernel proxies to the +appropriate sidecar based on scope + user identity. + +### Sidecar API Contract (Inbound to Kernel) + +| Endpoint | Purpose | Auth | +|----------|---------|------| +| `POST /api/v1/sidecar/register` | Register + declare capabilities | Token or mTLS | +| `POST /api/v1/sidecar/heartbeat` | Heartbeat + stats | Token or mTLS | +| `DELETE /api/v1/sidecar/deregister` | Graceful shutdown | Token or mTLS | +| `GET /api/v1/ext/{pkg}/*` | Read extension data (scoped) | Token or mTLS | +| `POST /api/v1/ext/{pkg}/*` | Write extension data (scoped) | Token or mTLS | +| `POST /api/v1/sidecar/emit` | Emit events | Token or mTLS | +| `GET /api/v1/sidecar/subscribe` | WebSocket event subscription | Token or mTLS | +| `POST /api/v1/sidecar/lib/{pkg}/{fn}` | Cross-package function call | Token or mTLS | + +**Scoping rules:** + +- Instance sidecars: can access `/api/v1/ext/{own_package}/*` only. +- User sidecars: can access `/api/v1/ext/{own_package}/*` scoped to + the owning user's data within that package. The sidecar sees the + same data the user would see through the UI. +- Event emission scoped to the sidecar's package prefix. + +### Sidecar API Contract (Outbound from Kernel) + +| Endpoint | Purpose | +|----------|---------| +| `POST {sidecar_endpoint}/api/v1/exec/{capability}` | Execute a capability | +| `GET {sidecar_endpoint}/api/v1/health` | Health check | + +The `/exec/{capability}` endpoint receives: + +```json +{ + "input": { ... }, + "context": { + "user_id": "...", + "package_id": "...", + "request_id": "..." + } +} +``` + +Returns: + +```json +{ + "output": { ... }, + "error": null +} +``` + +### Resource Limits + +What the kernel enforces at the proxy layer: + +- **Timeout:** Per-capability, from capability declaration. +- **Request rate:** Per-sidecar, configurable in admin. +- **Response size:** Default 10MB, configurable per-sidecar. +- **Concurrent calls:** Default 10, configurable per-sidecar. + +User sidecars have separate (typically lower) defaults: + +- **User sidecar rate limit:** Default 30/min (vs 100/min instance). +- **User sidecar concurrency:** Default 3 (vs 10 instance). +- **User sidecar response size:** Default 5MB (vs 10MB instance). + +Admin can adjust user sidecar defaults globally. Individual users +cannot override admin limits. + +### Manifest Integration + +Instance sidecar manifest: + +```json +{ + "id": "ml-inference", + "type": "full", + "tier": "sidecar", + "version": "1.0.0", + + "sidecar": { + "image": "gobha/armature-ml-inference:latest", + "required": true, + "health_endpoint": "/api/v1/health", + "env_hints": { + "ARMATURE_URL": "Kernel URL (auto-populated)", + "ARMATURE_TOKEN": "Registration token", + "MODEL_PATH": "Path to model files" + } + } +} +``` + +User sidecar manifest (installed as a regular package, sidecar runs +on user's machine): + +```json +{ + "id": "user-bridge", + "type": "full", + "tier": "sidecar", + "version": "1.0.0", + + "sidecar": { + "scope": "user", + "required": false, + "download_url": "https://github.com/armature/user-bridge/releases", + "env_hints": { + "ARMATURE_URL": "Your Armature instance URL", + "ARMATURE_TOKEN": "Generate from Settings → Sidecars" + } + }, + + "surfaces": ["/user-bridge/settings"] +} +``` + +`sidecar.scope: "user"` tells the kernel this is a user sidecar +package. The package installs normally (admin or self-install depending +on permissions), but the sidecar binary runs on the user's machine. + +### Tool Meta-Tool Integration + +When `llm-bridge` assembles tools for a completion: + +1. Instance-level actions from `sw.actions.list()` → available to all. +2. Instance sidecar capabilities → available to all. +3. **Requesting user's personal sidecar capabilities** → available to + that user only. + +This means different users get different tool sets. Jeff has +`user-bridge` running with `fs.search`, `terminal.exec`, and +`clipboard.get`. Alice doesn't. When Jeff asks "Hey Max, find the auth +file on my desktop," Max has `user-bridge.fs.search` in his tool set. +When Alice asks the same, that tool doesn't exist — Max tells her he +can't access her local files. + +The same AI persona, different capabilities per user, zero configuration. + +### Admin UI + +**Instance sidecar management:** +- Sidecar health status per registered instance sidecar. +- Capabilities list with call counts, error rates, latency. +- Token management: generate, revoke, list. +- Per-sidecar rate limit and concurrency configuration. + +**User sidecar admin controls:** +- Permission: `sidecar.connect_personal` — admin grants to specific + users or groups. +- Capability allowlist for user sidecars — admin restricts which + capabilities user sidecars can declare. +- Global user sidecar resource limits (rate, concurrency, response + size). +- Monitoring: list of all connected user sidecars with owner, endpoint, + capabilities, last heartbeat. +- Kill switch: admin can revoke any user sidecar token or sweep a + specific user's sidecars. + +**User settings (for users with `sidecar.connect_personal`):** +- Generate personal sidecar token. +- List personal connected sidecars with health status. +- Revoke own tokens. +- Download link / instructions for user sidecar binaries (from package + manifest `download_url`). + +### Monitoring + +Kernel-side metrics: + +- `sidecar_call_total{package, capability, scope, status}` — counter. +- `sidecar_call_duration_seconds{package, capability, scope}` — histogram. +- `sidecar_heartbeat_age_seconds{package, scope}` — gauge. +- `sidecar_active{package, scope}` — gauge. +- `sidecar_user_count` — gauge (number of connected user sidecars). + +--- + +## User Sidecar Use Cases + +### Local Bridge (Desktop Commander Pattern) + +A thin agent on the user's laptop that exposes local capabilities: + +```json +{ + "capabilities": [ + { + "name": "fs.search", + "description": "Search local filesystem by filename or content", + "input_schema": { + "properties": { + "query": { "type": "string" }, + "path": { "type": "string", "default": "~" }, + "content_search": { "type": "boolean", "default": false } + }, + "required": ["query"] + }, + "timeout_seconds": 15 + }, + { + "name": "fs.read", + "description": "Read a local file", + "input_schema": { + "properties": { + "path": { "type": "string" }, + "max_bytes": { "type": "integer", "default": 1048576 } + }, + "required": ["path"] + }, + "timeout_seconds": 10 + }, + { + "name": "terminal.exec", + "description": "Execute a shell command", + "input_schema": { + "properties": { + "command": { "type": "string" }, + "cwd": { "type": "string", "default": "~" }, + "timeout": { "type": "integer", "default": 30 } + }, + "required": ["command"] + }, + "timeout_seconds": 60 + }, + { + "name": "clipboard.get", + "description": "Read clipboard contents", + "input_schema": {}, + "timeout_seconds": 5 + } + ] +} +``` + +**Tool meta-tool flow:** + +``` +User: "Hey Max, find the database migration script I was + working on yesterday and add it to my notes" + +llm-bridge assembles (for this user): + Layer 5 tools: [...instance tools..., user-bridge.fs.search, + user-bridge.fs.read, notes.create, ...] + +LLM: + 1. Tool: user-bridge.fs.search({query: "migration", path: "~/code"}) + 2. Result: [{path: "~/code/armature/migrations/015_sidecar.sql", ...}] + 3. Tool: user-bridge.fs.read({path: "~/code/armature/migrations/015_sidecar.sql"}) + 4. Result: {content: "CREATE TABLE IF NOT EXISTS sidecar_registry..."} + 5. Tool: notes.create({title: "Migration 015 - Sidecar Registry", + body: "```sql\nCREATE TABLE..."}) + 6. Text: "Found your sidecar migration script and saved it to Notes." +``` + +Three extensions orchestrated: user's local filesystem, instance-level +notes, all through the same tool meta-tool pattern. + +### Personal Compute (Local Ollama) + +User runs Ollama on their workstation with a GPU: + +```json +{ + "capabilities": [ + { + "name": "complete", + "description": "LLM completion via local Ollama", + "input_schema": { + "properties": { + "prompt": { "type": "string" }, + "model": { "type": "string", "default": "llama3" } + }, + "required": ["prompt"] + }, + "timeout_seconds": 120 + }, + { + "name": "embed", + "description": "Local embedding via Ollama", + "input_schema": { + "properties": { + "text": { "type": "string" }, + "model": { "type": "string", "default": "nomic-embed-text" } + }, + "required": ["text"] + }, + "timeout_seconds": 30 + } + ] +} +``` + +`llm-bridge` can be configured (per-user setting or persona preference) +to route completions through the user's local Ollama instead of the +instance-level provider. BYOK taken to the extreme — bring your own +hardware. + +### Personal Automation + +A script that reacts to Armature events: + +```python +# Personal deploy watcher — connects to Armature, subscribes to +# events in the #deploys conversation, runs kubectl locally + +ws = connect(f'{KERNEL_URL}/api/v1/sidecar/subscribe', + token=TOKEN) + +for event in ws: + if event['type'] == 'chat.message' and 'deploy' in event['content']: + result = subprocess.run(['kubectl', 'get', 'pods', '-n', 'prod'], + capture_output=True, text=True) + # Post result back to chat + requests.post(f'{KERNEL_URL}/api/v1/sidecar/lib/chat-core/send', + json={'conversation_id': event['conversation_id'], + 'content': f'```\n{result.stdout}\n```'}, + headers={'Authorization': f'Bearer {TOKEN}'}) +``` + +Personal workflow glue. No instance-level deployment required. + +--- + +## RBAC for User Sidecars + +Three-layer permission model: + +### Layer 1: Admin Controls WHO + +New permission: `sidecar.connect_personal`. + +Admin grants to specific users, groups, or roles. Most users don't +need it. Developers, power users, AI-heavy workflows — they get the +permission. + +Default: not granted. Opt-in only. + +### Layer 2: Admin Controls WHAT + +**Capability allowlist for user sidecars.** + +Admin setting: `SIDECAR_USER_ALLOWED_CAPABILITIES` + +Default: `["fs.search", "fs.read", "clipboard.get"]` — safe read-only +operations. + +To enable terminal access: admin adds `"terminal.exec"` to the +allowlist. This is a deliberate escalation requiring admin action. + +A user sidecar that attempts to register a capability not on the +allowlist gets a clear error: "Capability 'terminal.exec' not permitted +for user sidecars. Contact your administrator." + +### Layer 3: User Controls VISIBILITY + +User sidecar capabilities are **private by default** — only the owning +user's requests invoke them. + +Optional: user can share a specific capability with their team via the +settings UI. Shared capabilities appear in team members' tool sets +(with attribution: "via Jeff's local bridge"). + +User sidecars are never instance-wide. That's what instance sidecars +are for. + +--- + +## Security Considerations + +### Instance Sidecars + +1. **Compromised binary** → can read/write own package data, call + exported functions. Mitigation: package-scoped access, audit logging, + token revocation. +2. **Token theft** → impersonate sidecar. Mitigation: package-scoped + tokens, mTLS eliminates theft, revocable. +3. **SSRF via sidecar.call** → Mitigation: endpoints validated at + registration, no runtime changes. +4. **Resource exhaustion** → Mitigation: per-sidecar concurrency and + rate limits, timeout enforcement, response size limits. +5. **Privilege escalation via lib.require** → Mitigation: calls + attributed to sidecar identity, target permission checks apply. + +### User Sidecars (Additional Concerns) + +6. **User deploys malicious sidecar** → registers capabilities that + exfiltrate data when called. Mitigation: capability allowlist (admin + controls what capabilities can be declared), private-by-default + (only the user's own requests trigger it — they're exfiltrating + from themselves). + +7. **User shares malicious capability with team** → team members + invoke it, sidecar captures their request data. Mitigation: shared + capabilities are clearly attributed ("via Jeff's local bridge"). + Admin can disable capability sharing entirely. Shared capabilities + run with the CALLING user's permissions, not the sidecar owner's. + +8. **User sidecar as pivot** → user's laptop compromised, attacker uses + connected sidecar to access Armature data. Mitigation: user sidecar + can only access data the user could already access through the UI. + No privilege escalation. User can revoke own tokens. Admin kill + switch. + +9. **Stale user sidecars** → user's laptop goes offline, sidecar + becomes stale. Mitigation: same sweep mechanism as instance + sidecars. 3× heartbeat interval = swept. User reconnects when + laptop comes back online. + +### Principle of Least Privilege + +- Instance sidecar: access own package data only, middleware-enforced. +- User sidecar: access own package data scoped to owning user only. +- Event emission scoped to package prefix. +- Capability allowlist for user sidecars (admin-controlled). +- Shared capabilities run with caller's permissions. + +--- + +## Version Plan + +### v0.14.0 — Sidecar Registry + Auth + +**Goal:** Sidecars can register, authenticate, and heartbeat. + +- Sidecar registry table with `scope` and `user_id` columns. +- Registration endpoint with JWT token validation. +- Heartbeat + sweep integration. +- Token generation (instance tokens via admin, user tokens via settings). +- `SIDECAR_AUTH_MODE` config: `token` or `mtls`. +- mTLS auth: `sidecar:` and `sidecar-user:` CN prefixes. +- Admin UI: sidecar health on packages page. + +**Instance sidecars only in this version.** User sidecar plumbing +exists in the schema but user token generation and RBAC gating land +in v0.14.5. + +### v0.14.1 — Capability Registration + Execution + +**Goal:** Sidecars declare capabilities. Starlark extensions call them. + +- Capability manifest in registration payload. +- `sidecar` Starlark module: `call()`, `available()`, `capabilities()`. +- Kernel proxy: `sidecar.call()` → `POST {endpoint}/exec/{cap}`. +- Input validation against declared schema. +- Timeout, response size, concurrent call limits. +- Error propagation. +- `sidecar.call` permission. +- Audit logging. + +### v0.14.2 — Kernel API Access + Event Bus + +**Goal:** Sidecars read/write their own data and participate in events. + +- Sidecar middleware: authenticate, scope to own package routes. +- `/api/v1/sidecar/lib/{pkg}/{fn}` — cross-package function calls. +- `/api/v1/sidecar/emit` — event emission (package-scoped). +- `/api/v1/sidecar/subscribe` — WebSocket event subscription. +- Frontend `sw.sidecar` SDK module. + +### v0.14.3 — Manifest Integration + Admin Polish + +**Goal:** Sidecar packages are first-class in install/admin flow. + +- `tier: "sidecar"` manifest support. +- `sidecar` manifest field (image, required, scope, env_hints, + download_url). +- Admin UI: deployment instructions from manifest. +- Admin UI: capability list with metrics. +- Admin UI: per-sidecar rate limit configuration. +- Token management UI. +- Monitoring metrics. + +### v0.14.4 — Reference Sidecar + Instance Quality Gate + +**Goal:** Ship one real instance sidecar. Prove the contract. + +**`armature-embed`** — minimal Python sidecar running +sentence-transformers for local embedding generation. One capability: +`embed(text) → float[]`. + +`vector-store` calls `sidecar.call('armature-embed', 'embed', {...})` +for local embedding without an external API. Completes the local-first +RAG story. + +**Instance sidecar quality gate:** + +- Registration, heartbeat, sweep lifecycle tested. +- Token and mTLS auth both functional. +- Capability execution with limits enforced. +- Package-scoped API access enforced. +- Event emission/subscription functional. +- `sw.sidecar` frontend module functional. +- Reference sidecar runs in Docker and K8s. +- Admin UI complete. +- Monitoring metrics in Grafana. + +### v0.14.5 — User Sidecars + +**Goal:** Users connect personal processes to Armature. + +- `sidecar.connect_personal` permission (admin-granted). +- User token generation in Settings → Sidecars. +- Capability allowlist for user sidecars (admin setting: + `SIDECAR_USER_ALLOWED_CAPABILITIES`). +- User sidecar registration with `scope: "user"`, `user_id` binding. +- Execution routing: `sidecar.call()` resolves user sidecars based + on request context (calling user gets their own sidecar). +- Capability sharing: user can share specific capabilities with team. + Shared caps attributed ("via Jeff's local bridge"). +- Separate resource limits for user sidecars (lower defaults). +- User settings UI: connected sidecars, token management, sidecar + instructions from package manifests. +- Admin UI: user sidecar monitoring, kill switch. +- Tool meta-tool integration: `llm-bridge` includes user's personal + sidecar capabilities in tool set assembly. + +**Reference user sidecar: `user-bridge`** — thin agent binary with +`fs.search`, `fs.read`, `clipboard.get` capabilities. Published as a +package with `sidecar.scope: "user"`. Binary as a GitHub release. + +**User sidecar quality gate:** + +- RBAC: user without `sidecar.connect_personal` cannot register. +- Capability allowlist enforced. +- User A cannot invoke user B's sidecar. +- Shared capabilities run with caller's permissions. +- Admin can revoke any user sidecar. +- Tool meta-tool assembles per-user tool sets correctly. +- `user-bridge` reference sidecar runs on macOS, Linux, Windows. + +--- + +## Schema Summary + +### New tables + +- `sidecar_registry` — sidecar_id, package_id, scope, user_id, + endpoint, heartbeat, capabilities (JSON), stats (JSON), + registered_at. (v0.14.0) +- `sidecar_tokens` — id, package_id, scope, user_id, token_hash, + created_by, expires_at, revoked_at. (v0.14.0) + +--- + +## Configuration + +| Setting | Default | Purpose | +|---------|---------|---------| +| `SIDECAR_AUTH_MODE` | `token` | `token` or `mtls` | +| `SIDECAR_HEARTBEAT_INTERVAL` | `10s` | Expected heartbeat frequency | +| `SIDECAR_STALE_THRESHOLD` | `30s` | 3× heartbeat = stale | +| `SIDECAR_MAX_RESPONSE_SIZE` | `10485760` (10MB) | Instance default | +| `SIDECAR_DEFAULT_CONCURRENCY` | `10` | Instance default | +| `SIDECAR_DEFAULT_RATE_LIMIT` | `100/min` | Instance default | +| `SIDECAR_USER_MAX_RESPONSE_SIZE` | `5242880` (5MB) | User default | +| `SIDECAR_USER_CONCURRENCY` | `3` | User default | +| `SIDECAR_USER_RATE_LIMIT` | `30/min` | User default | +| `SIDECAR_USER_ALLOWED_CAPABILITIES` | `["fs.search","fs.read","clipboard.get"]` | Admin-controlled allowlist | + +--- + +## Sidecar Author Contract + +### Minimum Viable Sidecar (Python) + +```python +from flask import Flask, request, jsonify +import requests, os, threading, time + +app = Flask(__name__) +KERNEL = os.environ['ARMATURE_URL'] +TOKEN = os.environ['ARMATURE_TOKEN'] + +def register(): + requests.post(f'{KERNEL}/api/v1/sidecar/register', + json={ + 'package_id': 'my-sidecar', + 'endpoint': f'http://localhost:8080', + 'capabilities': [{ + 'name': 'echo', + 'description': 'Echo input back', + 'input_schema': { + 'type': 'object', + 'properties': {'text': {'type': 'string'}}, + 'required': ['text'] + }, + 'timeout_seconds': 10 + }] + }, + headers={'Authorization': f'Bearer {TOKEN}'}) + +def heartbeat(): + while True: + time.sleep(10) + r = requests.post(f'{KERNEL}/api/v1/sidecar/heartbeat', + json={'stats': {}}, + headers={'Authorization': f'Bearer {TOKEN}'}) + if r.status_code == 404: + register() + +@app.route('/api/v1/exec/echo', methods=['POST']) +def exec_echo(): + return jsonify({ + 'output': {'text': request.json['input']['text']}, + 'error': None + }) + +@app.route('/api/v1/health') +def health(): + return jsonify({'status': 'ok'}) + +register() +threading.Thread(target=heartbeat, daemon=True).start() +app.run(port=8080) +``` + +Four things: register, heartbeat, `/exec/`, `/health`. That's the +contract. + +--- + +## Design Decisions + +| Decision | Rationale | +|----------|-----------| +| Connect-inward | Eliminates K8s RBAC, service mesh, DNS discovery. Works on any deployment target. | +| Two auth modes | Token for simple. mTLS for production. Same internal identity. | +| Cluster registry pattern | Proven heartbeat/sweep. No new coordination mechanism. | +| HTTP/JSON, no gRPC | KISS. 4-endpoint contract. Every language has HTTP. | +| Kernel proxies all calls | Auth, rate limits, audit, timeouts at single control point. | +| Package-scoped access | Compromised sidecar can't read other packages. | +| User sidecars as v0.14.5 | Prove instance contract first (v0.14.0–v0.14.4), then extend to users. Same mechanism, new scope. | +| Three-layer user RBAC | Admin controls who (permission), what (capability allowlist), user controls visibility (private/shared). Defense in depth. | +| Capability allowlist for user sidecars | Admin decides what user processes can expose. `terminal.exec` is a deliberate escalation. | +| Private-by-default user capabilities | User sidecar can't affect other users unless explicitly shared. | +| Shared caps run with caller's perms | Prevents privilege escalation through shared capabilities. | +| Reference instance sidecar: embedding | Simpler than LLM. Proves contract. Completes local RAG story. | +| Reference user sidecar: file bridge | Most compelling user sidecar demo. "Find a file on my laptop and save it to notes" is visceral. | +| Lower resource limits for user sidecars | User sidecars are less trusted (running on personal devices). Lower defaults, admin can't be overridden by users. | +| Separate from polish (v0.15.x) | Sidecar is security-critical infrastructure. Polish is quality-of-life. Don't mix. | + +--- + +## Future Considerations + +- **Streaming responses.** LLM sidecar needs token streaming. SSE or + chunked transfer. Deferred — batch covers embedding, classification, + transcoding. +- **SDK libraries.** Thin wrappers in Python, Go, Node, Rust for + registration + heartbeat + parsing. Convenience, not requirement. +- **Auto-scaling.** Multiple instances of same sidecar, kernel + load-balances. No schema change needed — registry supports multiple + rows per package. +- **GPU detection.** Kernel capability for manifest negotiation. +- **Sidecar-to-sidecar.** Direct calls for performance pipelines. + Currently goes through kernel. +- **User sidecar desktop app.** Electron/Tauri tray app that wraps the + user-bridge agent with a GUI for capability management and connection + status. Post-1.0. diff --git a/docs/ROADMAP-v010x-v100.md b/docs/ROADMAP-v010x-v100.md new file mode 100644 index 0000000..87a5983 --- /dev/null +++ b/docs/ROADMAP-v010x-v100.md @@ -0,0 +1,157 @@ +# ROADMAP — v0.10.x → v1.0.0 (Final) + +## Full Roadmap + +| Series | Title | Versions | Design Doc | +|--------|-------|----------|------------| +| v0.9.x | Multi-Surface + Workflow Redesign | 10 | — | +| **v0.10.x** | **Panels + Composable Layout** | 5 | `DESIGN-panels.md` | +| **v0.11.x** | **Notes Reference Extension** | 11 | `DESIGN-notes-v011x.md` | +| **v0.12.x** | **Chat Reference Extension** | 9 | `DESIGN-chat-v012x.md` | +| **v0.13.x** | **Reference Libraries + Extensions** | 9 | — | +| **v0.14.x** | **Sidecar Tier** | 6 | `DESIGN-sidecar-v014x.md` | +| **v0.15.x** | **Polish + Stability** | 3 | — | +| **v1.0.0** | **Stable Release** | — | — | + +**Total: ~53 versions from v0.10.0 to v1.0.0** + +--- + +## v0.10.x — Panels + Composable Layout + +| Version | Title | +|---------|-------| +| v0.10.0 | Panel Manifest + Lifecycle | +| v0.10.1 | FloatingPanel Primitive | +| v0.10.2 | Docked Panels + Mode Transitions | +| v0.10.3 | Panel Communication Patterns | +| v0.10.4 | Reference Panel: Notes (basic) | + +--- + +## v0.11.x — Notes Reference Extension + +| Version | Title | +|---------|-------| +| v0.11.0 | UI/UX Foundation | +| v0.11.1 | Deep Folders + Navigation | +| v0.11.2 | Wikilinks + Backlinks | +| v0.11.3 | Live Preview + Rich Editing | +| v0.11.4 | Note Sharing + Permissions | +| v0.11.5 | Graph + Outline Hardening | +| v0.11.6 | Quick Switcher + Commands | +| v0.11.7 | Daily Notes + Templates | +| v0.11.8 | Transclusion + Embeds | +| v0.11.9 | Composability: Slots + Actions | +| v0.11.10 | Panel Enhancement + Quality Gate | + +--- + +## v0.12.x — Chat Reference Extension + +| Version | Title | +|---------|-------| +| v0.12.0 | UI/UX Foundation | +| v0.12.1 | Conversation Folders + Attributes | +| v0.12.2 | Reactions + Threads + Pins | +| v0.12.3 | Rich Compose + Attachments | +| v0.12.4 | @Mentions + Notifications | +| v0.12.5 | Link Previews + Message Formatting | +| v0.12.6 | Conversation Themes + Personality | +| v0.12.7 | Composability: Slots + Actions | +| v0.12.8 | Panels + Quality Gate | + +--- + +## v0.13.x — Reference Libraries + Extensions + +| Version | Title | +|---------|-------| +| v0.13.0 | `vector-store` Library | +| v0.13.1 | `llm-bridge` Core Library | +| v0.13.2 | `llm-bridge` → Chat: Multi-Persona + Tool Meta-Tool | +| v0.13.3 | `llm-bridge` → Notes: AI Toolbar Actions | +| v0.13.4 | `file-share` Extension | +| v0.13.5 | `code-workspace` Extension | +| v0.13.6 | `image-gen` + `image-edit` Extensions | +| v0.13.7 | Tool Meta-Tool Hardening + Scoping | +| v0.13.8 | Integration Quality Gate | + +--- + +## v0.14.x — Sidecar Tier + +Connect-inward model. Instance sidecars (shared infrastructure) and +user sidecars (personal local tools). + +| Version | Title | +|---------|-------| +| v0.14.0 | Sidecar Registry + Auth | +| v0.14.1 | Capability Registration + Execution | +| v0.14.2 | Kernel API Access + Event Bus | +| v0.14.3 | Manifest Integration + Admin Polish | +| v0.14.4 | Reference Sidecar (`armature-embed`) + Instance Gate | +| v0.14.5 | User Sidecars + Reference (`user-bridge`) + User Gate | + +--- + +## v0.15.x — Polish + Stability + +| Version | Title | +|---------|-------| +| v0.15.0 | Native Dialog Audit | +| v0.15.1 | Versioned Migrations + `armature migrate` CLI | +| v0.15.2 | Pre-1.0 Schema Freeze + Upgrade Path Validation | + +--- + +## v1.0.0 Gate Criteria + +**Reference extensions:** +- Notes shipped (all v0.11.x) with UI quality review +- Chat shipped (all v0.12.x) with UI quality review +- At least 3 reference extensions beyond notes/chat + +**Composability:** +- `llm-bridge` extends both notes and chat through slots/actions +- Tool meta-tool: AI uses 3+ extension actions in a single turn +- At least 2 panels consumed cross-package +- At least 2 slot contributions per host surface + +**Sidecar:** +- Instance sidecar (`armature-embed`) deployed and functional +- User sidecar (`user-bridge`) functional on macOS/Linux/Windows +- Token + mTLS auth both tested +- User sidecar RBAC enforced (permission, capability allowlist, scoping) +- Tool meta-tool includes user sidecar capabilities per-user + +**Infrastructure:** +- Admin safety rails validated +- Multi-persona context archetypes demonstrated +- Sharing functional (notes + conversations) +- Headless E2E green on PG + SQLite +- All kernel Starlark modules documented +- All API routes covered by OpenAPI spec +- `armature migrate` CLI functional +- Upgrade path tested v0.8.0 → v1.0.0 +- Single-binary + Docker + K8s deployment paths documented +- Monitoring dashboard with kernel + sidecar metrics + +--- + +## Design Decisions Log + +| Decision | Rationale | +|----------|-----------| +| Panels as kernel primitive | Z-index, drag, layout are kernel concerns | +| UI/UX-first for reference extensions | Every feature builds on visual foundation | +| Notes → Chat → Libraries → Sidecar | Each builds on proven patterns from previous | +| Chat human-to-human first | AI is llm-bridge's job | +| Folder attributes as extension bridge | Zero coupling between chat and llm-bridge | +| Action registry as tool registry | Installed extensions = AI capabilities | +| 6-layer prompt architecture | Admin safety not overridable | +| Sidecar connect-inward | No K8s RBAC, no service mesh, no DNS discovery | +| Sidecar HTTP/JSON, not gRPC | KISS. 4-endpoint contract | +| User sidecars in v0.14.5 | Prove instance contract first, extend to users with same mechanism | +| Three-layer user sidecar RBAC | Admin controls who + what, user controls visibility | +| Separate polish (v0.15.x) | Security-critical infra and quality-of-life shouldn't share focus | diff --git a/packages/bug-report-triage/manifest.json b/packages/bug-report-triage/manifest.json index 84a6f33..582e7d2 100644 --- a/packages/bug-report-triage/manifest.json +++ b/packages/bug-report-triage/manifest.json @@ -19,7 +19,6 @@ "ordinal": 0, "stage_mode": "form", "audience": "public", - "stage_type": "simple", "auto_transition": false, "form_template": { "fieldsets": [ @@ -48,7 +47,6 @@ "ordinal": 1, "stage_mode": "form", "audience": "team", - "stage_type": "simple", "auto_transition": false, "form_template": { "fieldsets": [ @@ -71,7 +69,6 @@ "ordinal": 2, "stage_mode": "form", "audience": "team", - "stage_type": "simple", "auto_transition": false, "sla_seconds": 3600, "form_template": { @@ -91,7 +88,6 @@ "ordinal": 3, "stage_mode": "form", "audience": "team", - "stage_type": "simple", "auto_transition": false, "form_template": { "fieldsets": [ @@ -108,9 +104,8 @@ { "name": "verify", "ordinal": 4, - "stage_mode": "review", + "stage_mode": "form", "audience": "team", - "stage_type": "simple", "auto_transition": false, "form_template": { "fieldsets": [ diff --git a/packages/content-approval/manifest.json b/packages/content-approval/manifest.json index d417f05..33c11d3 100644 --- a/packages/content-approval/manifest.json +++ b/packages/content-approval/manifest.json @@ -19,7 +19,6 @@ "ordinal": 0, "stage_mode": "form", "audience": "team", - "stage_type": "simple", "auto_transition": false, "form_template": { "fieldsets": [ @@ -37,9 +36,8 @@ { "name": "review", "ordinal": 1, - "stage_mode": "review", + "stage_mode": "form", "audience": "team", - "stage_type": "simple", "auto_transition": false, "stage_config": { "validation": { @@ -53,7 +51,6 @@ "ordinal": 2, "stage_mode": "form", "audience": "team", - "stage_type": "simple", "auto_transition": false, "form_template": { "fieldsets": [ @@ -76,7 +73,6 @@ "ordinal": 3, "stage_mode": "form", "audience": "team", - "stage_type": "simple", "auto_transition": false, "form_template": { "fieldsets": [ diff --git a/packages/employee-onboarding/manifest.json b/packages/employee-onboarding/manifest.json index e068db8..312294e 100644 --- a/packages/employee-onboarding/manifest.json +++ b/packages/employee-onboarding/manifest.json @@ -40,7 +40,6 @@ "ordinal": 0, "stage_mode": "form", "audience": "team", - "stage_type": "simple", "auto_transition": false, "form_template": { "fieldsets": [ @@ -69,16 +68,14 @@ "ordinal": 1, "stage_mode": "automated", "audience": "system", - "stage_type": "automated", "auto_transition": true, "starlark_hook": "employee-onboarding:on_provision" }, { "name": "manager-signoff", "ordinal": 2, - "stage_mode": "review", + "stage_mode": "form", "audience": "team", - "stage_type": "simple", "auto_transition": false, "stage_config": { "validation": { @@ -93,7 +90,6 @@ "ordinal": 3, "stage_mode": "form", "audience": "team", - "stage_type": "simple", "auto_transition": false, "form_template": { "fieldsets": [ @@ -113,7 +109,6 @@ "ordinal": 4, "stage_mode": "automated", "audience": "system", - "stage_type": "automated", "auto_transition": true, "starlark_hook": "employee-onboarding:on_welcome" } diff --git a/packages/icd-test-runner/js/crud/team-workflows.js b/packages/icd-test-runner/js/crud/team-workflows.js index 9913c9f..660c30e 100644 --- a/packages/icd-test-runner/js/crud/team-workflows.js +++ b/packages/icd-test-runner/js/crud/team-workflows.js @@ -82,7 +82,7 @@ name: 'Team Intake', ordinal: 0, history_mode: 'full', - stage_mode: 'chat_only' + stage_mode: 'form' }); T.assertShape(d, T.S.workflowStage, 'stage'); T.assert(d.name === 'Team Intake', 'name mismatch'); @@ -94,7 +94,7 @@ name: 'Team Review', ordinal: 1, history_mode: 'summary', - stage_mode: 'review' + stage_mode: 'form' }); T.assertShape(d, T.S.workflowStage, 'stage'); stageIds.push(d.id); @@ -111,7 +111,7 @@ name: 'Team Intake (updated)', ordinal: 0, history_mode: 'full', - stage_mode: 'form_chat', + stage_mode: 'form', form_template: { fields: [{ key: 'name', type: 'text', label: 'Name', required: true }] } }); T.assert(typeof d === 'object', 'expected object'); diff --git a/packages/icd-test-runner/js/crud/workflows.js b/packages/icd-test-runner/js/crud/workflows.js index 55150ad..bdf7e96 100644 --- a/packages/icd-test-runner/js/crud/workflows.js +++ b/packages/icd-test-runner/js/crud/workflows.js @@ -408,7 +408,7 @@ var fwfSlug = testTag + '-form-wf'; var fChannelId = null; - await T.test('crud', 'workflows', 'form: create form_only workflow', async function () { + await T.test('crud', 'workflows', 'form: create form workflow', async function () { var wf = await T.apiPost('/workflows', { name: testTag + ' Form WF', slug: fwfSlug, @@ -422,7 +422,7 @@ name: 'Contact Info', ordinal: 0, history_mode: 'full', - stage_mode: 'form_only', + stage_mode: 'form', form_template: { fields: [ { key: 'name', type: 'text', label: 'Full Name', required: true, validation: { min_length: 2 } }, @@ -436,7 +436,7 @@ }); await T.apiPost('/workflows/' + fwfId + '/stages', { - name: 'Done', ordinal: 1, history_mode: 'full', stage_mode: 'chat_only' + name: 'Done', ordinal: 1, history_mode: 'full', stage_mode: 'form' }); await T.apiPatch('/workflows/' + fwfId, { is_active: true }); @@ -456,7 +456,7 @@ var d = await T.sessionGet('/w/' + fChannelId + '/form'); T.assert(d._status === 200, 'expected 200, got ' + d._status); T.assertHasKey(d, 'stage_mode', 'form response'); - T.assert(d.stage_mode === 'form_only', 'expected form_only, got ' + d.stage_mode); + T.assert(d.stage_mode === 'form', 'expected form, got ' + d.stage_mode); T.assertHasKey(d, 'form_template', 'form response'); T.assert(d.form_template.fields && d.form_template.fields.length === 3, 'expected 3 fields, got ' + (d.form_template.fields ? d.form_template.fields.length : 0)); @@ -520,7 +520,7 @@ var xwfSlug = testTag + '-xvisitor'; var chA = null, chB = null; - await T.test('crud', 'workflows', 'xvisitor: setup form_only workflow', async function () { + await T.test('crud', 'workflows', 'xvisitor: setup form workflow', async function () { var wf = await T.apiPost('/workflows', { name: testTag + ' XVisitor', slug: xwfSlug, @@ -530,7 +530,7 @@ T.registerCleanup(function () { if (xwfId) return T.safeDelete('/workflows/' + xwfId); }); await T.apiPost('/workflows/' + xwfId + '/stages', { - name: 'Form', ordinal: 0, stage_mode: 'form_only', history_mode: 'full', + name: 'Form', ordinal: 0, stage_mode: 'form', history_mode: 'full', form_template: { fields: [{ key: 'name', type: 'text', label: 'Name', required: true }] } @@ -627,7 +627,7 @@ var s1 = await T.apiPost('/workflows/' + wpWfId + '/stages', { name: 'Intake', ordinal: 0, - stage_mode: 'form_only', + stage_mode: 'form', history_mode: 'full', form_template: { fields: [{ key: 'name', type: 'text', label: 'Full Name', required: true }] } }); @@ -637,7 +637,7 @@ var s2 = await T.apiPost('/workflows/' + wpWfId + '/stages', { name: 'Review', ordinal: 1, - stage_mode: 'review', + stage_mode: 'form', history_mode: 'summary' }); T.assertShape(s2, T.S.workflowStage, 'stage2'); @@ -650,7 +650,7 @@ // Use the ICD test runner's own package ID (always installed when tests run). var realPkgId = 'icd-test-runner'; var stageBase = { - name: 'Intake', ordinal: 0, stage_mode: 'form_only', history_mode: 'full', + name: 'Intake', ordinal: 0, stage_mode: 'form', history_mode: 'full', form_template: { fields: [{ key: 'name', type: 'text', label: 'Full Name', required: true }] } }; @@ -720,8 +720,8 @@ T.assert(stList.length === 2, 'should have 2 stages, got ' + stList.length); T.assert(stList[0].name === 'Intake', 'stage 0 name should be Intake'); T.assert(stList[1].name === 'Review', 'stage 1 name should be Review'); - T.assert(stList[0].stage_mode === 'form_only', 'stage 0 mode should be form_only'); - T.assert(stList[1].stage_mode === 'review', 'stage 1 mode should be review'); + T.assert(stList[0].stage_mode === 'form', 'stage 0 mode should be form'); + T.assert(stList[1].stage_mode === 'form', 'stage 1 mode should be form'); }); } diff --git a/packages/webhook-notifier/manifest.json b/packages/webhook-notifier/manifest.json index fc7818c..b6c4bca 100644 --- a/packages/webhook-notifier/manifest.json +++ b/packages/webhook-notifier/manifest.json @@ -30,7 +30,6 @@ "ordinal": 0, "stage_mode": "form", "audience": "team", - "stage_type": "simple", "auto_transition": false, "form_template": { "fieldsets": [ @@ -50,16 +49,14 @@ "ordinal": 1, "stage_mode": "automated", "audience": "system", - "stage_type": "automated", "auto_transition": true, "starlark_hook": "webhook-notifier:on_fire" }, { "name": "result", "ordinal": 2, - "stage_mode": "review", + "stage_mode": "form", "audience": "team", - "stage_type": "simple", "auto_transition": false, "form_template": { "fieldsets": [ diff --git a/server/database/migrations/postgres/018_stage_mode_collapse.sql b/server/database/migrations/postgres/018_stage_mode_collapse.sql new file mode 100644 index 0000000..b5a845e --- /dev/null +++ b/server/database/migrations/postgres/018_stage_mode_collapse.sql @@ -0,0 +1,13 @@ +-- Armature — 018 Stage Mode Collapse +-- Deprecate stage_type validation. Collapse stage_mode: review → form. + +-- 1. Migrate existing review rows to form +UPDATE workflow_stages SET stage_mode = 'form' WHERE stage_mode = 'review'; + +-- 2. Replace CHECK constraint on stage_mode (drop review) +ALTER TABLE workflow_stages DROP CONSTRAINT IF EXISTS workflow_stages_stage_mode_check; +ALTER TABLE workflow_stages ADD CONSTRAINT workflow_stages_stage_mode_check + CHECK (stage_mode IN ('form', 'delegated', 'automated')); + +-- 3. Drop CHECK constraint on stage_type (any value accepted) +ALTER TABLE workflow_stages DROP CONSTRAINT IF EXISTS workflow_stages_stage_type_check; diff --git a/server/database/migrations/sqlite/018_stage_mode_collapse.sql b/server/database/migrations/sqlite/018_stage_mode_collapse.sql new file mode 100644 index 0000000..53c2842 --- /dev/null +++ b/server/database/migrations/sqlite/018_stage_mode_collapse.sql @@ -0,0 +1,5 @@ +-- Armature — 018 Stage Mode Collapse +-- Deprecate stage_type validation. Collapse stage_mode: review → form. +-- SQLite cannot ALTER CHECK constraints; app layer prevents new "review" inserts. + +UPDATE workflow_stages SET stage_mode = 'form' WHERE stage_mode = 'review'; diff --git a/server/handlers/workflow_engine_test.go b/server/handlers/workflow_engine_test.go index 3cb1bb0..510e7cf 100644 --- a/server/handlers/workflow_engine_test.go +++ b/server/handlers/workflow_engine_test.go @@ -18,7 +18,7 @@ func testEngine(t *testing.T) *workflow.Engine { return workflow.NewEngine(s, nil, nil) } -// seedEngineFixture creates a 3-stage workflow (form → review → form), +// seedEngineFixture creates a 3-stage workflow (form → form → form), // publishes it, and returns (workflowID, userID, teamID). func seedEngineFixture(t *testing.T, slug string) (string, string, string) { t.Helper() @@ -42,7 +42,7 @@ func seedEngineFixture(t *testing.T, slug string) (string, string, string) { stages := []models.WorkflowStage{ {WorkflowID: wf.ID, Ordinal: 0, Name: "intake", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple}, - {WorkflowID: wf.ID, Ordinal: 1, Name: "review", StageMode: models.StageModeReview, Audience: models.AudienceTeam, StageType: models.StageTypeSimple, AssignmentTeamID: &teamID}, + {WorkflowID: wf.ID, Ordinal: 1, Name: "review", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple, AssignmentTeamID: &teamID}, {WorkflowID: wf.ID, Ordinal: 2, Name: "final", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple}, } for i := range stages { @@ -134,8 +134,8 @@ func TestEngine_BranchRouting(t *testing.T) { stages := []models.WorkflowStage{ {WorkflowID: wf.ID, Ordinal: 0, Name: "intake", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple, BranchRules: branchRules}, - {WorkflowID: wf.ID, Ordinal: 1, Name: "normal-review", StageMode: models.StageModeReview, Audience: models.AudienceTeam, StageType: models.StageTypeSimple}, - {WorkflowID: wf.ID, Ordinal: 2, Name: "escalation", StageMode: models.StageModeReview, Audience: models.AudienceTeam, StageType: models.StageTypeSimple}, + {WorkflowID: wf.ID, Ordinal: 1, Name: "normal-review", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple}, + {WorkflowID: wf.ID, Ordinal: 2, Name: "escalation", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple}, } for i := range stages { s.Workflows.CreateStage(ctx, &stages[i]) @@ -184,7 +184,7 @@ func TestEngine_PublicEntry(t *testing.T) { stages := []models.WorkflowStage{ {WorkflowID: wf.ID, Ordinal: 0, Name: "public-form", StageMode: models.StageModeForm, Audience: models.AudiencePublic, StageType: models.StageTypeSimple}, - {WorkflowID: wf.ID, Ordinal: 1, Name: "team-review", StageMode: models.StageModeReview, Audience: models.AudienceTeam, StageType: models.StageTypeSimple}, + {WorkflowID: wf.ID, Ordinal: 1, Name: "team-review", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple}, } for i := range stages { s.Workflows.CreateStage(ctx, &stages[i]) @@ -253,7 +253,7 @@ func TestEngine_SignoffGate(t *testing.T) { "validation": map[string]any{"required_approvals": 2}, }) stages := []models.WorkflowStage{ - {WorkflowID: wf.ID, Ordinal: 0, Name: "gated-stage", StageMode: models.StageModeReview, Audience: models.AudienceTeam, StageType: models.StageTypeSimple, StageConfig: validationCfg}, + {WorkflowID: wf.ID, Ordinal: 0, Name: "gated-stage", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple, StageConfig: validationCfg}, {WorkflowID: wf.ID, Ordinal: 1, Name: "done", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple}, } for i := range stages { @@ -307,7 +307,7 @@ func TestEngine_SignoffRejection(t *testing.T) { "validation": map[string]any{"required_approvals": 1, "reject_action": "cancel"}, }) stages := []models.WorkflowStage{ - {WorkflowID: wf.ID, Ordinal: 0, Name: "review", StageMode: models.StageModeReview, Audience: models.AudienceTeam, StageType: models.StageTypeSimple, StageConfig: validationCfg}, + {WorkflowID: wf.ID, Ordinal: 0, Name: "review", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple, StageConfig: validationCfg}, {WorkflowID: wf.ID, Ordinal: 1, Name: "approved", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple}, } for i := range stages { @@ -590,7 +590,7 @@ func TestPackageInstall_ManifestRoundtrip(t *testing.T) { {WorkflowID: wf.ID, Ordinal: 1, Name: "classify", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple, BranchRules: branchRules}, {WorkflowID: wf.ID, Ordinal: 2, Name: "fix-critical", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple, SLASeconds: &sla}, {WorkflowID: wf.ID, Ordinal: 3, Name: "fix-normal", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple}, - {WorkflowID: wf.ID, Ordinal: 4, Name: "verify", StageMode: models.StageModeReview, Audience: models.AudienceTeam, StageType: models.StageTypeSimple}, + {WorkflowID: wf.ID, Ordinal: 4, Name: "verify", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple}, } for i := range stages { if err := s.Workflows.CreateStage(ctx, &stages[i]); err != nil { diff --git a/server/handlers/workflow_packages.go b/server/handlers/workflow_packages.go index 368eb3a..508ebd0 100644 --- a/server/handlers/workflow_packages.go +++ b/server/handlers/workflow_packages.go @@ -55,7 +55,6 @@ func (h *WorkflowPackageHandler) ExportWorkflowPackage(c *gin.Context) { "ordinal": s.Ordinal, "stage_mode": s.StageMode, "audience": s.Audience, - "stage_type": s.StageType, "auto_transition": s.AutoTransition, } if s.AssignmentTeamID != nil { @@ -230,6 +229,7 @@ func InstallWorkflowFromManifest(ctx *gin.Context, stores store.Stores, pkgID st StarlarkHook: s.StarlarkHook, SLASeconds: s.SLASeconds, } + st.StageMode = models.NormalizeStageModeInput(st.StageMode) if st.StageMode == "" { st.StageMode = models.StageModeDelegated } @@ -284,7 +284,7 @@ type workflowPkgStage struct { Ordinal int `json:"ordinal"` StageMode string `json:"stage_mode"` Audience string `json:"audience"` - StageType string `json:"stage_type"` + StageType string `json:"stage_type"` // deprecated — kept for backward-compat deserialization AutoTransition bool `json:"auto_transition"` AssignmentTeamID *string `json:"assignment_team_id,omitempty"` SurfacePkgID *string `json:"surface_pkg_id,omitempty"` diff --git a/server/handlers/workflow_store_test.go b/server/handlers/workflow_store_test.go index d1ccffe..04026b4 100644 --- a/server/handlers/workflow_store_test.go +++ b/server/handlers/workflow_store_test.go @@ -69,7 +69,7 @@ func seedWorkflowFixture(t *testing.T, s store.Stores, userID, teamID string) (s WorkflowID: wf.ID, Ordinal: 1, Name: "review", - StageMode: models.StageModeReview, + StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple, AssignmentTeamID: &teamID, diff --git a/server/handlers/workflows.go b/server/handlers/workflows.go index aa535f7..5e2138c 100644 --- a/server/handlers/workflows.go +++ b/server/handlers/workflows.go @@ -202,11 +202,12 @@ func (h *WorkflowHandler) CreateStage(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"}) return } + st.StageMode = models.NormalizeStageModeInput(st.StageMode) if st.StageMode == "" { st.StageMode = models.StageModeDelegated } if !models.ValidStageModes[st.StageMode] { - c.JSON(http.StatusBadRequest, gin.H{"error": "stage_mode must be form, review, delegated, or automated"}) + c.JSON(http.StatusBadRequest, gin.H{"error": "stage_mode must be form, delegated, or automated"}) return } if st.Audience == "" { @@ -219,18 +220,10 @@ func (h *WorkflowHandler) CreateStage(c *gin.Context) { if st.StageType == "" { st.StageType = models.StageTypeSimple } - if !models.ValidStageTypes[st.StageType] { - c.JSON(http.StatusBadRequest, gin.H{"error": "stage_type must be simple, dynamic, or automated"}) - return - } if st.StageMode == models.StageModeDelegated && st.SurfacePkgID == nil { c.JSON(http.StatusBadRequest, gin.H{"error": "delegated mode requires surface_pkg_id"}) return } - if (st.StageType == models.StageTypeDynamic || st.StageType == models.StageTypeAutomated) && st.StarlarkHook == nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "dynamic/automated stage_type requires starlark_hook"}) - return - } if st.Ordinal == 0 { existing, _ := h.stores.Workflows.ListStages(c.Request.Context(), st.WorkflowID) st.Ordinal = len(existing) @@ -252,26 +245,19 @@ func (h *WorkflowHandler) UpdateStage(c *gin.Context) { } st.ID = c.Param("sid") st.WorkflowID = c.Param("id") + st.StageMode = models.NormalizeStageModeInput(st.StageMode) if st.StageMode != "" && !models.ValidStageModes[st.StageMode] { - c.JSON(http.StatusBadRequest, gin.H{"error": "stage_mode must be form, review, delegated, or automated"}) + c.JSON(http.StatusBadRequest, gin.H{"error": "stage_mode must be form, delegated, or automated"}) return } if st.Audience != "" && !models.ValidAudiences[st.Audience] { c.JSON(http.StatusBadRequest, gin.H{"error": "audience must be team, public, or system"}) return } - if st.StageType != "" && !models.ValidStageTypes[st.StageType] { - c.JSON(http.StatusBadRequest, gin.H{"error": "stage_type must be simple, dynamic, or automated"}) - return - } if st.StageMode == models.StageModeDelegated && st.SurfacePkgID == nil { c.JSON(http.StatusBadRequest, gin.H{"error": "delegated mode requires surface_pkg_id"}) return } - if (st.StageType == models.StageTypeDynamic || st.StageType == models.StageTypeAutomated) && st.StarlarkHook == nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "dynamic/automated stage_type requires starlark_hook"}) - return - } if err := h.stores.Workflows.UpdateStage(c.Request.Context(), &st); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update stage"}) return diff --git a/server/models/workflow.go b/server/models/workflow.go index 95c297d..0c85204 100644 --- a/server/models/workflow.go +++ b/server/models/workflow.go @@ -56,9 +56,9 @@ type WorkflowStage struct { Name string `json:"name"` AssignmentTeamID *string `json:"assignment_team_id,omitempty"` FormTemplate json.RawMessage `json:"form_template"` - StageMode string `json:"stage_mode"` // form | review | delegated | automated + StageMode string `json:"stage_mode"` // form | delegated | automated Audience string `json:"audience"` // team | public | system - StageType string `json:"stage_type"` // simple | dynamic | automated + StageType string `json:"stage_type"` // deprecated — retained for backward compatibility AutoTransition bool `json:"auto_transition"` StageConfig json.RawMessage `json:"stage_config"` BranchRules json.RawMessage `json:"branch_rules"` @@ -72,7 +72,6 @@ type WorkflowStage struct { const ( StageModeForm = "form" - StageModeReview = "review" StageModeDelegated = "delegated" StageModeAutomated = "automated" ) @@ -80,12 +79,22 @@ const ( // ValidStageModes is the set of valid stage_mode values. var ValidStageModes = map[string]bool{ StageModeForm: true, - StageModeReview: true, StageModeDelegated: true, StageModeAutomated: true, } -// ── Stage Type Constants ──────────────────── +// NormalizeStageModeInput maps deprecated stage_mode values to their +// replacements. "review" → "form"; all others pass through unchanged. +func NormalizeStageModeInput(mode string) string { + if mode == "review" { + return StageModeForm + } + return mode +} + +// ── Stage Type Constants (deprecated) ─────── +// stage_type is redundant with starlark_hook presence and is no longer +// validated. Constants are retained for backward-compatible references. const ( StageTypeSimple = "simple" @@ -93,13 +102,6 @@ const ( StageTypeAutomated = "automated" ) -// ValidStageTypes is the set of valid stage_type values. -var ValidStageTypes = map[string]bool{ - StageTypeSimple: true, - StageTypeDynamic: true, - StageTypeAutomated: true, -} - // ── Audience Constants ────────────────────── const ( diff --git a/server/pages/pages.go b/server/pages/pages.go index deeeeb2..6504983 100644 --- a/server/pages/pages.go +++ b/server/pages/pages.go @@ -921,7 +921,7 @@ type WorkflowPageData struct { WorkflowDescription string SessionID string SessionName string - StageMode string // form | review | delegated | automated + StageMode string // form | delegated | automated StageName string FormTemplateJSON string // typed form template JSON (empty if delegated) TotalStages int @@ -948,7 +948,7 @@ type WorkflowLandingPageData struct { PersonaName string PersonaIcon string StageCount int - FirstStageMode string // form | review | delegated | automated + FirstStageMode string // form | delegated | automated ResumeURL string // non-empty if visitor has an active session } diff --git a/server/pages/templates/workflow-landing.html b/server/pages/templates/workflow-landing.html index e5752e7..f2a0775 100644 --- a/server/pages/templates/workflow-landing.html +++ b/server/pages/templates/workflow-landing.html @@ -154,7 +154,7 @@ {{end}} {{if .Data.ResumeURL}} diff --git a/server/pages/templates/workflow.html b/server/pages/templates/workflow.html index 090bcca..c99c542 100644 --- a/server/pages/templates/workflow.html +++ b/server/pages/templates/workflow.html @@ -178,7 +178,7 @@ {{end}} - + {{if .Data.AudienceMismatch}} -
- - -
- ${stageType !== 'simple' && html` + ${mode === 'automated' && html`