diff --git a/workspace-server/internal/handlers/handlers_additional_test.go b/workspace-server/internal/handlers/handlers_additional_test.go index 82af0bd6d..6135bdb06 100644 --- a/workspace-server/internal/handlers/handlers_additional_test.go +++ b/workspace-server/internal/handlers/handlers_additional_test.go @@ -337,7 +337,7 @@ func TestRegister_ProvisionerURLPreserved(t *testing.T) { WillReturnError(sql.ErrNoRows) mock.ExpectExec("INSERT INTO workspaces"). - WithArgs("ws-prov", "ws-prov", "http://localhost:8000", `{"name":"agent"}`, "push"). + WithArgs("ws-prov", "ws-prov", "http://localhost:8000", `{"name":"agent"}`, "push", ""). WillReturnResult(sqlmock.NewResult(0, 1)) // DB returns provisioner URL (127.0.0.1) — should take precedence over agent-reported URL diff --git a/workspace-server/internal/handlers/handlers_test.go b/workspace-server/internal/handlers/handlers_test.go index 16351f05f..c761daf42 100644 --- a/workspace-server/internal/handlers/handlers_test.go +++ b/workspace-server/internal/handlers/handlers_test.go @@ -180,7 +180,7 @@ func TestRegisterHandler(t *testing.T) { // Expect the upsert INSERT ... ON CONFLICT mock.ExpectExec("INSERT INTO workspaces"). - WithArgs("ws-123", "ws-123", "http://localhost:8000", `{"name":"test"}`, "push"). + WithArgs("ws-123", "ws-123", "http://localhost:8000", `{"name":"test"}`, "push", ""). WillReturnResult(sqlmock.NewResult(0, 1)) // Expect the SELECT url query (for cache URL logic) diff --git a/workspace-server/internal/handlers/kind_platform_root_integration_test.go b/workspace-server/internal/handlers/kind_platform_root_integration_test.go new file mode 100644 index 000000000..6df9d2c15 --- /dev/null +++ b/workspace-server/internal/handlers/kind_platform_root_integration_test.go @@ -0,0 +1,122 @@ +//go:build integration +// +build integration + +// kind_platform_root_integration_test.go — REAL Postgres gate for the +// platform-agent participant kind (RFC docs/design/rfc-platform-agent.md). +// +// Run with: +// +// INTEGRATION_DB_URL="postgres://postgres:test@localhost:55432/molecule?sslmode=disable" \ +// go test -tags=integration ./internal/handlers/ -run Integration_PlatformKind -v +// +// CI: piggybacks on the handlers-postgres-integration workflow (path filter +// includes workspace-server/internal/handlers/** and migrations/**). +// +// Why this is NOT a sqlmock test +// ------------------------------ +// The invariant "a platform agent must be the org root (parent_id IS NULL), +// which structurally also means at most one platform agent per org" is enforced +// by the workspaces_platform_root_check CHECK constraint in migration +// 20260606000000_workspaces_kind. sqlmock cannot execute DDL or evaluate a CHECK +// constraint, so only a real Postgres can prove the constraint actually rejects +// a non-root platform agent and accepts a root one. The Register handler's +// isPlatformRootViolation()/409 path depends on this constraint firing. + +package handlers + +import ( + "context" + "database/sql" + "fmt" + "strings" + "testing" + + "github.com/google/uuid" + _ "github.com/lib/pq" +) + +func integrationDB_PlatformKind(t *testing.T) *sql.DB { + t.Helper() + url := requireIntegrationDBURL(t) + conn, err := sql.Open("postgres", url) + if err != nil { + t.Fatalf("open: %v", err) + } + if err := conn.Ping(); err != nil { + t.Fatalf("ping: %v", err) + } + t.Cleanup(func() { conn.Close() }) + return conn +} + +// TestIntegration_PlatformKind_RootAllowed_NonRootRejected proves the three +// guarantees of the kind column against a real Postgres: +// +// 1. a fresh workspace defaults to kind='workspace'; +// 2. a root row (parent_id IS NULL) may be kind='platform'; +// 3. a non-root row (parent_id set) may NOT be kind='platform' — the +// workspaces_platform_root_check constraint rejects it (23514). +func TestIntegration_PlatformKind_RootAllowed_NonRootRejected(t *testing.T) { + conn := integrationDB_PlatformKind(t) + ctx := context.Background() + + prefix := fmt.Sprintf("itest-kind-%s", uuid.New().String()[:8]) + cleanup := func() { + if _, err := conn.ExecContext(ctx, + `DELETE FROM workspaces WHERE name LIKE $1`, prefix+"%"); err != nil { + t.Logf("cleanup (non-fatal): %v", err) + } + } + t.Cleanup(cleanup) + cleanup() // pre-test hygiene in the shared integration DB + + rootID := uuid.New().String() + childID := uuid.New().String() + + // 1. Default kind is 'workspace' when the column is omitted on INSERT. + if _, err := conn.ExecContext(ctx, ` + INSERT INTO workspaces (id, name, tier, runtime, status, parent_id) + VALUES ($1, $2, 2, 'claude-code', 'online', NULL) + `, rootID, prefix+"-root"); err != nil { + t.Fatalf("seed root: %v", err) + } + var gotKind string + if err := conn.QueryRowContext(ctx, + `SELECT kind FROM workspaces WHERE id = $1`, rootID).Scan(&gotKind); err != nil { + t.Fatalf("read kind: %v", err) + } + if gotKind != "workspace" { + t.Fatalf("default kind = %q, want \"workspace\"", gotKind) + } + + // 2. The root row may become a platform agent. + if _, err := conn.ExecContext(ctx, + `UPDATE workspaces SET kind = 'platform' WHERE id = $1`, rootID); err != nil { + t.Fatalf("promote root to platform: unexpected error: %v", err) + } + + // A child of the platform root (an ordinary workspace) inserts fine. + if _, err := conn.ExecContext(ctx, ` + INSERT INTO workspaces (id, name, tier, runtime, status, parent_id) + VALUES ($1, $2, 2, 'claude-code', 'online', $3) + `, childID, prefix+"-child", rootID); err != nil { + t.Fatalf("seed child: %v", err) + } + + // 3. The non-root child may NOT be a platform agent — the CHECK rejects it. + _, err := conn.ExecContext(ctx, + `UPDATE workspaces SET kind = 'platform' WHERE id = $1`, childID) + if err == nil { + t.Fatalf("non-root child accepted kind='platform' — constraint did not fire") + } + if !strings.Contains(err.Error(), "workspaces_platform_root_check") { + t.Fatalf("non-root platform rejection wanted workspaces_platform_root_check, got: %v", err) + } + + // And the unknown-kind value is rejected by workspaces_kind_check. + _, err = conn.ExecContext(ctx, + `UPDATE workspaces SET kind = 'bogus' WHERE id = $1`, rootID) + if err == nil || !strings.Contains(err.Error(), "workspaces_kind_check") { + t.Fatalf("unknown kind wanted workspaces_kind_check rejection, got: %v", err) + } +} diff --git a/workspace-server/internal/handlers/registry.go b/workspace-server/internal/handlers/registry.go index 9d7ffd58d..f37543db4 100644 --- a/workspace-server/internal/handlers/registry.go +++ b/workspace-server/internal/handlers/registry.go @@ -164,6 +164,20 @@ func (h *RegistryHandler) resolveDeliveryMode(ctx context.Context, workspaceID, return models.DeliveryModePush, nil } +// errPlatformNotRoot is the client-facing message when a register call tried to +// mark a non-root workspace as a platform agent. +const errPlatformNotRoot = "a platform agent must be the org root (parent_id must be null)" + +// isPlatformRootViolation reports whether err is the DB rejecting a register +// that tried to mark a non-root workspace as a platform agent (the +// workspaces_platform_root_check CHECK constraint). The handler maps it to a +// friendly HTTP 409 instead of a raw 500. The invariant — platform == org root, +// which structurally also guarantees one platform agent per org — is enforced +// race-proof at the DB level; this is just the friendly surface. +func isPlatformRootViolation(err error) bool { + return err != nil && strings.Contains(err.Error(), "workspaces_platform_root_check") +} + // Returns a non-nil error suitable for including in a 400 Bad Request response. func validateAgentURL(rawURL string) error { if rawURL == "" { @@ -277,6 +291,14 @@ func (h *RegistryHandler) Register(c *gin.Context) { return } + // Validate explicit kind if the agent declared one; empty is allowed and + // resolves to the row's existing value (or "workspace" default) in + // resolveKind below. Only the platform-agent container declares 'platform'. + if payload.Kind != "" && !models.IsValidKind(payload.Kind) { + c.JSON(http.StatusBadRequest, gin.H{"error": "kind must be 'workspace' or 'platform'"}) + return + } + ctx := c.Request.Context() // C18: prevent workspace URL hijacking on re-registration. @@ -390,9 +412,15 @@ func (h *RegistryHandler) Register(c *gin.Context) { // the row. Without this guard, bulk deletes left tier-3 stragglers because // the last pre-teardown heartbeat flipped status back to 'online' after // Delete's UPDATE. + // kind ($6) is the raw payload value (validated above; "" = unspecified). + // COALESCE(NULLIF($6,''), …) means: an explicit kind wins; an unspecified + // kind defaults to 'workspace' for a NEW row and KEEPS the existing kind on + // re-register (so a platform agent re-registering without kind is never + // downgraded). A non-root row asking for 'platform' is rejected by the + // workspaces_platform_root_check constraint → friendly 409 below. _, err = db.DB.ExecContext(ctx, ` - INSERT INTO workspaces (id, name, url, agent_card, status, last_heartbeat_at, delivery_mode) - VALUES ($1, $2, $3, $4::jsonb, 'online', now(), $5) + INSERT INTO workspaces (id, name, url, agent_card, status, last_heartbeat_at, delivery_mode, kind) + VALUES ($1, $2, $3, $4::jsonb, 'online', now(), $5, COALESCE(NULLIF($6, ''), 'workspace')) ON CONFLICT (id) DO UPDATE SET url = CASE WHEN workspaces.url LIKE 'http://127.0.0.1%' THEN workspaces.url @@ -402,10 +430,15 @@ func (h *RegistryHandler) Register(c *gin.Context) { status = 'online', last_heartbeat_at = now(), delivery_mode = EXCLUDED.delivery_mode, + kind = COALESCE(NULLIF($6, ''), workspaces.kind), updated_at = now() WHERE workspaces.status IS DISTINCT FROM 'removed' - `, payload.ID, payload.ID, urlForUpsert, agentCardStr, modeForUpsert) + `, payload.ID, payload.ID, urlForUpsert, agentCardStr, modeForUpsert, payload.Kind) if err != nil { + if isPlatformRootViolation(err) { + c.JSON(http.StatusConflict, gin.H{"error": errPlatformNotRoot}) + return + } log.Printf("Registry register error: %v (id=%s)", err, payload.ID) c.JSON(http.StatusInternalServerError, gin.H{"error": "registration failed"}) return diff --git a/workspace-server/internal/handlers/registry_test.go b/workspace-server/internal/handlers/registry_test.go index ed7f90467..a8407b956 100644 --- a/workspace-server/internal/handlers/registry_test.go +++ b/workspace-server/internal/handlers/registry_test.go @@ -72,7 +72,7 @@ func TestRegister_DBError(t *testing.T) { // DB insert fails mock.ExpectExec("INSERT INTO workspaces"). - WithArgs("ws-fail", "ws-fail", "http://localhost:8000", `{"name":"test"}`, "push"). + WithArgs("ws-fail", "ws-fail", "http://localhost:8000", `{"name":"test"}`, "push", ""). WillReturnError(sql.ErrConnDone) w := httptest.NewRecorder() @@ -647,7 +647,7 @@ func TestRegister_GuardAgainstResurrectingRemovedRow(t *testing.T) { // This regex-ish match requires the guard. If the handler ever drops // the clause the test fails because the emitted SQL won't match. mock.ExpectExec("ON CONFLICT.*WHERE workspaces.status IS DISTINCT FROM 'removed'"). - WithArgs("ws-resurrect", "ws-resurrect", "http://localhost:8000", `{"name":"x"}`, "push"). + WithArgs("ws-resurrect", "ws-resurrect", "http://localhost:8000", `{"name":"x"}`, "push", ""). WillReturnResult(sqlmock.NewResult(0, 0)) // 0 rows affected = correctly guarded mock.ExpectQuery("SELECT url FROM workspaces WHERE id"). WithArgs("ws-resurrect"). @@ -917,7 +917,7 @@ func TestRegister_C18_BootstrapAllowedNoTokens(t *testing.T) { // Workspace upsert proceeds normally. mock.ExpectExec("INSERT INTO workspaces"). - WithArgs("ws-new", "ws-new", "http://localhost:9100", `{"name":"new-agent"}`, "push"). + WithArgs("ws-new", "ws-new", "http://localhost:9100", `{"name":"new-agent"}`, "push", ""). WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectQuery("SELECT url FROM workspaces WHERE id"). @@ -1228,7 +1228,7 @@ func TestRegister_DBErrorResponseIsOpaque(t *testing.T) { // DB upsert fails with a descriptive internal error. mock.ExpectExec("INSERT INTO workspaces"). - WithArgs("ws-errtest", "ws-errtest", "http://localhost:9200", `{"name":"err-agent"}`, "push"). + WithArgs("ws-errtest", "ws-errtest", "http://localhost:9200", `{"name":"err-agent"}`, "push", ""). WillReturnError(sql.ErrConnDone) w := httptest.NewRecorder() @@ -1476,7 +1476,7 @@ func TestRegister_PollMode_AcceptsEmptyURL(t *testing.T) { // Upsert MUST run with empty URL (sql.NullString) and delivery_mode=poll. mock.ExpectExec("INSERT INTO workspaces"). - WithArgs(wsID, wsID, sql.NullString{}, `{"name":"poll-agent"}`, "poll"). + WithArgs(wsID, wsID, sql.NullString{}, `{"name":"poll-agent"}`, "poll", ""). WillReturnResult(sqlmock.NewResult(0, 1)) // SELECT url for cache: returns NULL/empty for poll-mode rows. The @@ -1591,6 +1591,89 @@ func TestRegister_InvalidDeliveryMode(t *testing.T) { } } +// TestRegister_InvalidKind rejects payloads that declare an unrecognised kind — +// only 'workspace' and 'platform' are valid. Mirrors the delivery_mode guard; +// the rejection happens before any DB access. +func TestRegister_InvalidKind(t *testing.T) { + setupTestDB(t) + setupTestRedis(t) + broadcaster := newTestBroadcaster() + handler := NewRegistryHandler(broadcaster) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("POST", "/registry/register", + bytes.NewBufferString(`{"id":"ws-x","url":"http://localhost:8000","agent_card":{"name":"a"},"kind":"bogus"}`)) + c.Request.Header.Set("Content-Type", "application/json") + + handler.Register(c) + + if w.Code != http.StatusBadRequest { + t.Errorf("invalid kind: expected 400, got %d: %s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), "kind") { + t.Errorf("expected error body to mention kind, got: %s", w.Body.String()) + } +} + +// TestRegister_PlatformKind_PersistsKind verifies that a workspace registering +// with kind="platform" has that value written through the upsert (the platform +// agent self-registers as the org root). The platform==root invariant itself is +// enforced by the workspaces_platform_root_check DB constraint and exercised by +// the integration test, which sqlmock cannot enforce. +func TestRegister_PlatformKind_PersistsKind(t *testing.T) { + mock := setupTestDB(t) + setupTestRedis(t) + broadcaster := newTestBroadcaster() + handler := NewRegistryHandler(broadcaster) + + const wsID = "ws-platform-agent" + + // Bootstrap path — no live tokens. + mock.ExpectQuery("SELECT COUNT\\(\\*\\) FROM workspace_auth_tokens"). + WithArgs(wsID). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0)) + + // delivery_mode="push" is set explicitly, so resolveDeliveryMode + // short-circuits (no SELECT delivery_mode lookup). The upsert MUST carry + // kind="platform" as the 6th arg. + mock.ExpectExec("INSERT INTO workspaces"). + WithArgs(wsID, wsID, "http://localhost:9100", `{"name":"concierge"}`, "push", "platform"). + WillReturnResult(sqlmock.NewResult(0, 1)) + + mock.ExpectQuery("SELECT url FROM workspaces WHERE id"). + WithArgs(wsID). + WillReturnRows(sqlmock.NewRows([]string{"url"}).AddRow("http://localhost:9100")) + + mock.ExpectExec("INSERT INTO structure_events"). + WillReturnResult(sqlmock.NewResult(0, 1)) + + // Token issuance — first-register path. + mock.ExpectQuery("SELECT COUNT\\(\\*\\) FROM workspace_auth_tokens"). + WithArgs(wsID). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0)) + mock.ExpectExec("INSERT INTO workspace_auth_tokens"). + WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectQuery(`SELECT platform_inbound_secret FROM workspaces WHERE id = \$1`). + WithArgs(wsID). + WillReturnRows(sqlmock.NewRows([]string{"platform_inbound_secret"}).AddRow(nil)) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("POST", "/registry/register", + bytes.NewBufferString(`{"id":"`+wsID+`","url":"http://localhost:9100","delivery_mode":"push","kind":"platform","agent_card":{"name":"concierge"}}`)) + c.Request.Header.Set("Content-Type", "application/json") + + handler.Register(c) + + if w.Code != http.StatusOK { + t.Fatalf("platform register: expected 200, got %d: %s", w.Code, w.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet expectations: %v", err) + } +} + // TestRegister_PollMode_PreservesExistingValue: when the row already // has delivery_mode=poll and the payload doesn't set it, the resolved // mode should be poll — i.e. "absent payload mode" must NOT silently @@ -1616,7 +1699,7 @@ func TestRegister_PollMode_PreservesExistingValue(t *testing.T) { // Upsert carries the resolved poll mode forward — even though // payload didn't restate it. URL still empty (poll-mode shape). mock.ExpectExec("INSERT INTO workspaces"). - WithArgs(wsID, wsID, sql.NullString{}, `{"name":"a"}`, "poll"). + WithArgs(wsID, wsID, sql.NullString{}, `{"name":"a"}`, "poll", ""). WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectQuery("SELECT url FROM workspaces WHERE id"). WithArgs(wsID). @@ -1685,7 +1768,7 @@ func TestRegister_ExternalRuntime_DefaultsToPoll(t *testing.T) { AddRow(sql.NullString{}, "external")) mock.ExpectExec("INSERT INTO workspaces"). - WithArgs(wsID, wsID, sql.NullString{}, `{"name":"a"}`, "poll"). + WithArgs(wsID, wsID, sql.NullString{}, `{"name":"a"}`, "poll", ""). WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectQuery("SELECT url FROM workspaces WHERE id"). WithArgs(wsID). @@ -1744,7 +1827,7 @@ func TestRegister_KimiRuntime_DefaultsToPoll(t *testing.T) { AddRow(sql.NullString{}, "kimi-cli")) mock.ExpectExec("INSERT INTO workspaces"). - WithArgs(wsID, wsID, sql.NullString{}, `{"name":"a"}`, "poll"). + WithArgs(wsID, wsID, sql.NullString{}, `{"name":"a"}`, "poll", ""). WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectQuery("SELECT url FROM workspaces WHERE id"). WithArgs(wsID). @@ -1804,7 +1887,7 @@ func TestRegister_NonExternalRuntime_StillDefaultsToPush(t *testing.T) { AddRow(sql.NullString{}, "claude-code")) mock.ExpectExec("INSERT INTO workspaces"). - WithArgs(wsID, wsID, "http://localhost:8000", `{"name":"a"}`, "push"). + WithArgs(wsID, wsID, "http://localhost:8000", `{"name":"a"}`, "push", ""). WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectQuery("SELECT url FROM workspaces WHERE id"). WithArgs(wsID). diff --git a/workspace-server/internal/models/workspace.go b/workspace-server/internal/models/workspace.go index 220c57494..ca899c765 100644 --- a/workspace-server/internal/models/workspace.go +++ b/workspace-server/internal/models/workspace.go @@ -13,11 +13,16 @@ import ( const DefaultMaxConcurrentTasks = 1 type Workspace struct { - ID string `json:"id" db:"id"` - Name string `json:"name" db:"name"` - Role sql.NullString `json:"role" db:"role"` - Tier int `json:"tier" db:"tier"` - Status string `json:"status" db:"status"` + ID string `json:"id" db:"id"` + Name string `json:"name" db:"name"` + Role sql.NullString `json:"role" db:"role"` + Tier int `json:"tier" db:"tier"` + Status string `json:"status" db:"status"` + // Kind: "workspace" (default) or "platform". A "platform" workspace is the + // org-level concierge (the platform agent) that sits at the org root and is + // the user's default A2A target. See migration + // 20260606000000_workspaces_kind + RFC docs/design/rfc-platform-agent.md. + Kind string `json:"kind" db:"kind"` SourceBundleID sql.NullString `json:"source_bundle_id" db:"source_bundle_id"` AgentCard json.RawMessage `json:"agent_card" db:"agent_card"` URL sql.NullString `json:"url" db:"url"` @@ -63,6 +68,21 @@ func IsValidDeliveryMode(s string) bool { return s == DeliveryModePush || s == DeliveryModePoll } +// Workspace kind constants. Matches the CHECK constraint in migration +// 20260606000000_workspaces_kind. KindPlatform marks the org-level concierge +// (the platform agent) which sits at the org root; see +// docs/design/rfc-platform-agent.md. +const ( + KindWorkspace = "workspace" + KindPlatform = "platform" +) + +// IsValidKind reports whether s is a recognised workspace kind. Empty string is +// NOT valid here — callers resolve the default (KindWorkspace) before calling. +func IsValidKind(s string) bool { + return s == KindWorkspace || s == KindPlatform +} + type RegisterPayload struct { ID string `json:"id" binding:"required"` // URL is required for push-mode workspaces; optional / unused for @@ -76,6 +96,12 @@ type RegisterPayload struct { // value on the workspace row, or default to push for new rows". // When set, must be one of DeliveryModePush / DeliveryModePoll. DeliveryMode string `json:"delivery_mode,omitempty"` + // Kind is optional. Empty string means "keep the existing value on the + // workspace row, or default to KindWorkspace for new rows". When set, must + // be one of KindWorkspace / KindPlatform. KindPlatform additionally requires + // the row to be its own org root (parent_id IS NULL) and to be the only + // platform agent in the org — enforced by the Register handler. + Kind string `json:"kind,omitempty"` } type HeartbeatPayload struct { diff --git a/workspace-server/internal/models/workspace_delivery_mode_test.go b/workspace-server/internal/models/workspace_delivery_mode_test.go index 0b8a2dc44..5f66b017a 100644 --- a/workspace-server/internal/models/workspace_delivery_mode_test.go +++ b/workspace-server/internal/models/workspace_delivery_mode_test.go @@ -34,6 +34,35 @@ func TestIsValidDeliveryMode_Invalid(t *testing.T) { } } +// ==================== IsValidKind ==================== + +func TestIsValidKind_Valid(t *testing.T) { + for _, k := range []string{KindWorkspace, KindPlatform} { + if !IsValidKind(k) { + t.Errorf("IsValidKind(%q) = false, want true", k) + } + } +} + +func TestIsValidKind_Invalid(t *testing.T) { + cases := []struct { + val string + want bool + }{ + {"", false}, // empty is not valid — callers resolve the default + {"platforms", false}, // typo + {"Platform", false}, // case-sensitive + {"platform ", false}, // trailing space + {"root", false}, // not a kind + {"user", false}, // the user is implicit, not a workspace kind + } + for _, tc := range cases { + if got := IsValidKind(tc.val); got != tc.want { + t.Errorf("IsValidKind(%q) = %v, want %v", tc.val, got, tc.want) + } + } +} + // ==================== WorkspaceStatus ==================== func TestWorkspaceStatus_String(t *testing.T) { diff --git a/workspace-server/migrations/20260606000000_workspaces_kind.down.sql b/workspace-server/migrations/20260606000000_workspaces_kind.down.sql new file mode 100644 index 000000000..f000e8e51 --- /dev/null +++ b/workspace-server/migrations/20260606000000_workspaces_kind.down.sql @@ -0,0 +1,7 @@ +-- Reverse the participant-kind discriminator. +-- Non-destructive: dropping the column makes every workspace an ordinary +-- workspace again (the platform agent loses its marker but its row survives). +DROP INDEX IF EXISTS idx_workspaces_kind; +ALTER TABLE workspaces DROP CONSTRAINT IF EXISTS workspaces_platform_root_check; +ALTER TABLE workspaces DROP CONSTRAINT IF EXISTS workspaces_kind_check; +ALTER TABLE workspaces DROP COLUMN IF EXISTS kind; diff --git a/workspace-server/migrations/20260606000000_workspaces_kind.up.sql b/workspace-server/migrations/20260606000000_workspaces_kind.up.sql new file mode 100644 index 000000000..8cefd3d82 --- /dev/null +++ b/workspace-server/migrations/20260606000000_workspaces_kind.up.sql @@ -0,0 +1,45 @@ +-- Participant-kind discriminator for the org-level platform agent. +-- (RFC: docs/design/rfc-platform-agent.md) +-- +-- 'workspace' (default) = an ordinary workspace / agent. +-- 'platform' = the org-level concierge (the "platform agent"). It is +-- the single org root (parent_id IS NULL) and the user's +-- default A2A chat target. Exactly one per org. +-- +-- There is no org_id column — an "org" is the parent_id-chain root resolved by +-- org_scope.go (orgRootID/sameOrg). "platform == org root" and "one platform +-- agent per org" are therefore enforced in the Register/create handlers, not in +-- pure SQL. This column is only the discriminator (default-target / billing +-- exclusion / UX), defined once here and mirrored by the Go constants +-- models.KindWorkspace / models.KindPlatform. +-- +-- Backward-compatible: every existing row defaults to 'workspace'. The CHECK is +-- added NOT VALID then validated so the ALTER can never fail on legacy data. +ALTER TABLE workspaces + ADD COLUMN IF NOT EXISTS kind TEXT NOT NULL DEFAULT 'workspace'; + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'workspaces_kind_check') THEN + ALTER TABLE workspaces + ADD CONSTRAINT workspaces_kind_check CHECK (kind IN ('workspace', 'platform')) NOT VALID; + ALTER TABLE workspaces VALIDATE CONSTRAINT workspaces_kind_check; + END IF; +END $$; + +-- platform == org root, enforced at the DB level (race-proof). A platform agent +-- MUST have parent_id IS NULL. Because an org is the subtree under a single +-- parent_id IS NULL root (org_scope.go) and only a root may be 'platform', this +-- also structurally guarantees at most ONE platform agent per org. The handler +-- additionally pre-checks this to return a friendly 409 instead of a raw 23514. +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'workspaces_platform_root_check') THEN + ALTER TABLE workspaces + ADD CONSTRAINT workspaces_platform_root_check + CHECK (kind <> 'platform' OR parent_id IS NULL) NOT VALID; + ALTER TABLE workspaces VALIDATE CONSTRAINT workspaces_platform_root_check; + END IF; +END $$; + +CREATE INDEX IF NOT EXISTS idx_workspaces_kind ON workspaces(kind);