diff --git a/.agents/skills/constructive-membership-types/SKILL.md b/.agents/skills/constructive-membership-types/SKILL.md new file mode 100644 index 0000000..3a3755e --- /dev/null +++ b/.agents/skills/constructive-membership-types/SKILL.md @@ -0,0 +1,186 @@ +--- +name: constructive-membership-types +description: "Membership types and dynamic entity provisioning — how to create custom entity types (channels, departments, teams) via the ORM, CLI, or blueprint definitions. Covers the entity hierarchy, permissions per entity type, and the provisioning lifecycle." +metadata: + author: constructive-io + version: "1.0.0" +--- + +# Membership Types & Dynamic Entity Provisioning + +Constructive has a hierarchical entity type system. Every scope of membership — app, org, channel, department, team — is a **membership type** with its own entity table, permissions, memberships, and security policies. + +Types 1 (app) and 2 (org) are built-in. Types 3+ are **dynamic** — you define them at runtime via the ORM, CLI, or blueprint definitions. + +Related skills: +- **Blueprints:** `constructive` → [blueprints.md](../constructive/references/blueprints.md) — how `constructBlueprint()` works +- **Blueprint definition format:** `constructive` → [blueprint-definition-format.md](../constructive/references/blueprint-definition-format.md) — table/relation/policy JSONB spec +- **Safegres (security):** `constructive-safegres` — Authz* policy types for RLS +- **SQL-level provisioning:** `entity-types-and-provisioning` skill in `constructive-db` + +--- + +## Core Concepts + +### Entity Type Hierarchy + +| Type ID | Name | Prefix | Entity Table | Created By | +|---------|------|--------|-------------|------------| +| 1 | App Member | `app` | `users` | Built-in | +| 2 | Organization Member | `org` | `users` (scoped) | Built-in | +| 3+ | Dynamic | varies | auto-created | You provision these | + +Every entity type gets: +- An **entity table** (e.g. `channels`, `departments`) +- A **permissions module** with bitmask-based permissions +- A **memberships module** for tracking who belongs to what +- **RLS security policies** on all tables +- Optional modules: limits, profiles, levels, invites + +### Permission Model + +Each level has a standard set of permissions. The `create_entity` permission means **"create the next level down"**: + +| Level | `create_entity` description | What it creates | +|-------|---------------------------|-----------------| +| App (type=1) | "Create organization entities." | Organizations | +| Org (type=2) | "Create child entities." | Channels, departments, etc. | +| Dynamic (type≥3) | "Create sub-entities." | Nested entity types | + +Other standard permissions: `admin_members`, `create_invites`, `admin_invites`, `admin_limits`, `admin_permissions`, `admin_entity`. + +### Parent-Child Relationships + +Every dynamic entity type has a **parent type**. The parent defaults to `org` (type=2), but can be any previously-provisioned type: + +``` +app (1) + └── org (2) + ├── channel (3) ← parent_entity = 'org' + ├── department (4) ← parent_entity = 'org' + │ └── team (5) ← parent_entity = 'department' + └── ... +``` + +Nested types must be provisioned **after** their parent type. + +--- + +## Three Ways to Provision Entity Types + +### 1. Blueprint Definition (Recommended) + +Add `membership_types` to the blueprint `definition` JSONB. These are processed in **Phase 0** — before tables and relations — so blueprint tables can reference the entity tables they create. + +See [blueprint-membership-types.md](./references/blueprint-membership-types.md) for the full spec and examples. + +### 2. ORM / GraphQL Mutation + +Use the `entityTypeProvision` table for direct provisioning outside of blueprints. + +See [orm-provisioning.md](./references/orm-provisioning.md) for ORM examples. + +### 3. CLI + +```bash +# Direct entity type provision (inserts into entity_type_provision trigger table) +constructive public:entity-type-provision create \ + --databaseId \ + --name "Channel Member" \ + --prefix "channel" \ + --description "Membership to a channel." \ + --parentEntity "org" \ + --isVisible true \ + --hasLimits false \ + --hasProfiles false \ + --hasLevels false \ + --skipEntityPolicies false +``` + +--- + +## What Gets Created + +When you provision a new entity type (e.g. prefix=`channel`), the system creates: + +### Tables +- `channels` — Entity table (with `id`, `name`, `owner_id`, `created_at`, `updated_at`) +- `channel_permissions` — Permission bitmasks per member +- `channel_permission_defaults` — Default permission values +- `channel_limits` — Rate limits per member (if `has_limits`) +- `channel_limit_defaults` — Default limit values (if `has_limits`) +- `channel_members` — Member list (user_id + entity_id) +- `channel_memberships` — Membership state (active, suspended, etc.) +- `channel_membership_defaults` — Default membership values +- `channel_grants` / `channel_admin_grants` / `channel_owner_grants` — Computed grants +- `channel_acl` — Access control list + +### Modules Registered +- `permissions_module:channel` +- `memberships_module:channel` +- `limits_module:channel` (if `has_limits`) +- `invites_module:channel` (auto-provisioned when `emails_module` exists) + +### Optional Modules +- `profiles_module:channel` (if `has_profiles`) — Named permission roles +- `levels_module:channel` (if `has_levels`) — Gamification/achievements + +--- + +## Querying Membership Types + +### List all types + +```typescript +const types = await db.membershipType.findMany({ + select: { + id: true, + name: true, + prefix: true, + description: true, + parentMembershipType: true, + hasLimits: true, + hasProfiles: true, + hasLevels: true, + } +}).execute(); +// Returns: [{ id: 1, name: 'App Member', prefix: 'app', ... }, ...] +``` + +### Find a specific type by prefix + +```typescript +const channelType = await db.membershipType.findMany({ + where: { prefix: { equalTo: 'channel' } }, + select: { id: true, name: true } +}).execute(); +``` + +### CLI + +```bash +constructive public:membership-type list --select id,name,prefix,parentMembershipType +constructive public:membership-type find --where.prefix channel --select id,name +``` + +--- + +## Querying Membership Types Module + +The `membershipTypesModule` tracks which databases have the membership types infrastructure installed: + +```typescript +const modules = await db.membershipTypesModule.findMany({ + where: { databaseId: { equalTo: dbId } }, + select: { id: true, tableName: true } +}).execute(); +``` + +--- + +## Cross-References + +- **Blueprint definition format:** [blueprint-definition-format.md](../constructive/references/blueprint-definition-format.md) — `membership_types` is a top-level key alongside `tables`, `relations`, etc. +- **ORM provisioning examples:** [orm-provisioning.md](./references/orm-provisioning.md) +- **Blueprint membership_types spec:** [blueprint-membership-types.md](./references/blueprint-membership-types.md) +- **SQL-level detail:** `entity-types-and-provisioning` skill in `constructive-db` repo diff --git a/.agents/skills/constructive-membership-types/references/blueprint-membership-types.md b/.agents/skills/constructive-membership-types/references/blueprint-membership-types.md new file mode 100644 index 0000000..6468d12 --- /dev/null +++ b/.agents/skills/constructive-membership-types/references/blueprint-membership-types.md @@ -0,0 +1,267 @@ +# Blueprint Membership Types (Phase 0) + +The `membership_types` array is a top-level key in the blueprint definition JSONB, alongside `tables`, `relations`, `indexes`, etc. Entries are processed in **Phase 0** of `constructBlueprint()` — before tables and relations — so blueprint tables can reference the entity tables they create. + +## Definition Shape + +```json +{ + "membership_types": [ + { + "name": "Channel Member", + "prefix": "channel", + "description": "Membership to a channel.", + "parent_entity": "org", + "table_name": null, + "is_visible": true, + "has_limits": false, + "has_profiles": false, + "has_levels": false, + "skip_entity_policies": false + } + ], + "tables": [ ... ], + "relations": [ ... ] +} +``` + +## Field Reference + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `name` | string | **Yes** | — | Human-readable name (e.g. `"Channel Member"`, `"Department Member"`) | +| `prefix` | string | **Yes** | — | SQL prefix for generated objects (e.g. `"channel"` → `channels` table, `channel_permissions`, etc.) | +| `description` | string | No | `null` | Optional description of the entity type | +| `parent_entity` | string | No | `"org"` | Parent type prefix. Must be an already-provisioned type | +| `table_name` | string | No | `prefix + 's'` | Override entity table name (e.g. `"rooms"` instead of default `"channels"`) | +| `is_visible` | boolean | No | `true` | Whether parent members can see child entities | +| `has_limits` | boolean | No | `false` | Provision a `limits_module` for this type | +| `has_profiles` | boolean | No | `false` | Provision a `profiles_module` for named permission roles | +| `has_levels` | boolean | No | `false` | Provision a `levels_module` for gamification | +| `skip_entity_policies` | boolean | No | `false` | Skip creating default RLS policies on the entity table | + +## TypeScript Type + +The `BlueprintMembershipType` interface (from `@constructive-io/node-type-registry`) defines the shape: + +```typescript +import type { BlueprintMembershipType, BlueprintDefinition } from '@constructive-io/node-type-registry'; + +const channelType: BlueprintMembershipType = { + name: 'Channel Member', + prefix: 'channel', + description: 'Membership to a channel.', + parent_entity: 'org', + has_limits: false, + has_profiles: false, + has_levels: false, +}; + +const definition: BlueprintDefinition = { + membership_types: [channelType], + tables: [ + { + table_name: 'messages', + nodes: ['DataId', 'DataTimestamps'], + fields: [ + { name: 'body', type: 'text' }, + ], + policies: [ + { + $type: 'AuthzEntityMembership', + data: { entity_field: 'channel_id', entity_type: 'channel' }, + privileges: ['select', 'insert', 'update', 'delete'], + permissive: true, + }, + ], + }, + ], + relations: [ + { + $type: 'RelationBelongsTo', + source_table: 'messages', + target_table: 'channels', + field_name: 'channel_id', + is_required: true, + }, + ], +}; +``` + +**Key:** Use `entity_type: 'channel'` (the prefix string) instead of a hardcoded `membership_type` integer. `constructBlueprint()` resolves the prefix to the correct `membership_type` number at construction time, since Phase 0 has already provisioned the type. This avoids fragile numeric references that depend on provisioning order. + +`target_table: 'channels'` works because `membership_types` entries are processed in Phase 0 and their entity tables are added to the `table_map` before Phase 1 (tables) and Phase 2 (relations). + +## Validation + +The `tg_validate_blueprint_definition` trigger validates `membership_types` entries on INSERT/UPDATE of both `blueprint` and `blueprint_template`. Required keys: `name`, `prefix`. All other keys are optional. + +## ORM: Create a Blueprint with Membership Types + +```typescript +// 1. Create a template with membership_types +const template = await db.blueprintTemplate.create({ + data: { + name: 'team_collaboration', + displayName: 'Team Collaboration', + ownerId: userId, + visibility: 'public', + categories: ['collaboration'], + tags: ['channels', 'messaging'], + definition: { + membership_types: [ + { + name: 'Channel Member', + prefix: 'channel', + description: 'Membership to a channel.', + parent_entity: 'org', + }, + ], + tables: [ + { + table_name: 'messages', + nodes: ['DataId', 'DataTimestamps'], + fields: [{ name: 'body', type: 'text' }], + policies: [ + { + $type: 'AuthzEntityMembership', + data: { entity_field: 'channel_id', entity_type: 'channel' }, + privileges: ['select', 'insert', 'update', 'delete'], + permissive: true, + }, + ], + }, + ], + relations: [ + { + $type: 'RelationBelongsTo', + source_table: 'messages', + target_table: 'channels', + field_name: 'channel_id', + is_required: true, + }, + ], + }, + }, + select: { id: true, definitionHash: true }, +}).execute(); + +// 2. Copy to an executable blueprint +const { blueprintId } = await db.mutation.copyTemplateToBlueprint({ + input: { + templateId: template.id, + databaseId: dbId, + ownerId: userId, + }, +}).execute(); + +// 3. Execute — Phase 0 provisions channel entity type, then tables/relations +const result = await db.mutation.constructBlueprint({ + input: { + blueprintId, + schemaId, + }, +}).execute(); +// result = { "channels": "", "messages": "" } +``` + +## CLI: Create a Blueprint with Membership Types + +```bash +# Create template +constructive public:blueprint-template create \ + --name team_collaboration \ + --displayName "Team Collaboration" \ + --ownerId \ + --definition '{ + "membership_types": [ + { + "name": "Channel Member", + "prefix": "channel", + "description": "Membership to a channel.", + "parent_entity": "org" + } + ], + "tables": [...], + "relations": [...] + }' + +# Copy to blueprint +constructive public:copy-template-to-blueprint \ + --input.templateId \ + --input.databaseId \ + --input.ownerId + +# Execute +constructive public:construct-blueprint \ + --input.blueprintId \ + --input.schemaId +``` + +## Nested Entity Types in Blueprints + +To create nested hierarchies (e.g. org → channel → thread), list entries in parent-first order: + +```json +{ + "membership_types": [ + { + "name": "Channel Member", + "prefix": "channel", + "parent_entity": "org" + }, + { + "name": "Thread Member", + "prefix": "thread", + "parent_entity": "channel" + } + ] +} +``` + +Phase 0 processes entries **in order**, so parent types must appear before child types. + +## Combining with Tables and Relations + +A common pattern: provision entity types in Phase 0, then create domain tables in Phase 1 that FK to the entity tables: + +```json +{ + "membership_types": [ + { "name": "Channel Member", "prefix": "channel", "parent_entity": "org" } + ], + "tables": [ + { + "table_name": "messages", + "nodes": [ + "DataId", + "DataTimestamps", + { "$type": "DataOwnershipInEntity", "data": { "entity_field": "channel_id" } } + ], + "fields": [ + { "name": "body", "type": "text" }, + { "name": "is_pinned", "type": "boolean" } + ], + "policies": [ + { + "$type": "AuthzEntityMembership", + "data": { "entity_field": "channel_id", "entity_type": "channel" }, + "privileges": ["select", "insert", "update", "delete"], + "permissive": true + } + ] + } + ], + "relations": [ + { + "$type": "RelationBelongsTo", + "source_table": "messages", + "target_table": "channels", + "field_name": "channel_id", + "is_required": true + } + ] +} +``` + +The `channels` table is created by Phase 0 provisioning, so the `RelationBelongsTo` in Phase 2 can reference it by name. diff --git a/.agents/skills/constructive-membership-types/references/orm-provisioning.md b/.agents/skills/constructive-membership-types/references/orm-provisioning.md new file mode 100644 index 0000000..b1c2eff --- /dev/null +++ b/.agents/skills/constructive-membership-types/references/orm-provisioning.md @@ -0,0 +1,151 @@ +# ORM Provisioning for Entity Types + +Direct entity type provisioning via the ORM — for cases where you want to provision entity types outside of blueprints. + +## entity_type_provision (Trigger Table) + +`entity_type_provision` is a **trigger table** — inserting a row fires a trigger that provisions the entire entity type (table, permissions, memberships, security). The INSERT returns the provisioning results. + +### Create a new entity type + +```typescript +const result = await db.entityTypeProvision.create({ + data: { + databaseId: database_id, + name: 'Channel Member', + prefix: 'channel', + description: 'Membership to a channel.', + parentEntity: 'org', + isVisible: true, + hasLimits: false, + hasProfiles: false, + hasLevels: false, + skipEntityPolicies: false, + }, + select: { + outMembershipType: true, + outEntityTableId: true, + outEntityTableName: true, + outInstalledModules: true, + }, +}).execute(); + +// result: +// { +// outMembershipType: 3, // assigned type ID +// outEntityTableId: '', // UUID of the channels table +// outEntityTableName: 'channels', // table name +// outInstalledModules: [ // modules installed +// 'permissions_module:channel', +// 'memberships_module:channel', +// 'invites_module:channel' +// ] +// } +``` + +### Field reference + +| ORM Field | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `databaseId` | UUID | **Yes** | — | Target database | +| `name` | string | **Yes** | — | Human-readable name (e.g. `"Channel Member"`) | +| `prefix` | string | **Yes** | — | SQL prefix (e.g. `"channel"` → `channels` table) | +| `description` | string | No | `null` | Description of the entity type | +| `parentEntity` | string | No | `"org"` | Parent type prefix | +| `tableName` | string | No | `prefix + 's'` | Override entity table name | +| `isVisible` | boolean | No | `true` | Parent members can see children | +| `hasLimits` | boolean | No | `false` | Provision limits module | +| `hasProfiles` | boolean | No | `false` | Provision profiles module | +| `hasLevels` | boolean | No | `false` | Provision levels module | +| `skipEntityPolicies` | boolean | No | `false` | Skip default RLS policies | + +### Output fields + +| ORM Field | Type | Description | +|-----------|------|-------------| +| `outMembershipType` | integer | Assigned type ID (3, 4, 5, ...) | +| `outEntityTableId` | UUID | UUID of the created entity table | +| `outEntityTableName` | string | Name of the created entity table | +| `outInstalledModules` | string[] | Array of installed module names | + +## CLI Equivalent + +```bash +constructive public:entity-type-provision create \ + --databaseId \ + --name "Channel Member" \ + --prefix "channel" \ + --description "Membership to a channel." \ + --parentEntity "org" \ + --isVisible true \ + --hasLimits false \ + --hasProfiles false \ + --hasLevels false \ + --skipEntityPolicies false \ + --select outMembershipType,outEntityTableId,outEntityTableName,outInstalledModules +``` + +## Nested Entity Types + +Provision parent types first, then children: + +```typescript +// 1. Provision channel (parent = org) +const channel = await db.entityTypeProvision.create({ + data: { + databaseId: dbId, + name: 'Channel Member', + prefix: 'channel', + description: 'Membership to a channel.', + parentEntity: 'org', + }, + select: { outMembershipType: true, outEntityTableName: true }, +}).execute(); +// channel.outMembershipType === 3 + +// 2. Provision thread (parent = channel) +const thread = await db.entityTypeProvision.create({ + data: { + databaseId: dbId, + name: 'Thread Member', + prefix: 'thread', + description: 'Membership to a thread.', + parentEntity: 'channel', + }, + select: { outMembershipType: true, outEntityTableName: true }, +}).execute(); +// thread.outMembershipType === 4 +``` + +## provisionMembershipTable (SQL Function) + +There is also a lower-level SQL function `provision_membership_table()` exposed via the ORM. It takes different parameters (integer `parentType` instead of string `parentEntity`): + +```typescript +const result = await db.mutation.provisionMembershipTable({ + input: { + vDatabaseId: dbId, + vName: 'Channel', + vPrefix: 'channel', + vDescription: 'Membership to a channel.', + vParentType: 2, // org type (integer, not prefix string) + vHasLimits: false, + vHasProfiles: false, + vHasLevels: false, + vSkipEntityPolicies: false, + }, +}).execute(); +``` + +**Prefer `entityTypeProvision`** over `provisionMembershipTable` — it uses string prefixes for parent types (more readable) and validates the parent exists. + +## After Provisioning + +Once a type is provisioned, run introspection + codegen to get typed SDK access to the new tables: + +```bash +# Regenerate types from the updated schema +cnc codegen --database +``` + +The new entity tables (`channels`, `channel_permissions`, etc.) will appear in the generated SDK like any other table. diff --git a/.agents/skills/constructive/SKILL.md b/.agents/skills/constructive/SKILL.md index aba270f..99e744a 100644 --- a/.agents/skills/constructive/SKILL.md +++ b/.agents/skills/constructive/SKILL.md @@ -15,7 +15,7 @@ Consolidated reference for the Constructive platform's core architecture: bluepr - Declarative schema provisioning system — define complete domain schemas as portable JSONB documents - Two-layer model: `blueprint_template` (shareable marketplace recipe) and `blueprint` (owned, executable instance scoped to a database) -- Definition format: tables with `nodes[]`, `fields[]`, `policies[]` (using `$type` discriminators) and `relations[]` +- Definition format: `membership_types[]` (Phase 0 entity provisioning), `tables[]` with `nodes[]`, `fields[]`, `policies[]` (using `$type` discriminators), and `relations[]` - `construct_blueprint()` executes a draft blueprint, provisioning real tables and relations via `secure_table_provision` + `relation_provision` - `copy_template_to_blueprint()` copies a template to a new blueprint with visibility checks and copy_count tracking - Merkle-style content-addressable hashing: `definition_hash` (Merkle root) and `table_hashes` (per-table UUIDv5 hashes) for deduplication, provenance tracking, and structural comparison diff --git a/.agents/skills/constructive/references/blueprint-definition-format.md b/.agents/skills/constructive/references/blueprint-definition-format.md index 53f5904..ff63292 100644 --- a/.agents/skills/constructive/references/blueprint-definition-format.md +++ b/.agents/skills/constructive/references/blueprint-definition-format.md @@ -8,6 +8,7 @@ The blueprint `definition` is a JSONB document that declaratively describes a co ```json { + "membership_types": [ ... ], "tables": [ ... ], "relations": [ ... ], "indexes": [ ... ], @@ -16,7 +17,43 @@ The blueprint `definition` is a JSONB document that declaratively describes a co } ``` -`tables` is required. `relations`, `indexes`, `full_text_search`, and `unique_constraints` are optional top-level arrays. Each can also be defined inline per-table (see below). `constructBlueprint()` collects from both locations. +`tables` is required. `membership_types`, `relations`, `indexes`, `full_text_search`, and `unique_constraints` are optional top-level arrays. Each of `indexes`, `full_text_search`, and `unique_constraints` can also be defined inline per-table (see below). `constructBlueprint()` collects from both locations. + +## Membership Types (Phase 0) + +`membership_types[]` provisions dynamic entity types **before** tables and relations. Each entry creates a full entity table with membership modules, permissions, and security policies via `entity_type_provision`. + +```json +{ + "membership_types": [ + { + "name": "Channel Member", + "prefix": "channel", + "description": "Membership to a channel.", + "parent_entity": "org" + } + ] +} +``` + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `name` | string | **Yes** | — | Human-readable name (e.g. `"Channel Member"`) | +| `prefix` | string | **Yes** | — | SQL prefix for generated objects (e.g. `"channel"` → `channels` table) | +| `description` | string | No | `null` | Description of the entity type | +| `parent_entity` | string | No | `"org"` | Parent type prefix. Must be already provisioned | +| `table_name` | string | No | `prefix + 's'` | Override entity table name | +| `is_visible` | boolean | No | `true` | Whether parent members can see children | +| `has_limits` | boolean | No | `false` | Provision a limits module | +| `has_profiles` | boolean | No | `false` | Provision a profiles module (named permission roles) | +| `has_levels` | boolean | No | `false` | Provision a levels module (gamification) | +| `skip_entity_policies` | boolean | No | `false` | Skip creating default RLS policies | + +**Processing order:** Entries are processed in array order. Parent types must appear before child types. + +**Table map integration:** Entity tables created by Phase 0 are added to the internal `table_map`, so subsequent `tables` and `relations` can reference them by name (e.g. `"target_table": "channels"`). + +See the [`constructive-membership-types`](../constructive-membership-types/SKILL.md) skill for the full membership types reference. ## Table Entries @@ -172,6 +209,8 @@ Each tuple is `[privilege, columns]` where `"*"` means all columns. See the [constructive-safegres](../constructive-safegres/SKILL.md) skill for all 14 Authz* policy types and their config shapes. +**`entity_type` resolution:** For membership-based policies (`AuthzMembership`, `AuthzEntityMembership`, `AuthzRelatedEntityMembership`, `AuthzPeerOwnership`, `AuthzRelatedPeerOwnership`), you can use `"entity_type": "channel"` (the prefix string) instead of `"membership_type": 3` (a hardcoded integer). The RLS parser resolves the prefix to the correct `membership_type` integer via `memberships_module` lookup. This is recommended for dynamic types (3+) where the int depends on provisioning order. Both forms continue to work. + **Processing:** All policies are applied after the table is created. Multiple permissive policies on the same privilege are ORed by PostgreSQL. Adding a restrictive policy (`"permissive": false`) creates an AND constraint. ## Relation Entries