diff --git a/.agents/skills/mobile-parity/SKILL.md b/.agents/skills/mobile-parity/SKILL.md index 720f9e5674..108c439fb6 100644 --- a/.agents/skills/mobile-parity/SKILL.md +++ b/.agents/skills/mobile-parity/SKILL.md @@ -39,7 +39,7 @@ desktop boundaries. Link to an existing UI contract when the feature reuses one. ## Mobile Design Contract -When a task changes composition, navigation, overlays, touch behavior, scrolling, or breakpoint behavior, state these choices in the working plan or task notes. Keep them brief; do not create a separate document unless the task already uses a spec or committed plan. For copy, icon, color, or content-only styling inside an unchanged surface, identify the nearest mobile exemplar and the rendered mobile check instead of forcing the full contract. +When a task changes composition, navigation, overlays, touch behavior, scrolling, or breakpoint behavior, state these choices in the working plan or task notes. Keep them brief; do not create a separate document unless the task already uses a spec or plan. For copy, icon, color, or content-only styling inside an unchanged surface, identify the nearest mobile exemplar and the rendered mobile check instead of forcing the full contract. - desktop user outcome and mobile entry point - nearest shipped mobile exemplar and which interaction/geometry it contributes diff --git a/.agents/skills/plan/SKILL.md b/.agents/skills/plan/SKILL.md index 408c5af48f..c04e135d10 100644 --- a/.agents/skills/plan/SKILL.md +++ b/.agents/skills/plan/SKILL.md @@ -1,6 +1,6 @@ --- name: plan -description: Create a committed implementation plan and work orders from approved requirements and current system designs. Use after specification work and before implementation. +description: Create an implementation plan and work orders from approved requirements and current system designs. Leave the design package uncommitted for review before implementation. --- # Create an Implementation Plan @@ -259,6 +259,11 @@ review after the PR opens. ### 5. End the design turn +Leave the full design package, including requirements, system designs, ADRs, +plan, and work orders, unstaged and uncommitted so the user can review its diff +in the workspace. Do not stage or commit these files unless the user explicitly +asks. Check `git status --short` and include the changed paths in the handoff. + Report the requirement IDs, system designs, plan, work orders, dependency order, exact checks, and open risks. For UI changes, also render a compact ASCII preview inline in the final conversation summary; links alone are not diff --git a/apps/backend/AGENTS.md b/apps/backend/AGENTS.md index 933e3f9cdd..fa22577edc 100644 --- a/apps/backend/AGENTS.md +++ b/apps/backend/AGENTS.md @@ -97,13 +97,14 @@ apps/backend/ │ └── worktree/ # Git worktree management for workspace isolation ``` -Canvas creation authority is recorded only by the trusted task authoring -adapter. It binds the workspace owner, creating session, task scope, and -policy version to a new draft. The first valid static web-app release may -consume that single-use authority for exact task-scoped grants in the same -transaction as release insertion and activation. Existing drafts, imports, -later permission increases, and revoked grants remain on the human review -path. Never use source metadata or a manifest trust field as authority. +Canvas creation authority comes only from the trusted task adapter and binds +the owner, session, task, and policy version. Its first valid static release +may consume that authority for exact workspace-ceiling grants in the activation +transaction. Existing drafts, imports, later permission increases, and +revoked grants still require human review; source metadata and manifests never +grant authority. +Canvas scopes expose `canvas_data_scope_transition_total` with fixed transition +and result labels. Lifecycle events carry IDs; metrics never label task data. ## Key Concepts @@ -293,8 +294,7 @@ Enforced by `apps/backend/.golangci.yml` (errors on new code only): When a PR fixup touches backend code, run `golangci-lint run ./... --new-from-rev="" --timeout=5m` from `apps/backend` with the PR base SHA before pushing; CI enforces changed-file complexity thresholds. - `internal/launcher/` — native launcher owning every entrypoint (`dev`, `start`, `run`, `service`); `dev` runs `make -C apps/backend dev` with Vite as a supervised child, state under `/.kandev-dev/`. The root `make dev` prebuilds only the copied launcher; the backend dev target builds the native agentctl and a linux/amd64 helper when the host is not Linux/amd64 (`docs/plans/go-dev-launcher/`). -- `internal/agentctl/AGENTS.md` — agentctl server route groups, adapter model, ACP protocol +- `internal/agentctl/AGENTS.md` — server routes, adapters, ACP; `cmd/mock-agent/AGENTS.md` — E2E scenario patterns and rebuild requirements - `internal/agentctl/server/api/AGENTS.md` — reverse-proxy body rewriting (`Accept-Encoding`), iframe-blocking header stripping - `internal/integrations/AGENTS.md` — playbook for adding a new third-party integration (Jira/Linear pattern) - `docs/i18n.md` ("Backend") — `internal/i18n` covers only what Go renders straight to a browser: the SPA-unavailable error pages and the shared-task artifacts (`share.html`, gist README and description). Both are complete. Everything else stays English by design; for new user-facing output prefer a stable error code the frontend translates. Use `i18n.Tf` for anything carrying a value — never `fmt.Sprintf` a translated string, and never build a plural in Go. A locale for output that outlives the request is resolved once at write time and threaded as an argument, not a context value (ADR `2026-08-01-share-artifact-locale.md`). -- `cmd/mock-agent/AGENTS.md` — predefined `/e2e:` scenarios vs inline `e2e:...` scripts, recipe for adding a scenario, and the rebuild-before-e2e requirement diff --git a/apps/backend/config/prompts/create-canvas.md b/apps/backend/config/prompts/create-canvas.md index 0d2e092859..769ed48375 100644 --- a/apps/backend/config/prompts/create-canvas.md +++ b/apps/backend/config/prompts/create-canvas.md @@ -16,8 +16,10 @@ canvas identity and whether its release is active, awaits permission review, or was unsuccessful. If publication is unsuccessful, report the failure and do not claim that the -canvas is published. If workspace access requires promotion, explain the user -action that is still required. +canvas is published. A new owner-authorized task canvas can use its declared +supported permissions across the current workspace before promotion. Promotion +adds workspace navigation. Do not claim that promotion is required to preview +workspace task data. A local build alone does not publish a canvas inside Kandev. If the tools remain unavailable, report the limitation instead of claiming that workspace files diff --git a/apps/backend/internal/backendapp/canvas_authoring.go b/apps/backend/internal/backendapp/canvas_authoring.go index 8889431f5c..42bdc45d81 100644 --- a/apps/backend/internal/backendapp/canvas_authoring.go +++ b/apps/backend/internal/backendapp/canvas_authoring.go @@ -351,12 +351,7 @@ func (s *canvasAuthoringService) PublishCanvas(ctx context.Context, request mcph return nil, err } defer s.endPublish(request.Agent.SessionID, item.ID) - expectedAuthority := instances.PublishAuthority{ - InstanceID: item.PluginInstanceID, ScopeKind: item.ScopeKind, - WorkspaceID: item.WorkspaceID, TaskID: item.TaskID, - Status: item.Status, ActiveReleaseID: item.ActiveReleaseID, - GrantGeneration: item.GrantGeneration, - } + expectedAuthority := canvasPublishAuthority(item) expectedBaseReleaseID := "" if editSession { expectedBaseReleaseID = target.ReleaseID @@ -364,6 +359,15 @@ func (s *canvasAuthoringService) PublishCanvas(ctx context.Context, request mcph return s.publishCanvasSource(ctx, execution, task, item, request, expectedAuthority, expectedBaseReleaseID) } +func canvasPublishAuthority(item *canvas.Canvas) instances.PublishAuthority { + return instances.PublishAuthority{ + InstanceID: item.PluginInstanceID, ScopeKind: item.ScopeKind, + DataScopeKind: item.DataScopeKind, WorkspaceID: item.WorkspaceID, + TaskID: item.TaskID, Status: item.Status, + ActiveReleaseID: item.ActiveReleaseID, GrantGeneration: item.GrantGeneration, + } +} + func validateCanvasPublishSource(item *canvas.Canvas, request mcphandlers.CanvasPublishRequest) error { if request.SourcePath != canvasSourceRoot(item.ID) { return canvasOperationError("invalid_source_root", "source_path must be the assigned canvas directory", nil) diff --git a/apps/backend/internal/backendapp/canvas_authoring_test.go b/apps/backend/internal/backendapp/canvas_authoring_test.go new file mode 100644 index 0000000000..08717f1ee1 --- /dev/null +++ b/apps/backend/internal/backendapp/canvas_authoring_test.go @@ -0,0 +1,28 @@ +package backendapp + +import ( + "testing" + + "github.com/kandev/kandev/internal/canvas" + "github.com/kandev/kandev/internal/plugins/instances" +) + +func TestCanvasPublishAuthorityCapturesPlacementAndDataScope(t *testing.T) { + item := &canvas.Canvas{ + PluginInstanceID: "instance-1", + ScopeKind: canvas.ScopeTask, + DataScopeKind: instances.ScopeWorkspace, + WorkspaceID: "workspace-1", + TaskID: "task-1", + Status: canvas.StatusActive, + GrantGeneration: 4, + } + + authority := canvasPublishAuthority(item) + if authority.ScopeKind != canvas.ScopeTask || authority.DataScopeKind != instances.ScopeWorkspace { + t.Fatalf("publish authority scopes = placement %q, data %q; want task placement and workspace data", authority.ScopeKind, authority.DataScopeKind) + } + if authority.InstanceID != item.PluginInstanceID || authority.WorkspaceID != item.WorkspaceID || authority.TaskID != item.TaskID || authority.Status != item.Status || authority.GrantGeneration != item.GrantGeneration { + t.Fatalf("publish authority = %+v, missing current canvas state", authority) + } +} diff --git a/apps/backend/internal/backendapp/canvas_routes.go b/apps/backend/internal/backendapp/canvas_routes.go index c5fe85985c..e3ceebbb65 100644 --- a/apps/backend/internal/backendapp/canvas_routes.go +++ b/apps/backend/internal/backendapp/canvas_routes.go @@ -23,6 +23,7 @@ import ( "github.com/kandev/kandev/internal/plugins/webapp" "github.com/kandev/kandev/internal/task/models" taskservice "github.com/kandev/kandev/internal/task/service" + userstore "github.com/kandev/kandev/internal/user/store" "github.com/kandev/kandev/internal/worktree/copyfiles" v1 "github.com/kandev/kandev/pkg/api/v1" ) @@ -38,6 +39,11 @@ type canvasHTTPHandler struct { distribution *canvasservice.DistributionService } +type canvasWorkspaceOwnerAccess interface { + AuthorizeWorkspaceAccess(context.Context, string) error + GetWorkspace(context.Context, string) (*models.Workspace, error) +} + type canvasEditTaskStore interface { AuthorizeWorkspaceAccess(context.Context, string) error GetWorkspace(context.Context, string) (*models.Workspace, error) @@ -370,6 +376,8 @@ func registerCanvasRoutes(p routeParams) { p.router.GET("/api/v1/canvases/:canvasID/releases", h.releases) p.router.GET("/api/v1/canvases/:canvasID/promotion-preview", h.promotionPreview) p.router.POST("/api/v1/canvases/:canvasID/promotion", h.promote) + p.router.GET("/api/v1/canvases/:canvasID/workspace-data-preview", h.workspaceDataPreview) + p.router.POST("/api/v1/canvases/:canvasID/workspace-data", h.enableWorkspaceData) p.router.POST("/api/v1/canvases/:canvasID/releases/:releaseID/approve", h.approveRelease) p.router.POST("/api/v1/canvases/:canvasID/releases/:releaseID/reject", h.rejectRelease) p.router.POST("/api/v1/canvases/:canvasID/rollback", h.rollback) @@ -390,6 +398,7 @@ type canvasHTTPResponse struct { TaskID string `json:"task_id,omitempty"` OriginTaskID string `json:"origin_task_id,omitempty"` ScopeKind string `json:"scope_kind"` + DataScopeKind string `json:"data_scope_kind"` Title string `json:"title"` CreatedBySessionID string `json:"created_by_session_id,omitempty"` PromotedByUserID string `json:"promoted_by_user_id,omitempty"` @@ -442,6 +451,7 @@ func canvasResponse(value canvasservice.Canvas) canvasHTTPResponse { TaskID: value.TaskID, OriginTaskID: value.OriginTaskID, ScopeKind: value.ScopeKind, + DataScopeKind: value.DataScopeKind, Title: value.Title, CreatedBySessionID: value.CreatedBySessionID, PromotedByUserID: value.PromotedByUserID, @@ -633,23 +643,25 @@ func (h *canvasHTTPHandler) promotionPreview(c *gin.Context) { preview.SourceSessionID, ) writeCanvasJSON(c, http.StatusOK, map[string]interface{}{ - "canvas_id": preview.Canvas.ID, - "title": preview.Canvas.Title, - "origin_task_id": preview.Canvas.OriginTaskID, - "active_release": releaseResponseFromCanvas(preview.Canvas), - "source_actor_kind": preview.SourceActorKind, - "source_user_id": preview.SourceUserID, - "source_task_id": preview.SourceTaskID, - "source_task_title": sourceLabels.TaskTitle, - "source_session_id": preview.SourceSessionID, - "source_session_name": sourceLabels.SessionName, - "permissions": preview.Permissions, - "active_release_id": preview.ActiveReleaseID, - "permission_digest": preview.PermissionDigest, - "grant_generation": preview.GrantGeneration, - "current_scope": preview.CurrentScope, - "target_scope": preview.TargetScope, - "placement": preview.Placement, + "canvas_id": preview.Canvas.ID, + "title": preview.Canvas.Title, + "origin_task_id": preview.Canvas.OriginTaskID, + "active_release": releaseResponseFromCanvas(preview.Canvas), + "source_actor_kind": preview.SourceActorKind, + "source_user_id": preview.SourceUserID, + "source_task_id": preview.SourceTaskID, + "source_task_title": sourceLabels.TaskTitle, + "source_session_id": preview.SourceSessionID, + "source_session_name": sourceLabels.SessionName, + "permissions": preview.Permissions, + "active_release_id": preview.ActiveReleaseID, + "permission_digest": preview.PermissionDigest, + "grant_generation": preview.GrantGeneration, + "current_scope": preview.CurrentScope, + "target_scope": preview.TargetScope, + "current_data_scope_kind": preview.CurrentDataScope, + "target_data_scope_kind": preview.TargetDataScope, + "placement": preview.Placement, }) } @@ -688,6 +700,56 @@ func (h *canvasHTTPHandler) promote(c *gin.Context) { writeCanvasJSON(c, http.StatusOK, canvasResponse(*updated)) } +func (h *canvasHTTPHandler) workspaceDataPreview(c *gin.Context) { + item, err := h.canvases.Get(c.Request.Context(), c.Param("canvasID")) + if err != nil { + h.writeError(c, err) + return + } + if item == nil || !h.authorizeWorkspaceOwner(c, item.WorkspaceID) { + return + } + preview, err := h.canvases.WorkspaceDataPreview(c.Request.Context(), item.ID) + if err != nil { + h.writeError(c, err) + return + } + writeCanvasJSON(c, http.StatusOK, map[string]interface{}{ + "canvas": canvasResponse(*preview.Canvas), + "active_release_id": preview.ActiveReleaseID, + "permission_digest": preview.PermissionDigest, + "grant_generation": preview.GrantGeneration, + "current_data_scope_kind": preview.CurrentDataScopeKind, + "target_data_scope_kind": preview.TargetDataScopeKind, + "permissions": preview.Permissions, + }) +} + +func (h *canvasHTTPHandler) enableWorkspaceData(c *gin.Context) { + item, err := h.canvases.Get(c.Request.Context(), c.Param("canvasID")) + if err != nil { + h.writeError(c, err) + return + } + if item == nil || !h.authorizeWorkspaceOwner(c, item.WorkspaceID) { + return + } + var request canvasPromotionRequest + if c.Request.Body == nil || c.ShouldBindJSON(&request) != nil || strings.TrimSpace(request.ExpectedReleaseID) == "" || strings.TrimSpace(request.ExpectedPermissionDigest) == "" || request.ExpectedGrantGeneration == nil || *request.ExpectedGrantGeneration < 0 { + writeCanvasError(c, http.StatusBadRequest, "workspace_data_review_required", nil) + return + } + updated, err := h.canvases.EnableWorkspaceDataReviewed( + c.Request.Context(), item.ID, canvasRuntimeUser(c), request.ExpectedReleaseID, + request.ExpectedPermissionDigest, *request.ExpectedGrantGeneration, + ) + if err != nil { + h.writeError(c, err) + return + } + writeCanvasJSON(c, http.StatusOK, canvasResponse(*updated)) +} + func (h *canvasHTTPHandler) approveRelease(c *gin.Context) { h.releaseMutation(c, func(ctx context.Context, canvasID, releaseID, userID string) (*canvasservice.Canvas, error) { return h.canvases.ApproveRelease(ctx, canvasID, releaseID, userID) @@ -886,6 +948,38 @@ func (h *canvasHTTPHandler) authorizeWorkspaceManage(c *gin.Context, workspaceID return true } +func (h *canvasHTTPHandler) authorizeWorkspaceOwner(c *gin.Context, workspaceID string) bool { + owner, err := checkCanvasWorkspaceOwner(c.Request.Context(), h.tasks, workspaceID, canvasRuntimeUser(c)) + if err != nil { + writeCanvasError(c, http.StatusNotFound, "canvas_not_found", nil) + return false + } + if !owner { + writeCanvasError(c, http.StatusForbidden, "workspace_owner_required", nil) + return false + } + return true +} + +func checkCanvasWorkspaceOwner(ctx context.Context, access canvasWorkspaceOwnerAccess, workspaceID, userID string) (bool, error) { + if access == nil { + return false, errors.New("canvas workspace access is unavailable") + } + if err := access.AuthorizeWorkspaceAccess(ctx, workspaceID); err != nil { + return false, err + } + workspace, err := access.GetWorkspace(ctx, workspaceID) + if err != nil { + return false, err + } + if workspace == nil { + return false, errors.New("canvas workspace was not found") + } + actorID := strings.TrimSpace(userID) + ownerID := strings.TrimSpace(workspace.OwnerID) + return actorID != "" && (ownerID == actorID || (ownerID == "" && actorID == userstore.DefaultUserID)), nil +} + func canvasRouteParam(c *gin.Context, names ...string) string { for _, name := range names { if value := c.Param(name); value != "" { diff --git a/apps/backend/internal/backendapp/canvas_routes_test.go b/apps/backend/internal/backendapp/canvas_routes_test.go new file mode 100644 index 0000000000..324f5fd25d --- /dev/null +++ b/apps/backend/internal/backendapp/canvas_routes_test.go @@ -0,0 +1,70 @@ +package backendapp + +import ( + "context" + "errors" + "testing" + + "github.com/kandev/kandev/internal/task/models" + userstore "github.com/kandev/kandev/internal/user/store" +) + +type canvasWorkspaceOwnerAccessStub struct { + workspace *models.Workspace + accessErr error + workspaceErr error + workspaceRead bool +} + +func (s *canvasWorkspaceOwnerAccessStub) AuthorizeWorkspaceAccess(context.Context, string) error { + return s.accessErr +} + +func (s *canvasWorkspaceOwnerAccessStub) GetWorkspace(context.Context, string) (*models.Workspace, error) { + s.workspaceRead = true + return s.workspace, s.workspaceErr +} + +func TestCheckCanvasWorkspaceOwnerRequiresCurrentWorkspaceOwner(t *testing.T) { + tests := []struct { + name string + userID string + workspace *models.Workspace + wantOwner bool + }{ + {name: "owner", userID: "owner-1", workspace: &models.Workspace{ID: "workspace-1", OwnerID: "owner-1"}, wantOwner: true}, + {name: "workspace member", userID: "member-1", workspace: &models.Workspace{ID: "workspace-1", OwnerID: "owner-1"}}, + {name: "empty user", workspace: &models.Workspace{ID: "workspace-1", OwnerID: "owner-1"}}, + {name: "auth-disabled default user owns an unowned workspace", userID: userstore.DefaultUserID, workspace: &models.Workspace{ID: "workspace-1"}, wantOwner: true}, + {name: "ordinary user cannot own an unowned workspace", userID: "member-1", workspace: &models.Workspace{ID: "workspace-1"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + access := &canvasWorkspaceOwnerAccessStub{workspace: tt.workspace} + owner, err := checkCanvasWorkspaceOwner(context.Background(), access, "workspace-1", tt.userID) + if err != nil { + t.Fatalf("checkCanvasWorkspaceOwner() error = %v", err) + } + if owner != tt.wantOwner { + t.Fatalf("owner = %t, want %t", owner, tt.wantOwner) + } + if !access.workspaceRead { + t.Fatal("workspace owner was not checked") + } + }) + } +} + +func TestCheckCanvasWorkspaceOwnerDoesNotReadWorkspaceAfterAccessDenial(t *testing.T) { + access := &canvasWorkspaceOwnerAccessStub{accessErr: errors.New("workspace access denied")} + owner, err := checkCanvasWorkspaceOwner(context.Background(), access, "workspace-1", "owner-1") + if err == nil { + t.Fatal("checkCanvasWorkspaceOwner() error = nil, want access denial") + } + if owner { + t.Fatal("owner = true after workspace access denial") + } + if access.workspaceRead { + t.Fatal("workspace details were read after access denial") + } +} diff --git a/apps/backend/internal/backendapp/canvas_runtime_routes.go b/apps/backend/internal/backendapp/canvas_runtime_routes.go index f9f6385ae7..f41d05b018 100644 --- a/apps/backend/internal/backendapp/canvas_runtime_routes.go +++ b/apps/backend/internal/backendapp/canvas_runtime_routes.go @@ -39,12 +39,13 @@ func (h *canvasHTTPHandler) runtimeBinding(c *gin.Context, canvas canvasservice. WebAppKey: app.Key, Placement: placement, ScopeKind: instance.ScopeKind, + DataScopeKind: instance.EffectiveDataScopeKind(), WorkspaceID: instance.WorkspaceID, TaskID: instance.TaskID, SessionID: instance.SessionID, RepositoryID: instance.RepositoryID, GrantGeneration: instance.GrantGeneration, - Permissions: runtimeGrantedPermissions(appManifest, instance.ScopeKind, grants), + Permissions: runtimeGrantedPermissions(appManifest, instance.EffectiveDataScopeKind(), grants), Artifact: webapp.Artifact{ Digest: release.PackageDigest, RelativePath: release.ArtifactPath, @@ -52,7 +53,7 @@ func (h *canvasHTTPHandler) runtimeBinding(c *gin.Context, canvas canvasservice. Available: true, }, Entry: app.Entry, - NetworkOrigins: runtimeNetworkOrigins(app, instance.ScopeKind, grants), + NetworkOrigins: runtimeNetworkOrigins(app, instance.EffectiveDataScopeKind(), grants), } runtime := h.plugins.WebRuntime() if runtime == nil { @@ -141,6 +142,10 @@ func (h *canvasHTTPHandler) writeError(c *gin.Context, err error) { status, code = http.StatusBadRequest, canvasErrorCodeInvalid case errors.Is(err, canvasservice.ErrStalePromotionReview): status, code = http.StatusConflict, "promotion_review_stale" + case errors.Is(err, canvasservice.ErrStaleWorkspaceDataReview): + status, code = http.StatusConflict, "workspace_data_review_stale" + case errors.Is(err, instances.ErrWorkspaceOwnerRequired): + status, code = http.StatusForbidden, "workspace_owner_required" case errors.Is(err, canvasservice.ErrStaleCanvasEdit): status, code = http.StatusConflict, "canvas_edit_stale" case errors.Is(err, canvasservice.ErrInvalidCanvas), errors.Is(err, canvasservice.ErrInvalidCanvasState): diff --git a/apps/backend/internal/canvas/authoring.go b/apps/backend/internal/canvas/authoring.go index a4827040f8..ae02ccb99b 100644 --- a/apps/backend/internal/canvas/authoring.go +++ b/apps/backend/internal/canvas/authoring.go @@ -74,9 +74,23 @@ type PromotionPreview struct { GrantGeneration int64 `json:"grant_generation"` CurrentScope string `json:"current_scope"` TargetScope string `json:"target_scope"` + CurrentDataScope string `json:"current_data_scope_kind"` + TargetDataScope string `json:"target_data_scope_kind"` Placement string `json:"placement"` } +// WorkspaceDataPreview is the owner-facing snapshot for widening a retained +// task canvas's data scope without changing its placement. +type WorkspaceDataPreview struct { + Canvas *Canvas `json:"canvas"` + Permissions PermissionSummary `json:"permissions"` + ActiveReleaseID string `json:"active_release_id"` + PermissionDigest string `json:"permission_digest"` + GrantGeneration int64 `json:"grant_generation"` + CurrentDataScopeKind string `json:"current_data_scope_kind"` + TargetDataScopeKind string `json:"target_data_scope_kind"` +} + // authoringInstanceStore is the release/governance extension implemented by // the durable plugin instance store. Keeping it optional preserves the small // lifecycle fake used by the basic canvas service tests. @@ -106,12 +120,20 @@ type reviewedPromotionStore interface { PromoteScopeAndGrantsReviewedTx(context.Context, *sqlx.Tx, string, string, string, []plugininstances.Grant, string, string, int64) error } +type reviewedWorkspaceDataStore interface { + EnableWorkspaceDataReviewedTx(context.Context, *sqlx.Tx, string, string, string, []plugininstances.Grant, string, string, int64) error +} + type conditionalReleaseStore interface { CreateReleaseIfAuthorityTx(context.Context, *sqlx.Tx, string, plugininstances.PublishAuthority, plugininstances.Release) error } type initialGrantStore interface { - AddInitialGrantsTx(context.Context, *sqlx.Tx, string, string, []plugininstances.Grant) error + AddInitialGrantsTx(context.Context, *sqlx.Tx, string, string, string, []plugininstances.Grant) error +} + +type initialPublicationDataScopeStore interface { + SetInitialPublicationDataScopeTx(context.Context, *sqlx.Tx, plugininstances.PublishAuthority, string) error } type creationAuthorityStore interface { @@ -294,7 +316,7 @@ func (s *Service) preparePublishedRelease(ctx context.Context, store authoringIn if err != nil { return plugininstances.Instance{}, plugininstances.Release{}, false, CreationAuthority{}, nil, err } - activated := permissionsFit(permissions, instance.ScopeKind, grants) + activated := permissionsFit(permissions, instance.EffectiveDataScopeKind(), grants) if creationAuthority.CanvasID != "" { activated = true } @@ -326,7 +348,7 @@ func (s *Service) preparePublishedReleaseForInstance(ctx context.Context, store if err != nil { return plugininstances.Instance{}, plugininstances.Release{}, false, err } - activated := permissionsFit(permissions, instance.ScopeKind, grants) + activated := permissionsFit(permissions, instance.EffectiveDataScopeKind(), grants) release, err := s.buildPublishedRelease(request, instance.ID, permissions, activated) if err != nil { return plugininstances.Instance{}, plugininstances.Release{}, false, err @@ -383,7 +405,7 @@ func (s *Service) resolveCreationAuthority(ctx context.Context, request PublishR if !creationAuthorityEligible(candidate, instance, request, grants, releases) { return CreationAuthority{}, nil, nil } - return candidate, grantsForManifest(permissions, candidate.OwnerUserID, ScopeTask), nil + return candidate, grantsForManifest(permissions, candidate.OwnerUserID, creationAuthorityDataScope(candidate)), nil } func (s *Service) buildPublishedRelease(request PublishRequest, instanceID string, permissions PermissionSummary, activated bool) (plugininstances.Release, error) { @@ -422,6 +444,9 @@ func persistPublishedRelease(ctx context.Context, store authoringInstanceStore, return false, ErrStaleCanvasPublish } err := persistAuthorityRelease(ctx, transactional, conditional, authorityStore, canvasID, instanceID, release, activated, expectedAuthority, creationAuthority, initialGrants, ownerUserID, allowUnownedWorkspace, sessionID, taskID) + if err == nil && activated && creationAuthority.CanvasID != "" && creationAuthorityDataScope(creationAuthority) == ScopeWorkspace { + recordCanvasScopeTransition(canvasScopeFirstPublication, canvasScopeEnabled) + } return err == nil, err } if expectedBaseReleaseID != "" { @@ -460,10 +485,19 @@ func persistAuthorityRelease(ctx context.Context, transactional transactionalAut if !ok || authorityStore == nil || creationAuthority.CanvasID != canvasID { return ErrStaleCanvasPublish } - if err := grantStore.AddInitialGrantsTx(ctx, tx, instanceID, ownerUserID, initialGrants); err != nil { + if err := authorityStore.ConsumeCreationAuthorityTx(ctx, tx, creationAuthority, ownerUserID, sessionID, taskID, allowUnownedWorkspace); err != nil { return err } - if err := authorityStore.ConsumeCreationAuthorityTx(ctx, tx, creationAuthority, ownerUserID, sessionID, taskID, allowUnownedWorkspace); err != nil { + if creationAuthorityDataScope(creationAuthority) == ScopeWorkspace { + scopeStore, ok := transactional.(initialPublicationDataScopeStore) + if !ok { + return ErrStaleCanvasPublish + } + if err := scopeStore.SetInitialPublicationDataScopeTx(ctx, tx, expectedAuthority, release.ID); err != nil { + return err + } + } + if err := grantStore.AddInitialGrantsTx(ctx, tx, instanceID, release.ID, ownerUserID, initialGrants); err != nil { return err } } @@ -568,10 +602,91 @@ func (s *Service) PromotionPreview(ctx context.Context, canvasID string) (*Promo GrantGeneration: canvas.GrantGeneration, CurrentScope: ScopeTask, TargetScope: ScopeWorkspace, + CurrentDataScope: canvas.DataScopeKind, + TargetDataScope: ScopeWorkspace, Placement: manifest.WebAppPlacementWorkspace, }, nil } +// WorkspaceDataPreview describes the active release and exact permissions a +// workspace owner would enable for a retained task canvas. +func (s *Service) WorkspaceDataPreview(ctx context.Context, canvasID string) (*WorkspaceDataPreview, error) { + if err := s.ready(); err != nil { + return nil, err + } + store, ok := s.instances.(authoringInstanceStore) + if !ok { + return nil, ErrCanvasNotConfigured + } + canvas, err := s.Get(ctx, canvasID) + if err != nil { + return nil, err + } + if canvas.ScopeKind != ScopeTask || canvas.Status != StatusActive || canvas.ActiveReleaseID == "" || + canvas.ActiveReleaseStatus != ValidationValid || canvas.DataScopeKind != ScopeTask { + return nil, fmt.Errorf("%w: canvas must have an active task-only release", ErrInvalidCanvasState) + } + release, err := store.GetRelease(ctx, canvas.ActiveReleaseID) + if err != nil { + return nil, err + } + if release.InstanceID != canvas.PluginInstanceID || release.ValidationStatus != ValidationValid { + return nil, plugininstances.ErrInvalidRelease + } + m, err := manifestFromRelease(release) + if err != nil { + return nil, err + } + return &WorkspaceDataPreview{ + Canvas: canvas, + Permissions: ManifestPermissions(m), + ActiveReleaseID: release.ID, + PermissionDigest: PermissionDigest(release), + GrantGeneration: canvas.GrantGeneration, + CurrentDataScopeKind: canvas.DataScopeKind, + TargetDataScopeKind: ScopeWorkspace, + }, nil +} + +// EnableWorkspaceDataReviewed changes only data scope and declared grants. +// Placement, task ownership, and the active package remain unchanged. +func (s *Service) EnableWorkspaceDataReviewed(ctx context.Context, canvasID, userID, expectedReleaseID, expectedPermissionDigest string, expectedGrantGeneration int64) (*Canvas, error) { + if err := s.ready(); err != nil { + return nil, err + } + if strings.TrimSpace(userID) == "" { + return nil, fmt.Errorf("%w: approving user is required", ErrInvalidCanvas) + } + preview, err := s.WorkspaceDataPreview(ctx, canvasID) + if err != nil { + return nil, err + } + grants := grantsForManifest(preview.Permissions, userID, ScopeWorkspace) + transactional, ok := s.instances.(transactionalAuthoringStore) + if !ok { + return nil, ErrStaleWorkspaceDataReview + } + reviewed, ok := s.instances.(reviewedWorkspaceDataStore) + if !ok { + return nil, ErrStaleWorkspaceDataReview + } + if err := transactional.WithTransaction(ctx, func(tx *sqlx.Tx) error { + return reviewed.EnableWorkspaceDataReviewedTx(ctx, tx, preview.Canvas.PluginInstanceID, preview.Canvas.WorkspaceID, userID, grants, expectedReleaseID, expectedPermissionDigest, expectedGrantGeneration) + }); err != nil { + return nil, err + } + recordCanvasScopeTransition(canvasScopeReviewedUpgrade, canvasScopeEnabled) + updated, err := s.Get(ctx, canvasID) + if err != nil { + return nil, err + } + s.mu.Lock() + publisher := s.publisher + s.mu.Unlock() + publishEvent(ctx, publisher, lifecycleEvent(EventWorkspaceDataEnabled, *updated)) + return updated, nil +} + // PromoteCanvas atomically approves the active declaration and changes the // instance scope with the canvas metadata provenance. The durable store // transaction keeps both lifecycle authorities consistent after a crash. @@ -605,6 +720,11 @@ func (s *Service) PromoteCanvasReviewed(ctx context.Context, canvasID, userID, e if err != nil { return nil, err } + result := canvasScopePreserved + if preview.CurrentDataScope != ScopeWorkspace && updated.DataScopeKind == ScopeWorkspace { + result = canvasScopeExpanded + } + recordCanvasScopeTransition(canvasScopePromotion, result) s.mu.Lock() publisher := s.publisher s.mu.Unlock() @@ -675,7 +795,7 @@ func (s *Service) ApproveRelease(ctx context.Context, canvasID, releaseID, userI if err != nil { return nil, err } - if err := store.ApproveRelease(ctx, canvas.PluginInstanceID, releaseID, userID, grantsForManifest(ManifestPermissions(m), userID, canvas.ScopeKind)); err != nil { + if err := store.ApproveRelease(ctx, canvas.PluginInstanceID, releaseID, userID, grantsForManifest(ManifestPermissions(m), userID, canvas.DataScopeKind)); err != nil { return nil, err } if err := prunePublishedReleases(ctx, store, canvas.PluginInstanceID); err != nil { @@ -768,7 +888,7 @@ func (s *Service) RollbackRelease(ctx context.Context, canvasID, releaseID strin if err != nil { return nil, err } - if !permissionsFit(ManifestPermissions(m), instance.ScopeKind, grants) { + if !permissionsFit(ManifestPermissions(m), instance.EffectiveDataScopeKind(), grants) { return nil, fmt.Errorf("%w: rollback requires permission review", plugininstances.ErrInvalidRelease) } if err := store.ActivateRelease(ctx, instance.ID, release.ID); err != nil { @@ -1012,11 +1132,11 @@ func invalidLocalCanvasManifest() error { } func creationAuthorityPolicyIsCurrent(authority CreationAuthority) bool { - return authority.PolicyVersion == CreationAuthorityPolicyVersion && authority.ConsumedAt.IsZero() && authority.OwnerUserID != "" && authority.CreatingSessionID != "" && authority.TaskID != "" + return creationAuthorityPolicyVersionSupported(authority.PolicyVersion) && authority.ConsumedAt.IsZero() && authority.OwnerUserID != "" && authority.CreatingSessionID != "" && authority.TaskID != "" } func creationAuthorityInstanceMatches(authority CreationAuthority, instance plugininstances.Instance, grants []plugininstances.Grant, releases []plugininstances.Release) bool { - return instance.SourceKind == plugininstances.SourceLocalCanvas && instance.ScopeKind == ScopeTask && instance.Status == StatusPending && instance.ActiveReleaseID == "" && instance.GrantGeneration == 0 && instance.TaskID == authority.TaskID && len(grants) == 0 && len(releases) == 0 + return instance.SourceKind == plugininstances.SourceLocalCanvas && instance.ScopeKind == ScopeTask && instance.EffectiveDataScopeKind() == ScopeTask && instance.Status == StatusPending && instance.ActiveReleaseID == "" && instance.GrantGeneration == 0 && instance.TaskID == authority.TaskID && len(grants) == 0 && len(releases) == 0 } func creationAuthoritySourceMatches(authority CreationAuthority, instance plugininstances.Instance, request PublishRequest) bool { diff --git a/apps/backend/internal/canvas/authoring_test.go b/apps/backend/internal/canvas/authoring_test.go index 3debf4dc9d..53e6e0f9ed 100644 --- a/apps/backend/internal/canvas/authoring_test.go +++ b/apps/backend/internal/canvas/authoring_test.go @@ -85,6 +85,9 @@ func TestCanvasCreationAuthorityFirstPublish(t *testing.T) { if err != nil { t.Fatalf("get instance: %v", err) } + if instance.DataScopeKind != "" || instance.EffectiveDataScopeKind() != ScopeTask { + t.Fatalf("new owner-authorized canvas scope = explicit %q/effective %q, want task until its first publication is consumed", instance.DataScopeKind, instance.EffectiveDataScopeKind()) + } pkg := testCanvasPackage("owner-first", []string{"tasks"}) pkg.Manifest.Capabilities.APIWrite = []string{"messages"} pkg.Manifest.Capabilities.Events = []string{"task.updated"} @@ -114,6 +117,9 @@ func TestCanvasCreationAuthorityFirstPublish(t *testing.T) { if updated.ActiveReleaseID != result.Release.ID || updated.PluginID != result.Release.PluginID { t.Fatalf("updated instance = %+v, want active release and package identity", updated) } + if updated.ScopeKind != ScopeTask || updated.EffectiveDataScopeKind() != ScopeWorkspace { + t.Fatalf("updated scopes = placement %q, effective data %q, want task and workspace", updated.ScopeKind, updated.EffectiveDataScopeKind()) + } grants, err := instanceStore.ListGrants(context.Background(), created.PluginInstanceID) if err != nil { t.Fatalf("list initial grants: %v", err) @@ -136,8 +142,8 @@ func TestCanvasCreationAuthorityFirstPublish(t *testing.T) { if grant.PermissionKind == "network" { key += grant.NetworkOrigin } - if _, ok := wantGrants[key]; !ok || grant.ScopeCeiling != ScopeTask || grant.ApprovedBy != "owner-1" { - t.Fatalf("initial grant = %+v, want task-scoped owner grant", grant) + if _, ok := wantGrants[key]; !ok || grant.ScopeCeiling != ScopeWorkspace || grant.ApprovedBy != "owner-1" { + t.Fatalf("initial grant = %+v, want workspace-scoped owner grant", grant) } wantGrants[key] = true } @@ -156,6 +162,73 @@ func TestCanvasCreationAuthorityFirstPublish(t *testing.T) { } } +func TestCanvasCreationAuthorityVersionOneRetainsTaskDataCeiling(t *testing.T) { + service, instanceStore, pool := newCanvasService(t) + created := createCanvas(t, service, CreateCanvasRequest{ + WorkspaceID: "workspace-1", TaskID: "task-1", Title: "Legacy task authority", + CreatedBySessionID: "session-1", OwnerUserID: "owner-1", + }) + if _, err := pool.Writer().Exec(`UPDATE canvas_creation_authority SET policy_version = 1 WHERE canvas_id = ?`, created.ID); err != nil { + t.Fatalf("set legacy authority version: %v", err) + } + instance, err := instanceStore.Get(context.Background(), created.PluginInstanceID) + if err != nil { + t.Fatalf("get instance: %v", err) + } + result, err := service.PublishPackage(context.Background(), PublishRequest{ + CanvasID: created.ID, Package: testCanvasPackage("legacy-task-authority", []string{"tasks"}), + Artifact: webapp.Artifact{Digest: "legacy-task-authority", RelativePath: "releases/legacy-task-authority", Bytes: 1}, + ExpectedAuthority: instance.PublishAuthority(), SourceActorKind: "agent", SourceUserID: "owner-1", + SourceTaskID: "task-1", SourceSessionID: "session-1", + }) + if err != nil { + t.Fatalf("publish with legacy authority: %v", err) + } + if !result.Activated { + t.Fatalf("legacy authority result = %+v, want active task-scoped first release", result) + } + updated, err := instanceStore.Get(context.Background(), created.PluginInstanceID) + if err != nil { + t.Fatalf("get published instance: %v", err) + } + if updated.EffectiveDataScopeKind() != ScopeTask { + t.Fatalf("legacy authority effective data scope = %q, want task", updated.EffectiveDataScopeKind()) + } + grants, err := instanceStore.ListGrants(context.Background(), created.PluginInstanceID) + if err != nil { + t.Fatalf("list legacy initial grants: %v", err) + } + if len(grants) != 1 || grants[0].ScopeCeiling != ScopeTask { + t.Fatalf("legacy authority grants = %+v, want one task-scoped task-read grant", grants) + } +} + +func TestPublishCanvasAuthorityNormalizesLegacyNullDataScope(t *testing.T) { + service, instanceStore, _ := newCanvasService(t) + created := createCanvas(t, service, CreateCanvasRequest{WorkspaceID: "workspace-1", TaskID: "task-1", Title: "Legacy data scope"}) + canvas, err := service.Get(context.Background(), created.ID) + if err != nil { + t.Fatalf("get canvas: %v", err) + } + instance, err := instanceStore.Get(context.Background(), created.PluginInstanceID) + if err != nil { + t.Fatalf("get instance: %v", err) + } + expected := instance.PublishAuthority() + expected.DataScopeKind = canvas.DataScopeKind + result, err := service.PublishPackage(context.Background(), PublishRequest{ + CanvasID: created.ID, Package: testCanvasPackage("legacy-null-data-scope", []string{"tasks"}), + Artifact: webapp.Artifact{Digest: "legacy-null-data-scope", RelativePath: "releases/legacy-null-data-scope", Bytes: 1}, + ExpectedAuthority: expected, SourceActorKind: "agent", + }) + if err != nil { + t.Fatalf("publish with projected legacy data scope: %v", err) + } + if result.Activated || !result.PermissionRequired { + t.Fatalf("legacy task-scope publication = %+v, want task permission review", result) + } +} + func TestCanvasCreationAuthorityRejectsMismatchedSource(t *testing.T) { service, instanceStore, _ := newCanvasService(t) created := createCanvas(t, service, CreateCanvasRequest{ @@ -185,12 +258,26 @@ func TestCanvasCreationAuthorityRejectsMismatchedSource(t *testing.T) { if result.Activated || !result.PermissionRequired { t.Fatalf("mismatched owner result = %+v, want manual review", result) } + updated, err := instanceStore.Get(context.Background(), created.PluginInstanceID) + if err != nil { + t.Fatalf("get mismatched-owner instance: %v", err) + } + if updated.EffectiveDataScopeKind() != ScopeTask { + t.Fatalf("mismatched-owner effective data scope = %q, want task", updated.EffectiveDataScopeKind()) + } + approved, err := service.ApproveRelease(context.Background(), created.ID, result.Release.ID, "member-1") + if err != nil { + t.Fatalf("member approves task-scoped fallback release: %v", err) + } + if approved.DataScopeKind != ScopeTask { + t.Fatalf("member approval widened effective data scope to %q, want task", approved.DataScopeKind) + } grants, err := instanceStore.ListGrants(context.Background(), created.PluginInstanceID) if err != nil { - t.Fatalf("list mismatched-owner grants: %v", err) + t.Fatalf("list fallback grants: %v", err) } - if len(grants) != 0 { - t.Fatalf("mismatched-owner grants = %+v, want none", grants) + if len(grants) != 1 || grants[0].ScopeCeiling != ScopeTask { + t.Fatalf("member approval grants = %+v, want one task-scoped grant", grants) } authority, err := service.repo.GetCreationAuthority(context.Background(), created.ID) if err != nil { @@ -252,12 +339,22 @@ func TestCanvasCreationAuthorityDoesNotApproveLaterIncrease(t *testing.T) { if second.Activated || !second.PermissionRequired { t.Fatalf("later owner release = %+v, want pending review", second) } + if _, err := service.ApproveRelease(context.Background(), created.ID, second.Release.ID, "member-1"); !errors.Is(err, plugininstances.ErrWorkspaceOwnerRequired) { + t.Fatalf("non-owner approval of workspace-data increase = %v, want ErrWorkspaceOwnerRequired", err) + } + approved, err := service.ApproveRelease(context.Background(), created.ID, second.Release.ID, "owner-1") + if err != nil { + t.Fatalf("owner approves workspace-data increase: %v", err) + } + if approved.ActiveReleaseID != second.Release.ID { + t.Fatalf("active release after owner approval = %q, want %q", approved.ActiveReleaseID, second.Release.ID) + } current, err := instanceStore.Get(context.Background(), created.PluginInstanceID) if err != nil { t.Fatalf("get current instance: %v", err) } - if current.ActiveReleaseID != first.Release.ID { - t.Fatalf("active release after later increase = %q, want %q", current.ActiveReleaseID, first.Release.ID) + if current.ActiveReleaseID != second.Release.ID { + t.Fatalf("active release after owner-approved increase = %q, want %q", current.ActiveReleaseID, second.Release.ID) } } diff --git a/apps/backend/internal/canvas/release_projection.go b/apps/backend/internal/canvas/release_projection.go index 372f3a74fa..e3c0388272 100644 --- a/apps/backend/internal/canvas/release_projection.go +++ b/apps/backend/internal/canvas/release_projection.go @@ -82,7 +82,7 @@ func effectiveGrantProjection(instance plugininstances.Instance, summary Permiss declared := permissionKeys(summary) result := make([]GrantProjection, 0, len(grants)) for _, grant := range grants { - if !grantScopeCovers(grant.ScopeCeiling, instance.ScopeKind) { + if !grantScopeCovers(grant.ScopeCeiling, instance.EffectiveDataScopeKind()) { continue } permission := grant.PermissionKind + ":" + grant.Resource diff --git a/apps/backend/internal/canvas/repository.go b/apps/backend/internal/canvas/repository.go index 28befb0eb2..6cccc1e770 100644 --- a/apps/backend/internal/canvas/repository.go +++ b/apps/backend/internal/canvas/repository.go @@ -304,7 +304,7 @@ WHERE a.canvas_id = ?`, // while rechecking its owner, session, task, policy, task scope, and current // workspace ownership. func (r *Repository) ConsumeCreationAuthorityTx(ctx context.Context, tx *sqlx.Tx, authority CreationAuthority, ownerUserID, sessionID, taskID string, allowUnownedWorkspace bool) error { - if authority.CanvasID == "" || ownerUserID == "" || sessionID == "" || taskID == "" { + if authority.CanvasID == "" || !creationAuthorityPolicyVersionSupported(authority.PolicyVersion) || ownerUserID == "" || sessionID == "" || taskID == "" { return ErrStaleCanvasPublish } now := time.Now().UTC().Format(time.RFC3339Nano) @@ -321,7 +321,7 @@ WHERE canvas_creation_authority.canvas_id = ? AND owner_user_id = ? AND creating AND m.task_id = ? AND (w.owner_id = ? OR (? AND COALESCE(w.owner_id, '') = '')) )`, - ), now, authority.CanvasID, ownerUserID, sessionID, CreationAuthorityPolicyVersion, taskID, ownerUserID, allowUnownedWorkspace) + ), now, authority.CanvasID, ownerUserID, sessionID, authority.PolicyVersion, taskID, ownerUserID, allowUnownedWorkspace) if err != nil { return err } diff --git a/apps/backend/internal/canvas/scope_metrics.go b/apps/backend/internal/canvas/scope_metrics.go new file mode 100644 index 0000000000..6bd8342bfa --- /dev/null +++ b/apps/backend/internal/canvas/scope_metrics.go @@ -0,0 +1,24 @@ +package canvas + +import "expvar" + +var canvasDataScopeTransitions = expvar.NewMap("canvas_data_scope_transition_total") + +type canvasScopeTransition string +type canvasScopeTransitionResult string + +const ( + canvasScopeFirstPublication canvasScopeTransition = "first_publication" + canvasScopeReviewedUpgrade canvasScopeTransition = "reviewed_upgrade" + canvasScopePromotion canvasScopeTransition = "promotion" +) + +const ( + canvasScopeEnabled canvasScopeTransitionResult = "data_scope_enabled" + canvasScopeExpanded canvasScopeTransitionResult = "data_scope_expanded" + canvasScopePreserved canvasScopeTransitionResult = "data_scope_preserved" +) + +func recordCanvasScopeTransition(transition canvasScopeTransition, result canvasScopeTransitionResult) { + canvasDataScopeTransitions.Add("transition="+string(transition)+";result="+string(result), 1) +} diff --git a/apps/backend/internal/canvas/scope_metrics_test.go b/apps/backend/internal/canvas/scope_metrics_test.go new file mode 100644 index 0000000000..2479ebbeed --- /dev/null +++ b/apps/backend/internal/canvas/scope_metrics_test.go @@ -0,0 +1,42 @@ +package canvas + +import ( + "expvar" + "strconv" + "testing" +) + +func canvasScopeMetricCount(t *testing.T, label string) int64 { + t.Helper() + value := expvar.Get("canvas_data_scope_transition_total") + if value == nil { + t.Fatal("canvas data-scope transition counter is not registered") + } + metrics, ok := value.(*expvar.Map) + if !ok { + t.Fatalf("canvas data-scope transition metric has type %T, want *expvar.Map", value) + } + var count int64 + metrics.Do(func(keyValue expvar.KeyValue) { + if keyValue.Key != label { + return + } + parsed, err := strconv.ParseInt(keyValue.Value.String(), 10, 64) + if err != nil { + t.Errorf("canvas data-scope metric %q value = %q: %v", label, keyValue.Value.String(), err) + return + } + count = parsed + }) + return count +} + +func TestRecordCanvasScopeTransitionUsesFixedLabels(t *testing.T) { + label := "transition=reviewed_upgrade;result=data_scope_enabled" + before := canvasScopeMetricCount(t, label) + recordCanvasScopeTransition(canvasScopeReviewedUpgrade, canvasScopeEnabled) + after := canvasScopeMetricCount(t, label) + if after-before != 1 { + t.Fatalf("transition counter delta = %d, want 1", after-before) + } +} diff --git a/apps/backend/internal/canvas/service.go b/apps/backend/internal/canvas/service.go index 51f9937fc2..a39810b2eb 100644 --- a/apps/backend/internal/canvas/service.go +++ b/apps/backend/internal/canvas/service.go @@ -737,7 +737,7 @@ func (s *Service) buildCanvas(ctx context.Context, metadata CanvasMetadata, inst return Canvas{}, err } if instance.ActiveReleaseID == "" { - return s.addPendingRelease(ctx, &canvas, instance.ID, instance.ScopeKind, grants) + return s.addPendingRelease(ctx, &canvas, instance.ID, instance.EffectiveDataScopeKind(), grants) } release, err := s.instances.GetRelease(ctx, instance.ActiveReleaseID) if errors.Is(err, plugininstances.ErrNotFound) { @@ -755,7 +755,7 @@ func (s *Service) buildCanvas(ctx context.Context, metadata CanvasMetadata, inst canvas.ActiveReleaseStatus = release.ValidationStatus canvas.ActiveReleaseError = release.ValidationError canvas.EffectiveGrants = effectiveGrantProjection(instance, ReleasePermissionSummary(release), grants) - canvas.ActiveRelease = releaseMetadata(release, instance.ScopeKind, grants) + canvas.ActiveRelease = releaseMetadata(release, instance.EffectiveDataScopeKind(), grants) return canvas, nil } @@ -866,6 +866,7 @@ func canvasFromMetadataInstance(metadata CanvasMetadata, instance plugininstance TaskID: metadata.TaskID, OriginTaskID: metadata.OriginTaskID, ScopeKind: instance.ScopeKind, + DataScopeKind: instance.EffectiveDataScopeKind(), Title: metadata.Title, CreatedBySessionID: metadata.CreatedBySessionID, PromotedByUserID: metadata.PromotedByUserID, @@ -896,6 +897,7 @@ func lifecycleEvent(eventType string, canvas Canvas) LifecycleEvent { WorkspaceID: canvas.WorkspaceID, TaskID: canvas.TaskID, ScopeKind: canvas.ScopeKind, + DataScopeKind: canvas.DataScopeKind, Status: canvas.Status, ActiveReleaseID: canvas.ActiveReleaseID, ActiveReleaseStatus: canvas.ActiveReleaseStatus, diff --git a/apps/backend/internal/canvas/types.go b/apps/backend/internal/canvas/types.go index 93b60273a3..a3bd09b7ea 100644 --- a/apps/backend/internal/canvas/types.go +++ b/apps/backend/internal/canvas/types.go @@ -16,14 +16,26 @@ import ( const ( // CanvasPluginID identifies the synthetic plugin binding used by local // canvas instances. The package identity remains in the release metadata. - CanvasPluginID = "kandev.canvas" - MaxTitleLength = 200 - CreationAuthorityPolicyVersion = 1 + CanvasPluginID = "kandev.canvas" + MaxTitleLength = 200 + creationAuthorityTaskPolicyVersion = 1 + CreationAuthorityPolicyVersion = 2 MaxTaskCanvases = plugininstances.MaxTaskInstances MaxWorkspaceCanvases = plugininstances.MaxWorkspaceInstances ) +func creationAuthorityPolicyVersionSupported(version int) bool { + return version == creationAuthorityTaskPolicyVersion || version == CreationAuthorityPolicyVersion +} + +func creationAuthorityDataScope(authority CreationAuthority) string { + if authority.PolicyVersion == CreationAuthorityPolicyVersion { + return ScopeWorkspace + } + return ScopeTask +} + const ( ScopeTask = plugininstances.ScopeTask ScopeWorkspace = plugininstances.ScopeWorkspace @@ -55,15 +67,16 @@ const ( ) var ( - ErrCanvasNotFound = errors.New("canvas not found") - ErrInvalidCanvas = errors.New("invalid canvas") - ErrInvalidCanvasState = errors.New("invalid canvas state") - ErrCanvasMetadataBroken = errors.New("canvas metadata is inconsistent with its plugin instance") - ErrCanvasNotConfigured = errors.New("canvas lifecycle is not configured") - ErrStalePromotionReview = plugininstances.ErrStalePromotionReview - ErrStaleCanvasEdit = plugininstances.ErrStaleCanvasEdit - ErrStaleCanvasPublish = plugininstances.ErrStaleCanvasPublish - ErrInvalidLifecycleState = plugininstances.ErrInvalidLifecycleState + ErrCanvasNotFound = errors.New("canvas not found") + ErrInvalidCanvas = errors.New("invalid canvas") + ErrInvalidCanvasState = errors.New("invalid canvas state") + ErrCanvasMetadataBroken = errors.New("canvas metadata is inconsistent with its plugin instance") + ErrCanvasNotConfigured = errors.New("canvas lifecycle is not configured") + ErrStalePromotionReview = plugininstances.ErrStalePromotionReview + ErrStaleWorkspaceDataReview = plugininstances.ErrStaleWorkspaceDataReview + ErrStaleCanvasEdit = plugininstances.ErrStaleCanvasEdit + ErrStaleCanvasPublish = plugininstances.ErrStaleCanvasPublish + ErrInvalidLifecycleState = plugininstances.ErrInvalidLifecycleState // These aliases preserve the stable plugin admission errors while keeping // the canvas package convenient for service callers and API adapters. @@ -109,6 +122,7 @@ type Canvas struct { TaskID string `json:"task_id,omitempty"` OriginTaskID string `json:"origin_task_id,omitempty"` ScopeKind string `json:"scope_kind"` + DataScopeKind string `json:"data_scope_kind"` Title string `json:"title"` CreatedBySessionID string `json:"created_by_session_id,omitempty"` PromotedByUserID string `json:"promoted_by_user_id,omitempty"` @@ -215,6 +229,7 @@ type LifecycleEvent struct { WorkspaceID string `json:"workspace_id"` TaskID string `json:"task_id,omitempty"` ScopeKind string `json:"scope_kind"` + DataScopeKind string `json:"data_scope_kind"` Status string `json:"status"` ActiveReleaseID string `json:"active_release_id,omitempty"` ActiveReleaseStatus string `json:"active_release_status,omitempty"` @@ -226,6 +241,7 @@ const ( EventReleaseActivated = "canvas.release.activated" EventReleasePermissionRequired = "canvas.release.permission_required" EventPromoted = "canvas.promoted" + EventWorkspaceDataEnabled = "canvas.workspace_data_enabled" EventArchived = "canvas.archived" EventRestored = "canvas.restored" EventRemoved = "canvas.removed" diff --git a/apps/backend/internal/canvas/workspace_data_scope_test.go b/apps/backend/internal/canvas/workspace_data_scope_test.go new file mode 100644 index 0000000000..a48c437535 --- /dev/null +++ b/apps/backend/internal/canvas/workspace_data_scope_test.go @@ -0,0 +1,143 @@ +package canvas + +import ( + "context" + "errors" + "testing" + + "github.com/kandev/kandev/internal/plugins/instances" + "github.com/kandev/kandev/internal/plugins/webapp" +) + +func TestFirstPublicationRecordsWorkspaceDataTransition(t *testing.T) { + service, instanceStore, _ := newCanvasService(t) + created := createCanvas(t, service, CreateCanvasRequest{ + WorkspaceID: "workspace-1", TaskID: "task-1", Title: "Owner-authorized canvas", + CreatedBySessionID: "session-1", OwnerUserID: "owner-1", + }) + instance, err := instanceStore.Get(context.Background(), created.PluginInstanceID) + if err != nil { + t.Fatalf("get instance: %v", err) + } + metric := "transition=first_publication;result=data_scope_enabled" + before := canvasScopeMetricCount(t, metric) + result, err := service.PublishPackage(context.Background(), PublishRequest{ + CanvasID: created.ID, + Package: testCanvasPackage("workspace-metric", []string{"tasks"}), + Artifact: webapp.Artifact{Digest: "workspace-metric", RelativePath: "releases/workspace-metric", Bytes: 1}, + ExpectedAuthority: instance.PublishAuthority(), + SourceActorKind: "agent", + SourceUserID: "owner-1", + SourceTaskID: "task-1", + SourceSessionID: "session-1", + }) + if err != nil { + t.Fatalf("publish owner-authorized canvas: %v", err) + } + if !result.Activated { + t.Fatalf("publish result = %+v, want activated first release", result) + } + if after := canvasScopeMetricCount(t, metric); after-before != 1 { + t.Fatalf("first-publication scope metric delta = %d, want 1", after-before) + } +} + +func TestWorkspaceDataUpgradePreservesPlacementAndRequiresFreshOwnerReview(t *testing.T) { + service, instanceStore, _ := newCanvasService(t) + canvas := createCanvas(t, service, CreateCanvasRequest{ + WorkspaceID: "workspace-1", TaskID: "task-1", Title: "Legacy canvas", + }) + published := publishTestPackage(t, service, canvas.ID, "legacy-workspace-review", []string{"tasks"}) + if _, err := service.ApproveRelease(context.Background(), canvas.ID, published.Release.ID, "owner-1"); err != nil { + t.Fatalf("approve legacy task release: %v", err) + } + + preview, err := service.WorkspaceDataPreview(context.Background(), canvas.ID) + if err != nil { + t.Fatalf("workspace data preview: %v", err) + } + if preview.CurrentDataScopeKind != instances.ScopeTask || preview.TargetDataScopeKind != instances.ScopeWorkspace || + preview.ActiveReleaseID != published.Release.ID || preview.Permissions.Reads[0] != "tasks" { + t.Fatalf("workspace data preview = %+v, want task-to-workspace review of the active task read", preview) + } + if err := instanceStore.AddGrant(context.Background(), instances.Grant{ + InstanceID: canvas.PluginInstanceID, PermissionKind: "api_read", Resource: "tasks", + ScopeCeiling: instances.ScopeTask, ApprovedBy: "owner-1", + }); err != nil { + t.Fatalf("advance grant generation: %v", err) + } + if err := instanceStore.AddGrant(context.Background(), instances.Grant{ + InstanceID: canvas.PluginInstanceID, PermissionKind: "api_write", Resource: "messages", + ScopeCeiling: instances.ScopeTask, ApprovedBy: "owner-1", + }); err != nil { + t.Fatalf("seed stale task-ceiling grant: %v", err) + } + if _, err := service.EnableWorkspaceDataReviewed(context.Background(), canvas.ID, "owner-1", preview.ActiveReleaseID, preview.PermissionDigest, preview.GrantGeneration); !errors.Is(err, instances.ErrStaleWorkspaceDataReview) { + t.Fatalf("stale workspace data confirmation = %v, want ErrStaleWorkspaceDataReview", err) + } + unchanged, err := instanceStore.Get(context.Background(), canvas.PluginInstanceID) + if err != nil { + t.Fatalf("get unchanged instance: %v", err) + } + if unchanged.ScopeKind != ScopeTask || unchanged.EffectiveDataScopeKind() != instances.ScopeTask { + t.Fatalf("stale review changed scopes to placement %q/data %q", unchanged.ScopeKind, unchanged.EffectiveDataScopeKind()) + } + + preview, err = service.WorkspaceDataPreview(context.Background(), canvas.ID) + if err != nil { + t.Fatalf("refresh workspace data preview: %v", err) + } + if _, err := service.EnableWorkspaceDataReviewed(context.Background(), canvas.ID, "owner-2", preview.ActiveReleaseID, preview.PermissionDigest, preview.GrantGeneration); !errors.Is(err, instances.ErrWorkspaceOwnerRequired) { + t.Fatalf("foreign owner confirmation = %v, want ErrWorkspaceOwnerRequired", err) + } + metric := "transition=reviewed_upgrade;result=data_scope_enabled" + before := canvasScopeMetricCount(t, metric) + upgraded, err := service.EnableWorkspaceDataReviewed(context.Background(), canvas.ID, "owner-1", preview.ActiveReleaseID, preview.PermissionDigest, preview.GrantGeneration) + if err != nil { + t.Fatalf("owner enables workspace data: %v", err) + } + if after := canvasScopeMetricCount(t, metric); after-before != 1 { + t.Fatalf("reviewed-upgrade scope metric delta = %d, want 1", after-before) + } + if upgraded.ScopeKind != ScopeTask || upgraded.DataScopeKind != instances.ScopeWorkspace || + upgraded.TaskID != "task-1" || upgraded.ActiveReleaseID != published.Release.ID || upgraded.PromotedAt != nil { + t.Fatalf("upgraded canvas = %+v, want unchanged task placement and active release", upgraded) + } + grants, err := instanceStore.ListGrants(context.Background(), canvas.PluginInstanceID) + if err != nil { + t.Fatalf("list upgraded grants: %v", err) + } + if len(grants) != 1 || grants[0].PermissionKind != "api_read" || grants[0].Resource != "tasks" || grants[0].ScopeCeiling != instances.ScopeWorkspace || grants[0].ApprovedBy != "owner-1" { + t.Fatalf("upgraded grants = %+v, want one owner-approved workspace task grant", grants) + } +} + +func TestPromotionRecordsExpandedDataScope(t *testing.T) { + service, _, _ := newCanvasService(t) + created := createCanvas(t, service, CreateCanvasRequest{ + WorkspaceID: "workspace-1", TaskID: "task-1", Title: "Promoted canvas", + }) + published := publishTestPackage(t, service, created.ID, "promotion-scope-metric", []string{"tasks"}) + if _, err := service.ApproveRelease(context.Background(), created.ID, published.Release.ID, "owner-1"); err != nil { + t.Fatalf("approve task-scoped release: %v", err) + } + preview, err := service.PromotionPreview(context.Background(), created.ID) + if err != nil { + t.Fatalf("promotion preview: %v", err) + } + metric := "transition=promotion;result=data_scope_expanded" + before := canvasScopeMetricCount(t, metric) + promoted, err := service.PromoteCanvasReviewed( + context.Background(), created.ID, "owner-1", preview.ActiveReleaseID, + preview.PermissionDigest, preview.GrantGeneration, + ) + if err != nil { + t.Fatalf("promote task-scoped canvas: %v", err) + } + if promoted.ScopeKind != ScopeWorkspace || promoted.DataScopeKind != instances.ScopeWorkspace { + t.Fatalf("promoted scopes = placement %q, data %q, want workspace/workspace", promoted.ScopeKind, promoted.DataScopeKind) + } + if after := canvasScopeMetricCount(t, metric); after-before != 1 { + t.Fatalf("promotion scope metric delta = %d, want 1", after-before) + } +} diff --git a/apps/backend/internal/events/types.go b/apps/backend/internal/events/types.go index 5032eb8730..788b13e9f2 100644 --- a/apps/backend/internal/events/types.go +++ b/apps/backend/internal/events/types.go @@ -41,6 +41,7 @@ const ( CanvasReleaseActivated = "canvas.release.activated" CanvasReleasePermissionRequired = "canvas.release.permission_required" CanvasPromoted = "canvas.promoted" + CanvasWorkspaceDataEnabled = "canvas.workspace_data_enabled" CanvasArchived = "canvas.archived" CanvasRestored = "canvas.restored" CanvasRemoved = "canvas.removed" diff --git a/apps/backend/internal/gateway/websocket/canvas_notifications.go b/apps/backend/internal/gateway/websocket/canvas_notifications.go index e8d217e9a4..8975d30a63 100644 --- a/apps/backend/internal/gateway/websocket/canvas_notifications.go +++ b/apps/backend/internal/gateway/websocket/canvas_notifications.go @@ -44,6 +44,7 @@ func RegisterCanvasNotifications(ctx context.Context, eventBus bus.EventBus, hub {events.CanvasReleaseActivated, ws.ActionCanvasReleaseActivated}, {events.CanvasReleasePermissionRequired, ws.ActionCanvasReleasePermissionRequired}, {events.CanvasPromoted, ws.ActionCanvasPromoted}, + {events.CanvasWorkspaceDataEnabled, ws.ActionCanvasWorkspaceDataEnabled}, {events.CanvasArchived, ws.ActionCanvasArchived}, {events.CanvasRestored, ws.ActionCanvasRestored}, {events.CanvasRemoved, ws.ActionCanvasRemoved}, diff --git a/apps/backend/internal/mcp/canvasskill/files/SKILL.md b/apps/backend/internal/mcp/canvasskill/files/SKILL.md index bca7c1a9d9..eed9a60fc3 100644 --- a/apps/backend/internal/mcp/canvasskill/files/SKILL.md +++ b/apps/backend/internal/mcp/canvasskill/files/SKILL.md @@ -27,12 +27,13 @@ core bundle again during the same authoring task. ID and source path. Read validation diagnostics and correct rejected source before publishing again. -The first valid release of a new owner-created task canvas uses the returned +The first valid release of a new owner-authorized task canvas uses the returned initial permission policy. It can activate without a second approval for its -declared supported task-scoped data, event, state, and exact HTTPS-origin -permissions. A later permission increase, imported package, or workspace -promotion still requires human review. Do not add a trust flag to the -manifest, and do not request permissions outside the policy. +declared supported permissions. Kandev data access is limited to the current +workspace, while the canvas remains placed in its creating task. Imported +packages and later permission increases need human review. Promotion changes +workspace navigation. Do not add a trust flag to the manifest, and do not +request permissions outside the policy. ## Core application contract @@ -66,7 +67,8 @@ Use the returned `manifest_scaffold` as the starting point. New manifests use least one `task-canvas` or `workspace-canvas` placement. Declare only the `api_read`, `api_write`, `events`, `state`, and `network_origins` permissions that the application needs. The owner-authorized first release can receive -only these supported task-scoped grants. The entry and all relative assets +only its declared supported permissions, with Kandev data limited to the +current workspace. The entry and all relative assets must be in the published package. ## Browser protocol summary diff --git a/apps/backend/internal/mcp/canvasskill/files/references/browser-api.md b/apps/backend/internal/mcp/canvasskill/files/references/browser-api.md index df907de1ef..848c45cdbb 100644 --- a/apps/backend/internal/mcp/canvasskill/files/references/browser-api.md +++ b/apps/backend/internal/mcp/canvasskill/files/references/browser-api.md @@ -24,6 +24,7 @@ show a retry state for transient reads. "web_app_key": "main", "placement": "task-canvas", "scope_kind": "task", + "data_scope_kind": "workspace", "workspace_id": "workspace-id", "task_id": "task-id", "session_id": "session-id", @@ -34,7 +35,9 @@ show a retry state for transient reads. Scope identifiers are omitted when they do not apply. `capabilities` contains the effective, approved permission keys. It is not a replacement for handling -permission errors from later requests. +permission errors from later requests. `scope_kind` describes canvas placement +and lifecycle. `data_scope_kind` describes the Kandev data boundary. A task +canvas can have `scope_kind: "task"` and `data_scope_kind: "workspace"`. ## Data routes @@ -51,9 +54,11 @@ All data responses use JSON. Collection responses use this envelope: and accepts any value from 1 to 200. Unlike the gRPC Host API, this surface does not clamp an out-of-range `limit` into that window: a supplied value outside 1..200 is rejected outright with HTTP 400 `invalid_request`, and only -an omitted `limit` falls back to the 50-row default. A task-scoped canvas is -restricted to its task. A workspace-scoped canvas is restricted to its -workspace. +an omitted `limit` falls back to the 50-row default. Task data scope returns +only the bound task. Workspace data scope returns tasks from the instance's +trusted workspace, including when the canvas is still placed in one task. A +`workspace_id` filter must match that workspace; another workspace is denied. +Follow `page_info.next_cursor` until it is omitted to load every page. | Method | Route | Permission | Use | | --- | --- | --- | --- | diff --git a/apps/backend/internal/mcp/canvasskill/files/references/manifest.md b/apps/backend/internal/mcp/canvasskill/files/references/manifest.md index 68db109f4b..ce06417c77 100644 --- a/apps/backend/internal/mcp/canvasskill/files/references/manifest.md +++ b/apps/backend/internal/mcp/canvasskill/files/references/manifest.md @@ -67,10 +67,11 @@ the old direct requests cannot continue under the old binding. Capabilities are declarations, not grants. Use `api_read` for `tasks` and `workflows`, `api_write` for `tasks` and `messages`, `events` for event -subscriptions, and `state: true` for instance state. The owner-authorized -first release can receive only these supported task-scoped grants. The -operator reviews a later permission increase, an imported package, or a -workspace promotion. Request only the capabilities used by the application. +subscriptions, and `state: true` for instance state. The first owner-authorized +release can receive only its declared supported permissions, with Kandev data +limited to the current workspace. The canvas remains placed in its creating +task until promotion. Imported packages and later permission increases need +human review. Request only the capabilities used by the application. The package must include the declared entry document and every local asset it references. Bundle executable dependencies. A build tool, package manager, or diff --git a/apps/backend/internal/plugins/instances/store.go b/apps/backend/internal/plugins/instances/store.go index b51117e7de..81c30a592e 100644 --- a/apps/backend/internal/plugins/instances/store.go +++ b/apps/backend/internal/plugins/instances/store.go @@ -21,6 +21,7 @@ import ( "github.com/jmoiron/sqlx" "github.com/kandev/kandev/internal/db" + userstore "github.com/kandev/kandev/internal/user/store" ) const ( @@ -66,6 +67,8 @@ var ( ErrInstallationStorageLimit = errors.New("canvas installation storage limit exceeded") ErrInvalidRelease = errors.New("invalid plugin release") ErrStalePromotionReview = errors.New("canvas promotion review is stale") + ErrStaleWorkspaceDataReview = errors.New("canvas workspace data review is stale") + ErrWorkspaceOwnerRequired = errors.New("current workspace owner is required") ErrStaleCanvasEdit = errors.New("canvas edit base release is stale") ErrStaleCanvasPublish = errors.New("canvas publish authority is stale") ErrInvalidLifecycleState = errors.New("plugin instance lifecycle state does not allow this operation") @@ -100,6 +103,16 @@ func ValidateScope(kind string, ids ScopeIdentifiers) error { return nil } +func validateDataScope(sourceKind, placementScope, dataScope string) error { + if dataScope == "" || dataScope == placementScope { + return nil + } + if sourceKind == SourceLocalCanvas && placementScope == ScopeTask && dataScope == ScopeWorkspace { + return nil + } + return ErrInvalidScope +} + func scopeIdentifiersFor(kind string) map[string]bool { switch kind { case ScopeInstance: @@ -122,6 +135,7 @@ type Instance struct { PluginID string SourceKind string ScopeKind string + DataScopeKind string WorkspaceID string TaskID string SessionID string @@ -133,6 +147,21 @@ type Instance struct { UpdatedAt time.Time } +// EffectiveDataScopeKind preserves the placement-bound behavior of stored +// instances whose nullable data scope predates the independent scope field. +func (i Instance) EffectiveDataScopeKind() string { + return EffectiveDataScopeKind(i.ScopeKind, i.DataScopeKind) +} + +// EffectiveDataScopeKind resolves legacy rows and runtime bindings that do +// not carry a separate data scope. +func EffectiveDataScopeKind(placementScope, dataScope string) string { + if dataScope == "" { + return placementScope + } + return dataScope +} + // PublishAuthority is the durable identity captured when a trusted authoring // request is authorized. A publish must match every field in this snapshot at // the release insertion boundary so a scope transition, archive, or competing @@ -141,6 +170,7 @@ type Instance struct { type PublishAuthority struct { InstanceID string ScopeKind string + DataScopeKind string WorkspaceID string TaskID string SessionID string @@ -152,14 +182,14 @@ type PublishAuthority struct { func (i Instance) PublishAuthority() PublishAuthority { return PublishAuthority{ - InstanceID: i.ID, ScopeKind: i.ScopeKind, WorkspaceID: i.WorkspaceID, + InstanceID: i.ID, ScopeKind: i.ScopeKind, DataScopeKind: i.EffectiveDataScopeKind(), WorkspaceID: i.WorkspaceID, TaskID: i.TaskID, SessionID: i.SessionID, RepositoryID: i.RepositoryID, Status: i.Status, ActiveReleaseID: i.ActiveReleaseID, GrantGeneration: i.GrantGeneration, } } func (a PublishAuthority) IsZero() bool { - return a.InstanceID == "" && a.ScopeKind == "" && a.WorkspaceID == "" && + return a.InstanceID == "" && a.ScopeKind == "" && a.DataScopeKind == "" && a.WorkspaceID == "" && a.TaskID == "" && a.SessionID == "" && a.RepositoryID == "" && a.Status == "" && a.ActiveReleaseID == "" && a.GrantGeneration == 0 } @@ -247,6 +277,7 @@ CREATE TABLE IF NOT EXISTS plugin_instances ( plugin_id TEXT NOT NULL, source_kind TEXT NOT NULL, scope_kind TEXT NOT NULL, + data_scope_kind TEXT, workspace_id TEXT NOT NULL DEFAULT '', task_id TEXT NOT NULL DEFAULT '', session_id TEXT NOT NULL DEFAULT '', @@ -321,6 +352,9 @@ func (s *Store) initSchema() error { return err } } + if _, err := s.db.Exec(`ALTER TABLE plugin_instances ADD COLUMN data_scope_kind TEXT`); err != nil && !db.IsDuplicateColumnError(err) { + return err + } return nil } @@ -358,6 +392,9 @@ func (s *Store) CreateTx(ctx context.Context, tx *sqlx.Tx, instance Instance) er if err := ValidateScope(instance.ScopeKind, ScopeIdentifiers{WorkspaceID: instance.WorkspaceID, TaskID: instance.TaskID, SessionID: instance.SessionID, RepositoryID: instance.RepositoryID}); err != nil { return err } + if err := validateDataScope(instance.SourceKind, instance.ScopeKind, instance.DataScopeKind); err != nil { + return err + } if err := s.checkAdmission(ctx, tx, instance.ScopeKind, instance.WorkspaceID, instance.TaskID); err != nil { return err } @@ -370,8 +407,8 @@ func (s *Store) CreateTx(ctx context.Context, tx *sqlx.Tx, instance Instance) er updatedAt = createdAt } _, err := tx.ExecContext(ctx, tx.Rebind(` -INSERT INTO plugin_instances (id, plugin_id, source_kind, scope_kind, workspace_id, task_id, session_id, repository_id, status, active_release_id, grant_generation, created_at, updated_at) -VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`), instance.ID, instance.PluginID, instance.SourceKind, instance.ScopeKind, instance.WorkspaceID, instance.TaskID, instance.SessionID, instance.RepositoryID, instance.Status, instance.ActiveReleaseID, instance.GrantGeneration, createdAt.Format(time.RFC3339Nano), updatedAt.Format(time.RFC3339Nano)) +INSERT INTO plugin_instances (id, plugin_id, source_kind, scope_kind, data_scope_kind, workspace_id, task_id, session_id, repository_id, status, active_release_id, grant_generation, created_at, updated_at) +VALUES (?, ?, ?, ?, NULLIF(?, ''), ?, ?, ?, ?, ?, ?, ?, ?, ?)`), instance.ID, instance.PluginID, instance.SourceKind, instance.ScopeKind, instance.DataScopeKind, instance.WorkspaceID, instance.TaskID, instance.SessionID, instance.RepositoryID, instance.Status, instance.ActiveReleaseID, instance.GrantGeneration, createdAt.Format(time.RFC3339Nano), updatedAt.Format(time.RFC3339Nano)) if err != nil { return err } @@ -385,7 +422,7 @@ func (s *Store) ListBySource(ctx context.Context, sourceKind string, includeRemo if strings.TrimSpace(sourceKind) == "" { return nil, ErrInvalidScope } - query := `SELECT id, plugin_id, source_kind, scope_kind, workspace_id, task_id, session_id, repository_id, status, active_release_id, grant_generation, created_at, updated_at FROM plugin_instances WHERE source_kind = ?` + query := `SELECT id, plugin_id, source_kind, scope_kind, data_scope_kind, workspace_id, task_id, session_id, repository_id, status, active_release_id, grant_generation, created_at, updated_at FROM plugin_instances WHERE source_kind = ?` args := []any{sourceKind} if !includeRemoved { query += ` AND status <> ?` @@ -453,7 +490,7 @@ func (s *Store) checkAdmissionExcept(ctx context.Context, tx *sqlx.Tx, scopeKind func (s *Store) Get(ctx context.Context, id string) (Instance, error) { var row instanceRow - err := s.ro.GetContext(ctx, &row, s.ro.Rebind(`SELECT id, plugin_id, source_kind, scope_kind, workspace_id, task_id, session_id, repository_id, status, active_release_id, grant_generation, created_at, updated_at FROM plugin_instances WHERE id = ?`), id) + err := s.ro.GetContext(ctx, &row, s.ro.Rebind(`SELECT id, plugin_id, source_kind, scope_kind, data_scope_kind, workspace_id, task_id, session_id, repository_id, status, active_release_id, grant_generation, created_at, updated_at FROM plugin_instances WHERE id = ?`), id) if errors.Is(err, sql.ErrNoRows) { return Instance{}, ErrNotFound } @@ -487,7 +524,7 @@ func (s *Store) GetMany(ctx context.Context, ids []string) ([]Instance, error) { for i, id := range unique { args[i] = id } - rows, err := s.ro.QueryxContext(ctx, s.ro.Rebind(`SELECT id, plugin_id, source_kind, scope_kind, workspace_id, task_id, session_id, repository_id, status, active_release_id, grant_generation, created_at, updated_at FROM plugin_instances WHERE id IN (`+placeholders+`)`), args...) + rows, err := s.ro.QueryxContext(ctx, s.ro.Rebind(`SELECT id, plugin_id, source_kind, scope_kind, data_scope_kind, workspace_id, task_id, session_id, repository_id, status, active_release_id, grant_generation, created_at, updated_at FROM plugin_instances WHERE id IN (`+placeholders+`)`), args...) if err != nil { return nil, err } @@ -508,7 +545,7 @@ func (s *Store) GetMany(ctx context.Context, ids []string) ([]Instance, error) { } func (s *Store) List(ctx context.Context, workspaceID string, includeRemoved bool) ([]Instance, error) { - query := `SELECT id, plugin_id, source_kind, scope_kind, workspace_id, task_id, session_id, repository_id, status, active_release_id, grant_generation, created_at, updated_at FROM plugin_instances WHERE workspace_id = ?` + query := `SELECT id, plugin_id, source_kind, scope_kind, data_scope_kind, workspace_id, task_id, session_id, repository_id, status, active_release_id, grant_generation, created_at, updated_at FROM plugin_instances WHERE workspace_id = ?` args := []any{workspaceID} if !includeRemoved { query += ` AND status <> ?` @@ -558,7 +595,7 @@ func (s *Store) Restore(ctx context.Context, id string) error { } defer func() { _ = tx.Rollback() }() var row instanceRow - if err := tx.GetContext(ctx, &row, tx.Rebind(`SELECT id, plugin_id, source_kind, scope_kind, workspace_id, task_id, session_id, repository_id, status, active_release_id, grant_generation, created_at, updated_at FROM plugin_instances WHERE id = ?`), id); err != nil { + if err := tx.GetContext(ctx, &row, tx.Rebind(`SELECT id, plugin_id, source_kind, scope_kind, data_scope_kind, workspace_id, task_id, session_id, repository_id, status, active_release_id, grant_generation, created_at, updated_at FROM plugin_instances WHERE id = ?`), id); err != nil { if errors.Is(err, sql.ErrNoRows) { return ErrNotFound } @@ -611,7 +648,7 @@ func (s *Store) SetScope(ctx context.Context, id, scopeKind string, ids ScopeIde } defer func() { _ = tx.Rollback() }() var current instanceRow - if err := tx.GetContext(ctx, ¤t, tx.Rebind(`SELECT id, plugin_id, source_kind, scope_kind, workspace_id, task_id, session_id, repository_id, status, active_release_id, grant_generation, created_at, updated_at FROM plugin_instances WHERE id = ?`), id); err != nil { + if err := tx.GetContext(ctx, ¤t, tx.Rebind(`SELECT id, plugin_id, source_kind, scope_kind, data_scope_kind, workspace_id, task_id, session_id, repository_id, status, active_release_id, grant_generation, created_at, updated_at FROM plugin_instances WHERE id = ?`), id); err != nil { if errors.Is(err, sql.ErrNoRows) { return ErrNotFound } @@ -620,6 +657,9 @@ func (s *Store) SetScope(ctx context.Context, id, scopeKind string, ids ScopeIde if current.Status == StatusRemoved { return ErrNotFound } + if err := validateDataScope(current.SourceKind, scopeKind, current.DataScopeKind.String); err != nil { + return err + } if err := s.checkAdmissionExcept(ctx, tx, scopeKind, ids.WorkspaceID, ids.TaskID, id); err != nil { return err } @@ -718,7 +758,7 @@ func (s *Store) ActivateReleaseTx(ctx context.Context, tx *sqlx.Tx, instanceID, } var current instanceRow if err := tx.GetContext(ctx, ¤t, tx.Rebind( - `SELECT id, plugin_id, source_kind, scope_kind, workspace_id, task_id, session_id, repository_id, status, active_release_id, grant_generation, created_at, updated_at FROM plugin_instances WHERE id = ?`, + `SELECT id, plugin_id, source_kind, scope_kind, data_scope_kind, workspace_id, task_id, session_id, repository_id, status, active_release_id, grant_generation, created_at, updated_at FROM plugin_instances WHERE id = ?`, ), instanceID); err != nil { if errors.Is(err, sql.ErrNoRows) { return ErrNotFound @@ -743,7 +783,7 @@ func (s *Store) ActivateReleaseTx(ctx context.Context, tx *sqlx.Tx, instanceID, } return err } - if release.ValidationStatus != ValidationValid || !declaredGrantsFitInstance(ctx, tx, instanceID, current.ScopeKind, release.DeclaredPermissionsJSON) { + if release.ValidationStatus != ValidationValid || !declaredGrantsFitInstance(ctx, tx, instanceID, EffectiveDataScopeKind(current.ScopeKind, current.DataScopeKind.String), release.DeclaredPermissionsJSON) { return ErrInvalidRelease } result, err := tx.ExecContext(ctx, tx.Rebind( @@ -1106,6 +1146,79 @@ func (s *Store) PromoteScopeAndGrantsReviewedTx(ctx context.Context, tx *sqlx.Tx return s.promoteScopeAndGrantsReviewedTx(ctx, tx, instanceID, workspaceID, approvedBy, grants, expectedReleaseID, expectedPermissionDigest, expectedGrantGeneration) } +// EnableWorkspaceDataReviewedTx widens only a retained task canvas's data +// scope after the current workspace owner confirms its current release. +func (s *Store) EnableWorkspaceDataReviewedTx(ctx context.Context, tx *sqlx.Tx, instanceID, workspaceID, approvedBy string, grants []Grant, expectedReleaseID, expectedPermissionDigest string, expectedGrantGeneration int64) error { + if err := validateWorkspaceDataReviewRequest(instanceID, workspaceID, approvedBy, expectedReleaseID, expectedPermissionDigest, expectedGrantGeneration); err != nil { + return err + } + current, declaredJSON, err := loadPromotionState(ctx, tx, instanceID) + if err != nil { + return err + } + if err := validateWorkspaceDataReviewState(current, declaredJSON, workspaceID, expectedReleaseID, expectedPermissionDigest, expectedGrantGeneration); err != nil { + return err + } + if err := validateGrantsForDeclaration(declaredJSON, ScopeWorkspace, grants); err != nil { + return err + } + if err := verifyWorkspaceOwnerTx(ctx, tx, workspaceID, approvedBy); err != nil { + return err + } + return updateWorkspaceDataScopeTx(ctx, tx, current, workspaceID, approvedBy, grants, expectedReleaseID, expectedGrantGeneration) +} + +func validateWorkspaceDataReviewRequest(instanceID, workspaceID, approvedBy, expectedReleaseID, expectedPermissionDigest string, expectedGrantGeneration int64) error { + if strings.TrimSpace(instanceID) == "" || strings.TrimSpace(workspaceID) == "" || strings.TrimSpace(approvedBy) == "" || + strings.TrimSpace(expectedReleaseID) == "" || strings.TrimSpace(expectedPermissionDigest) == "" || expectedGrantGeneration < 0 { + return ErrStaleWorkspaceDataReview + } + return nil +} + +func validateWorkspaceDataReviewState(current instanceRow, declaredJSON, workspaceID, expectedReleaseID, expectedPermissionDigest string, expectedGrantGeneration int64) error { + if current.SourceKind != SourceLocalCanvas || current.ScopeKind != ScopeTask || current.WorkspaceID != workspaceID || + current.TaskID == "" || current.Status != StatusActive || current.DataScopeKind.Valid && current.DataScopeKind.String == ScopeWorkspace { + return ErrInvalidLifecycleState + } + if current.ActiveReleaseID != expectedReleaseID || current.GrantGeneration != expectedGrantGeneration || + permissionDigest(declaredJSON) != expectedPermissionDigest || EffectiveDataScopeKind(current.ScopeKind, current.DataScopeKind.String) != ScopeTask { + return ErrStaleWorkspaceDataReview + } + return nil +} + +func verifyWorkspaceOwnerTx(ctx context.Context, tx *sqlx.Tx, workspaceID, approvedBy string) error { + ownerUpdate, err := tx.ExecContext(ctx, tx.Rebind( + `UPDATE workspaces SET owner_id = owner_id WHERE id = ? AND (COALESCE(owner_id, '') = ? OR (COALESCE(owner_id, '') = '' AND ? = ?))`, + ), workspaceID, approvedBy, approvedBy, userstore.DefaultUserID) + if err != nil { + return err + } + if affected, _ := ownerUpdate.RowsAffected(); affected == 0 { + return ErrWorkspaceOwnerRequired + } + return nil +} + +func updateWorkspaceDataScopeTx(ctx context.Context, tx *sqlx.Tx, current instanceRow, workspaceID, approvedBy string, grants []Grant, expectedReleaseID string, expectedGrantGeneration int64) error { + result, err := tx.ExecContext(ctx, tx.Rebind( + `UPDATE plugin_instances SET data_scope_kind = ?, grant_generation = grant_generation + 1, updated_at = ? WHERE id = ? AND source_kind = ? AND scope_kind = ? AND workspace_id = ? AND task_id = ? AND status = ? AND active_release_id = ? AND grant_generation = ? AND COALESCE(data_scope_kind, '') = ?`, + ), ScopeWorkspace, time.Now().UTC().Format(time.RFC3339Nano), current.ID, SourceLocalCanvas, ScopeTask, workspaceID, current.TaskID, StatusActive, expectedReleaseID, expectedGrantGeneration, current.DataScopeKind.String) + if err != nil { + return err + } + if affected, _ := result.RowsAffected(); affected == 0 { + return ErrStaleWorkspaceDataReview + } + if _, err := tx.ExecContext(ctx, tx.Rebind( + `DELETE FROM plugin_instance_grants WHERE plugin_instance_id = ?`, + ), current.ID); err != nil { + return err + } + return insertInstanceGrantsTx(ctx, tx, current.ID, approvedBy, grants) +} + func (s *Store) promoteScopeAndGrantsReviewedTx(ctx context.Context, tx *sqlx.Tx, instanceID, workspaceID, approvedBy string, grants []Grant, expectedReleaseID, expectedPermissionDigest string, expectedGrantGeneration int64) error { if err := validatePromotionRequest(instanceID, workspaceID, approvedBy); err != nil { return err @@ -1129,7 +1242,7 @@ func (s *Store) promoteScopeAndGrantsReviewedTx(ctx context.Context, tx *sqlx.Tx func loadPromotionState(ctx context.Context, tx *sqlx.Tx, instanceID string) (instanceRow, string, error) { var current instanceRow if err := tx.GetContext(ctx, ¤t, tx.Rebind( - `SELECT id, plugin_id, source_kind, scope_kind, workspace_id, task_id, session_id, repository_id, status, active_release_id, grant_generation, created_at, updated_at FROM plugin_instances WHERE id = ?`, + `SELECT id, plugin_id, source_kind, scope_kind, data_scope_kind, workspace_id, task_id, session_id, repository_id, status, active_release_id, grant_generation, created_at, updated_at FROM plugin_instances WHERE id = ?`, ), instanceID); err != nil { if errors.Is(err, sql.ErrNoRows) { return instanceRow{}, "", ErrNotFound @@ -1231,14 +1344,20 @@ func (s *Store) ApproveReleaseTx(ctx context.Context, tx *sqlx.Tx, instanceID, r if release.ValidationStatus != ValidationPendingPermission { return ErrInvalidRelease } - if err := validateGrantsForDeclaration(release.DeclaredPermissionsJSON, release.ScopeKind, grants); err != nil { + effectiveScope := EffectiveDataScopeKind(release.ScopeKind, release.DataScopeKind) + if release.SourceKind == SourceLocalCanvas && effectiveScope == ScopeWorkspace { + if err := verifyWorkspaceOwnerTx(ctx, tx, release.WorkspaceID, approvedBy); err != nil { + return err + } + } + if err := validateGrantsForDeclaration(release.DeclaredPermissionsJSON, effectiveScope, grants); err != nil { return err } existingGrants, err := loadInstanceGrants(ctx, tx, instanceID) if err != nil { return err } - if !grantsCoverDeclaration(release.DeclaredPermissionsJSON, release.ScopeKind, append(existingGrants, grants...)) { + if !grantsCoverDeclaration(release.DeclaredPermissionsJSON, effectiveScope, append(existingGrants, grants...)) { return ErrInvalidScope } if err := insertInstanceGrantsTx(ctx, tx, instanceID, approvedBy, grants); err != nil { @@ -1262,14 +1381,17 @@ type pendingReleaseRow struct { PluginID string `db:"plugin_id"` DeclaredPermissionsJSON string `db:"declared_permissions_json"` ValidationStatus string `db:"validation_status"` + SourceKind string `db:"source_kind"` + WorkspaceID string `db:"workspace_id"` ScopeKind string `db:"scope_kind"` + DataScopeKind string `db:"data_scope_kind"` Status string `db:"status"` } func loadPendingReleaseTx(ctx context.Context, tx *sqlx.Tx, instanceID, releaseID string) (pendingReleaseRow, error) { var release pendingReleaseRow if err := tx.GetContext(ctx, &release, tx.Rebind( - `SELECT r.plugin_id, r.declared_permissions_json, r.validation_status, i.scope_kind, i.status FROM plugin_releases r JOIN plugin_instances i ON i.id = r.instance_id WHERE r.id = ? AND r.instance_id = ?`, + `SELECT r.plugin_id, r.declared_permissions_json, r.validation_status, i.source_kind, i.workspace_id, i.scope_kind, COALESCE(i.data_scope_kind, '') AS data_scope_kind, i.status FROM plugin_releases r JOIN plugin_instances i ON i.id = r.instance_id WHERE r.id = ? AND r.instance_id = ?`, ), releaseID, instanceID); err != nil { if errors.Is(err, sql.ErrNoRows) { return pendingReleaseRow{}, ErrNotFound @@ -1383,8 +1505,8 @@ func (s *Store) CreateReleaseIfAuthorityTx(ctx context.Context, tx *sqlx.Tx, ins return ErrInvalidRelease } result, err := tx.ExecContext(ctx, tx.Rebind( - `UPDATE plugin_instances SET updated_at = updated_at WHERE id = ? AND scope_kind = ? AND workspace_id = ? AND task_id = ? AND session_id = ? AND repository_id = ? AND status = ? AND active_release_id = ? AND grant_generation = ?`, - ), instanceID, expected.ScopeKind, expected.WorkspaceID, expected.TaskID, expected.SessionID, expected.RepositoryID, expected.Status, expected.ActiveReleaseID, expected.GrantGeneration) + `UPDATE plugin_instances SET updated_at = updated_at WHERE id = ? AND scope_kind = ? AND COALESCE(NULLIF(data_scope_kind, ''), scope_kind) = ? AND workspace_id = ? AND task_id = ? AND session_id = ? AND repository_id = ? AND status = ? AND active_release_id = ? AND grant_generation = ?`, + ), instanceID, expected.ScopeKind, expected.DataScopeKind, expected.WorkspaceID, expected.TaskID, expected.SessionID, expected.RepositoryID, expected.Status, expected.ActiveReleaseID, expected.GrantGeneration) if err != nil { return err } @@ -1659,8 +1781,8 @@ func (s *Store) AddGrant(ctx context.Context, grant Grant) error { // canvas creation. It does not advance grant_generation because SetPluginIDTx // increments it before the public ActivateReleaseTx call in the same // activation transaction. The empty-grant case is valid. -func (s *Store) AddInitialGrantsTx(ctx context.Context, tx *sqlx.Tx, instanceID, approvedBy string, grants []Grant) error { - if strings.TrimSpace(instanceID) == "" || strings.TrimSpace(approvedBy) == "" { +func (s *Store) AddInitialGrantsTx(ctx context.Context, tx *sqlx.Tx, instanceID, releaseID, approvedBy string, grants []Grant) error { + if strings.TrimSpace(instanceID) == "" || strings.TrimSpace(releaseID) == "" || strings.TrimSpace(approvedBy) == "" { return ErrInvalidScope } var exists int @@ -1672,6 +1794,27 @@ func (s *Store) AddInitialGrantsTx(ctx context.Context, tx *sqlx.Tx, instanceID, if exists == 0 { return ErrNotFound } + var current struct { + ScopeKind string `db:"scope_kind"` + DataScopeKind string `db:"data_scope_kind"` + } + if err := tx.GetContext(ctx, ¤t, tx.Rebind( + `SELECT scope_kind, COALESCE(data_scope_kind, '') AS data_scope_kind FROM plugin_instances WHERE id = ?`, + ), instanceID); err != nil { + return err + } + var release struct { + DeclaredPermissionsJSON string `db:"declared_permissions_json"` + ValidationStatus string `db:"validation_status"` + } + if err := tx.GetContext(ctx, &release, tx.Rebind( + `SELECT declared_permissions_json, validation_status FROM plugin_releases WHERE id = ? AND instance_id = ?`, + ), releaseID, instanceID); err != nil || release.ValidationStatus != ValidationValid { + return ErrInvalidRelease + } + if err := validateGrantsForDeclaration(release.DeclaredPermissionsJSON, EffectiveDataScopeKind(current.ScopeKind, current.DataScopeKind), grants); err != nil { + return err + } var existing int if err := tx.GetContext(ctx, &existing, tx.Rebind( `SELECT COUNT(*) FROM plugin_instance_grants WHERE plugin_instance_id = ?`, @@ -1684,6 +1827,37 @@ func (s *Store) AddInitialGrantsTx(ctx context.Context, tx *sqlx.Tx, instanceID, return insertInstanceGrantsTx(ctx, tx, instanceID, approvedBy, grants) } +// SetInitialPublicationDataScopeTx widens a pending task canvas only after its +// creation authority has been consumed in the caller's transaction. +func (s *Store) SetInitialPublicationDataScopeTx(ctx context.Context, tx *sqlx.Tx, expected PublishAuthority, releaseID string) error { + if expected.InstanceID == "" || expected.ScopeKind != ScopeTask || expected.DataScopeKind != ScopeTask || + expected.Status != StatusPending || expected.ActiveReleaseID != "" || expected.WorkspaceID == "" || + expected.TaskID == "" || strings.TrimSpace(releaseID) == "" { + return ErrStaleCanvasPublish + } + result, err := tx.ExecContext(ctx, tx.Rebind( + `UPDATE plugin_instances +SET data_scope_kind = ?, grant_generation = grant_generation + 1, updated_at = ? +WHERE id = ? AND source_kind = ? AND scope_kind = ? AND COALESCE(NULLIF(data_scope_kind, ''), scope_kind) = ? + AND workspace_id = ? AND task_id = ? AND session_id = ? AND repository_id = ? + AND status = ? AND active_release_id = ? AND grant_generation = ? + AND NOT EXISTS (SELECT 1 FROM plugin_instance_grants WHERE plugin_instance_id = ?) + AND EXISTS (SELECT 1 FROM plugin_releases WHERE instance_id = ? AND id = ? AND validation_status = ?) + AND NOT EXISTS (SELECT 1 FROM plugin_releases WHERE instance_id = ? AND id <> ?)`), + ScopeWorkspace, time.Now().UTC().Format(time.RFC3339Nano), expected.InstanceID, SourceLocalCanvas, + expected.ScopeKind, expected.DataScopeKind, expected.WorkspaceID, expected.TaskID, expected.SessionID, + expected.RepositoryID, expected.Status, expected.ActiveReleaseID, expected.GrantGeneration, + expected.InstanceID, expected.InstanceID, releaseID, ValidationValid, expected.InstanceID, releaseID, + ) + if err != nil { + return err + } + if affected, _ := result.RowsAffected(); affected == 0 { + return ErrStaleCanvasPublish + } + return nil +} + func (s *Store) ListGrants(ctx context.Context, instanceID string) ([]Grant, error) { rows, err := s.ro.QueryxContext(ctx, s.ro.Rebind(`SELECT plugin_instance_id, permission_kind, resource, network_origin, scope_ceiling, approved_by, approved_at FROM plugin_instance_grants WHERE plugin_instance_id = ? ORDER BY permission_kind, resource, network_origin`), instanceID) if err != nil { @@ -2210,19 +2384,20 @@ func (s *Store) RemoveInstanceTx(ctx context.Context, tx *sqlx.Tx, id string) er } type instanceRow struct { - ID string `db:"id"` - PluginID string `db:"plugin_id"` - SourceKind string `db:"source_kind"` - ScopeKind string `db:"scope_kind"` - WorkspaceID string `db:"workspace_id"` - TaskID string `db:"task_id"` - SessionID string `db:"session_id"` - RepositoryID string `db:"repository_id"` - Status string `db:"status"` - ActiveReleaseID string `db:"active_release_id"` - GrantGeneration int64 `db:"grant_generation"` - CreatedAt string `db:"created_at"` - UpdatedAt string `db:"updated_at"` + ID string `db:"id"` + PluginID string `db:"plugin_id"` + SourceKind string `db:"source_kind"` + ScopeKind string `db:"scope_kind"` + DataScopeKind sql.NullString `db:"data_scope_kind"` + WorkspaceID string `db:"workspace_id"` + TaskID string `db:"task_id"` + SessionID string `db:"session_id"` + RepositoryID string `db:"repository_id"` + Status string `db:"status"` + ActiveReleaseID string `db:"active_release_id"` + GrantGeneration int64 `db:"grant_generation"` + CreatedAt string `db:"created_at"` + UpdatedAt string `db:"updated_at"` } func (r instanceRow) instance() (Instance, error) { @@ -2234,7 +2409,7 @@ func (r instanceRow) instance() (Instance, error) { if err != nil { return Instance{}, err } - return Instance{ID: r.ID, PluginID: r.PluginID, SourceKind: r.SourceKind, ScopeKind: r.ScopeKind, WorkspaceID: r.WorkspaceID, TaskID: r.TaskID, SessionID: r.SessionID, RepositoryID: r.RepositoryID, Status: r.Status, ActiveReleaseID: r.ActiveReleaseID, GrantGeneration: r.GrantGeneration, CreatedAt: created, UpdatedAt: updated}, nil + return Instance{ID: r.ID, PluginID: r.PluginID, SourceKind: r.SourceKind, ScopeKind: r.ScopeKind, DataScopeKind: r.DataScopeKind.String, WorkspaceID: r.WorkspaceID, TaskID: r.TaskID, SessionID: r.SessionID, RepositoryID: r.RepositoryID, Status: r.Status, ActiveReleaseID: r.ActiveReleaseID, GrantGeneration: r.GrantGeneration, CreatedAt: created, UpdatedAt: updated}, nil } type releaseRow struct { diff --git a/apps/backend/internal/plugins/instances/store_test.go b/apps/backend/internal/plugins/instances/store_test.go index 835ed0fd62..89c91eee03 100644 --- a/apps/backend/internal/plugins/instances/store_test.go +++ b/apps/backend/internal/plugins/instances/store_test.go @@ -14,6 +14,7 @@ import ( _ "github.com/mattn/go-sqlite3" "github.com/kandev/kandev/internal/db" + userstore "github.com/kandev/kandev/internal/user/store" ) func newTestStore(t *testing.T) *Store { @@ -44,6 +45,181 @@ func TestValidateScopeRequiresOnlyIdentifiersForScope(t *testing.T) { } } +func TestInstanceDataScopeIsIndependentAndDefaultsToPlacement(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + if err := store.Create(ctx, Instance{ + ID: "workspace-data", PluginID: "canvas-board", SourceKind: SourceLocalCanvas, + ScopeKind: ScopeTask, DataScopeKind: ScopeWorkspace, WorkspaceID: "workspace-1", + TaskID: "task-1", Status: StatusActive, + }); err != nil { + t.Fatalf("Create workspace-data task canvas: %v", err) + } + workspaceData, err := store.Get(ctx, "workspace-data") + if err != nil { + t.Fatalf("Get workspace-data task canvas: %v", err) + } + if workspaceData.ScopeKind != ScopeTask || workspaceData.EffectiveDataScopeKind() != ScopeWorkspace { + t.Fatalf("scopes = placement %q, effective data %q, want task and workspace", workspaceData.ScopeKind, workspaceData.EffectiveDataScopeKind()) + } + + if err := store.Create(ctx, Instance{ + ID: "legacy", PluginID: "canvas-board", SourceKind: SourceLocalCanvas, + ScopeKind: ScopeTask, WorkspaceID: "workspace-1", TaskID: "task-2", Status: StatusActive, + }); err != nil { + t.Fatalf("Create legacy task canvas: %v", err) + } + legacy, err := store.Get(ctx, "legacy") + if err != nil { + t.Fatalf("Get legacy task canvas: %v", err) + } + if legacy.DataScopeKind != "" || legacy.EffectiveDataScopeKind() != ScopeTask { + t.Fatalf("legacy scopes = explicit %q, effective %q, want empty and task", legacy.DataScopeKind, legacy.EffectiveDataScopeKind()) + } +} + +func TestInstanceRejectsUntrustedDataScopeCombinations(t *testing.T) { + store := newTestStore(t) + for _, instance := range []Instance{ + {ID: "installed", PluginID: "plugin", SourceKind: SourceInstalled, ScopeKind: ScopeTask, DataScopeKind: ScopeWorkspace, WorkspaceID: "workspace-1", TaskID: "task-1", Status: StatusActive}, + {ID: "session-data", PluginID: "canvas", SourceKind: SourceLocalCanvas, ScopeKind: ScopeTask, DataScopeKind: ScopeSession, WorkspaceID: "workspace-1", TaskID: "task-2", Status: StatusActive}, + {ID: "instance", PluginID: "canvas", SourceKind: SourceLocalCanvas, ScopeKind: ScopeTask, DataScopeKind: ScopeInstance, WorkspaceID: "workspace-1", TaskID: "task-3", Status: StatusActive}, + } { + if err := store.Create(context.Background(), instance); !errors.Is(err, ErrInvalidScope) { + t.Errorf("Create(%s) = %v, want ErrInvalidScope", instance.ID, err) + } + } +} + +func TestExistingInstanceTableMigratesWithTaskDataScopeFallback(t *testing.T) { + connection, err := sql.Open("sqlite3", filepath.Join(t.TempDir(), "legacy-instances.db")) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + connection.SetMaxOpenConns(1) + pool := db.NewPool(sqlx.NewDb(connection, "sqlite3"), sqlx.NewDb(connection, "sqlite3")) + t.Cleanup(func() { _ = pool.Close() }) + if _, err := pool.Writer().Exec(`CREATE TABLE plugin_instances ( + id TEXT PRIMARY KEY, plugin_id TEXT NOT NULL, source_kind TEXT NOT NULL, scope_kind TEXT NOT NULL, + workspace_id TEXT NOT NULL DEFAULT '', task_id TEXT NOT NULL DEFAULT '', session_id TEXT NOT NULL DEFAULT '', + repository_id TEXT NOT NULL DEFAULT '', status TEXT NOT NULL, active_release_id TEXT NOT NULL DEFAULT '', + grant_generation INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL + )`); err != nil { + t.Fatalf("create legacy plugin_instances schema: %v", err) + } + created := time.Now().UTC().Format(time.RFC3339Nano) + if _, err := pool.Writer().Exec(`INSERT INTO plugin_instances + (id, plugin_id, source_kind, scope_kind, workspace_id, task_id, status, created_at, updated_at) + VALUES ('legacy', 'canvas', 'local_canvas', 'task', 'workspace-1', 'task-1', 'active', ?, ?)`, created, created); err != nil { + t.Fatalf("insert legacy instance: %v", err) + } + store, err := NewStore(pool) + if err != nil { + t.Fatalf("NewStore migrates legacy table: %v", err) + } + instance, err := store.Get(context.Background(), "legacy") + if err != nil { + t.Fatalf("Get legacy instance: %v", err) + } + if instance.DataScopeKind != "" || instance.EffectiveDataScopeKind() != ScopeTask { + t.Fatalf("legacy scopes = explicit %q, effective %q, want empty and task", instance.DataScopeKind, instance.EffectiveDataScopeKind()) + } +} + +func TestEnableWorkspaceDataRequiresCurrentOwnerReview(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + if _, err := store.db.Exec(`CREATE TABLE workspaces (id TEXT PRIMARY KEY, owner_id TEXT NOT NULL DEFAULT '')`); err != nil { + t.Fatalf("create workspaces table: %v", err) + } + if _, err := store.db.Exec(`INSERT INTO workspaces (id, owner_id) VALUES ('workspace-1', 'owner-1')`); err != nil { + t.Fatalf("insert workspace: %v", err) + } + if err := store.Create(ctx, Instance{ + ID: "instance-1", PluginID: "canvas", SourceKind: SourceLocalCanvas, + ScopeKind: ScopeTask, WorkspaceID: "workspace-1", TaskID: "task-1", Status: StatusActive, + }); err != nil { + t.Fatalf("create task canvas: %v", err) + } + declared := json.RawMessage(`{"reads":["tasks"]}`) + if err := store.CreateRelease(ctx, Release{ + ID: "release-1", PluginID: "canvas", InstanceID: "instance-1", PackageDigest: "digest-1", + SourceKind: SourceLocalCanvas, ManifestJSON: json.RawMessage(`{}`), DeclaredPermissionsJSON: declared, + ArtifactPath: "releases/one", ArtifactBytes: 1, ValidationStatus: ValidationValid, + }); err != nil { + t.Fatalf("create active release: %v", err) + } + if err := store.AddGrant(ctx, Grant{InstanceID: "instance-1", PermissionKind: "api_read", Resource: "tasks", ScopeCeiling: ScopeTask, ApprovedBy: "owner-1"}); err != nil { + t.Fatalf("add task grant: %v", err) + } + if err := store.AddGrant(ctx, Grant{InstanceID: "instance-1", PermissionKind: "api_write", Resource: "messages", ScopeCeiling: ScopeTask, ApprovedBy: "owner-1"}); err != nil { + t.Fatalf("add stale task grant: %v", err) + } + if err := store.SetActiveRelease(ctx, "instance-1", "release-1"); err != nil { + t.Fatalf("activate release: %v", err) + } + instanceBeforeReview, err := store.Get(ctx, "instance-1") + if err != nil { + t.Fatalf("get instance before review: %v", err) + } + + workspaceGrant := []Grant{{InstanceID: "instance-1", PermissionKind: "api_read", Resource: "tasks", ScopeCeiling: ScopeWorkspace}} + apply := func(userID string, generation int64, grants []Grant) error { + return store.WithTransaction(ctx, func(tx *sqlx.Tx) error { + return store.EnableWorkspaceDataReviewedTx(ctx, tx, "instance-1", "workspace-1", userID, grants, "release-1", permissionDigest(string(declared)), generation) + }) + } + if err := apply("owner-2", instanceBeforeReview.GrantGeneration, workspaceGrant); !errors.Is(err, ErrWorkspaceOwnerRequired) { + t.Fatalf("foreign owner confirmation = %v, want ErrWorkspaceOwnerRequired", err) + } + if err := apply("owner-1", instanceBeforeReview.GrantGeneration+1, workspaceGrant); !errors.Is(err, ErrStaleWorkspaceDataReview) { + t.Fatalf("stale review confirmation = %v, want ErrStaleWorkspaceDataReview", err) + } + undeclared := append(append([]Grant(nil), workspaceGrant...), Grant{InstanceID: "instance-1", PermissionKind: "api_write", Resource: "messages", ScopeCeiling: ScopeWorkspace}) + if err := apply("owner-1", instanceBeforeReview.GrantGeneration, undeclared); !errors.Is(err, ErrInvalidScope) { + t.Fatalf("undeclared grant confirmation = %v, want ErrInvalidScope", err) + } + if err := apply("owner-1", instanceBeforeReview.GrantGeneration, workspaceGrant); err != nil { + t.Fatalf("owner confirmation: %v", err) + } + + instance, err := store.Get(ctx, "instance-1") + if err != nil { + t.Fatalf("get upgraded instance: %v", err) + } + if instance.ScopeKind != ScopeTask || instance.DataScopeKind != ScopeWorkspace || instance.ActiveReleaseID != "release-1" || instance.GrantGeneration != instanceBeforeReview.GrantGeneration+1 { + t.Fatalf("upgraded instance = %+v, want task placement, workspace data, same release, generation increment", instance) + } + grants, err := store.ListGrants(ctx, "instance-1") + if err != nil { + t.Fatalf("list upgraded grants: %v", err) + } + if len(grants) != 1 || grants[0].ScopeCeiling != ScopeWorkspace || grants[0].ApprovedBy != "owner-1" { + t.Fatalf("upgraded grants = %+v, want one owner-approved workspace task grant", grants) + } +} + +func TestVerifyWorkspaceOwnerAllowsAuthDisabledUnownedWorkspace(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + if _, err := store.db.Exec(`CREATE TABLE workspaces (id TEXT PRIMARY KEY, owner_id TEXT NOT NULL DEFAULT '')`); err != nil { + t.Fatalf("create workspaces table: %v", err) + } + if _, err := store.db.Exec(`INSERT INTO workspaces (id, owner_id) VALUES ('workspace-1', '')`); err != nil { + t.Fatalf("insert unowned workspace: %v", err) + } + if err := store.WithTransaction(ctx, func(tx *sqlx.Tx) error { + return verifyWorkspaceOwnerTx(ctx, tx, "workspace-1", userstore.DefaultUserID) + }); err != nil { + t.Fatalf("default user review of unowned workspace: %v", err) + } + if err := store.WithTransaction(ctx, func(tx *sqlx.Tx) error { + return verifyWorkspaceOwnerTx(ctx, tx, "workspace-1", "member-1") + }); !errors.Is(err, ErrWorkspaceOwnerRequired) { + t.Fatalf("ordinary user review of unowned workspace = %v, want ErrWorkspaceOwnerRequired", err) + } +} + func TestCreateInstanceAdmissionIsAtomicAtTaskAndWorkspaceLimits(t *testing.T) { store := newTestStore(t) ctx := context.Background() diff --git a/apps/backend/internal/plugins/webapp/tokens.go b/apps/backend/internal/plugins/webapp/tokens.go index 826e4eb622..c8eccf932d 100644 --- a/apps/backend/internal/plugins/webapp/tokens.go +++ b/apps/backend/internal/plugins/webapp/tokens.go @@ -34,6 +34,7 @@ type CapabilityBinding struct { WebAppKey string Placement string ScopeKind string + DataScopeKind string WorkspaceID string TaskID string SessionID string diff --git a/apps/backend/internal/plugins/webapp_events.go b/apps/backend/internal/plugins/webapp_events.go index 85dc641949..76542149e2 100644 --- a/apps/backend/internal/plugins/webapp_events.go +++ b/apps/backend/internal/plugins/webapp_events.go @@ -143,7 +143,7 @@ func projectWebAppEvent(source *bus.Event) (webapp.EventInput, bool) { func isProjectableWebAppEvent(eventType string) bool { switch eventType { case events.CanvasCreated, events.CanvasReleaseActivated, events.CanvasReleasePermissionRequired, - events.CanvasPromoted, events.CanvasArchived, events.CanvasRestored, events.CanvasRemoved, + events.CanvasPromoted, events.CanvasWorkspaceDataEnabled, events.CanvasArchived, events.CanvasRestored, events.CanvasRemoved, events.TaskCreated, events.TaskUpdated, events.TaskStateChanged, events.TaskDeleted, events.TaskMoved, events.TaskQueuePromoted, events.TaskDependenciesResolved, events.TaskDependencyFailed, events.WorkspaceCreated, events.WorkspaceUpdated, events.WorkspaceDeleted, @@ -160,7 +160,7 @@ func isProjectableWebAppEvent(eventType string) bool { func publicEventFields(eventType string) []string { canvasFields := []string{ - "canvas_id", "workspace_id", "task_id", "scope_kind", "status", "title", "plugin_id", "plugin_instance_id", + "canvas_id", "workspace_id", "task_id", "scope_kind", "data_scope_kind", "status", "title", "plugin_id", "plugin_instance_id", "release_id", "active_release_id", "validation_status", "validation_error", "source_actor_kind", "source_user_id", "source_task_id", "source_session_id", "placement", "protocol_version", } @@ -234,7 +234,7 @@ func webAppBusEventMatchesInstance(item instances.Instance, scope webapp.EventSc if scope.WorkspaceID != "" && item.WorkspaceID != scope.WorkspaceID { return false } - switch item.ScopeKind { + switch item.EffectiveDataScopeKind() { case instances.ScopeInstance: return true case instances.ScopeWorkspace: @@ -290,7 +290,7 @@ func webAppEventMatchesBinding(scope webapp.EventScope, binding webapp.Capabilit if scope.InstanceID != "" && scope.InstanceID != binding.InstanceID { return false } - switch binding.ScopeKind { + switch instances.EffectiveDataScopeKind(binding.ScopeKind, binding.DataScopeKind) { case instances.ScopeInstance: return true case instances.ScopeWorkspace: diff --git a/apps/backend/internal/plugins/webapp_events_test.go b/apps/backend/internal/plugins/webapp_events_test.go index f6af5b8e51..c32613ce83 100644 --- a/apps/backend/internal/plugins/webapp_events_test.go +++ b/apps/backend/internal/plugins/webapp_events_test.go @@ -10,9 +10,11 @@ import ( ) func TestProjectWebAppEventDropsInternalPayloadFields(t *testing.T) { - event := bus.NewEvent(events.CanvasReleaseActivated, "canvas", map[string]any{ + event := bus.NewEvent(events.CanvasWorkspaceDataEnabled, "canvas", map[string]any{ "canvas_id": "canvas-1", "workspace_id": "workspace-1", + "scope_kind": "task", + "data_scope_kind": "workspace", "release_id": "release-1", "active_release_id": "release-1", "secret": strings.Repeat("s", 128), @@ -36,6 +38,10 @@ func TestProjectWebAppEventDropsInternalPayloadFields(t *testing.T) { if projected.Scope.WorkspaceID != "workspace-1" || projected.Scope.InstanceID != "" { t.Fatalf("projected scope = %+v", projected.Scope) } + projectedData, ok := projected.Data.(map[string]any) + if !ok || projectedData["data_scope_kind"] != "workspace" { + t.Fatalf("projected data = %#v, want workspace data scope", projected.Data) + } } // TestProjectWebAppEventDropsDependencyProjectionFields covers the refresh diff --git a/apps/backend/internal/plugins/webapp_protocol.go b/apps/backend/internal/plugins/webapp_protocol.go index bd45d585ed..ce862e1b42 100644 --- a/apps/backend/internal/plugins/webapp_protocol.go +++ b/apps/backend/internal/plugins/webapp_protocol.go @@ -166,6 +166,7 @@ type webAppContext struct { WebAppKey string `json:"web_app_key"` Placement string `json:"placement"` ScopeKind string `json:"scope_kind"` + DataScopeKind string `json:"data_scope_kind"` WorkspaceID string `json:"workspace_id,omitempty"` TaskID string `json:"task_id,omitempty"` SessionID string `json:"session_id,omitempty"` @@ -190,6 +191,7 @@ func (s *Service) handleWebAppContext(w http.ResponseWriter, r *http.Request, bi WebAppKey: binding.WebAppKey, Placement: binding.Placement, ScopeKind: binding.ScopeKind, + DataScopeKind: instances.EffectiveDataScopeKind(binding.ScopeKind, binding.DataScopeKind), WorkspaceID: binding.WorkspaceID, TaskID: binding.TaskID, SessionID: binding.SessionID, @@ -364,6 +366,9 @@ func (s *Service) validateWebAppBinding(ctx context.Context, binding webapp.Capa } instance, err := store.Get(ctx, binding.InstanceID) if err != nil || instance.Status != instances.StatusActive || instance.PluginID != binding.PluginID || + instance.ScopeKind != binding.ScopeKind || + instance.EffectiveDataScopeKind() != instances.EffectiveDataScopeKind(binding.ScopeKind, binding.DataScopeKind) || + instance.WorkspaceID != binding.WorkspaceID || instance.TaskID != binding.TaskID || instance.ActiveReleaseID != binding.ReleaseID || instance.GrantGeneration != binding.GrantGeneration { return webapp.ErrRuntimeTokenStale } @@ -395,13 +400,14 @@ func (s *Service) validateWebAppPermissions(ctx context.Context, binding webapp. if err != nil { return err } + dataScope := instances.EffectiveDataScopeKind(binding.ScopeKind, binding.DataScopeKind) for _, permission := range webAppDeclaredPermissions(m, binding.WebAppKey) { - if !webAppGrantCovers(permission, binding.ScopeKind, grants) { + if !webAppGrantCovers(permission, dataScope, grants) { return errors.New("web app permission grant is stale") } } for _, origin := range binding.NetworkOrigins { - if !webAppOriginGranted(origin, binding.ScopeKind, grants) { + if !webAppOriginGranted(origin, dataScope, grants) { return errors.New("web app network grant is stale") } } diff --git a/apps/backend/internal/plugins/webapp_protocol_data.go b/apps/backend/internal/plugins/webapp_protocol_data.go index ebc8cd6849..b67109a93a 100644 --- a/apps/backend/internal/plugins/webapp_protocol_data.go +++ b/apps/backend/internal/plugins/webapp_protocol_data.go @@ -40,7 +40,7 @@ func (s *Service) listWebAppTasks(ctx context.Context, w http.ResponseWriter, r IncludeArchived: includeArchived, IncludeEphemeral: true, } - if binding.ScopeKind == instances.ScopeTask { + if instances.EffectiveDataScopeKind(binding.ScopeKind, binding.DataScopeKind) == instances.ScopeTask { // Resolved via the non-attaching fetchTask, and dependencies attached // only below, after the discard checks: matching the attachment rule // the non-task-scope branch below already documents for itself. @@ -278,7 +278,7 @@ func (s *Service) listWebAppWorkflows(ctx context.Context, w http.ResponseWriter } workflows = append(workflows, items...) } - if binding.ScopeKind == instances.ScopeTask { + if instances.EffectiveDataScopeKind(binding.ScopeKind, binding.DataScopeKind) == instances.ScopeTask { task, err := host.fetchTaskForScopeCheck(ctx, binding.TaskID) if err != nil || task == nil { writeWebAppError(w, webAppProtocolStatus(err), webAppErrorCode(err)) @@ -318,7 +318,7 @@ func (s *Service) listWebAppWorkflowSteps(ctx context.Context, w http.ResponseWr } } } - if binding.ScopeKind == instances.ScopeTask { + if instances.EffectiveDataScopeKind(binding.ScopeKind, binding.DataScopeKind) == instances.ScopeTask { task, err := host.fetchTaskForScopeCheck(ctx, binding.TaskID) if err != nil || task == nil || task.WorkflowID != workflowID { writeWebAppError(w, http.StatusNotFound, "not_found") @@ -380,13 +380,14 @@ func (s *Service) webAppWorkflowWorkspaceIDs(ctx context.Context, r *http.Reques } func webAppTaskMatches(ctx context.Context, host *pluginHost, binding webapp.CapabilityBinding, task pluginsdk.Task) bool { - if binding.ScopeKind == instances.ScopeInstance { + dataScope := instances.EffectiveDataScopeKind(binding.ScopeKind, binding.DataScopeKind) + if dataScope == instances.ScopeInstance { return true } if task.WorkspaceID != binding.WorkspaceID { return false } - switch binding.ScopeKind { + switch dataScope { case instances.ScopeWorkspace: return true case instances.ScopeTask: diff --git a/apps/backend/internal/plugins/webapp_protocol_dependencies_test.go b/apps/backend/internal/plugins/webapp_protocol_dependencies_test.go index 36ad4a865f..4947efd3d9 100644 --- a/apps/backend/internal/plugins/webapp_protocol_dependencies_test.go +++ b/apps/backend/internal/plugins/webapp_protocol_dependencies_test.go @@ -135,6 +135,47 @@ func TestListWebAppTasks_DerivesDependenciesAfterScopeNarrowing(t *testing.T) { require.Contains(t, recorder.Body.String(), `"blocked":true`) } +func TestListWebAppTasks_TaskPlacementUsesWorkspaceDataScopeAndPagination(t *testing.T) { + d := newTestDataHost(manifest.Capabilities{APIRead: []string{"tasks"}}) + d.tasks.workspaces = []*taskmodels.Workspace{{ID: "ws-1"}, {ID: "ws-2"}} + d.tasks.tasksByWorkspace = map[string][]*taskmodels.Task{ + "ws-1": { + {ID: "task-a", WorkspaceID: "ws-1", CreatedAt: time.Date(2026, 1, 3, 0, 0, 0, 0, time.UTC)}, + {ID: "task-b", WorkspaceID: "ws-1", CreatedAt: time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC)}, + }, + "ws-2": {{ID: "foreign", WorkspaceID: "ws-2", CreatedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)}}, + } + svc := &Service{taskData: d.tasks} + binding := webapp.CapabilityBinding{ + ScopeKind: instances.ScopeTask, DataScopeKind: instances.ScopeWorkspace, + WorkspaceID: "ws-1", TaskID: "task-a", Permissions: []string{"api_read:tasks"}, + } + + first := httptest.NewRecorder() + svc.handleWebAppProtocol(first, httptest.NewRequest(http.MethodGet, "/?limit=1", nil), "", binding, "v1/data/tasks") + require.Equal(t, http.StatusOK, first.Code, first.Body.String()) + var firstPage webAppPage[webAppTask] + require.NoError(t, json.Unmarshal(first.Body.Bytes(), &firstPage)) + require.Len(t, firstPage.Items, 1) + require.Equal(t, "task-a", firstPage.Items[0].ID) + require.True(t, firstPage.PageInfo.HasMore) + require.NotEmpty(t, firstPage.PageInfo.NextCursor) + + second := httptest.NewRecorder() + secondRequest := httptest.NewRequest(http.MethodGet, "/?limit=1&cursor="+firstPage.PageInfo.NextCursor, nil) + svc.handleWebAppProtocol(second, secondRequest, "", binding, "v1/data/tasks") + require.Equal(t, http.StatusOK, second.Code, second.Body.String()) + var secondPage webAppPage[webAppTask] + require.NoError(t, json.Unmarshal(second.Body.Bytes(), &secondPage)) + require.Len(t, secondPage.Items, 1) + require.Equal(t, "task-b", secondPage.Items[0].ID) + + foreign := httptest.NewRecorder() + foreignRequest := httptest.NewRequest(http.MethodGet, "/?workspace_id=ws-2", nil) + svc.handleWebAppProtocol(foreign, foreignRequest, "", binding, "v1/data/tasks") + require.Equal(t, http.StatusForbidden, foreign.Code) +} + func TestListWebAppTasks_FanOutRefusalBecomesResponseTooLarge(t *testing.T) { d := newTestDataHost(manifest.Capabilities{APIRead: []string{"tasks"}}) task := &taskmodels.Task{ID: "task-1", WorkspaceID: "ws-1"} diff --git a/apps/backend/internal/plugins/webapp_protocol_json.go b/apps/backend/internal/plugins/webapp_protocol_json.go index 9a85eaaaa6..6e6eb681dd 100644 --- a/apps/backend/internal/plugins/webapp_protocol_json.go +++ b/apps/backend/internal/plugins/webapp_protocol_json.go @@ -119,7 +119,11 @@ func dependencyRedactionScopeFromBinding(binding webapp.CapabilityBinding, direc for _, id := range directlyReadableIDs { ids[id] = struct{}{} } - return dependencyRedactionScope{kind: binding.ScopeKind, workspaceID: binding.WorkspaceID, directlyReadableIDs: ids} + return dependencyRedactionScope{ + kind: instances.EffectiveDataScopeKind(binding.ScopeKind, binding.DataScopeKind), + workspaceID: binding.WorkspaceID, + directlyReadableIDs: ids, + } } // admits reports whether ref may be disclosed as a directly readable task diff --git a/apps/backend/internal/plugins/webapp_protocol_test.go b/apps/backend/internal/plugins/webapp_protocol_test.go index dc8a024443..f0c01eeed9 100644 --- a/apps/backend/internal/plugins/webapp_protocol_test.go +++ b/apps/backend/internal/plugins/webapp_protocol_test.go @@ -30,7 +30,7 @@ func TestWebAppProtocolContextDoesNotExposeCredentials(t *testing.T) { binding := webapp.CapabilityBinding{ UserID: "user-1", InstanceID: "instance-1", PluginID: "plugin-1", ReleaseID: "release-1", WebAppKey: "board", Placement: "task-canvas", - ScopeKind: "task", WorkspaceID: "workspace-1", TaskID: "task-1", + ScopeKind: "task", DataScopeKind: "workspace", WorkspaceID: "workspace-1", TaskID: "task-1", Permissions: []string{"api_read:tasks", "state"}, } request := httptest.NewRequest(http.MethodGet, "/", nil) @@ -49,7 +49,7 @@ func TestWebAppProtocolContextDoesNotExposeCredentials(t *testing.T) { if _, ok := body["token"]; ok { t.Fatal("context exposed token") } - if body["instance_id"] != "instance-1" || body["protocol_version"] != float64(1) { + if body["instance_id"] != "instance-1" || body["protocol_version"] != float64(1) || body["scope_kind"] != "task" || body["data_scope_kind"] != "workspace" { t.Fatalf("context = %#v", body) } } diff --git a/apps/backend/internal/plugins/webapp_protocol_transitions.go b/apps/backend/internal/plugins/webapp_protocol_transitions.go index 52351a5761..e3ece2835b 100644 --- a/apps/backend/internal/plugins/webapp_protocol_transitions.go +++ b/apps/backend/internal/plugins/webapp_protocol_transitions.go @@ -85,7 +85,7 @@ func (s *Service) listWebAppWorkflowTransitionGroups(ctx context.Context, w http writeWebAppError(w, http.StatusNotFound, "not_found") return } - if binding.ScopeKind != instances.ScopeWorkspace || binding.WorkspaceID == "" || !host.capabilities.CanRead(resourceTasks) || !host.capabilities.CanRead(resourceWorkflows) { + if instances.EffectiveDataScopeKind(binding.ScopeKind, binding.DataScopeKind) != instances.ScopeWorkspace || binding.WorkspaceID == "" || !host.capabilities.CanRead(resourceTasks) || !host.capabilities.CanRead(resourceWorkflows) { writeWebAppError(w, http.StatusForbidden, "plugin_permission_denied") return } diff --git a/apps/backend/internal/plugins/webapp_protocol_transitions_test.go b/apps/backend/internal/plugins/webapp_protocol_transitions_test.go index 8f15318bdf..9448fbb157 100644 --- a/apps/backend/internal/plugins/webapp_protocol_transitions_test.go +++ b/apps/backend/internal/plugins/webapp_protocol_transitions_test.go @@ -71,14 +71,16 @@ func TestWebAppTransitionGroupsRequireWorkspaceAndBothGrants(t *testing.T) { return recorder } require.Equal(t, http.StatusForbidden, call("wf-1").Code) - binding.ScopeKind = instances.ScopeWorkspace + binding.DataScopeKind = instances.ScopeWorkspace + require.Equal(t, http.StatusOK, call("wf-1").Code) + require.Equal(t, 1, source.groupCalls) binding.Permissions = []string{"api_read:workflows"} require.Equal(t, http.StatusForbidden, call("wf-1").Code) binding.Permissions = []string{"api_read:tasks", "api_read:workflows"} require.Equal(t, http.StatusNotFound, call("foreign-wf").Code) - require.Zero(t, source.groupCalls) + require.Equal(t, 1, source.groupCalls) allowed := call("wf-1") require.Equal(t, http.StatusOK, allowed.Code, allowed.Body.String()) - require.Equal(t, 1, source.groupCalls) + require.Equal(t, 2, source.groupCalls) require.Contains(t, allowed.Body.String(), `"count":2`) } diff --git a/apps/backend/internal/prompts/store/create_canvas_test.go b/apps/backend/internal/prompts/store/create_canvas_test.go index 7d76c24edb..1391c20712 100644 --- a/apps/backend/internal/prompts/store/create_canvas_test.go +++ b/apps/backend/internal/prompts/store/create_canvas_test.go @@ -28,7 +28,8 @@ func TestCreateCanvasBuiltin(t *testing.T) { "source directory", "authorized live Kandev data", "awaits permission review", - "requires promotion", + "adds workspace navigation", + "Do not claim that promotion is required to preview", "local build alone does not publish", } { if !strings.Contains(prompt.Content, want) { diff --git a/apps/backend/pkg/websocket/actions.go b/apps/backend/pkg/websocket/actions.go index 41de531ce5..b49c206ebf 100644 --- a/apps/backend/pkg/websocket/actions.go +++ b/apps/backend/pkg/websocket/actions.go @@ -107,6 +107,7 @@ const ( ActionCanvasReleaseActivated = "canvas.release.activated" ActionCanvasReleasePermissionRequired = "canvas.release.permission_required" ActionCanvasPromoted = "canvas.promoted" + ActionCanvasWorkspaceDataEnabled = "canvas.workspace_data_enabled" ActionCanvasArchived = "canvas.archived" ActionCanvasRestored = "canvas.restored" ActionCanvasRemoved = "canvas.removed" diff --git a/apps/web/components/settings/canvas-host-actions.tsx b/apps/web/components/settings/canvas-host-actions.tsx index 8bd6940850..220ca2779b 100644 --- a/apps/web/components/settings/canvas-host-actions.tsx +++ b/apps/web/components/settings/canvas-host-actions.tsx @@ -4,12 +4,18 @@ import { IconDots } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { useTranslation } from "react-i18next"; import type { Canvas } from "@/lib/api/domains/canvas-api"; -import { CanvasPromotionDialog, CanvasReleaseDialog } from "./canvas-lifecycle-dialogs"; +import { + CanvasPromotionDialog, + CanvasReleaseDialog, + CanvasWorkspaceDataDialog, +} from "./canvas-lifecycle-dialogs"; export function CanvasHostDialogs({ canvas, promotionOpen, onPromotionOpenChange, + workspaceDataOpen, + onWorkspaceDataOpenChange, releasesOpen, onReleasesOpenChange, onPromotionCompleted, @@ -18,6 +24,8 @@ export function CanvasHostDialogs({ canvas: Canvas | null; promotionOpen: boolean; onPromotionOpenChange: (open: boolean) => void; + workspaceDataOpen: boolean; + onWorkspaceDataOpenChange: (open: boolean) => void; releasesOpen: boolean; onReleasesOpenChange: (open: boolean) => void; onPromotionCompleted: () => void; @@ -31,6 +39,12 @@ export function CanvasHostDialogs({ onOpenChange={onPromotionOpenChange} onCompleted={onPromotionCompleted} /> + = { "canvases:editCanvas": "Edit canvas", "canvases:releasesAndPermissions": "Releases and permissions", "canvases:promoteCanvas": "Promote canvas", + "canvases:enableWorkspaceData": "Enable workspace data", + "canvases:enableWorkspaceDataHelp": "Enable declared workspace data access.", + "canvases:taskDataScope": "Task data", + "canvases:workspaceDataScope": "Workspace data", + "canvases:taskPlacementScope": "Task", + "canvases:workspacePlacementScope": "Workspace", "canvases:canvasActions": "Canvas actions", "canvases:canvases": "Canvases", "canvases:openInNewTab": "Open in new tab", @@ -45,6 +51,7 @@ vi.mock("@/components/plugins/canvas-page", () => ({ CanvasPage: () => null })); vi.mock("./canvas-lifecycle-dialogs", () => ({ CanvasPromotionDialog: () => null, CanvasReleaseDialog: () => null, + CanvasWorkspaceDataDialog: () => null, })); import { @@ -160,12 +167,56 @@ describe("canvas host action guidance", () => { }); }); +describe("legacy workspace data action", () => { + it("offers the legacy workspace data review on desktop and mobile actions", () => { + const legacyCanvas = { + ...canvas, + status: "active" as const, + active_release_status: "valid" as const, + }; + const onEnableWorkspaceData = vi.fn(); + render( + <> + + + , + ); + + const buttons = screen.getAllByRole("button", { name: "Enable workspace data" }); + expect(buttons).toHaveLength(2); + buttons.forEach((button) => fireEvent.click(button)); + expect(onEnableWorkspaceData).toHaveBeenCalledTimes(2); + }); +}); + describe("canvas host chrome", () => { it("keeps state content in the body and leaves the shared header for title/actions", () => { render( <> { expect(screen.getByTestId("canvas-host-header").textContent).toContain("Task canvas"); expect(screen.getByTestId("canvas-host-header").textContent).toContain("Release actions"); + expect(screen.getByTestId("canvas-data-scope").textContent).toBe("Workspace data"); expect(screen.getByTestId("canvas-host-state").textContent).toContain("Canvas unavailable"); expect( screen diff --git a/apps/web/components/settings/canvas-host-components.tsx b/apps/web/components/settings/canvas-host-components.tsx index cb2393be96..8f4998b10b 100644 --- a/apps/web/components/settings/canvas-host-components.tsx +++ b/apps/web/components/settings/canvas-host-components.tsx @@ -2,6 +2,7 @@ import type { ReactNode } from "react"; import { + IconDatabase, IconEdit, IconExternalLink, IconLayoutGrid, @@ -15,7 +16,11 @@ import { Button } from "@kandev/ui/button"; import { MobilePickerSheet } from "@/components/task/mobile/mobile-picker-sheet"; import { PanelHeaderBarSplit } from "@/components/task/panel-primitives"; import { CanvasPage } from "@/components/plugins/canvas-page"; -import { canvasHref, type Canvas } from "@/lib/api/domains/canvas-api"; +import { + canvasCanEnableWorkspaceData, + canvasHref, + type Canvas, +} from "@/lib/api/domains/canvas-api"; import { CanvasMobileActionsButton } from "./canvas-host-actions"; import { canvasLockHelp, canvasPromotionHelp } from "./canvas-host-desktop-actions"; export { CanvasHostDialogs, CanvasMobileActionsButton } from "./canvas-host-actions"; @@ -137,6 +142,7 @@ export function CanvasHostBody({ export function CanvasHostHeader({ title, + dataScopeLabel, isMobile, menuOpen, onOpenActions, @@ -145,6 +151,7 @@ export function CanvasHostHeader({ overflowActions, }: { title: string; + dataScopeLabel?: string; isMobile: boolean; menuOpen: boolean; onOpenActions: () => void; @@ -157,7 +164,16 @@ export function CanvasHostHeader({ data-testid="canvas-host-header" left={
- {title} + {title} + {dataScopeLabel && ( + + {dataScopeLabel} + + )} {renameAction}
} @@ -327,6 +343,35 @@ function MobileCanvasPromoteAction({ ); } +function MobileCanvasWorkspaceDataAction({ + canvas, + onEnable, + t, +}: { + canvas: Canvas | null; + onEnable?: () => void; + t: (key: string) => string; +}) { + if (!canvas || !onEnable || !canvasCanEnableWorkspaceData(canvas)) return null; + + return ( + + + + ); +} + function MobileCanvasShareAction({ onShare, t, @@ -377,6 +422,7 @@ export function MobileCanvasActions({ onReleases, onShare, onRename, + onEnableWorkspaceData, onSelectCanvas, editing, }: { @@ -389,6 +435,7 @@ export function MobileCanvasActions({ onReleases: () => void; onShare: () => void; onRename: () => void; + onEnableWorkspaceData?: () => void; onSelectCanvas: (canvas: Canvas) => void; editing: boolean; }) { @@ -427,6 +474,7 @@ export function MobileCanvasActions({ )} {canvas && } + {canvas?.scope_kind === "task" && ( string): string { return canvas.status === "archived" @@ -59,6 +65,7 @@ export function CanvasDesktopActions({ onPromote, onReleases, onShare, + onEnableWorkspaceData, }: { canvas: Canvas; editing: boolean; @@ -66,6 +73,7 @@ export function CanvasDesktopActions({ onPromote: () => void; onReleases: () => void; onShare: () => void; + onEnableWorkspaceData?: () => void; }) { const { t } = useTranslation(); const lifecycleLocked = canvas.status === "archived" || canvas.status === "disabled"; @@ -112,6 +120,24 @@ export function CanvasDesktopActions({ {t("canvases:shareCanvas")} + {onEnableWorkspaceData && canvasCanEnableWorkspaceData(canvas) && ( + + + + )} {canvas.scope_kind === "task" && ( void; onReleases: () => void; onShare: () => void; + onEnableWorkspaceData?: () => void; omitPrimaryAction?: boolean; }; +function CanvasWorkspaceDataMenuAction({ + canvas, + onEnableWorkspaceData, + t, +}: { + canvas: Canvas; + onEnableWorkspaceData?: () => void; + t: (key: string) => string; +}) { + if (!onEnableWorkspaceData || !canvasCanEnableWorkspaceData(canvas)) return null; + + return ( + + + {t("canvases:enableWorkspaceData")} + + ); +} + export function CanvasDesktopPrimaryAction({ canvas, editing, @@ -197,6 +247,7 @@ function CanvasDesktopOverflowMenuItemsContent({ onPromote, onReleases, onShare, + onEnableWorkspaceData, omitPrimaryAction, t, }: CanvasDesktopOverflowActionsProps & { t: (key: string) => string }) { @@ -238,6 +289,11 @@ function CanvasDesktopOverflowMenuItemsContent({ {t("canvases:shareCanvas")} + {canvas.scope_kind === "task" && !omitPrimaryAction && ( void; desktopActions: ReactNode; @@ -43,6 +47,7 @@ export function CanvasHostFrame({ setMenuOpen(true)} @@ -61,13 +66,16 @@ export function CanvasHostFrame({ {title} {renameAction} - ) : undefined + ) } + subtitle={isMobile ? undefined : dataScopeLabel} backHref="/" backLabel={t("sidebar:home")} scroll="none" diff --git a/apps/web/components/settings/canvas-host-route-view.tsx b/apps/web/components/settings/canvas-host-route-view.tsx index cb930031d0..f68ee4cbcc 100644 --- a/apps/web/components/settings/canvas-host-route-view.tsx +++ b/apps/web/components/settings/canvas-host-route-view.tsx @@ -18,7 +18,7 @@ import { MobileCanvasActions, type CanvasHostState, } from "./canvas-host-components"; -import { type Canvas } from "@/lib/api/domains/canvas-api"; +import { canvasDataScope, type Canvas } from "@/lib/api/domains/canvas-api"; type CanvasHostRouteViewProps = { canvasId: string; @@ -31,17 +31,20 @@ type CanvasHostRouteViewProps = { error: string | null; menuOpen: boolean; promotionOpen: boolean; + workspaceDataOpen: boolean; releasesOpen: boolean; shareOpen: boolean; renameOpen: boolean; editing: boolean; setMenuOpen: (open: boolean) => void; setPromotionOpen: (open: boolean) => void; + setWorkspaceDataOpen: (open: boolean) => void; setReleasesOpen: (open: boolean) => void; setShareOpen: (open: boolean) => void; setRenameOpen: (open: boolean) => void; onEdit: () => void; onPromote: () => void; + onEnableWorkspaceData: () => void; onReleases: () => void; onShare: () => void; onRename: () => void; @@ -57,6 +60,8 @@ function CanvasHostRouteDialogs({ canvas, promotionOpen, setPromotionOpen, + workspaceDataOpen, + setWorkspaceDataOpen, releasesOpen, setReleasesOpen, shareOpen, @@ -70,6 +75,8 @@ function CanvasHostRouteDialogs({ | "canvas" | "promotionOpen" | "setPromotionOpen" + | "workspaceDataOpen" + | "setWorkspaceDataOpen" | "releasesOpen" | "setReleasesOpen" | "shareOpen" @@ -85,6 +92,8 @@ function CanvasHostRouteDialogs({ canvas={canvas} promotionOpen={promotionOpen} onPromotionOpenChange={setPromotionOpen} + workspaceDataOpen={workspaceDataOpen} + onWorkspaceDataOpenChange={setWorkspaceDataOpen} releasesOpen={releasesOpen} onReleasesOpenChange={setReleasesOpen} onPromotionCompleted={onPromotionCompleted} @@ -134,6 +143,7 @@ function CanvasHostRouteMobileActions({ setMenuOpen, onEdit, onPromote, + onEnableWorkspaceData, onReleases, onShare, onRename, @@ -147,6 +157,7 @@ function CanvasHostRouteMobileActions({ | "setMenuOpen" | "onEdit" | "onPromote" + | "onEnableWorkspaceData" | "onReleases" | "onShare" | "onRename" @@ -160,6 +171,7 @@ function CanvasHostRouteMobileActions({ onOpenChange={setMenuOpen} onEdit={onEdit} onPromote={onPromote} + onEnableWorkspaceData={onEnableWorkspaceData} onReleases={onReleases} onShare={onShare} onRename={onRename} @@ -187,6 +199,7 @@ function createCanvasDesktopActions(canvas: Canvas | null, props: CanvasHostRout onPromote: props.onPromote, onReleases: props.onReleases, onShare: props.onShare, + onEnableWorkspaceData: props.onEnableWorkspaceData, }; return { actions: , @@ -196,10 +209,35 @@ function createCanvasDesktopActions(canvas: Canvas | null, props: CanvasHostRout }; } +function useCanvasHeaderPresentation(canvas: Canvas | null, isMobile: boolean) { + const { t } = useTranslation(); + const title = canvas?.title || t("canvases:canvas"); + const dataScopeLabel = canvas + ? t( + canvasDataScope(canvas) === "workspace" + ? "canvases:workspaceDataScope" + : "canvases:taskDataScope", + ) + : undefined; + const mobileTitleSlot = + isMobile && dataScopeLabel ? ( + + {title} + + {dataScopeLabel} + + + ) : undefined; + return { title, dataScopeLabel, mobileTitleSlot }; +} + export function CanvasHostRouteView(props: CanvasHostRouteViewProps) { const { canvas, isMobile, menuOpen, setMenuOpen } = props; const { t } = useTranslation(); - const title = canvas?.title || t("canvases:canvas"); + const { title, dataScopeLabel, mobileTitleSlot } = useCanvasHeaderPresentation(canvas, isMobile); const desktopActions = createCanvasDesktopActions(canvas, props); const renameAction = canvas ? ( + + + ); +} + +export function CanvasWorkspaceDataDialog({ + canvas, + open, + onOpenChange, + onCompleted, +}: { + canvas: Canvas | null; + open: boolean; + onOpenChange: (open: boolean) => void; + onCompleted?: (canvas: Canvas) => void; +}) { + const { t } = useTranslation(); + const { isMobile } = useResponsiveBreakpoint(); + const { preview, loading, confirming, error, confirm, unsupportedPermissions } = + useCanvasWorkspaceDataReview(canvas, open, onOpenChange, onCompleted); + const surfaceClassName = isMobile + ? "!left-0 !top-0 !h-dvh !max-h-dvh !w-screen !max-w-none !translate-x-0 !translate-y-0 flex flex-col gap-0 overflow-hidden rounded-none p-0 [padding-top:max(1rem,env(safe-area-inset-top))]" + : "flex max-h-[min(90dvh,48rem)] w-full flex-col gap-0 overflow-hidden p-0 sm:max-w-lg"; + + // The footer uses a labeled Cancel action instead of an icon-only close button. + return ( + + + + {t("canvases:enableWorkspaceData")} + + {t("canvases:workspaceDataReviewDescription", { title: canvas?.title ?? "" })} + + + + onOpenChange(false)} + onConfirm={() => void confirm()} + t={t} + /> + + + ); +} diff --git a/apps/web/e2e/tests/canvas/canvas-authenticated-proxy.spec.ts b/apps/web/e2e/tests/canvas/canvas-authenticated-proxy.spec.ts index 9a2094f7e9..b02fc8ac16 100644 --- a/apps/web/e2e/tests/canvas/canvas-authenticated-proxy.spec.ts +++ b/apps/web/e2e/tests/canvas/canvas-authenticated-proxy.spec.ts @@ -76,7 +76,7 @@ test.describe("authenticated same-origin canvas runtime", () => { true, ); - await frame.getByTestId("canvas-fixture-continue").click(); + await frame.getByTestId("canvas-fixture-continue").dispatchEvent("click"); await expect(frame.getByTestId("canvas-fixture-message-status")).toHaveText("accepted"); await expect .poll(async () => diff --git a/apps/web/e2e/tests/canvas/canvas-fixture.ts b/apps/web/e2e/tests/canvas/canvas-fixture.ts index 4aee211ff9..1ef2285fe9 100644 --- a/apps/web/e2e/tests/canvas/canvas-fixture.ts +++ b/apps/web/e2e/tests/canvas/canvas-fixture.ts @@ -20,6 +20,8 @@ export type CanvasRecord = { workspace_id: string; task_id?: string; scope_kind?: string; + data_scope_kind?: string; + grant_generation?: number; status?: string; active_release_id?: string; active_release_status?: string; @@ -47,6 +49,31 @@ export type SeededCanvas = { session: SessionPage; }; +export async function seedCanvasWorkspacePreview(apiClient: ApiClient, seedData: SeedData) { + const previewTask = await apiClient.seedTask( + seedData.workspaceId, + "E2E Canvas Workspace Preview Task", + { workflow_id: seedData.workflowId, workflow_step_id: seedData.startStepId }, + ); + const foreignWorkspaceName = "E2E Canvas Foreign Workspace"; + try { + const foreignWorkspace = await apiClient.createWorkspace(foreignWorkspaceName); + return { + workspaceTaskId: previewTask.task_id, + foreignWorkspaceId: foreignWorkspace.id, + cleanup: async () => { + await apiClient.deleteTask(previewTask.task_id).catch(() => undefined); + await apiClient + .deleteWorkspace(foreignWorkspace.id, foreignWorkspaceName) + .catch(() => undefined); + }, + }; + } catch (error) { + await apiClient.deleteTask(previewTask.task_id).catch(() => undefined); + throw error; + } +} + export async function expectCanvasFrameFillsHost(page: Page): Promise { await expect .poll( @@ -182,6 +209,7 @@ export async function waitForSessionWorkspace( export type CanvasSourceOptions = { noPermissions?: boolean; minimalPermissions?: boolean; + foreignWorkspaceId?: string; }; function canvasCapabilities(options?: CanvasSourceOptions): string[] { @@ -230,10 +258,12 @@ function canvasManifest(canvas: CanvasRecord, options?: CanvasSourceOptions): st ].join("\n"); } -function canvasFixtureScript(canvas: CanvasRecord): string { +function canvasFixtureScript(canvas: CanvasRecord, options?: CanvasSourceOptions): string { const taskID = JSON.stringify(canvas.task_id ?? ""); + const foreignWorkspaceID = JSON.stringify(options?.foreignWorkspaceId ?? ""); return String.raw`(() => { const taskId = ${taskID}; + const foreignWorkspaceId = ${foreignWorkspaceID}; const text = (testId, value) => { const element = document.querySelector('[data-testid="' + testId + '"]'); if (element) element.textContent = String(value); @@ -260,6 +290,23 @@ function canvasFixtureScript(canvas: CanvasRecord): string { let streamReader; let streamCompletion = Promise.resolve(); + const renderTasks = (tasks) => { + const list = document.querySelector('[data-testid="canvas-fixture-task-list"]'); + if (!list) return; + list.replaceChildren(); + tasks.forEach((candidate) => { + const item = document.createElement("article"); + item.className = "task-item"; + const title = document.createElement("h2"); + title.textContent = candidate.title || "Workspace task"; + const status = document.createElement("span"); + status.className = "task-status"; + status.textContent = candidate.status || "Open"; + item.append(title, status); + list.append(item); + }); + }; + const renderAppearance = () => { const root = document.documentElement; const styles = getComputedStyle(root); @@ -275,12 +322,22 @@ function canvasFixtureScript(canvas: CanvasRecord): string { }); const loadProjection = async () => { - const [context, taskPage, workflowPage] = await Promise.all([ + const [context, tasks, workflowPage] = await Promise.all([ api("./_kandev/v1/context"), - api("./_kandev/v1/data/tasks?limit=10"), + (async () => { + const items = []; + let cursor = ""; + do { + const query = new URLSearchParams({ limit: "1" }); + if (cursor) query.set("cursor", cursor); + const page = await api("./_kandev/v1/data/tasks?" + query.toString()); + items.push(...(page.items || [])); + cursor = page.page_info?.next_cursor || ""; + } while (cursor); + return items; + })(), api("./_kandev/v1/data/workflows?limit=10"), ]); - const tasks = taskPage.items || []; task = tasks.find((candidate) => candidate.id === taskId) || tasks[0]; const workflow = (workflowPage.items || []).find( (candidate) => candidate.id === task?.workflow_id, @@ -293,8 +350,19 @@ function canvasFixtureScript(canvas: CanvasRecord): string { } text("canvas-fixture-context", context.task_id || "workspace"); text("canvas-fixture-task-count", tasks.length); + text("canvas-fixture-task-ids", tasks.map((candidate) => candidate.id).join(",")); + renderTasks(tasks); text("canvas-fixture-workflow-count", workflowPage.items?.length || 0); text("canvas-fixture-step-id", task?.workflow_step_id || ""); + text("canvas-fixture-refresh-status", "refreshed"); + if (foreignWorkspaceId) { + try { + await api("./_kandev/v1/data/tasks?workspace_id=" + encodeURIComponent(foreignWorkspaceId)); + text("canvas-fixture-foreign-workspace-status", "unexpected-allowed"); + } catch (error) { + text("canvas-fixture-foreign-workspace-status", "denied:" + error.status); + } + } }; const parseEvent = (block) => { @@ -425,6 +493,12 @@ function canvasFixtureScript(canvas: CanvasRecord): string { document.querySelector('[data-testid="canvas-fixture-resync"]')?.addEventListener("click", () => { void connectEvents("old-generation:1"); }); + document.querySelector('[data-testid="canvas-fixture-refresh"]')?.addEventListener("click", () => { + text("canvas-fixture-refresh-status", "refreshing"); + void loadProjection().catch((error) => { + text("canvas-fixture-refresh-status", "error:" + error.status); + }); + }); text("canvas-fixture-script", "inline-ready"); renderAppearance(); @@ -445,33 +519,39 @@ export function writeCanvasSource( [ "", '', - ' E2E Plugin Canvas', + ' E2E Plugin Canvas', + " ", " ", '
', - '

E2E Plugin Canvas

E2E Plugin Canvas

', + '

Workspace preview

Your workspace tasks

A live view of work across this workspace.

', '
', - '

loading

', - '

loading

', - '

0

', - '

0

', - '

loading

', - '

idle

', - '

idle

', - '

idle

', - '

loading

', - '

0

', - '

idle

', - '

loading

', - '

loading

', - '

loading

', - ' ', - ' ', - ' ', - ' ', - ' ', + '

All workspace tasks

0 tasks
', + '
', + '
', + ' ", "
", "
", - ` `, + ` `, ' ', " ", "", diff --git a/apps/web/e2e/tests/canvas/mobile-plugin-canvas.spec.ts b/apps/web/e2e/tests/canvas/mobile-plugin-canvas.spec.ts index 2dab6bcddf..3c3de3c149 100644 --- a/apps/web/e2e/tests/canvas/mobile-plugin-canvas.spec.ts +++ b/apps/web/e2e/tests/canvas/mobile-plugin-canvas.spec.ts @@ -13,6 +13,7 @@ import { publishTaskCanvas, removeCanvas, promoteCanvas, + seedCanvasWorkspacePreview, seedTaskCanvas, waitForTaskCanvas, } from "./canvas-fixture"; @@ -348,6 +349,117 @@ test.describe("Plugin-backed canvases on mobile", () => { } }); + test("reviews workspace data for a legacy task canvas before enabling it", async ({ + testPage, + apiClient, + backend, + seedData, + }) => { + test.setTimeout(150_000); + + const releaseFeature = await enableCanvasFeature(backend, apiClient, seedData.workspaceId); + const canvasIds: string[] = []; + try { + const seeded = await seedTaskCanvas(testPage, apiClient, seedData, true); + canvasIds.push(seeded.canvas.id); + const activeCanvas = await approvePendingCanvasThroughHost( + testPage, + apiClient, + seeded.canvas, + ); + const legacyCanvas = { ...activeCanvas, data_scope_kind: "task" }; + const permissionDigest = "legacy-task-canvas-review"; + const grantGeneration = activeCanvas.grant_generation ?? 0; + let workspaceDataEnabled = false; + let submittedReview: Record | undefined; + const canvasURL = new RegExp(`/api/v1/canvases/${activeCanvas.id}(?:\\?.*)?$`); + + await testPage.route(canvasURL, async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + ...activeCanvas, + data_scope_kind: workspaceDataEnabled ? "workspace" : "task", + }), + }); + }); + await testPage.route( + new RegExp(`/api/v1/canvases/${activeCanvas.id}/workspace-data-preview(?:\\?.*)?$`), + async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + canvas: legacyCanvas, + active_release_id: activeCanvas.active_release_id, + permission_digest: permissionDigest, + grant_generation: grantGeneration, + current_data_scope_kind: "task", + target_data_scope_kind: "workspace", + permissions: { + reads: ["tasks"], + writes: ["messages"], + events: ["task.updated"], + shared_state: false, + external_origins: [], + }, + }), + }); + }, + ); + await testPage.route( + new RegExp(`/api/v1/canvases/${activeCanvas.id}/workspace-data(?:\\?.*)?$`), + async (route) => { + submittedReview = route.request().postDataJSON() as Record; + workspaceDataEnabled = true; + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ ...legacyCanvas, data_scope_kind: "workspace" }), + }); + }, + ); + + await testPage.goto(canvasHref(activeCanvas.id)); + await expect(testPage.getByTestId("canvas-data-scope")).toHaveText("Task data"); + await testPage.getByTestId("canvas-mobile-actions").tap(); + const actions = testPage.getByTestId("canvas-mobile-actions-sheet"); + await actions.getByTestId("canvas-action-enable-workspace-data").tap(); + + const review = testPage.getByTestId("canvas-workspace-data-dialog"); + await expect(review).toBeVisible(); + await expect(review.getByTestId("canvas-workspace-data-release")).toHaveText( + activeCanvas.active_release_id ?? "", + ); + await expect(review.getByTestId("canvas-workspace-data-current-scope")).toHaveText( + "Task data", + ); + await expect(review.getByTestId("canvas-workspace-data-target-scope")).toHaveText( + "Workspace data", + ); + await expect(review.getByText("Read task data")).toBeVisible(); + await review.getByRole("button", { name: "Enable workspace data", exact: true }).tap(); + + await expect(review).toHaveCount(0); + await expect(testPage.getByTestId("canvas-data-scope")).toHaveText("Workspace data"); + expect(submittedReview).toEqual({ + expected_release_id: activeCanvas.active_release_id, + expected_permission_digest: permissionDigest, + expected_grant_generation: grantGeneration, + }); + await testPage.getByTestId("canvas-mobile-actions").tap(); + await expect( + testPage + .getByTestId("canvas-mobile-actions-sheet") + .getByTestId("canvas-action-enable-workspace-data"), + ).toHaveCount(0); + } finally { + await Promise.all(canvasIds.map((canvasId) => removeCanvas(apiClient, canvasId))); + await releaseFeature(); + } + }); + test("uses a focused route, workspace navigation, and an inset action drawer", async ({ testPage, apiClient, @@ -358,8 +470,12 @@ test.describe("Plugin-backed canvases on mobile", () => { const releaseFeature = await enableCanvasFeature(backend, apiClient, seedData.workspaceId); const canvasIds: string[] = []; + let workspacePreview: Awaited> | undefined; try { - const seeded = await seedTaskCanvas(testPage, apiClient, seedData, true); + workspacePreview = await seedCanvasWorkspacePreview(apiClient, seedData); + const seeded = await seedTaskCanvas(testPage, apiClient, seedData, true, { + foreignWorkspaceId: workspacePreview.foreignWorkspaceId, + }); canvasIds.push(seeded.canvas.id); const activeCanvas = await approvePendingCanvasThroughHost( testPage, @@ -390,6 +506,21 @@ test.describe("Plugin-backed canvases on mobile", () => { ); await expect(fixture.getByTestId("canvas-fixture-context")).toHaveText(seeded.taskId); await expect(fixture.getByTestId("canvas-fixture-sse-status")).toHaveText("connected"); + const expectedTaskCount = (await apiClient.listTasks(seedData.workspaceId)).tasks.length; + expect(expectedTaskCount).toBeGreaterThanOrEqual(2); + await fixture.getByTestId("canvas-fixture-refresh").tap(); + await expect(fixture.getByTestId("canvas-fixture-refresh-status")).toHaveText("refreshed"); + await expect(fixture.getByTestId("canvas-fixture-task-count")).toHaveText( + String(expectedTaskCount), + ); + await expect(fixture.locator(".task-item")).toHaveCount(expectedTaskCount); + await expect(fixture.getByTestId("canvas-fixture-task-ids")).toContainText(seeded.taskId); + await expect(fixture.getByTestId("canvas-fixture-task-ids")).toContainText( + workspacePreview.workspaceTaskId, + ); + await expect(fixture.getByTestId("canvas-fixture-foreign-workspace-status")).toHaveText( + "denied:403", + ); const actionsButton = testPage.getByTestId("canvas-mobile-actions"); await expect(actionsButton).toBeVisible(); @@ -409,7 +540,7 @@ test.describe("Plugin-backed canvases on mobile", () => { const promotionDialog = testPage.getByTestId("canvas-promotion-dialog"); await expect(promotionDialog).toBeVisible(); await expect(promotionDialog.getByTestId("canvas-promotion-target-scope")).toHaveText( - "workspace", + "Workspace", ); await promotionDialog.getByRole("button", { name: "Confirm promotion", exact: true }).tap(); @@ -480,6 +611,7 @@ test.describe("Plugin-backed canvases on mobile", () => { ).toBeVisible(); } finally { await Promise.all(canvasIds.map((canvasId) => removeCanvas(apiClient, canvasId))); + await workspacePreview?.cleanup(); await releaseFeature(); } }); diff --git a/apps/web/e2e/tests/canvas/plugin-canvas.spec.ts b/apps/web/e2e/tests/canvas/plugin-canvas.spec.ts index dab666cc9d..fb501dc202 100644 --- a/apps/web/e2e/tests/canvas/plugin-canvas.spec.ts +++ b/apps/web/e2e/tests/canvas/plugin-canvas.spec.ts @@ -11,6 +11,7 @@ import { expectCanvasFrameFillsHost, listCanvasReleases, removeCanvas, + seedCanvasWorkspacePreview, seedTaskCanvas, waitForSessionWorkspace, writeCanvasSource, @@ -150,8 +151,12 @@ test.describe("Plugin-backed canvases in the desktop task workbench", () => { const releaseFeature = await enableCanvasFeature(backend, apiClient, seedData.workspaceId); let canvasId: string | undefined; + let workspacePreview: Awaited> | undefined; try { - const seeded = await seedTaskCanvas(testPage, apiClient, seedData); + workspacePreview = await seedCanvasWorkspacePreview(apiClient, seedData); + const seeded = await seedTaskCanvas(testPage, apiClient, seedData, false, { + foreignWorkspaceId: workspacePreview.foreignWorkspaceId, + }); canvasId = seeded.canvas.id; await expect(testPage.getByTestId("dockview-task-layout")).toBeVisible(); @@ -281,7 +286,21 @@ test.describe("Plugin-backed canvases in the desktop task workbench", () => { .getByTestId("canvas-fixture-appearance-background") .textContent(); await expect(fixture.getByTestId("canvas-fixture-context")).toHaveText(seeded.taskId); - await expect(fixture.getByTestId("canvas-fixture-task-count")).toHaveText("1"); + const expectedTaskCount = (await apiClient.listTasks(seedData.workspaceId)).tasks.length; + expect(expectedTaskCount).toBeGreaterThanOrEqual(2); + await fixture.getByTestId("canvas-fixture-refresh").click(); + await expect(fixture.getByTestId("canvas-fixture-refresh-status")).toHaveText("refreshed"); + await expect(fixture.getByTestId("canvas-fixture-task-count")).toHaveText( + String(expectedTaskCount), + ); + await expect(fixture.locator(".task-item")).toHaveCount(expectedTaskCount); + await expect(fixture.getByTestId("canvas-fixture-task-ids")).toContainText(seeded.taskId); + await expect(fixture.getByTestId("canvas-fixture-task-ids")).toContainText( + workspacePreview.workspaceTaskId, + ); + await expect(fixture.getByTestId("canvas-fixture-foreign-workspace-status")).toHaveText( + "denied:403", + ); await expect(fixture.getByTestId("canvas-fixture-workflow-count")).toHaveText("1"); await expect(fixture.getByTestId("canvas-fixture-step-id")).not.toHaveText("loading"); await expect(fixture.getByTestId("canvas-fixture-sse-status")).toHaveText("connected"); @@ -357,6 +376,7 @@ test.describe("Plugin-backed canvases in the desktop task workbench", () => { .not.toBe(lightBackground); } finally { if (canvasId) await removeCanvas(apiClient, canvasId); + await workspacePreview?.cleanup(); await releaseFeature(); } }); diff --git a/apps/web/e2e/tests/canvas/workflow-transition-history.spec.ts b/apps/web/e2e/tests/canvas/workflow-transition-history.spec.ts index 470823749b..7ac87f2a4d 100644 --- a/apps/web/e2e/tests/canvas/workflow-transition-history.spec.ts +++ b/apps/web/e2e/tests/canvas/workflow-transition-history.spec.ts @@ -8,7 +8,7 @@ import { seedTaskCanvas, } from "./canvas-fixture"; -test("task and workspace transition reads stay inside canvas scope", async ({ +test("workspace transition groups remain available before and after task canvas promotion", async ({ testPage, apiClient, backend, @@ -45,13 +45,16 @@ test("task and workspace transition reads stay inside canvas scope", async ({ item.to_workflow_step_id === seedData.startStepId, ), ).toBe(true); - const taskScopeGroups = await body.evaluate( - async (_, flowId) => - (await fetch(`./_kandev/v1/data/workflows/${encodeURIComponent(flowId)}/transition-groups`)) - .status, - seedData.workflowId, + const prePromotionGroups = await body.evaluate(async (_, flowId) => { + const response = await fetch( + `./_kandev/v1/data/workflows/${encodeURIComponent(flowId)}/transition-groups?limit=200`, + ); + return { status: response.status, body: await response.json() }; + }, seedData.workflowId); + expect(prePromotionGroups.status).toBe(200); + expect(prePromotionGroups.body.items.some((item: { count: number }) => item.count > 0)).toBe( + true, ); - expect(taskScopeGroups).toBe(403); const workspace = await promoteCanvas(apiClient, approved); await testPage.goto(canvasHref(workspace.id)); const groups = await testPage diff --git a/apps/web/lib/api/domains/canvas-api.test.ts b/apps/web/lib/api/domains/canvas-api.test.ts index 415a0b86b1..7497ec9a34 100644 --- a/apps/web/lib/api/domains/canvas-api.test.ts +++ b/apps/web/lib/api/domains/canvas-api.test.ts @@ -2,9 +2,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { archiveCanvas, confirmCanvasPromotion, + enableCanvasWorkspaceData, getCanvasRuntime, listTaskCanvases, listWorkspaceCanvases, + requestCanvasWorkspaceData, requestCanvasPromotion, type CanvasPromotionPreview, type CanvasRuntimeResponse, @@ -153,7 +155,64 @@ describe("canvas host and lifecycle endpoints", () => { }), }); }); +}); +describe("workspace data scope endpoint", () => { + it("reviews and confirms a task canvas workspace data upgrade", async () => { + const preview = { + canvas: { + id: "canvas-1", + plugin_instance_id: "instance-1", + plugin_id: "plugin-1", + workspace_id: "workspace-1", + task_id: "task-1", + scope_kind: "task", + data_scope_kind: "task", + title: "Canvas", + status: "active", + }, + active_release_id: "release-3", + permission_digest: "digest-3", + grant_generation: 9, + current_data_scope_kind: "task", + target_data_scope_kind: "workspace", + permissions: { reads: ["tasks"] }, + }; + fetchSpy + .mockResolvedValueOnce(jsonResponse(preview)) + .mockResolvedValueOnce(jsonResponse({ ...preview.canvas, data_scope_kind: "workspace" })); + + await expect( + requestCanvasWorkspaceData("canvas-1", { baseUrl: API_BASE_URL }), + ).resolves.toEqual(preview); + await enableCanvasWorkspaceData( + "canvas-1", + { + expected_release_id: "release-3", + expected_permission_digest: "digest-3", + expected_grant_generation: 9, + }, + { baseUrl: API_BASE_URL }, + ); + + expect(fetchSpy.mock.calls[0][0]).toBe( + `${API_BASE_URL}/api/v1/canvases/canvas-1/workspace-data-preview`, + ); + expect(fetchSpy.mock.calls[1][0]).toBe( + `${API_BASE_URL}/api/v1/canvases/canvas-1/workspace-data`, + ); + expect(fetchSpy.mock.calls[1][1]).toMatchObject({ + method: "POST", + body: JSON.stringify({ + expected_release_id: "release-3", + expected_permission_digest: "digest-3", + expected_grant_generation: 9, + }), + }); + }); +}); + +describe("canvas archive endpoint", () => { it("archives with a mutation request and preserves the API response", async () => { fetchSpy.mockResolvedValueOnce(jsonResponse({ id: "canvas-1", status: "archived" })); diff --git a/apps/web/lib/api/domains/canvas-api.ts b/apps/web/lib/api/domains/canvas-api.ts index 8b00033a00..58272da063 100644 --- a/apps/web/lib/api/domains/canvas-api.ts +++ b/apps/web/lib/api/domains/canvas-api.ts @@ -40,6 +40,7 @@ export type Canvas = { origin_task_id?: string; created_by_session_id?: string; scope_kind: CanvasScopeKind | string; + data_scope_kind?: CanvasScopeKind | string; title: string; status: CanvasStatus | string; active_release_id?: string; @@ -53,6 +54,19 @@ export type Canvas = { updated_at?: string; }; +export function canvasDataScope(canvas: Pick): string { + return canvas.data_scope_kind || canvas.scope_kind; +} + +export function canvasCanEnableWorkspaceData(canvas: Canvas): boolean { + return ( + canvas.scope_kind === "task" && + canvasDataScope(canvas) === "task" && + canvas.status === "active" && + canvas.active_release_status === "valid" + ); +} + export type CanvasListResponse = { canvases: Canvas[]; total?: number; @@ -68,6 +82,7 @@ export type CanvasRuntimeResponse = { canvas?: Canvas; binding?: { scope_kind?: CanvasScopeKind | string; + data_scope_kind?: CanvasScopeKind | string; grant_generation?: number; }; }; @@ -104,9 +119,21 @@ export type CanvasPromotionPreview = { grant_generation?: number; current_scope?: CanvasScopeKind | string; target_scope?: CanvasScopeKind | string; + current_data_scope_kind?: CanvasScopeKind | string; + target_data_scope_kind?: CanvasScopeKind | string; placement?: string; }; +export type CanvasWorkspaceDataPreview = { + canvas: Canvas; + active_release_id: string; + permission_digest: string; + grant_generation: number; + current_data_scope_kind: CanvasScopeKind | string; + target_data_scope_kind: CanvasScopeKind | string; + permissions?: CanvasPermissionReview; +}; + export type CanvasReleaseListResponse = { releases: CanvasRelease[]; }; @@ -198,6 +225,28 @@ export function confirmCanvasPromotion( return mutate(canvasPath(canvasId, "/promotion"), "POST", review, options); } +export function requestCanvasWorkspaceData( + canvasId: string, + options?: ApiRequestOptions, +): Promise { + return fetchJson( + canvasPath(canvasId, "/workspace-data-preview"), + options, + ); +} + +export function enableCanvasWorkspaceData( + canvasId: string, + review: { + expected_release_id: string; + expected_permission_digest: string; + expected_grant_generation: number; + }, + options?: ApiRequestOptions, +): Promise { + return mutate(canvasPath(canvasId, "/workspace-data"), "POST", review, options); +} + export function listCanvasReleases( canvasId: string, options?: ApiRequestOptions, diff --git a/apps/web/lib/api/domains/canvas-error-copy.ts b/apps/web/lib/api/domains/canvas-error-copy.ts index 96e8c64736..83ac16a25d 100644 --- a/apps/web/lib/api/domains/canvas-error-copy.ts +++ b/apps/web/lib/api/domains/canvas-error-copy.ts @@ -18,6 +18,8 @@ const CANVAS_API_ERROR_TRANSLATIONS: Record = { artifact_unavailable: CANVAS_UNAVAILABLE_KEY, active_release_missing: CANVAS_UNAVAILABLE_KEY, promotion_review_stale: "canvases:actionFailed", + workspace_data_review_stale: "canvases:workspaceDataReviewStale", + workspace_owner_required: "canvases:workspaceOwnerRequired", canvas_edit_stale: "canvases:actionFailed", }; diff --git a/apps/web/src/locales/en/canvases.json b/apps/web/src/locales/en/canvases.json index 762a83fec0..bcfce73f9c 100644 --- a/apps/web/src/locales/en/canvases.json +++ b/apps/web/src/locales/en/canvases.json @@ -32,11 +32,25 @@ "promoteCanvas": "Promote canvas", "promoteCanvasHelp": "Make this task canvas available in workspace navigation.", "promoteCanvasUnavailable": "Promotion is available after a valid release.", + "taskDataScope": "Task data", + "workspaceDataScope": "Workspace data", + "taskPlacementScope": "Task", + "workspacePlacementScope": "Workspace", + "enableWorkspaceData": "Enable workspace data", + "enableWorkspaceDataHelp": "Allow this canvas to use its declared permissions across the current workspace. This does not move it into workspace navigation.", + "workspaceDataReviewDescription": "Review the active release and its declared permissions for {{title}}.", + "workspaceDataReviewScopeChange": "This changes data access only. The canvas stays in its current task.", + "workspaceDataReviewStale": "Canvas or permission details changed. Close this review and open it again.", + "workspaceOwnerRequired": "Only the current workspace owner can enable workspace data.", + "enablingWorkspaceData": "Enabling workspace data", + "activeRelease": "Active release", "openInNewTab": "Open in new tab", "openInNewTabHelp": "Open this canvas in a focused browser tab.", "promoteCanvasDescription": "Review permissions before moving {{title}} to workspace scope.", "loadingPermissions": "Loading requested permissions", "promotionScopeChange": "Promotion makes this canvas available in workspace navigation.", + "promotionDataAccessPreserved": "Promotion changes placement only. Workspace data access already applies and will stay the same.", + "promotionDataAccessChanges": "Promotion changes placement and expands declared data permissions to the workspace.", "noAdditionalPermissions": "No additional permissions were requested.", "sharedState": "Shared state", "promotingCanvas": "Promoting canvas", @@ -76,6 +90,8 @@ "promotionSourceTask": "Source task", "promotionSourceSession": "Source session", "promotionTargetScope": "Target scope", + "promotionCurrentDataScope": "Data access before promotion", + "promotionTargetDataScope": "Data access after promotion", "promotionPlacement": "Workspace placement", "permissionReads": "API reads", "permissionWrites": "API writes", diff --git a/apps/web/src/locales/ja/canvases.json b/apps/web/src/locales/ja/canvases.json index 99761b428a..92c2a73fd8 100644 --- a/apps/web/src/locales/ja/canvases.json +++ b/apps/web/src/locales/ja/canvases.json @@ -32,11 +32,25 @@ "promoteCanvas": "キャンバスを昇格", "promoteCanvasHelp": "このタスクキャンバスをワークスペースのナビゲーションで利用できるようにします。", "promoteCanvasUnavailable": "有効なリリース後に昇格できます。", + "taskDataScope": "タスクデータ", + "workspaceDataScope": "ワークスペースデータ", + "taskPlacementScope": "タスク", + "workspacePlacementScope": "ワークスペース", + "enableWorkspaceData": "ワークスペースデータを有効にする", + "enableWorkspaceDataHelp": "このキャンバスが宣言済みの権限を現在のワークスペース全体で使用できるようにします。キャンバスの配置は変わりません。", + "workspaceDataReviewDescription": "{{title}} のアクティブなリリースと宣言済みの権限を確認します。", + "workspaceDataReviewScopeChange": "データアクセスのみを変更します。キャンバスは現在のタスクに残ります。", + "workspaceDataReviewStale": "キャンバスまたは権限の情報が変更されました。この画面を閉じて、もう一度開いてください。", + "workspaceOwnerRequired": "ワークスペースデータを有効にできるのは現在のワークスペース所有者のみです。", + "enablingWorkspaceData": "ワークスペースデータを有効にしています", + "activeRelease": "アクティブなリリース", "openInNewTab": "新しいタブで開く", "openInNewTabHelp": "このキャンバスを専用のブラウザータブで開きます。", "promoteCanvasDescription": "{{title}} をワークスペーススコープに移動する前に権限を確認してください。", "loadingPermissions": "要求された権限を読み込み中", "promotionScopeChange": "昇格すると、このキャンバスがワークスペースのナビゲーションで利用できるようになります。", + "promotionDataAccessPreserved": "昇格では配置のみが変わります。ワークスペースデータへのアクセスはそのまま維持されます。", + "promotionDataAccessChanges": "昇格では配置が変わり、宣言済みのデータ権限がワークスペース全体に広がります。", "noAdditionalPermissions": "追加の権限は要求されていません。", "sharedState": "共有状態", "promotingCanvas": "キャンバスを昇格中", @@ -76,6 +90,8 @@ "promotionSourceTask": "ソースタスク", "promotionSourceSession": "ソースセッション", "promotionTargetScope": "ターゲットスコープ", + "promotionCurrentDataScope": "昇格前のデータアクセス", + "promotionTargetDataScope": "昇格後のデータアクセス", "promotionPlacement": "ワークスペース内の配置", "permissionReads": "API 読み取り", "permissionWrites": "API 書き込み", diff --git a/apps/web/src/locales/pseudo/canvases.json b/apps/web/src/locales/pseudo/canvases.json index e3fed1022e..871ca39733 100644 --- a/apps/web/src/locales/pseudo/canvases.json +++ b/apps/web/src/locales/pseudo/canvases.json @@ -32,11 +32,25 @@ "promoteCanvas": "Ƥŕōḿōţē ćàńvàś", "promoteCanvasHelp": "Ḿàķē ţĥĩś ţàśķ ćàńvàś àvàĩĺàƀĺē ĩń ŵōŕķśƥàćē ńàvĩĝàţĩōń.", "promoteCanvasUnavailable": "Ƥŕōḿōţĩōń ĩś àvàĩĺàƀĺē àƒţēŕ à vàĺĩď ŕēĺēàśē.", + "taskDataScope": "Ţàśķ ďàţà", + "workspaceDataScope": "Ŵōŕķśƥàćē ďàţà", + "taskPlacementScope": "Ţàśķ", + "workspacePlacementScope": "Ŵōŕķśƥàćē", + "enableWorkspaceData": "Ēńàƀĺē ŵōŕķśƥàćē ďàţà", + "enableWorkspaceDataHelp": "Àĺĺōŵ ţĥĩś ćàńvàś ţō ũśē ĩţś ďēćĺàŕēď ƥēŕḿĩśśĩōńś àćŕōśś ţĥē ćũŕŕēńţ ŵōŕķśƥàćē. Ţĥĩś ďōēś ńōţ ḿōvē ĩţ ĩńţō ŵōŕķśƥàćē ńàvĩĝàţĩōń.", + "workspaceDataReviewDescription": "Ŕēvĩēŵ ţĥē àćţĩvē ŕēĺēàśē àńď ĩţś ďēćĺàŕēď ƥēŕḿĩśśĩōńś ƒōŕ {{title}}.", + "workspaceDataReviewScopeChange": "Ţĥĩś ćĥàńĝēś ďàţà àććēśś ōńĺŷ. Ţĥē ćàńvàś śţàŷś ĩń ĩţś ćũŕŕēńţ ţàśķ.", + "workspaceDataReviewStale": "Ţĥē ćàńvàś ōŕ ƥēŕḿĩśśĩōń ďēţàĩĺś ĥàvē ćĥàńĝēď. Ćĺōśē ţĥĩś ŕēvĩēŵ àńď ōƥēń ĩţ àĝàĩń.", + "workspaceOwnerRequired": "Ōńĺŷ ţĥē ćũŕŕēńţ ŵōŕķśƥàćē ōŵńēŕ ćàń ēńàƀĺē ŵōŕķśƥàćē ďàţà.", + "enablingWorkspaceData": "Ēńàƀĺĩńĝ ŵōŕķśƥàćē ďàţà", + "activeRelease": "Àćţĩvē ŕēĺēàśē", "openInNewTab": "Ōƥēń ĩń ńēŵ ţàƀ", "openInNewTabHelp": "Ōƥēń ţĥĩś ćàńvàś ĩń à ƒōćũśēď ƀŕōŵśēŕ ţàƀ.", "promoteCanvasDescription": "Ŕēvĩēŵ ƥēŕḿĩśśĩōńś ƀēƒōŕē ḿōvĩńĝ {{title}} ţō ŵōŕķśƥàćē śćōƥē.", "loadingPermissions": "Ĺōàďĩńĝ ŕēqũēśţēď ƥēŕḿĩśśĩōńś", "promotionScopeChange": "Ƥŕōḿōţĩōń ḿàķēś ţĥĩś ćàńvàś àvàĩĺàƀĺē ĩń ŵōŕķśƥàćē ńàvĩĝàţĩōń.", + "promotionDataAccessPreserved": "Ƥŕōḿōţĩōń ćĥàńĝēś ƥĺàćēḿēńţ ōńĺŷ. Ŵōŕķśƥàćē ďàţà àććēśś àĺŕēàďŷ àƥƥĺĩēś àńď ŵĩĺĺ śţàŷ ţĥē śàḿē.", + "promotionDataAccessChanges": "Ƥŕōḿōţĩōń ćĥàńĝēś ƥĺàćēḿēńţ àńď ēxƥàńďś ďēćĺàŕēď ďàţà ƥēŕḿĩśśĩōńś ţō ţĥē ŵōŕķśƥàćē.", "noAdditionalPermissions": "Ńō àďďĩţĩōńàĺ ƥēŕḿĩśśĩōńś ŵēŕē ŕēqũēśţēď.", "sharedState": "Śĥàŕēď śţàţē", "promotingCanvas": "Ƥŕōḿōţĩńĝ ćàńvàś", @@ -76,6 +90,8 @@ "promotionSourceTask": "Śōũŕćē ţàśķ", "promotionSourceSession": "Śōũŕćē śēśśĩōń", "promotionTargetScope": "Ţàŕĝēţ śćōƥē", + "promotionCurrentDataScope": "Ďàţà àććēśś ƀēƒōŕē ƥŕōḿōţĩōń", + "promotionTargetDataScope": "Ďàţà àććēśś àƒţēŕ ƥŕōḿōţĩōń", "promotionPlacement": "Ŵōŕķśƥàćē ƥĺàćēḿēńţ", "permissionReads": "ÀƤĨ ŕēàďś", "permissionWrites": "ÀƤĨ ŵŕĩţēś", diff --git a/apps/web/src/locales/pt-pt/canvases.json b/apps/web/src/locales/pt-pt/canvases.json index 23a21093e5..2314a718c5 100644 --- a/apps/web/src/locales/pt-pt/canvases.json +++ b/apps/web/src/locales/pt-pt/canvases.json @@ -32,11 +32,25 @@ "promoteCanvas": "Promover canvas", "promoteCanvasHelp": "Disponibiliza este canvas de tarefa na navegação da área de trabalho.", "promoteCanvasUnavailable": "A promoção fica disponível depois de uma versão válida.", + "taskDataScope": "Dados da tarefa", + "workspaceDataScope": "Dados do workspace", + "taskPlacementScope": "Tarefa", + "workspacePlacementScope": "Área de trabalho", + "enableWorkspaceData": "Ativar dados do workspace", + "enableWorkspaceDataHelp": "Permite que este canvas use as permissões declaradas em todo o workspace atual. Não adiciona o canvas à navegação do workspace.", + "workspaceDataReviewDescription": "Reveja a versão ativa e as permissões declaradas de {{title}}.", + "workspaceDataReviewScopeChange": "Isto altera apenas o acesso aos dados. O canvas mantém-se na tarefa atual.", + "workspaceDataReviewStale": "Os dados do canvas ou das permissões foram alterados. Feche esta revisão e volte a abri-la.", + "workspaceOwnerRequired": "Apenas o proprietário atual do workspace pode ativar os dados do workspace.", + "enablingWorkspaceData": "A ativar os dados do workspace", + "activeRelease": "Versão ativa", "openInNewTab": "Abrir num novo separador", "openInNewTabHelp": "Abre este canvas num separador focado do navegador.", "promoteCanvasDescription": "Reveja as permissões antes de mover {{title}} para o âmbito do workspace.", "loadingPermissions": "A carregar as permissões pedidas", "promotionScopeChange": "A promoção disponibiliza este canvas na navegação do workspace.", + "promotionDataAccessPreserved": "A promoção altera apenas a localização. O acesso aos dados do workspace mantém-se.", + "promotionDataAccessChanges": "A promoção altera a localização e alarga ao workspace as permissões de dados declaradas.", "noAdditionalPermissions": "Não foram pedidas permissões adicionais.", "sharedState": "Estado partilhado", "promotingCanvas": "A promover o canvas", @@ -76,6 +90,8 @@ "promotionSourceTask": "Tarefa de origem", "promotionSourceSession": "Sessão de origem", "promotionTargetScope": "Âmbito de destino", + "promotionCurrentDataScope": "Acesso aos dados antes da promoção", + "promotionTargetDataScope": "Acesso aos dados depois da promoção", "promotionPlacement": "Localização no workspace", "permissionReads": "Leituras da API", "permissionWrites": "Escritas da API", diff --git a/apps/web/src/locales/zh-cn/canvases.json b/apps/web/src/locales/zh-cn/canvases.json index 44586e61df..07fe505fc7 100644 --- a/apps/web/src/locales/zh-cn/canvases.json +++ b/apps/web/src/locales/zh-cn/canvases.json @@ -32,11 +32,25 @@ "promoteCanvas": "提升画布", "promoteCanvasHelp": "让此任务画布显示在工作区导航中。", "promoteCanvasUnavailable": "有效版本获批准后才能提升。", + "taskDataScope": "任务数据", + "workspaceDataScope": "工作区数据", + "taskPlacementScope": "任务", + "workspacePlacementScope": "工作区", + "enableWorkspaceData": "启用工作区数据", + "enableWorkspaceDataHelp": "允许此画布在当前工作区内使用其已声明的权限。此操作不会将画布移至工作区导航。", + "workspaceDataReviewDescription": "查看 {{title}} 的活动版本和已声明权限。", + "workspaceDataReviewScopeChange": "此操作只会更改数据访问权限。画布仍位于当前任务中。", + "workspaceDataReviewStale": "画布或权限信息已更改。请关闭此审核并重新打开。", + "workspaceOwnerRequired": "只有当前工作区所有者可以启用工作区数据。", + "enablingWorkspaceData": "正在启用工作区数据", + "activeRelease": "活动版本", "openInNewTab": "在新标签页中打开", "openInNewTabHelp": "在聚焦的浏览器标签页中打开此画布。", "promoteCanvasDescription": "将 {{title}} 移至工作区范围前,请先查看权限。", "loadingPermissions": "正在加载请求的权限", "promotionScopeChange": "提升后,此画布会出现在工作区导航中。", + "promotionDataAccessPreserved": "提升只会更改位置。此画布已经可以访问工作区数据,提升后仍保持不变。", + "promotionDataAccessChanges": "提升会更改位置,并将已声明的数据权限扩展到工作区。", "noAdditionalPermissions": "未请求其他权限。", "sharedState": "共享状态", "promotingCanvas": "正在提升画布", @@ -76,6 +90,8 @@ "promotionSourceTask": "来源任务", "promotionSourceSession": "来源会话", "promotionTargetScope": "目标范围", + "promotionCurrentDataScope": "提升前的数据访问范围", + "promotionTargetDataScope": "提升后的数据访问范围", "promotionPlacement": "工作区位置", "permissionReads": "API 读取", "permissionWrites": "API 写入", diff --git a/apps/web/src/locales/zh-hk/canvases.json b/apps/web/src/locales/zh-hk/canvases.json index e688abdfb5..3023206adb 100644 --- a/apps/web/src/locales/zh-hk/canvases.json +++ b/apps/web/src/locales/zh-hk/canvases.json @@ -32,11 +32,25 @@ "promoteCanvas": "提升畫布", "promoteCanvasHelp": "讓此任務畫布顯示在工作區導航中。", "promoteCanvasUnavailable": "有效版本獲批准後才能提升。", + "taskDataScope": "任務數據", + "workspaceDataScope": "工作區數據", + "taskPlacementScope": "任務", + "workspacePlacementScope": "工作區", + "enableWorkspaceData": "啓用工作區數據", + "enableWorkspaceDataHelp": "允許此畫布在目前工作區內使用其已聲明的權限。此操作不會將畫布移至工作區導航。", + "workspaceDataReviewDescription": "查看 {{title}} 的活動版本和已聲明權限。", + "workspaceDataReviewScopeChange": "此操作只會更改數據存取權限。畫布仍位於目前任務中。", + "workspaceDataReviewStale": "畫布或權限資訊已更改。請關閉此審核並重新開啟。", + "workspaceOwnerRequired": "只有目前工作區所有者可以啓用工作區數據。", + "enablingWorkspaceData": "正在啓用工作區數據", + "activeRelease": "活動版本", "openInNewTab": "在新標籤頁中開啟", "openInNewTabHelp": "在聚焦的瀏覽器標籤頁中開啟此畫布。", "promoteCanvasDescription": "將 {{title}} 移至工作區範圍前,請先查看權限。", "loadingPermissions": "正在載入請求的權限", "promotionScopeChange": "提升後,此畫布會出現在工作區導航中。", + "promotionDataAccessPreserved": "提升只會更改位置。此畫布已經可以存取工作區數據,提升後仍保持不變。", + "promotionDataAccessChanges": "提升會更改位置,並將已聲明的數據權限擴展到工作區。", "noAdditionalPermissions": "未請求其他權限。", "sharedState": "共享狀態", "promotingCanvas": "正在提升畫布", @@ -76,6 +90,8 @@ "promotionSourceTask": "來源任務", "promotionSourceSession": "來源工作階段", "promotionTargetScope": "目標範圍", + "promotionCurrentDataScope": "提升前的數據存取範圍", + "promotionTargetDataScope": "提升後的數據存取範圍", "promotionPlacement": "工作區位置", "permissionReads": "API 讀取", "permissionWrites": "API 寫入", diff --git a/apps/web/src/locales/zh-tw/canvases.json b/apps/web/src/locales/zh-tw/canvases.json index dc82a3813f..2b52bf4e6c 100644 --- a/apps/web/src/locales/zh-tw/canvases.json +++ b/apps/web/src/locales/zh-tw/canvases.json @@ -32,11 +32,25 @@ "promoteCanvas": "提升畫布", "promoteCanvasHelp": "讓此任務畫布顯示在工作區導覽中。", "promoteCanvasUnavailable": "有效版本獲批准後才能提升。", + "taskDataScope": "任務資料", + "workspaceDataScope": "工作區資料", + "taskPlacementScope": "任務", + "workspacePlacementScope": "工作區", + "enableWorkspaceData": "啟用工作區資料", + "enableWorkspaceDataHelp": "允許此畫布在目前工作區內使用其已宣告的權限。此操作不會將畫布移至工作區導覽。", + "workspaceDataReviewDescription": "檢視 {{title}} 的活動版本和已宣告權限。", + "workspaceDataReviewScopeChange": "此操作只會更改資料存取權限。畫布仍位於目前任務中。", + "workspaceDataReviewStale": "畫布或權限資訊已更改。請關閉此稽核並重新開啟。", + "workspaceOwnerRequired": "只有目前工作區所有者可以啟用工作區資料。", + "enablingWorkspaceData": "正在啟用工作區資料", + "activeRelease": "活動版本", "openInNewTab": "在新標籤頁中開啟", "openInNewTabHelp": "在聚焦的瀏覽器標籤頁中開啟此畫布。", "promoteCanvasDescription": "將 {{title}} 移至工作區範圍前,請先檢視權限。", "loadingPermissions": "正在載入請求的權限", "promotionScopeChange": "提升後,此畫布會出現在工作區導覽中。", + "promotionDataAccessPreserved": "提升只會更改位置。此畫布已經可以存取工作區資料,提升後仍保持不變。", + "promotionDataAccessChanges": "提升會更改位置,並將已宣告的資料權限擴充套件到工作區。", "noAdditionalPermissions": "未請求其他權限。", "sharedState": "共享狀態", "promotingCanvas": "正在提升畫布", @@ -76,6 +90,8 @@ "promotionSourceTask": "來源任務", "promotionSourceSession": "來源工作階段", "promotionTargetScope": "目標範圍", + "promotionCurrentDataScope": "提升前的資料存取範圍", + "promotionTargetDataScope": "提升後的資料存取範圍", "promotionPlacement": "工作區位置", "permissionReads": "API 讀取", "permissionWrites": "API 寫入", diff --git a/docs/decisions/2026-08-26-plugin-backed-web-app-canvases.md b/docs/decisions/2026-08-26-plugin-backed-web-app-canvases.md index baaf7a2714..ce1b5a5f17 100644 --- a/docs/decisions/2026-08-26-plugin-backed-web-app-canvases.md +++ b/docs/decisions/2026-08-26-plugin-backed-web-app-canvases.md @@ -7,6 +7,8 @@ The initial-grant rule is amended by [owner-authorized canvas creation](2026-09-10-canvas-creation-authority.md). +Task placement and data scope are separated for owner-authorized canvases by +[task canvas workspace data](2026-09-23-task-canvas-workspace-data.md). The bounded startup acknowledgement is specified in the [runtime design](../specs/plugins/system-design/isolated-web-app-contributions.md#runtime-startup-protocol); it adds no privileged host API. diff --git a/docs/decisions/2026-09-10-canvas-creation-authority.md b/docs/decisions/2026-09-10-canvas-creation-authority.md index 0b9c51e434..1c0aba7f06 100644 --- a/docs/decisions/2026-09-10-canvas-creation-authority.md +++ b/docs/decisions/2026-09-10-canvas-creation-authority.md @@ -4,6 +4,10 @@ **Date:** 2026-09-10 **Area:** backend, protocol, security +The task-data initial grant ceiling below is amended by +[task canvas workspace data](2026-09-23-task-canvas-workspace-data.md). +It remains the historical rule for version-1 authority. + ## Context A user asks their task agent to create a canvas, then must approve its first diff --git a/docs/decisions/2026-09-23-task-canvas-workspace-data.md b/docs/decisions/2026-09-23-task-canvas-workspace-data.md new file mode 100644 index 0000000000..a4b0e53157 --- /dev/null +++ b/docs/decisions/2026-09-23-task-canvas-workspace-data.md @@ -0,0 +1,72 @@ +# ADR-2026-09-23-task-canvas-workspace-data: Separate canvas placement from data scope + +**Status:** accepted +**Date:** 2026-09-23 +**Area:** backend, frontend, protocol, security + +## Context + +Users create canvases from tasks and review their behavior before promotion. +The original canvas contract binds task placement and data scope together. A +task coordinator that calls the supported canvas task-list endpoint therefore +receives only its creating task, even when the package declares task reads and +the workspace has many tasks. Granting every capability would not remove that +scope filter. + +The [owner-authorized creation decision](2026-09-10-canvas-creation-authority.md) +already treats the user's request as authority for the first release's exact +declared grants. The [same-origin decision](2026-09-19-trusted-same-origin-canvases.md) +also establishes that locally run canvas code has the viewer's ordinary browser +authority. The relative canvas API should provide a coherent supported path +without relying on direct first-party API calls. + +## Decision + +A task canvas has separate placement and data scopes. For a newly +owner-authorized local canvas, the first valid release receives only its +declared supported capabilities, with data scope limited to its current +workspace. It remains placed in the creating task until the user promotes it. +The task-list API can show every task in that workspace before promotion; +current user authorization still applies. Promotion governs placement and +sharing, and still requires human confirmation. + +The data scope is server-owned, persisted, included in runtime bindings, and +checked alongside grants on every canvas-protocol request. Agent or manifest +input cannot choose a different workspace. Existing task-only releases retain +their scope until a workspace owner reviews their exact declared permissions +and explicitly enables workspace data access. Later permission increases and +imported packages continue through their existing review paths. + +This amends the task-scoped first-grant ceiling in the 2026-09-10 decision and +the coupling of task canvas and task data scope in the 2026-08-26 decision. It +does not change the same-origin trust decision or grant undeclared capabilities. + +## Consequences + +- An owner-created canvas can be evaluated with representative live workspace + data and supported actions before it appears in workspace navigation. +- Permission review remains tied to the exact capabilities a release uses; + there is no blanket read, write, network, secret, or backend permission. +- The runtime, persistence, review projection, and authoring guidance must + carry placement and data scopes independently. +- Existing task canvases require an explicit access upgrade or promotion to + show workspace data. No retained release is silently broadened on upgrade. +- The upgrade path must check current ownership and a versioned review + snapshot atomically, then invalidate older runtime tokens. + +## Alternatives Considered + +- **Grant all possible permissions by default:** Does not remove the task-list + scope filter and gives unused writes and network access to generated code. +- **Require promotion before representative preview:** Leaves the user unable + to judge the app before placing it in workspace navigation. +- **Call ordinary Kandev APIs from canvas code:** Works only with browser user + authority and bypasses the supported capability contract; capability-only + hosts would still show one task. +- **Silently widen all existing task canvases:** Changes retained release + authority without a review of the code and exact declared capabilities. + +## Related contracts + +- [Workspace preview requirements](../specs/canvases/requirements/task-canvas-workspace-preview.md) +- [Workspace preview design](../specs/canvases/system-design/task-canvas-workspace-preview.md) diff --git a/docs/plans/task-canvas-workspace-preview/plan.md b/docs/plans/task-canvas-workspace-preview/plan.md new file mode 100644 index 0000000000..71321f16dc --- /dev/null +++ b/docs/plans/task-canvas-workspace-preview/plan.md @@ -0,0 +1,152 @@ +--- +created: 2026-09-23 +status: complete +requirements: + - REQ-CANVASES-WORKSPACE-PREVIEW-001 + - REQ-CANVASES-LOCAL-CREATION-001 + - REQ-CANVASES-AGENT-WEB-APPS-003 +system_design: + - ../../specs/canvases/system-design/task-canvas-workspace-preview.md + - ../../specs/canvases/system-design/local-creation-authority.md + - ../../specs/canvases/system-design/agent-authored-web-apps.md + - ../../specs/plugins/system-design/canvas-workspace-data-scope.md +legacy_specs: [] +--- + +# Implementation Plan: Task canvas workspace data preview + +## Overview + +Let an owner-created canvas use its declared capabilities across the current +workspace while it remains in the creating task. First add the server-owned +data-scope and review transaction, then expose the scope and legacy upgrade +in the host, authoring guidance, and browser tests. Promotion continues to +control workspace placement. + +## Scope + +### In scope + +- Workspace task lists and all currently supported declared canvas data, + write, event, state, and network capabilities before promotion. +- A separate server-owned data scope on plugin instances and runtime bindings. +- An explicit, reviewed workspace-data upgrade for existing task canvases. +- Desktop and phone host scope presentation, localized copy, authoring and + public documentation, and focused browser coverage. + +### Out of scope + +- Cross-workspace and installation-wide data access. +- Blanket grants for undeclared capabilities, new capability kinds, or + managed agent-generated backend code. +- Silent widening of retained releases. + +## Technical approach + +1. Add nullable `data_scope_kind` to plugin instance persistence with null + fallback to the existing placement `scope_kind`. Create version-2 local + canvas authority with workspace data scope and exact workspace-ceiling + declared grants. Keep other instances and historical authority unchanged. +2. Carry data scope through `webapp.CapabilityBinding`, grant validation, and + the relative browser protocol. Use it for tasks, workflows, writes, + messages, dependency projection, and events. Bind every operation to the + trusted workspace and current user authorization. Follow the + [plugin runtime design](../../specs/plugins/system-design/canvas-workspace-data-scope.md). +3. Add an owner-reviewed workspace-data upgrade for retained task canvases + using the active release ID, permission digest, and grant generation as + conditional transaction inputs. Keep placement and package unchanged. +4. Project placement and data scope separately in canvas context and host + review. Update authoring instructions and public canvas/API docs. Use the + existing desktop action menu and phone action drawer for legacy upgrade. + +Likely backend files include `internal/plugins/instances/store.go`, +`internal/plugins/webapp/tokens.go`, `internal/plugins/webapp_protocol*.go`, +`internal/canvas/authoring.go`, `internal/canvas/service.go`, and the canvas +HTTP handler/controller. Existing E2E fixture expectations for one task must +change to a multi-task workspace fixture. + +## ASCII UI preview + +`UI-01: Task canvas host, new release (desktop)`. Entry: task canvas panel. +The host header stays fixed; the application owns its existing scroll region. +The badge reports data scope, while Promote controls placement. Labels are +illustrative; the distinction is required by +`AC-CANVASES-WORKSPACE-PREVIEW-001.3-.4`. + +```text +Task coordinator [Workspace data] [Releases and permissions] [Promote canvas] +-------------------------------------------------------------------------- +Existing tasks [Refresh] +Available 3 Active 2 Completed 1 +Task A Task B Task C +``` + +`UI-02: Existing task-only canvas (desktop)`. Entry: the same task panel. +The upgrade dialog uses the current permission-review layout and keeps +Cancel and Enable actions visible. It does not promote the canvas. + +```text +Task coordinator [Task data] [Enable workspace data] [Promote canvas] + | + Enable workspace data for this canvas? + Release: Task coordinator 1.0.0 + Reads: tasks in this workspace + Writes/events/network: exact declared permissions + [Cancel] [Enable workspace data] +``` + +`UI-03: Existing task-only canvas (phone)`. Entry: focused task canvas route. +The existing Actions drawer exposes the upgrade; the review opens as a +full-height view with one scrolling permission region and safe-area actions. + +```text +< Task Task coordinator [Actions] + Task data + | Actions drawer: Enable workspace data + v +Enable workspace data +Release and declared permissions [scrolls] +------------------------------------------- +[Cancel] [Enable workspace data] [fixed, safe area] +``` + +## Tests + +| Criteria | Evidence | +| --- | --- | +| `.1`, `.2`, `.8` | Plugin instance and web-app protocol tests for exact grants, all workspace pages, foreign denial, writes, workflows, messages, events, and dependencies. | +| `.3`, `.4` | Canvas service promotion and projection tests. | +| `.5`, `.6` | Conditional legacy-upgrade transaction and stale/owner/revocation tests. | +| `.7` | Canvas host component tests and desktop/mobile E2E. | + +## E2E tests + +- `apps/web/e2e/tests/canvas/plugin-canvas.spec.ts` (`chromium`): owner-created + task canvas sees two or more workspace tasks and refreshes before promotion; + promotion leaves the list intact. Covers `.1`, `.3`, `.4`. +- `apps/web/e2e/tests/canvas/mobile-plugin-canvas.spec.ts` + (`mobile-chrome`): same data before promotion plus task-only legacy upgrade + from the phone action drawer. Covers `.5`, `.7`. + +## Work orders + +- [x] [Task 01: Workspace data scope and legacy upgrade](task-01-workspace-data-scope.md) +- [x] [Task 02: Host scope review and browser proof](task-02-host-scope-review.md) + +Task 02 depends on Task 01. Both run in the primary session unless the user +later explicitly authorizes another arrangement. + +## Verification results + +Passed: backend canvas/plugin tests, focused frontend Vitest coverage, web +typecheck, i18n validation, desktop and mobile canvas Playwright suites, spec +index validation, specification lint, and public documentation validation. + +## Risks + +- Grant checks currently equate instance scope with data scope. Missing a + validation or event path could leak data or leave preview inconsistent. +- Existing task releases must retain their one-task behavior until reviewed; + a broad migration would silently change authority. +- Same-origin canvas code can call ordinary user APIs already; the supported + relative protocol must remain internally coherent in capability-only hosts. diff --git a/docs/plans/task-canvas-workspace-preview/task-01-workspace-data-scope.md b/docs/plans/task-canvas-workspace-preview/task-01-workspace-data-scope.md new file mode 100644 index 0000000000..94297f0534 --- /dev/null +++ b/docs/plans/task-canvas-workspace-preview/task-01-workspace-data-scope.md @@ -0,0 +1,112 @@ +--- +id: "01-workspace-data-scope" +title: "Workspace data scope and legacy upgrade" +status: complete +wave: 1 +depends_on: [] +plan: "plan.md" +requirements: + - REQ-CANVASES-WORKSPACE-PREVIEW-001 + - REQ-CANVASES-LOCAL-CREATION-001 + - REQ-CANVASES-AGENT-WEB-APPS-003 +acceptance_criteria: + - AC-CANVASES-WORKSPACE-PREVIEW-001.1 + - AC-CANVASES-WORKSPACE-PREVIEW-001.2 + - AC-CANVASES-WORKSPACE-PREVIEW-001.3 + - AC-CANVASES-WORKSPACE-PREVIEW-001.5 + - AC-CANVASES-WORKSPACE-PREVIEW-001.6 + - AC-CANVASES-WORKSPACE-PREVIEW-001.8 + - AC-CANVASES-LOCAL-CREATION-001.2 + - AC-CANVASES-AGENT-WEB-APPS-003.2 +system_design: + - ../../specs/canvases/system-design/task-canvas-workspace-preview.md + - ../../specs/canvases/system-design/local-creation-authority.md + - ../../specs/canvases/system-design/agent-authored-web-apps.md + - ../../specs/plugins/system-design/canvas-workspace-data-scope.md +--- + +# Task 01: Workspace data scope and legacy upgrade + +## Summary + +Persist a separate data scope for canvas instances and honor it throughout the +relative web-app protocol. New owner-authorized task canvases get exact +declared workspace grants on first publication; existing task-only canvases +can obtain them only through a conditional owner-reviewed upgrade. + +## In scope + +- SQLite/PostgreSQL instance mapping and null fallback; version-2 creation + authority and first-publication transaction. +- Runtime binding, grant checks, task/workflow/write/message/event/dependency + enforcement, and additive context projection. +- Bounded expvar counters for first publication, reviewed upgrades, and + promotion data-scope results, without per-canvas metric labels. +- Workspace-data preview and confirmation endpoints for retained task canvases. +- Promotion behavior with an already workspace-data task canvas and tests for + foreign workspaces, stale reviews, owner drift, and revoked grants. + +## Out of scope + +- Host action, dialog, translations, browser tests, and public docs. + +## Acceptance + +1. A new task-placed local canvas can list and operate on all authorized tasks + in its own workspace through declared capabilities before promotion; no + foreign workspace or undeclared capability is admitted. +2. Existing task-only instances keep their current behavior until an owner + confirms an exact, current release review; confirmation changes grants and + data scope atomically without changing placement or release. +3. Promotion preserves workspace data access when it already exists, while + stale or invalid runtime bindings and reviews fail closed. + +## Verification + +Run from the repository root: + +```bash +(cd apps/backend && go test ./internal/canvas ./internal/plugins/instances ./internal/plugins ./internal/plugins/webapp ./internal/backendapp) +python3 scripts/list-docs.py validate +``` + +## Files likely touched + +- `apps/backend/internal/plugins/instances/store.go` +- `apps/backend/internal/plugins/webapp/tokens.go` +- `apps/backend/internal/plugins/webapp_protocol.go` +- `apps/backend/internal/plugins/webapp_protocol_data.go` +- `apps/backend/internal/plugins/webapp_events.go` +- `apps/backend/internal/plugins/webapp_protocol_json.go` +- `apps/backend/internal/canvas/authoring.go` +- `apps/backend/internal/canvas/service.go` +- `apps/backend/internal/canvas/types.go` +- `apps/backend/internal/backendapp/` canvas HTTP handlers and tests +- Adjacent `*_test.go` files + +## Dependencies + +None. + +## Risks + +Every route that uses placement scope today must be classified by whether it +filters data, determines discovery, or checks grant validity. Missing a route +could make the preview inconsistent or broaden an unintended path. + +## Parallelism + +`sequential` + +## Inputs + +- [Workspace preview requirements](../../specs/canvases/requirements/task-canvas-workspace-preview.md) +- [Workspace preview design](../../specs/canvases/system-design/task-canvas-workspace-preview.md) +- [Scope decision](../../decisions/2026-09-23-task-canvas-workspace-data.md) + +## Results + +Passed: `go test ./internal/canvas ./internal/plugins/instances +./internal/plugins ./internal/plugins/webapp ./internal/backendapp` and +`python3 scripts/list-docs.py validate`. The canvas tests verify fixed-label +counter increments for initial publication, reviewed upgrade, and promotion. diff --git a/docs/plans/task-canvas-workspace-preview/task-02-host-scope-review.md b/docs/plans/task-canvas-workspace-preview/task-02-host-scope-review.md new file mode 100644 index 0000000000..cea8ba38ac --- /dev/null +++ b/docs/plans/task-canvas-workspace-preview/task-02-host-scope-review.md @@ -0,0 +1,141 @@ +--- +id: "02-host-scope-review" +title: "Host scope review and browser proof" +status: complete +wave: 2 +depends_on: + - "01-workspace-data-scope" +plan: "plan.md" +requirements: + - REQ-CANVASES-WORKSPACE-PREVIEW-001 + - REQ-CANVASES-AGENT-WEB-APPS-003 +acceptance_criteria: + - AC-CANVASES-WORKSPACE-PREVIEW-001.1 + - AC-CANVASES-WORKSPACE-PREVIEW-001.4 + - AC-CANVASES-WORKSPACE-PREVIEW-001.5 + - AC-CANVASES-WORKSPACE-PREVIEW-001.7 + - AC-CANVASES-AGENT-WEB-APPS-003.1 +system_design: + - ../../specs/canvases/system-design/task-canvas-workspace-preview.md + - ../../specs/canvases/system-design/agent-authored-web-apps.md +--- + +# Task 02: Host scope review and browser proof + +## Summary + +Show task placement and workspace data access separately in the host and +promotion review. Give older task-only canvases an owner-reviewed access +action on desktop and phone, update author guidance, and prove live workspace +task refresh before promotion in browser tests. + +## In scope + +- Canvas API client/projections, existing host actions and review component, + localized copy in six catalogs, and phone action drawer. +- Versioned bundled authoring reference, public canvas and plugin API docs, + and concise scoped `AGENTS.md` updates if implementation changes their + current statements. +- Component and desktop/mobile Playwright tests using multiple tasks in one + workspace and a foreign-workspace negative case. + +## Out of scope + +- New canvas-authored UI template, broad UI redesign, installation-wide task + listing, and silent legacy grant migration. + +## Acceptance + +1. Desktop and phone hosts label the data scope accurately and offer the + upgrade review only for eligible task-only canvases; the action does not + promote or republish. +2. Promotion review distinguishes placement from data access and keeps exact + declared permission details visible on both viewports. +3. Browser tests show a new task canvas refreshing at least two workspace + tasks before promotion and an existing one expanding access after review. + +## ASCII UI preview + +`UI-01: Task canvas host, new release (desktop)` and +`UI-02: Existing task-only canvas (desktop)` use the same header shown in +[the full plan](plan.md#ascii-ui-preview): + +```text +New: Task coordinator [Workspace data] [Releases] [Promote canvas] +Existing: Task coordinator [Task data] [Enable workspace data] [Promote canvas] +``` + +`UI-03: Existing task-only canvas (phone)` uses the existing focused route +and Actions drawer. Its review is full-height with one scroll region and +fixed safe-area actions: + +```text +< Task Task coordinator [Actions] + Task data +Actions > Enable workspace data +Review declared permissions [scrolls] +[Cancel] [Enable workspace data] [fixed, safe area] +``` + +The scope label and action availability are required by +`AC-CANVASES-WORKSPACE-PREVIEW-001.4-.5` and `.7`; text spacing is +illustrative. Reuse the shipped canvas host and phone drawer. + +## Verification + +Run from the repository root: + +```bash +(cd apps/web && pnpm exec vitest run components/settings/canvas-lifecycle-dialogs.test.tsx components/settings/canvas-host-components.test.tsx lib/api/domains/canvas-api.test.ts) +(cd apps/web && pnpm run typecheck) +(cd apps/web && pnpm run i18n:check) +(cd apps/web && pnpm e2e:run --host --project chromium tests/canvas/plugin-canvas.spec.ts) +(cd apps/web && pnpm e2e:run --host --project mobile-chrome tests/canvas/mobile-plugin-canvas.spec.ts) +python3 scripts/list-docs.py validate +python3 scripts/lint-spec-files.py --all +node scripts/validate-public-docs.mjs +``` + +## Files likely touched + +- `apps/web/lib/api/domains/canvas-api.ts` +- `apps/web/components/settings/canvas-host-actions.tsx` +- `apps/web/components/settings/canvas-lifecycle-dialogs.tsx` +- `apps/web/components/settings/canvas-host-route*.tsx` +- `apps/web/src/locales/*/canvases.json` +- `apps/web/e2e/tests/canvas/plugin-canvas.spec.ts` +- `apps/web/e2e/tests/canvas/mobile-plugin-canvas.spec.ts` +- `apps/backend/internal/mcp/canvasskill/files/references/browser-api.md` +- `apps/backend/config/prompts/create-canvas.md` +- `docs/public/canvases.md` +- `docs/public/plugins-authoring.md` +- Adjacent component tests and `AGENTS.md` files when needed + +## Dependencies + +Task 01 supplies the data-scope projection and conditional access endpoint. + +## Risks + +The current canvas fixture expects one task. The browser proof must create a +second task in the same workspace and retain a foreign-workspace negative case +without changing unrelated fixture behavior. + +## Parallelism + +`sequential` + +## Inputs + +- [Workspace preview requirements](../../specs/canvases/requirements/task-canvas-workspace-preview.md) +- [Workspace preview design](../../specs/canvases/system-design/task-canvas-workspace-preview.md) +- [Full UI preview](plan.md#ascii-ui-preview) + +## Results + +Passed: focused Vitest suite (41 tests), web typecheck, i18n checks, desktop +canvas Playwright suite, mobile canvas Playwright suite (5 tests), spec index +validation, specification lint, and public documentation validation. The phone +browser test verifies both scope labels and the legacy review flow; it uses a +test-only legacy API response, while backend service/store tests verify the +transactional grant upgrade. diff --git a/docs/public/canvases.md b/docs/public/canvases.md index 77be5fffe9..05e0eb359b 100644 --- a/docs/public/canvases.md +++ b/docs/public/canvases.md @@ -53,7 +53,18 @@ Kandev validates the package before it stores or runs the release. An invalid dr ## Use a canvas -A task canvas belongs to one task. A workspace canvas appears in workspace navigation and uses workspace scope. Promotion changes the same canvas from task scope to workspace scope. It does not copy the canvas. +A task canvas stays in its creating task until promotion. After the first valid +release, a new owner-authorized task canvas can use its declared supported +permissions across the current workspace. This lets the app preview the full +workspace task list before it appears in workspace navigation. Imported and +older task canvases keep their existing data scope until an authorized review +widens it. + +A workspace canvas appears in workspace navigation. Promotion changes the +canvas placement and keeps its identity, active release, state, and release +history. For a new canvas that already has workspace data access, promotion +changes placement only. The host review identifies whether it changes data +access for a legacy canvas. The app uses relative requests such as `./_kandev/v1/data/tasks` and `./_kandev/v1/state`. Kandev remains the source of truth for task, workflow, and message data. Canvas state stores app-specific shared state. It does not replace Kandev domain data. @@ -115,7 +126,7 @@ and **Releases and permissions** outside the failed frame. Retry creates a new runtime binding and startup attempt. This check confirms document and context startup; it does not certify application business health. -Kandev calculates effective access from the package declaration, instance grant, trusted task or workspace scope, and current caller authorization. A release receives only the intersection of those permissions. See [Security and trust](security.md#isolated-web-applications) for the security boundary. +Kandev calculates effective access from the package declaration, instance grant, trusted data scope, and current caller authorization. A release receives only the intersection of those permissions. See [Security and trust](security.md#isolated-web-applications) for the security boundary. ### Return to a task after publication @@ -136,13 +147,17 @@ Only a user can promote a task canvas. An agent cannot promote, demote, grant pe 1. Open the task canvas and choose its promotion action. 2. Read the review dialog before you continue. 3. Review every Kandev data read and write, event subscription, shared-state permission, and exact external HTTPS origin. -4. Confirm the task-to-workspace scope change and workspace placement. +4. Confirm the placement change to workspace navigation. The confirmation includes the active release ID, permission declaration digest, and grant generation that you reviewed. If any of these changes before confirmation, Kandev rejects the request as stale and requires a new review. -The promotion keeps the canvas identity, active release, state, and release history. A workspace canvas then appears in workspace navigation. Canceling the review leaves the task canvas unchanged. +The promotion keeps the canvas identity, active release, state, and release +history. A workspace canvas then appears in workspace navigation. A new +owner-authorized task canvas already has workspace data access, so promotion +changes placement only. A legacy task canvas uses the permission review shown +by its host. Canceling the review leaves the task canvas unchanged. If promotion adds a permission, Kandev keeps the current active release until a user approves the new grant. The new release stays pending permission until that review finishes. @@ -193,7 +208,7 @@ Use these recovery actions: - Roll back to the retained prior release after a failed publish. - Restore the database and matching artifact directory after a storage loss. See [Canvas artifacts and recovery](operations.md#canvas-artifacts-and-recovery). - Archive a canvas to hide it from normal discovery. Restore it from the canvas controls. -- Remove a task canvas to remove its task-scoped data. Removing a task does not remove a canvas already promoted to a workspace. +- Remove a task canvas to remove the canvas and its data. Removing a task does not remove a canvas already promoted to a workspace. - Remove a workspace canvas to remove its grants, state, tokens, releases, and artifacts after cleanup completes. Kandev records artifact cleanup before it removes release ownership. A worker completes cleanup after the database transaction and retries it after a restart. diff --git a/docs/public/plugins-authoring.md b/docs/public/plugins-authoring.md index dc8cf95b5b..b563d1e9b1 100644 --- a/docs/public/plugins-authoring.md +++ b/docs/public/plugins-authoring.md @@ -121,11 +121,12 @@ does not need a Go backend or an injected Kandev JavaScript API. the app needs. 5. Package the manifest and static files as a gzip-compressed tar archive. -For a new owner-created task canvas, the first valid release can receive the -declared supported task-scoped permissions through its initial permission -policy. Imported packages and later permission increases need human approval. -Keep `network_origins` as exact HTTPS origins. Do not use wildcards, paths, -credentials, query strings, or fragments. +For a new owner-authorized task canvas, the first valid release can receive +only its declared supported permissions through the initial permission policy. +Kandev data access is limited to the current workspace while the canvas stays +placed in its creating task. Imported packages and later permission increases +need human approval. Keep `network_origins` as exact HTTPS origins. Do not use +wildcards, paths, credentials, query strings, or fragments. For example, a page can read task data with the browser Fetch API: @@ -137,9 +138,12 @@ if (!response.ok) { const tasks = await response.json(); ``` -Use `./_kandev/v1/events` for the event stream. Keep all protocol paths -relative so the same package works in task and workspace scope. Do not copy a -capability URL from the host into the app. +Use `./_kandev/v1/events` for the event stream. Read both `scope_kind` and +`data_scope_kind` from `./_kandev/v1/context`: the first describes placement, +and the second describes the Kandev data boundary. Keep all protocol paths +relative so the same package works in task and workspace placement. For task +lists, follow `page_info.next_cursor` to load every page. Do not set +`workspace_id` to another workspace or copy a capability URL from the host. For recorded workflow movement, the browser can GET `./_kandev/v1/data/tasks/{task_id}/step-transitions` with diff --git a/docs/specs/canvases/README.md b/docs/specs/canvases/README.md index 58d718312c..f1970ffc89 100644 --- a/docs/specs/canvases/README.md +++ b/docs/specs/canvases/README.md @@ -10,8 +10,8 @@ last_updated: 2026-09-10 # Canvases Canvases own agent-authored task applications and their promotion to workspace -applications. The system owns canvas scope, source lineage, release selection, -editing sessions, discovery, and portable canvas distribution. +applications. The system owns canvas placement and data scope, source lineage, +release selection, editing sessions, discovery, and portable canvas distribution. The Plugins system owns the web-application runtime, permissions, Kandev data access, state, events, and package validation. The task system @@ -20,18 +20,21 @@ remains authoritative for task data and permissions. ## Requirements - [Agent-authored web-app canvases](requirements/agent-authored-web-apps.md) +- [Task canvas workspace data preview](requirements/task-canvas-workspace-preview.md) - [Canvas marketplace and sharing](requirements/marketplace-sharing.md) - [Deprecated collaborative canvases](requirements/collaborative-canvases.md) ## System design - [Agent-authored web-app canvases](system-design/agent-authored-web-apps.md) +- [Task canvas workspace data preview](system-design/task-canvas-workspace-preview.md) - [Canvas marketplace and sharing](system-design/marketplace-sharing.md) - [Superseded collaborative canvases](system-design/collaborative-canvases.md) ## Related context - [Runtime and permission fix plan](../../plans/canvas-runtime-permission-fixes/plan.md) +- [Task canvas workspace data preview plan](../../plans/task-canvas-workspace-preview/plan.md) - [Agent MCP discovery guidance](../agents/system-design/mcp-tool-discovery-guidance.md) - [MCP discovery and canvas prompt plan](../../plans/mcp-discovery-canvas-prompts/plan.md) - [GitHub Copilot App Canvas reference](../../copilot-canvas-reference.md) diff --git a/docs/specs/canvases/requirements/agent-authored-web-apps.md b/docs/specs/canvases/requirements/agent-authored-web-apps.md index 39779ac9fb..f913b7b0e7 100644 --- a/docs/specs/canvases/requirements/agent-authored-web-apps.md +++ b/docs/specs/canvases/requirements/agent-authored-web-apps.md @@ -6,7 +6,7 @@ system: canvases owners: - canvases created: 2026-08-26 -last_updated: 2026-09-21 +last_updated: 2026-09-23 --- # Agent-authored web-app canvases Requirements @@ -15,7 +15,8 @@ last_updated: 2026-09-21 A canvas is a custom web application that an agent creates for one task. A user can promote a useful task canvas to its workspace. A workspace canvas -appears in workspace navigation and can use workspace-scoped data. +appears in workspace navigation. An owner-authorized task canvas can already +use its workspace's data while the user reviews it in the originating task. The Canvases system owns the canvas scope, source lineage, release selection, promotion, editing flow, and discovery. The Plugins system owns the web-application @@ -24,13 +25,13 @@ runtime and its data contract. ## Terminology - **Task canvas:** A canvas that belongs to one task and appears only in that - task. + task. Its placement does not determine its approved data scope. - **Workspace canvas:** A canvas that belongs to one workspace and appears in workspace navigation. Agent-authored canvases reach this scope by promotion; [distribution](marketplace-sharing.md) also defines reviewed package installs. - **Draft:** Editable canvas source in an authorized agent workspace. - **Release:** An immutable package that passed validation. -- **Promotion:** A user action that changes a task canvas to workspace scope. +- **Promotion:** A user action that changes a task canvas to workspace placement. ## Requirements @@ -120,8 +121,8 @@ browser session end. ### REQ-CANVASES-AGENT-WEB-APPS-003: User-controlled promotion -**Intent:** A useful task canvas becomes a workspace application only after a -user reviews its scope and permissions. +**Intent:** A useful task canvas becomes available in workspace navigation only +after a user reviews its placement and permissions. **User story:** As a user, I want to promote a useful task canvas, so that I can open it from the workspace sidebar. @@ -132,7 +133,8 @@ can open it from the workspace sidebar. system shall show the requested data, write, event, state, and network permissions before confirmation. - **AC-CANVASES-AGENT-WEB-APPS-003.2:** When the user confirms promotion, the - same canvas identity and active release shall change to workspace scope. + same canvas identity and active release shall change to workspace placement. + A release that already has workspace data access shall keep that access. - **AC-CANVASES-AGENT-WEB-APPS-003.3:** When promotion completes, the canvas shall appear in navigation for that workspace only. - **AC-CANVASES-AGENT-WEB-APPS-003.4:** An agent shall not promote, demote, or @@ -342,6 +344,7 @@ including missing references, lookup failures, and passthrough exclusions. - [Canvas runtime and task-entry recovery](../../../plans/canvas-runtime-entry-recovery/plan.md) - [Direct canvas creation and saved prompt](../../../plans/canvas-direct-creation/plan.md) +- [Task canvas workspace data preview](../../../plans/task-canvas-workspace-preview/plan.md) ## Out of scope diff --git a/docs/specs/canvases/requirements/local-creation-authority.md b/docs/specs/canvases/requirements/local-creation-authority.md index 322f3b0315..492a009346 100644 --- a/docs/specs/canvases/requirements/local-creation-authority.md +++ b/docs/specs/canvases/requirements/local-creation-authority.md @@ -6,7 +6,7 @@ system: canvases owners: - canvases created: 2026-09-10 -last_updated: 2026-09-10 +last_updated: 2026-09-23 --- # Owner-authorized canvas creation @@ -39,10 +39,11 @@ imported code and later changes cannot claim that creation authority. - **AC-CANVASES-LOCAL-CREATION-001.1:** The first valid release of an owner-authorized task canvas shall activate without a separate permission approval. Its grants shall contain only its declared, supported permissions. -- **AC-CANVASES-LOCAL-CREATION-001.2:** Initial grants shall remain within the - task scope and current owner access. They can cover supported data reads, - writes, events, instance state, and exact HTTPS external origins. They shall - not enable remote scripts, managed backend execution, or workspace access. +- **AC-CANVASES-LOCAL-CREATION-001.2:** Initial grants shall contain only the + declared supported data reads, writes, events, instance state, and exact HTTPS + external origins. For a new owner-authorized canvas, those grants shall cover + the current workspace while the canvas remains in its task. They shall not + enable another workspace, remote scripts, or managed backend execution. - **AC-CANVASES-LOCAL-CREATION-001.3:** Agent fields, package metadata, display titles, and ownership of an imported package shall not establish local creation authority. Missing or mismatched trusted identity shall deny it. @@ -71,3 +72,4 @@ a marketplace, import UI, collaborator role, or ongoing blanket approval. ## Implementation plans - [Canvas runtime and permission fixes](../../../plans/canvas-runtime-permission-fixes/plan.md) +- [Task canvas workspace data preview](../../../plans/task-canvas-workspace-preview/plan.md) diff --git a/docs/specs/canvases/requirements/task-canvas-workspace-preview.md b/docs/specs/canvases/requirements/task-canvas-workspace-preview.md new file mode 100644 index 0000000000..427605ce7a --- /dev/null +++ b/docs/specs/canvases/requirements/task-canvas-workspace-preview.md @@ -0,0 +1,88 @@ +--- +id: canvases-task-canvas-workspace-preview +title: Task canvas workspace data preview +status: draft +system: canvases +owners: + - canvases +created: 2026-09-23 +last_updated: 2026-09-23 +--- + +# Task canvas workspace data preview Requirements + +## Overview + +A user creates a canvas from a task and reviews the live application there +before deciding whether to add it to workspace navigation. Its task placement +must not reduce the data available to an owner-authorized first release. +Canvases owns the creation and promotion lifecycle; the Plugins system owns the +runtime data contract and grant enforcement. + +## Terminology + +- **Placement scope:** Where Kandev presents a canvas and which task owns its + unpromoted lifecycle. +- **Data scope:** The largest Kandev data boundary admitted by a release's + declared and granted capabilities. Data scope never overrides the viewing + user's current authorization. + +## Requirements + +### REQ-CANVASES-WORKSPACE-PREVIEW-001: Representative task canvas preview + +**Intent:** A locally requested canvas works with the current workspace's live +data before the user chooses to promote it. + +**User story:** As a user creating a canvas from a task, I want to see all tasks +in my current workspace, so that I can judge the canvas before promotion. + +#### Acceptance criteria + +- **AC-CANVASES-WORKSPACE-PREVIEW-001.1:** After the first valid release of an + owner-authorized task canvas activates, its declared task read capability + shall list and read all currently authorized tasks in the canvas's workspace, + including tasks added after publication. Refresh and pagination shall reach + the full result set. A task in another workspace shall remain unavailable. +- **AC-CANVASES-WORKSPACE-PREVIEW-001.2:** Before promotion, every supported + capability declared by that first release shall operate across the same + workspace where the capability has a workspace data meaning. Undeclared or + unsupported capabilities shall remain unavailable, and every operation shall + obey current user authorization. +- **AC-CANVASES-WORKSPACE-PREVIEW-001.3:** The canvas shall remain discoverable + only in its originating task until a user promotes it. Task removal shall + retain its existing cleanup behavior. Promotion shall still require user + confirmation, but it shall not be required to preview workspace data. +- **AC-CANVASES-WORKSPACE-PREVIEW-001.4:** The canvas context and host review + shall distinguish task placement from workspace data access. Promotion review + shall identify whether it changes data access or only placement. +- **AC-CANVASES-WORKSPACE-PREVIEW-001.5:** An existing task canvas with + task-only data access shall keep that access after upgrade until a current + workspace owner explicitly enables workspace data access. The review shall + name the active release and its declared permissions. Confirmation shall + widen only those declared grants without promoting or republishing the + canvas; cancellation shall change nothing. +- **AC-CANVASES-WORKSPACE-PREVIEW-001.6:** A stale review, changed owner, + revoked grant, missing task or workspace, or failed permission check shall + deny the access change without widening data exposure. A new release that + adds a capability after first publication shall still await permission + review. +- **AC-CANVASES-WORKSPACE-PREVIEW-001.7:** Desktop and phone users shall be + able to inspect the data scope and complete the existing-canvas access review + from the task canvas host. The phone flow shall remain usable with one + vertical scroll region, safe-area clearance, and touch controls. +- **AC-CANVASES-WORKSPACE-PREVIEW-001.8:** A canvas built under this contract + shall receive only workspace data authorized for its own workspace. Its + runtime token, manifest, or agent input shall not select a different + workspace or expand its capabilities. + +## Out of scope + +- Data from other workspaces or installation-wide grants. +- Blanket grants for capabilities a release does not declare. +- New canvas plugin capability types or arbitrary backend execution. +- Automatic widening of existing published releases. + +## Implementation plans + +- [Task canvas workspace data preview](../../../plans/task-canvas-workspace-preview/plan.md) diff --git a/docs/specs/canvases/system-design/agent-authored-web-apps.md b/docs/specs/canvases/system-design/agent-authored-web-apps.md index 8303031951..dc469b05d3 100644 --- a/docs/specs/canvases/system-design/agent-authored-web-apps.md +++ b/docs/specs/canvases/system-design/agent-authored-web-apps.md @@ -6,7 +6,7 @@ system: canvases owners: - canvases created: 2026-08-26 -last_updated: 2026-09-21 +last_updated: 2026-09-23 requirements: - REQ-CANVASES-AGENT-WEB-APPS-001 - REQ-CANVASES-AGENT-WEB-APPS-002 @@ -110,9 +110,11 @@ Use a small lifecycle record instead of declarative canvas blocks. - nullable `archived_at` - `created_at` and `updated_at` -The plugin instance owns `scope_kind`, active release, status, and grants. The -canvas service is the only service that changes the task or workspace scope of -a canvas instance. +The plugin instance owns placement `scope_kind`, effective data scope, active +release, status, and grants. The canvas service is the only service that +changes task or workspace placement or authorizes a canvas data-scope change. +The [workspace preview design](task-canvas-workspace-preview.md) separates +these scopes for locally requested task canvases. The canvas and plugin instance have a one-to-one relationship. Canvas removal removes the plugin instance, runtime tokens, grants, state, pending releases, @@ -366,13 +368,14 @@ The promotion dialog shows: - each event subject - shared state access - each exact external network origin -- the change from task scope to workspace scope +- the change from task placement to workspace placement, with any data-scope + difference identified separately - the new workspace navigation placement -The user can cancel without a state change. Confirmation creates the approved -workspace grants and changes the plugin instance scope in one transaction. The -canvas keeps its ID, plugin instance, active release, state, and release -history. +The user can cancel without a state change. Confirmation creates any needed +workspace grants and changes the plugin instance placement scope in one +transaction. The canvas keeps its ID, plugin instance, active release, state, +and release history. Promotion publishes one lifecycle event after commit. The task and workspace navigation projections then refresh. @@ -641,8 +644,10 @@ connection. An edit Quick Chat session also verifies its trusted target canvas metadata. Task canvases cannot appear in another task. Workspace canvases can serve task -surfaces only inside their workspace. Promotion does not grant access to tasks, -repositories, sessions, or external services by itself. +surfaces only inside their workspace. A task canvas can use approved workspace +data before promotion under the [workspace preview contract](task-canvas-workspace-preview.md). +Promotion does not grant undeclared access to tasks, repositories, sessions, +or external services. The plugin runtime repeats resource authorization for each request. Canvas authorization does not replace task or workspace authorization. diff --git a/docs/specs/canvases/system-design/guided-canvas-task-launch.md b/docs/specs/canvases/system-design/guided-canvas-task-launch.md index bfe0898c60..9ea32529d8 100644 --- a/docs/specs/canvases/system-design/guided-canvas-task-launch.md +++ b/docs/specs/canvases/system-design/guided-canvas-task-launch.md @@ -152,7 +152,8 @@ The definition in `config/prompts/create-canvas.md` contains this guidance: > Call `publish_canvas_kandev` and address any validation errors. > Report the canvas identity and whether its release is active, awaits permission review, or was unsuccessful. > If publication is unsuccessful, report the failure and do not claim that the canvas is published. -> If workspace access requires promotion, explain the user action that is still required. +> A new owner-authorized task canvas can use its declared supported permissions across the current workspace before promotion. +> Promotion adds workspace navigation. Do not claim that promotion is required to preview workspace task data. > A local build alone does not publish a canvas inside Kandev. > If the tools remain unavailable, report the limitation instead of claiming that workspace files are a Kandev canvas. diff --git a/docs/specs/canvases/system-design/local-creation-authority.md b/docs/specs/canvases/system-design/local-creation-authority.md index 9e69d1e4b4..27779f2e3c 100644 --- a/docs/specs/canvases/system-design/local-creation-authority.md +++ b/docs/specs/canvases/system-design/local-creation-authority.md @@ -6,7 +6,7 @@ system: canvases owners: - canvases created: 2026-09-10 -last_updated: 2026-09-10 +last_updated: 2026-09-23 requirements: - REQ-CANVASES-LOCAL-CREATION-001 --- @@ -20,7 +20,8 @@ owns grant storage, release activation, and runtime enforcement. This design implements `REQ-CANVASES-LOCAL-CREATION-001`; the authority, transaction, and compatibility sections map to criteria .1-.3, .4-.5, and .6-.7 respectively. -See [the creation authority decision](../../../decisions/2026-09-10-canvas-creation-authority.md) +See [the creation authority decision](../../../decisions/2026-09-10-canvas-creation-authority.md), +[its workspace-data amendment](../../../decisions/2026-09-23-task-canvas-workspace-data.md), and [plugin grants](../../plugins/system-design/isolated-web-app-contributions.md#instance-grants). ## Trusted creation authority @@ -40,7 +41,7 @@ Add a canvas-owned `canvas_creation_authority` table through | `canvas_id` | Primary key, references the canvas lifecycle identity | | `owner_user_id` | Trusted workspace owner at creation | | `creating_session_id` | Trusted source session | -| `policy_version` | Integer 1 for the initial-publication policy | +| `policy_version` | Integer 1 for historical task-data grants; version 2 for new workspace-data grants | | `consumed_at` | Empty until the first release transaction consumes it | Create the row in the canvas/instance creation transaction. No agent-facing @@ -52,7 +53,9 @@ Existing databases acquire the empty table; do not backfill authority. At publication require every predicate: a nonzero captured `PublishAuthority`, local-canvas instance source, task scope, matching task and workspace, matching creating session, unchanged current owner, pending instance, no active or -persisted release, no previous grant changes, and unconsumed policy version 1. +persisted release, no previous grant changes, and an unconsumed supported +policy version. New authority uses version 2; historical version-1 authority +keeps its task data ceiling and is never upgraded by publication alone. Check the full instance snapshot again inside the transaction. Re-read the current task/workspace ownership through the same transaction and serialize against ownership changes; the adapter's earlier lookup alone is insufficient. @@ -64,10 +67,12 @@ The trusted adapter supplies the freshly resolved owner; caller-controlled ## Grant derivation and transaction Derive grants from `ManifestPermissions` and the existing static web-app -capability vocabulary. Restrict Kandev resource access to the recorded task, -using normal runtime authorization. Normalize each declared external origin -through `NormalizeNetworkOrigins`; initial external grants count as delegated -owner approval, with that owner as `ApprovedBy`. This is deliberate: a locally +capability vocabulary. Version-2 authority binds the approved data scope to +the recorded workspace while the canvas remains task-placed. Historical +version-1 authority retains task data scope. See the +[workspace preview design](task-canvas-workspace-preview.md). Normalize each +declared external origin through `NormalizeNetworkOrigins`; initial external +grants count as delegated owner approval, with that owner as `ApprovedBy`. A locally requested app can contact its declared HTTPS service on its first run. Unsupported capabilities and backend/native contributions cannot gain access through this path. Do not add a generic manifest-controlled auto-approve flag. @@ -96,8 +101,9 @@ schema replay and backup enumeration must include this table. ## API and compatibility Return an additive `initial_permission_policy` field in the create response: -version 1, eligibility, scope `task`, supported permission kinds, and the exact -HTTPS-origin constraint. This is descriptive output, never a publish input. +the policy version, eligibility, placement `task`, data scope, supported +permission kinds, and exact HTTPS-origin constraint. This is descriptive +output, never a publish input. It must not claim a permission ceiling that the backend does not enforce. Keep the existing `activated` and `permission_required` publish result fields. diff --git a/docs/specs/canvases/system-design/task-canvas-workspace-preview.md b/docs/specs/canvases/system-design/task-canvas-workspace-preview.md new file mode 100644 index 0000000000..1eb0309a8d --- /dev/null +++ b/docs/specs/canvases/system-design/task-canvas-workspace-preview.md @@ -0,0 +1,168 @@ +--- +id: canvases-task-canvas-workspace-preview-design +title: Task canvas workspace data preview design +status: draft +system: canvases +owners: + - canvases +created: 2026-09-23 +last_updated: 2026-09-23 +requirements: + - REQ-CANVASES-WORKSPACE-PREVIEW-001 +--- + +# Task canvas workspace data preview System Design + +## Boundary and mapping + +Canvases owns when a locally requested task canvas gains workspace data scope, +how an older canvas can opt in, and when promotion changes placement. Plugins +persists the instance's effective data scope and enforces it through the shared +web-app runtime. Task services remain authoritative for data and current user +access. This design implements `REQ-CANVASES-WORKSPACE-PREVIEW-001`. + +| Criteria | Design sections | +| --- | --- | +| `AC-CANVASES-WORKSPACE-PREVIEW-001.1`, `.2`, `.8` | Data scope and runtime enforcement | +| `AC-CANVASES-WORKSPACE-PREVIEW-001.3`, `.4` | Lifecycle and presentation | +| `AC-CANVASES-WORKSPACE-PREVIEW-001.5`, `.6`, `.7` | Existing canvas upgrade and host flow | + +See [the workspace preview decision](../../../decisions/2026-09-23-task-canvas-workspace-data.md) +and [the existing creation authority](local-creation-authority.md). + +## Data scope and persistence + +Keep `plugin_instances.scope_kind` as the placement/lifecycle scope. Add a +nullable `data_scope_kind` to `plugin_instances`; null means the existing +`scope_kind` so older rows and non-canvas plugins retain their behavior. A +non-null value is valid only when it equals the placement scope, or when a +`local_canvas` task instance uses `workspace` data scope with its existing +trusted `workspace_id` and `task_id`. Reject `instance` and foreign workspace +data scope for canvases. Do not accept the field from agent, manifest, or +browser input. Store it through SQLite and PostgreSQL schema paths, backup, +snapshot, and instance row mapping. + +At trusted canvas creation, record workspace data scope only for a new task +canvas with owner-authorized creation provenance. The task remains the canvas +owner and the plugin instance's placement scope. The first valid release +derives grants from exactly its declared supported capabilities with a +`workspace` ceiling, then consumes creation authority and activates in the +existing transaction. Raise `CreationAuthorityPolicyVersion` for newly issued +authority; consumed version-1 rows remain historical evidence and are never +reissued. If provenance or current owner checks fail, publication follows the +normal permission-review path and cannot infer a workspace grant. + +All grant-fit, release activation, and runtime-binding validation for a web +app use its effective data scope. A grant ceiling cannot be interpreted as +covering a wider data scope merely because it covers the placement scope. A +later declaration increase still creates a pending release. Revocation bumps +the existing grant generation and invalidates runtime tokens. + +## Runtime enforcement + +`webapp.CapabilityBinding` carries both placement `ScopeKind` and effective +`DataScopeKind`. Issue both from the persisted instance, and compare both with +the current instance on every token validation. Existing bindings with an +empty data scope resolve to placement scope. The relative +`./_kandev/v1/context` response retains `scope_kind` for placement and adds +`data_scope_kind` for authors to label the available data accurately. + +Use the effective data scope for the web-app task list, single-task read, +task update, message dispatch, workflow list and steps, task dependency edge +projection, and subscribed task events. A workspace data scope binds all +queries and mutations to the instance's trusted `workspace_id`, including +explicit `workspace_id` filters. It does not derive workspace IDs from the +request or allow `ScopeInstance`. The task-list route uses its existing +pagination path instead of the bound-task shortcut. Each operation still +checks the declared capability, effective grant, and current user/domain +authorization. Other plugin instances continue to use their existing scope +rules. + +Never use the ordinary authenticated Kandev HTTP API as an implicit escape +from the canvas protocol. The [same-origin trust decision](../../../decisions/2026-09-19-trusted-same-origin-canvases.md) +acknowledges that browser code can call those APIs, but the documented canvas +contract and capability-only runtime must remain coherent. + +## Lifecycle and presentation + +`canvas_lifecycle_metadata.task_id` and the instance's placement scope retain +task discovery and deletion semantics. Workspace data scope does not add the +canvas to workspace navigation. Promotion remains a reviewed human action: +it changes placement to workspace, clears task placement, preserves the active +release, and leaves workspace data scope intact. The review states that data +access is already workspace-wide when that is true. A task-only legacy canvas +still has its existing promotion behavior, including grant expansion. + +Project `data_scope_kind` alongside `scope_kind` in the canvas and runtime +responses. The task host exposes a compact data-scope label in its existing +header/action surface. Releases and permissions and promotion review explain +placement separately from data access. The bundled authoring guidance and +public canvas documentation tell authors to read both context fields and to +declare only capabilities they use. Revise the saved `create-canvas` prompt's +promotion guidance so it describes workspace navigation rather than saying +that workspace data always requires promotion. No generated package rewrite +is needed. + +## Existing canvas upgrade + +Do not silently widen a retained release. An active task canvas with task-only +data scope offers **Enable workspace data** in the existing host action menu. +The backend returns a review snapshot of the active release, permission +digest, grant generation, current/target data scope, and exact declared +permissions. Only a currently authorized workspace owner may confirm it. + +Confirmation rechecks the canvas's task and workspace identity, owner, active +release, permission digest, and grant generation inside one transaction. It +upserts workspace-ceiling grants only for that release's declared permissions, +sets `data_scope_kind=workspace`, increments grant generation, and publishes a +single lifecycle refresh after commit. It neither promotes the canvas nor +republishes the artifact. On stale review or any failed check it changes +nothing. A canceled review has no write. Imported or legacy packages receive +no automatic exemption. + +Use the existing canvas HTTP controller's preview/confirm pattern with +`GET /api/v1/canvases/{id}/workspace-data-preview` and +`POST /api/v1/canvases/{id}/workspace-data`. The request carries the same +release ID, permission digest, and grant generation preconditions as promotion. +Return an updated canvas projection after success. Keep the action unavailable +for pending, archived, removed, already workspace-data, or foreign canvases. + +## Desktop and phone host flow + +Desktop keeps the current canvas panel and action toolbar. The data-scope +label sits with existing host metadata, and a legacy canvas exposes the upgrade +action from its current actions. Phone keeps the focused canvas route and +secondary-action drawer; the same upgrade opens the existing full-height +review surface. The header and actions remain fixed while one body region +scrolls, with safe-area clearance and touch targets. Both presentations share +one review state and backend snapshot; neither relies on canvas-authored UI. + +## Failure and observability + +Denied or stale access review returns the existing safe error style and leaves +the active release and old scope usable. A missing task or workspace makes +its runtime binding stale. Scope or grant changes invalidate old tokens; the +host reloads the runtime through its existing recovery path. Record bounded +scope-transition result counters and IDs already allowed for canvas lifecycle +logging. Do not log package source, permission bodies, tokens, or task data. + +## Verification strategy + +- Instance-store tests cover null fallback, invalid combinations, transaction + rollback, snapshot/backup mapping, and grant-generation invalidation. +- Canvas service tests cover version-2 owner authority, exact declarations, + legacy review, stale snapshots, owner drift, and promotion without data + expansion. +- Web-app protocol tests cover list pagination, task read/write, workflow, + message, event, dependency projection, foreign-workspace denial, and old + task-scoped behavior. +- Desktop and mobile Playwright exercise an owner-created task canvas with at + least two tasks, refresh before promotion, and an older canvas's explicit + access upgrade. The mobile route and review remain touch-usable. + +## Related designs + +- [Canvas lifecycle and promotion](agent-authored-web-apps.md) +- [Owner-authorized first publication](local-creation-authority.md) +- [Plugin web-app runtime](../../plugins/system-design/isolated-web-app-contributions.md) +- [Plugin canvas data-scope runtime](../../plugins/system-design/canvas-workspace-data-scope.md) diff --git a/docs/specs/plugins/system-design/canvas-workspace-data-scope.md b/docs/specs/plugins/system-design/canvas-workspace-data-scope.md new file mode 100644 index 0000000000..51abb7a1e4 --- /dev/null +++ b/docs/specs/plugins/system-design/canvas-workspace-data-scope.md @@ -0,0 +1,72 @@ +--- +id: plugins-canvas-workspace-data-scope-design +title: Canvas placement and data scope in the web-app runtime +status: draft +system: plugins +owners: + - kandev +created: 2026-09-23 +last_updated: 2026-09-23 +requirements: [] +--- + +# Canvas placement and data scope in the web-app runtime + +## Boundary + +This is the Plugins runtime implementation contract for the +[Canvas workspace preview requirement](../../canvases/requirements/task-canvas-workspace-preview.md). +Canvases decides when an owner-authorized local app may use workspace data. +Plugins persists and enforces that decision; it does not infer authority from +the manifest or browser request. The existing +[isolated web-app design](isolated-web-app-contributions.md) remains the general +runtime contract. + +## Instance model and grants + +Add nullable `plugin_instances.data_scope_kind`. A null value resolves to +`scope_kind` for retained rows and non-canvas instances. A non-null workspace +data scope is valid only for a `local_canvas` instance placed in a task or +workspace with a trusted `workspace_id`. Reject any attempt to use an +installation-wide data scope for a canvas. Creation, reviewed upgrade, and +promotion update the field and grants through transactions owned by the +canvas lifecycle service. + +Grant fit, release activation, and runtime validation compare each declared +permission with the effective data scope. An exact task grant cannot cover +workspace data. A workspace grant authorizes only the bound workspace and is +still intersected with current user and resource access. Revocation and scope +changes increment the existing grant generation. + +## Runtime binding and protocol + +Add effective `DataScopeKind` to `webapp.CapabilityBinding`, sourced from the +persisted instance and checked against it on every request. Keep `ScopeKind` +for placement and task lifecycle. The relative context response exposes both +`scope_kind` and `data_scope_kind`; empty historical data scope resolves to +placement scope. + +Task lists, single-task reads, task writes, messages, workflow reads, task +dependency projections, and events use data scope. Workspace-data task +canvases use the paginated workspace path, bounded by the trusted workspace +ID, rather than the task-only shortcut. Explicit foreign `workspace_id` +filters fail. Non-canvas instances retain current behavior. + +## Compatibility and failure + +The schema migration does not widen old rows. Historical runtime bindings +and grants remain task-scoped until a reviewed update changes them. A stale +binding, missing workspace/task, revoked grant, or mismatched data scope fails +closed through existing safe errors. Current plugin manifests and immutable +canvas packages need no rewrite. + +## Verification + +Test SQLite and PostgreSQL mapping, legacy null fallback, invalid scope +combinations, runtime token invalidation, exact grants, all workspace task +pages, task read/write and message paths, workflow and event scope, dependency +redaction, and foreign-workspace denial. + +## Related decision + +- [Task canvas workspace data](../../../decisions/2026-09-23-task-canvas-workspace-data.md)