Constructive Platform Architecture Overview
Concise reference for agents that need platform details beyond the phase instructions.
Core Model
- Constructive is a PostgreSQL-first app platform with generated GraphQL APIs.
- Users are organizations. A user's personal org is created at sign-up, and
user.idis the org id used by entity-scoped tables. - App developers should work through SDK-level APIs, not raw SQL internals.
Platform Baseline
Before app provisioning begins, the local constructive database must already be a real Constructive platform deployment.
Minimum proof:
metaschema_public.databaseexistshttp://auth.localhost:3000/graphqlresponds as the platform auth endpointhttp://api.localhost:3000/graphqlresponds as the platform API endpoint
If that baseline is missing, Phase 1 is incomplete. Do not inspect internals with SQL to compensate.
Endpoint Map
| Endpoint | Purpose | Auth |
|---|---|---|
http://auth.localhost:3000/graphql |
Platform auth (sign-up, sign-in) | None |
http://api.localhost:3000/graphql |
Platform API (database creation) | Platform JWT |
http://modules.localhost:3000/graphql |
Platform modules API (module and entity-type provisioning) | Platform JWT |
http://auth-<subdomain>.localhost:3000/graphql |
Per-database auth | None |
http://api-<subdomain>.localhost:3000/graphql |
Per-database app (data) API | Per-database JWT |
http://admin-<subdomain>.localhost:3000/graphql |
Per-database admin API | Per-database JWT |
The <subdomain> is a platform-assigned random identifier (e.g., b16-fatal-rose-mosquito), not the database name — never derive hostnames from the app slug. Query your endpoints by databaseId via the SDK: db.domain.findMany (hostnames), db.api.findMany (surfaces), db.query.resolveRoute({ requestHost }) to verify — then write the returned values into .env (see constructive-secrets-config §4.3).
The per-database data endpoint is api-<subdomain> — the server routes to the correct database off the Host header (the path is always /graphql).
Node.js caveat: *.localhost does not resolve reliably in Node.js. The SDK handles Host header routing automatically for both Node.js and browser environments.
Frontend caveat: the sandbox template only manages the platform (schema-builder) token. A platform token does not authenticate api-<subdomain> (per-database data) calls. If your frontend uses per-database app endpoints, it must establish and store a separate per-database app session via auth-<subdomain>.
Control Plane vs Data Plane
Every operation belongs to one of two planes, and each plane has its own token. The plane decides which endpoint and which bearer a call needs.
| Control plane | Data plane | |
|---|---|---|
| Endpoints | auth.<host>, api.<host>, modules.<host> (platform) |
auth-<subdomain>.<host>, api-<subdomain>.<host>, admin-<subdomain>.<host> |
| Token | Platform account JWT (platform sign-in) | Per-database app JWT (sign-in via auth-<subdomain>) or an API key minted on that database |
| Operations | Database creation, module provisioning, entity-type provisioning (entityTypeProvision), domains, codegen sources |
App data CRUD, app users and sessions, principals, API keys, verifyPassword, step-up |
A platform token never authenticates a data-plane call. Each database issues its own JWTs from its own auth module — the two token families have different issuers and audiences, and the data API validates only its own. The reverse also holds: a per-database token or API key cannot call the platform API. When a flow crosses planes (for example: provision an entity type, then mint a key scoped to its rows), it must hold both tokens and send each to its own plane.
API keys are data-plane credentials: a key minted on one database authenticates api-<subdomain> for that database only. See constructive-principals for the mint flow, and constructive-entities → orm-provisioning.md for control-plane entity-type provisioning.
Provisioning Flow
Phase 2.3 uses TypeScript Blueprints (BlueprintDefinition from node-type-registry) with @constructive-io/sdk for programmatic provisioning. Agents write typed schema modules, then run two scripts.
- Run
pnpm run create-db— signs up via auth API, provisions database with an explicit module list (theemail-passwordflow's module list — nevermodules: ['all'], which silently installs nothing; see gotchas.md PROVISION-001), writes credentials to.env. - Run
pnpm run provision:- (SQL only) Configure database-level settings (
deterministic_ids,simple_schema_names,schema_use_underscores) viaALTER DATABASE - Run schema modules (each defines a
BlueprintDefinitionand callsprovisionBlueprint()) - (SQL only) Apply post-provisioning workarounds (
auto-verify-email,fix-membership-defaults) via direct SQL - (SQL only) Reset provision-only settings (deterministic IDs)
- (SQL only) Configure database-level settings (
- Generate schema, SDK, and CLI from the live app endpoint (Phase 2.4).
- Build the frontend against the generated SDK (Phase 2.5+).
Steps marked (SQL only) require a direct PostgreSQL connection and are included in the
provision.tstemplate behind apgAvailableguard. If the agent only has GraphQL API access, they are skipped automatically.
The resulting app tables must live in the Constructive-managed schema family for the database, not in a custom side schema.
In local runs this commonly looks like:
- app tables in
<prefix>-app-public - membership defaults in
<prefix>-memberships-public
These are different schemas, but they share the same Constructive-managed prefix.
Data Module and Policy Pairing
Data Module (nodeType) |
Preferred Policy (policyType) |
Fields Created |
|---|---|---|
DataId |
Any | id |
DataDirectOwner |
AuthzDirectOwner |
id, owner_id |
DataEntityMembership |
AuthzEntityMembership |
id, entity_id |
DataOwnershipInEntity |
AuthzEntityMembership (and/or AuthzDirectOwner) |
id, owner_id, entity_id |
DataTimestamps |
Any | id, created_at, updated_at |
DataPeoplestamps |
Any | id, created_by, updated_by |
DataPrincipalstamps |
Any | id, created_by_principal, updated_by_principal |
DataPublishable |
AuthzPublishable |
id, is_published, published_at |
DataSoftDelete |
Any | id, deleted_at, is_deleted |
Notes:
- Every Data module creates
idby default. - When composing modules on one table, use
nodeData: { include_id: false }on the second and later calls. DataPeoplestampsonly adds user FKs wheninclude_user_fk: true.DataPeoplestamps(human,current_user_id()) andDataPrincipalstamps(acting principal — agent/API key/service,current_principal_id()) are independent and opt-in; compose both to record human owner and acting principal. Principal columns never carry a user FK.
Authz Policy Types
Read the constructive-security skill for the full list of 14 valid Authz* policy types, their configs, and semantics. Read the constructive-db-data-modules skill for the Data* → Authz* pairing table.
There is no AuthzOwnershipInEntity type. DataOwnershipInEntity pairs with AuthzEntityMembership and/or AuthzDirectOwner.
Prefer AuthzEntityMembership over AuthzMembership for entity-scoped app data.
Common Policy Configurations (Quick Reference)
For the full 14-type reference including AuthzComposite, read the constructive-security skill. Below are the 6 types most likely needed in typical apps.
| policyType | policyData | Use Case |
|---|---|---|
AuthzEntityMembership |
{ "entity_field": "entity_id", "membership_type": 2 } |
Org-scoped data: all org members can access. Default choice for most app tables |
AuthzDirectOwner |
{ "entity_field": "owner_id" } |
Personal data: only the row creator can access |
AuthzDirectOwnerAny |
{ "entity_fields": ["sender_id", "receiver_id"] } |
Multi-owner: any of the listed user fields grants access |
AuthzAllowAll |
{} |
Public reference data. WARNING: any authenticated user can read AND write |
AuthzPublishable |
{} |
Draft/published gating. Combine with an identity policy |
AuthzDenyAll |
{} |
Explicitly block a privilege |
Membership Type Values
1= App scope (global membership)2= Org scope (entity-bound membership) — use this for most app data3= Group scope
App Brief Mapping
When build/app-brief.yaml is present:
naming.db_namedrives the database name. Per-database endpoints are platform-assigned (discovered fromcreate-db.tsoutput).naming.*_packagevalues drive package names used in examples and generated code.app.workspace_rootdefines where the app is built.data_model.tablesanddata_model.relationsdefine the schema to provision.acceptance.required_flowsdefines what the app-specific UI must satisfy.