Rule change
The convention documented in .agents/resource-visibility.md says a workspace-scoped entity gets a
nullable workspace_id BIGINT column, not a workspace_<entity> relation table — and it then
carves out an exception: "Six relation tables deliberately remain … the resulting mixed state is
intentional, not drift."
That exception is withdrawn. The rule now applies to the existing tables on master too: collapse
every workspace_* relation table into a nullable workspace_id column on the related table.
workspace_user is the one exception that stays — it is genuinely many-to-many (a user belongs to
several workspaces with equal standing) and is correct as a relation table. workspace itself is not a
relation table and is untouched.
Scope — five tables
| Relation table (module) |
Target table (module) |
Java entity |
Call sites |
workspace_connection (automation-configuration) |
connection (platform-connection) |
WorkspaceConnection |
~14 files |
workspace_api_key (automation-configuration) |
api_key (platform-security) |
WorkspaceApiKey |
~2 files |
workspace_data_table (automation-data-table) |
data_table (platform-data-table) |
WorkspaceDataTable |
~4 files |
workspace_knowledge_base (automation-knowledge-base) |
knowledge_base (platform-knowledge-base) |
WorkspaceKnowledgeBase |
~4 files |
workspace_mcp_server (automation-ai-mcp) |
mcp_server (platform-mcp) |
WorkspaceMcpServer |
~24 files |
Each also has a Workspace*Repository, a Workspace*Service/Impl, and in some cases a
Workspace*Facade built on top.
Why this is the right shape
- The code already assumes an owner, not a membership.
WorkspaceConnectionRepository.findByConnectionId
returns Optional<WorkspaceConnection>, not a List. workspace_knowledge_base,
workspace_data_table and workspace_mcp_server all carry a UNIQUE (workspace_id, <entity>_id)
constraint. Nothing exposes an API that puts one row in two workspaces.
- The current state is already inconsistent within a single module.
knowledge_base uses the
relation table, while its own child knowledge_base_source carries a plain workspace_id column
(20260508000001_platform_knowledge_base_source.xml).
- It removes a join from every list query. These relations are read on nearly every workspace-scoped
list and visibility check.
Design constraints
1. The column goes on a platform table, so it must be nullable. connection, api_key,
data_table, knowledge_base and mcp_server are all platform-owned and shared with embedded —
mcp_server even has a type (PlatformType) column and embedded writes rows through
EmbeddedMcpServerFacadeImpl. Embedded rows keep workspace_id = NULL; null is a real state, and the
Java field is Long, never long.
2. No foreign key to workspace. Follow the existing platform precedent,
platform-notification/…/20260720000003_notification_add_workspace_id.xml: nullable BIGINT, a plain
index on the column, no addForeignKeyConstraint — workspace is an automation-owned table and a
platform changelog must not depend on it. Read that file first; its comment block on empty <rollback/>
under MARK_RAN preconditions applies verbatim here. Confirm on the way through that nothing relied on
the current FK to block workspace deletion (there is no WorkspaceService.delete today).
3. These tables are released — do not edit init changelogs in place. workspace_connection and
workspace_knowledge_base are defined in 00000000000001_*_init.xml files and ship in v0.31.4. Each
collapse needs a new changelog per module: add column → backfill → drop the relation table.
4. Two tables have no uniqueness guarantee. workspace_connection and workspace_api_key have no
UNIQUE (workspace_id, …) constraint, so a duplicate row is possible in a customer database even though
the code never creates one. The backfill must pick deterministically (e.g. MIN(id)) rather than assume
one row, and should report anything it collapses.
5. workspace_data_table is a straight revert.
20260612000001_automation_data_table_workspace_relation.xml changesets -2/-3 migrated
data_table.workspace_id into the relation table and dropped the column. The new changelog undoes
exactly that, and must stay idempotent for databases that have run the round trip in both directions
(guard with columnExists / not columnExists preconditions, as that file already does).
6. workspace_mcp_server backfill has a hardcoded workspace. Changeset 20250827000002-2 assigns
every mcp_server row to workspace 1049. The reverse backfill must read from the relation table, not
re-derive that constant.
Work per table
Docs to update in the same change
.agents/resource-visibility.md — delete the "Six relation tables deliberately remain" paragraph;
state that workspace_user is the only remaining relation table.
CLAUDE.md — the cross-cutting bullet currently ends "Six pre-existing relation tables deliberately
remain."
docs/superpowers/specs/2026-07-25-workspace-relation-table-convention-revision.md — add a note that
the grandfather clause was withdrawn, and why.
Out of scope
workspace_user — stays as a relation table.
- The
resource_grant table — named-user grants are the sanctioned polymorphic relation table and are
unaffected.
- The
visibility column model — orthogonal; workspace_id expresses ownership, visibility expresses
reach.
Acceptance
- No
workspace_<entity> table remains except workspace_user.
grep -rn 'tableName="workspace' --include="*.xml" server returns only workspace and workspace_user
creations plus the new drop changesets.
- A database restored from a
v0.31.4 dump migrates with every row's workspace preserved, and embedded
rows land with workspace_id IS NULL.
./gradlew check and ./gradlew testIntegration pass — the Testcontainers int tests build the schema
from scratch and are the real proof the changelogs are correct.
Rule change
The convention documented in
.agents/resource-visibility.mdsays a workspace-scoped entity gets anullable
workspace_id BIGINTcolumn, not aworkspace_<entity>relation table — and it thencarves out an exception: "Six relation tables deliberately remain … the resulting mixed state is
intentional, not drift."
That exception is withdrawn. The rule now applies to the existing tables on
mastertoo: collapseevery
workspace_*relation table into a nullableworkspace_idcolumn on the related table.workspace_useris the one exception that stays — it is genuinely many-to-many (a user belongs toseveral workspaces with equal standing) and is correct as a relation table.
workspaceitself is not arelation table and is untouched.
Scope — five tables
workspace_connection(automation-configuration)connection(platform-connection)WorkspaceConnectionworkspace_api_key(automation-configuration)api_key(platform-security)WorkspaceApiKeyworkspace_data_table(automation-data-table)data_table(platform-data-table)WorkspaceDataTableworkspace_knowledge_base(automation-knowledge-base)knowledge_base(platform-knowledge-base)WorkspaceKnowledgeBaseworkspace_mcp_server(automation-ai-mcp)mcp_server(platform-mcp)WorkspaceMcpServerEach also has a
Workspace*Repository, aWorkspace*Service/Impl, and in some cases aWorkspace*Facadebuilt on top.Why this is the right shape
WorkspaceConnectionRepository.findByConnectionIdreturns
Optional<WorkspaceConnection>, not aList.workspace_knowledge_base,workspace_data_tableandworkspace_mcp_serverall carry aUNIQUE (workspace_id, <entity>_id)constraint. Nothing exposes an API that puts one row in two workspaces.
knowledge_baseuses therelation table, while its own child
knowledge_base_sourcecarries a plainworkspace_idcolumn(
20260508000001_platform_knowledge_base_source.xml).list and visibility check.
Design constraints
1. The column goes on a platform table, so it must be nullable.
connection,api_key,data_table,knowledge_baseandmcp_serverare all platform-owned and shared with embedded —mcp_servereven has atype(PlatformType) column and embedded writes rows throughEmbeddedMcpServerFacadeImpl. Embedded rows keepworkspace_id = NULL; null is a real state, and theJava field is
Long, neverlong.2. No foreign key to
workspace. Follow the existing platform precedent,platform-notification/…/20260720000003_notification_add_workspace_id.xml: nullableBIGINT, a plainindex on the column, no
addForeignKeyConstraint—workspaceis an automation-owned table and aplatform changelog must not depend on it. Read that file first; its comment block on empty
<rollback/>under
MARK_RANpreconditions applies verbatim here. Confirm on the way through that nothing relied onthe current FK to block workspace deletion (there is no
WorkspaceService.deletetoday).3. These tables are released — do not edit init changelogs in place.
workspace_connectionandworkspace_knowledge_baseare defined in00000000000001_*_init.xmlfiles and ship inv0.31.4. Eachcollapse needs a new changelog per module: add column → backfill → drop the relation table.
4. Two tables have no uniqueness guarantee.
workspace_connectionandworkspace_api_keyhave noUNIQUE (workspace_id, …)constraint, so a duplicate row is possible in a customer database even thoughthe code never creates one. The backfill must pick deterministically (e.g.
MIN(id)) rather than assumeone row, and should report anything it collapses.
5.
workspace_data_tableis a straight revert.20260612000001_automation_data_table_workspace_relation.xmlchangesets-2/-3migrateddata_table.workspace_idinto the relation table and dropped the column. The new changelog undoesexactly that, and must stay idempotent for databases that have run the round trip in both directions
(guard with
columnExists/not columnExistspreconditions, as that file already does).6.
workspace_mcp_serverbackfill has a hardcoded workspace. Changeset20250827000002-2assignsevery
mcp_serverrow to workspace1049. The reverse backfill must read from the relation table, notre-derive that constant.
Work per table
addColumn workspace_id BIGINT(nullable) +createIndex, guarded by anot columnExistsprecondition with an empty<rollback/>.UPDATE <table> SET workspace_id = (SELECT … FROM workspace_<table> …),deduplicated where no unique constraint exists.
tableExists).Workspace<Entity>domain class, its repository, its service interface + impl, andtheir tests.
Long workspaceIdwith@Column("workspace_id").findAllByWorkspaceIdon the relation repository becomes afindAllByWorkspaceId(or aworkspace_idpredicate) on the target repository;create(entityId, workspaceId)becomes a field set at creation;delete<Entity>(entityId)disappears because thecolumn dies with the row.
WorkspaceConnectionFacadeImpl,WorkspaceKnowledgeBaseFacadeImpl— and the comments in them that describe the join table.Docs to update in the same change
.agents/resource-visibility.md— delete the "Six relation tables deliberately remain" paragraph;state that
workspace_useris the only remaining relation table.CLAUDE.md— the cross-cutting bullet currently ends "Six pre-existing relation tables deliberatelyremain."
docs/superpowers/specs/2026-07-25-workspace-relation-table-convention-revision.md— add a note thatthe grandfather clause was withdrawn, and why.
Out of scope
workspace_user— stays as a relation table.resource_granttable — named-user grants are the sanctioned polymorphic relation table and areunaffected.
visibilitycolumn model — orthogonal;workspace_idexpresses ownership,visibilityexpressesreach.
Acceptance
workspace_<entity>table remains exceptworkspace_user.grep -rn 'tableName="workspace' --include="*.xml" serverreturns onlyworkspaceandworkspace_usercreations plus the new drop changesets.
v0.31.4dump migrates with every row's workspace preserved, and embeddedrows land with
workspace_id IS NULL../gradlew checkand./gradlew testIntegrationpass — the Testcontainers int tests build the schemafrom scratch and are the real proof the changelogs are correct.