Critical Gotchas
Read this file before every phase. It contains only the invariants that repeatedly cause agent failures.
CODEGEN-001
Never write raw GraphQL strings, schemas, or queries.
Constructive workflow:
- Provision tables via TypeScript blueprints (
BlueprintDefinitionfromnode-type-registry) using@constructive-io/sdk(Phase 2.3). - Generate
.graphqlschema files and typed SDK code from the live endpoint via@constructive-io/graphql-codegen. - Use generated hooks and ORM only.
Do not hand-write .graphql files, inline SDL, or raw query strings.
CODEGEN-002
Never modify generated SDK/ORM code. Files under src/generated/ are outputs of @constructive-io/graphql-codegen and will be overwritten on the next codegen run.
If the generated code is missing functionality:
- Check the codegen config —
orm: trueanddocs: { skills: true }should be set. - Re-run codegen (
pnpm codegenin the SDK package). - If the issue persists after regeneration, it is a codegen limitation — report it as blocking. Do NOT patch generated files.
Common mistake: adding JWT decoding, entityId inference, or custom wrapper logic directly into generated ORM models or client files. These changes will be lost on regeneration and indicate a misunderstanding of the SDK contract.
ENTITY-ID-001
Tables provisioned with AuthzEntityMembership policy have a required entity_id column enforced by RLS. The platform does NOT auto-populate entity_id — the client must pass it explicitly on every create.
In Constructive, user.id IS the personal organization id. After per-database sign-in, use the userId from the sign-in response as the entityId for all create mutations:
const signIn = await authClient.mutation.signIn(...).execute();
const entityId = signIn.signIn.result.userId;
// Every create on an AuthzEntityMembership table needs entityId
await db.project.create({
data: { id: uuid(), name: 'My Project', entityId },
select: { id: true },
}).unwrap();
If entityId is omitted, the RLS policy evaluates against NULL and the insert silently fails or is rejected.
APP-BRIEF-001
If build/app-brief.yaml is present, all app-specific values must come from it.
That includes:
- database name
- package names
- endpoints
- routes
- data model
- acceptance flows
If the current task provides a different task-specific spec, use that file instead of inventing placeholders.
BLUEPRINT-001
The blueprint examples in skill-supplements.md contain generic tables (boards, lists, cards). They are structural references only.
When building a non-benchmark app:
- Do NOT copy the example and edit table names. Build the
BlueprintDefinitionfrom scratch using the app brief'sdata_modelsection. - Use
node-type-registrytypes for autocomplete and validation. - Use the Policy Decision Tree to select the correct
Authz*policy for each table. - Write all nodes, grants, and policies inline in each table definition — do not use shared constants.
- Relations use
source_table/target_table.
If you copy-edit the example verbatim, you will carry over generic tables into your domain — which is a critical error.
RLS-POLICY-001
Default a basic (org-less) app's tables to owner-scoped AuthzDirectOwner, NOT AuthzEntityMembership. Picking the policy by tenancy model:
AuthzDirectOwner(default — each user owns their rows).nodes: ['DataId', 'DataDirectOwner', …],use_rls: true,policies: [{ $type: 'AuthzDirectOwner', privileges: ['select','insert','update','delete'], permissive: true, data: { entity_field: 'owner_id' } }]. Compiles toowner_id = jwt_public.current_user_id()(USING for s/u/d, WITH CHECK for insert). Proven 1/1/1/1 CRUD e2e. The client setsownerIdto the authed user's id on create — WITH CHECK rejects any other value (anti-spoof). Optionally force it server-side with the node{ $type: 'DataForceCurrentUser', data: { field_name: 'owner_id' } }.- FK prereq:
owner_idFKs to the per-tenant users table, so the authed user must exist in-tenant — sign up via the TENANT endpoint (auth-<sub>.localhost), NOT baseauth.localhost, or the insert FK-violates.
- FK prereq:
AuthzAllowAll(app-wide shared pool, no ownership).nodes: ['DataId', { $type: 'DataTimestamps', data: { include_id: false } }],use_rls: true,policies: [{ $type: 'AuthzAllowAll', privileges: ['select','insert','update','delete'], permissive: true }].AuthzEntityMembership+membership_type: 2(org/b2b tenancy) — ONLY with org modules. On any app WITHOUT org/b2b/memberships modules (i.e. theauth:hardenedpreset, or any list without them) this does NOT silently 0-row: it FAILS HARD atconstructBlueprinttime withstatus: failed, errorDetails: "NOT_FOUND (memberships_module)"(the org-scoped SPRT does not exist), so the table is never created. Only use it when you provisioned theb2b:storagemodule preset.AuthzAppMembership(membership_type 1) constructs but then silently denies all CRUD (0 rows, no error) until the actor'sapp_membershipis approved+active (is_approved/is_active/is_verified = TRUE). If you must use it, add the post-provision approval UPDATE; otherwise preferAuthzDirectOwner.
AuthzDirectOwner's config key isentity_field(value = the owner column, e.g.'owner_id'), NOTowner_field(that belongs toAuthzMemberOwner/AuthzPeerOwnership/AuthzRelatedPeerOwnership/AuthzOrgHierarchy). Usingowner_fieldtriggersMISSING_REQUIRED_FIELD.
RLS-USERS-UPDATE-001
updateUser returns 200 but persists 0 rows (silent no-op) on the dynamically-provisioned per-tenant users table. The dynamic provisioner enables RLS + a column UPDATE grant to authenticated (username/display_name/profile_picture) but emits ONLY an auth_sel SELECT policy and no UPDATE policy, so RLS rejects every update. This is deterministic (verified identical across tenants) and is not expressible in the blueprint — users is module-owned.
Fix = a CONTROL-PLANE step (what that means; the provision.ts template already runs it): AFTER createDatabaseProvisionModule + constructBlueprint, issue createSecureTableProvision on http://modules.localhost:3000/graphql with the SAME sudo/admin token used for provisioning:
createSecureTableProvision(input: { secureTableProvision: {
databaseId: <tenant db uuid from the provision result>,
schemaId: <id from metaschema_public.schema WHERE database_id=<db> AND name='users_public'>,
tableId: <id from metaschema_public.table WHERE schema_id=<that> AND name='users'>,
tableName: "users",
useRls: true,
policies: [{ "$type": "AuthzDirectOwner", "permissive": true, "privileges": ["update"],
"policy_name": "self_update", "data": { "entity_field": "id" } }]
}})
This emits auth_upd_self_update (FOR UPDATE TO authenticated USING id = jwt_public.current_user_id()) and updateUser then persists end-to-end. Required for any flow that writes the users table (profile / updateUser / account-settings). (Platform gap, flagged upstream: the per-tenant provisioner should emit this policy itself — the static seed schema has auth_upd/auth_upd_admin_updates, the dynamic path omits them. The control-plane step is the app-side reconciliation until the platform fixes it.)
RLS-ORG-RECONCILE-001
B2B (org) flows need MORE than the basic recipe — the org counterpart to RLS-USERS-UPDATE-001. Only
applies to the opt-in b2b tier (a flow whose backend.modules carry the org-scoped
memberships/hierarchy tuples — organization / org-members / org-roles / org-invites /
app-memberships). The users self-update step + app_public blueprint grants cover auth:hardened, but org
flows write module-owned org tables and gate org creation on the create_entity app-permission bit
(bit 5 = 0x20) the basic recipe never sets — so org create / member writes are RLS-denied even with auth
in place. The full 3-step reconciliation (grant create_entity; authenticated INSERT/UPDATE on the
tenant users table; reconcile org_memberships INSERT/UPDATE + org_member_profiles SELECT, all via
createSecureTableProvision) + its provision.ts placement lives in skill-supplements.md → "Org-flow
extension"; verify the exact mutations against the constructive-security skill (do not invent a
snippet). This is an app-side workaround for a platform provisioner gap tracked internally (the
durable fix is upstream).
FIELD-TYPE-001
Text default values in blueprint fields must be wrapped in single quotes inside the JSON double quotes, because the defaultValue is a raw SQL expression:
{ "name": "status", "type": "text", "defaultValue": "'pending'" }
Common mistakes:
"defaultValue": "pending"— missing single quotes; PostgreSQL interpretspendingas a column name and provisioning fails."defaultValue": "'true'"for a boolean field — use"defaultValue": "true"(no single quotes) forbooleantype."defaultValue": "'0'"for an integer field — use"defaultValue": "0"(no single quotes) forintegertype.
Rule: single-quote wrappers are only needed for text type defaults. Numeric, boolean, and function defaults (now(), uuid_generate_v4()) are bare SQL.
SQL-001
Do not use ad hoc SQL to inspect, provision, or reverse-engineer the Constructive platform.
Allowed SQL is limited to:
auto-verify-emailfix-membership-defaults- bounded verification queries already defined in the repo scripts
If the SDK surface does not match the live platform, use a documented compatibility shim if one exists. Do not replace Phase 2.2 with a direct SQL provisioning path.
PLATFORM-001
Phase 1 is only complete when the local constructive database already contains the Constructive system baseline.
Minimum baseline:
metaschema_public.databaseexistshttp://auth.localhost:3000/graphqlpasses GraphQLPOSTverificationhttp://api.localhost:3000/graphqlpasses GraphQLPOSTverification
Phase 2.2 assumes that baseline and uses the live platform plus SDK as the source of truth. If the baseline is missing or those endpoints do not behave like Constructive GraphQL endpoints, stop and repair the local platform instead of probing with SQL.
SERVER-001
cnc server is a background process that can die at any time (crash, OOM, shell exit). Before starting any phase that calls the platform (2.2, 2.3, 2.4, 2.5), verify the server is still healthy:
STATUS="$(curl -s -o /dev/null -w "%{http_code}" http://api.localhost:3000/graphql 2>/dev/null || echo "000")"
if [ "$STATUS" != "405" ]; then
echo "cnc server is down (got HTTP $STATUS). Restarting..."
lsof -ti :3000 | xargs kill -9 2>/dev/null; sleep 1
nohup bash -c 'eval "$(pgpm env)" && PGDATABASE=constructive cnc server --port=3000 --origin "*"' > /tmp/cnc-server.log 2>&1 &
for i in $(seq 1 30); do
S="$(curl -s -o /dev/null -w "%{http_code}" http://api.localhost:3000/graphql 2>/dev/null || echo "000")"
[ "$S" = "405" ] && break
sleep 1
done
fi
The agent can and should restart the server itself. Do not ask the user to restart it. The server runs from any directory — it only needs PGDATABASE=constructive, pgpm env for Postgres connection variables, and --origin "*" to avoid an interactive CORS prompt.
If the server starts but GraphQL POST verification still fails, common causes are:
cncis outdated — runnpm i -g @constructive-io/cli@latest- another
cnc serveron a different port is conflicting with connection pools — kill it withlsof -ti :5555 | xargs kill -9 /tmp/cnc-server.logcontains PostGraphile or pg-cache errors
SDK-001
Applies to post-provision app code (not the provisioning scripts themselves).
findOne is id-based, not where-based.
db.table.findOne({ id, select })
SDK-002
Applies to post-provision app code (not the provisioning scripts themselves).
signIn returns accessToken, not jwtToken.
SDK-003
Per-database PostGraphile APIs expose plural connection queries only. Query the plural field and filter with condition.
SDK-004
The
provision.tstemplate handles granteeName/roleName compatibility via@constructive-io/sdk. Refer to the SDK-004 history if you encounter issues in post-provision app code.
SDK-005
The
provision.tstemplate handles UUID generation via@constructive-io/sdk.
Some live app schemas require explicit UUID id values on create mutations even when the generated TypeScript input types omit them.
If create mutations fail with an error like:
Field "id" of required type "UUID!" was not provided
do not switch to raw GraphQL.
Use a narrow compatibility shim:
- generate a UUID client-side
- pass it through the generated SDK or hook create call
- if the generated TypeScript type rejects the extra
idfield, use a small local wrapper function that encapsulates the type narrowing — do not useas anyin component code
This keeps the run on the generated SDK path while acknowledging schema/codegen drift.
SDK-006
If secureTableProvision fails with UNKNOWN_POLICY_TYPE:
- The policy type string does not exist on the live platform. This is a config error, not a platform bug.
- Read the
constructive-securityskill for the full list of valid Authz* policy types (14 leaf types). - Read the
constructive-db-data-modulesskill for the Data* → Authz* pairing table. - Replace the invalid policy type with the correct pairing.
Common case: DataOwnershipInEntity uses AuthzEntityMembership (with entity_field: 'entity_id', membership_type: 2) and/or AuthzDirectOwner (with entity_field: 'owner_id'). There is no AuthzOwnershipInEntity type.
Do not stop. Do not switch to SQL. The fix is always a different valid policy type from the constructive-security skill.
SDK-007
revokeSession returns SESSION_NOT_FOUND when you pass the id from a signUp/signIn result. That id is a UUIDv5 identity/credential id, NOT the sessions row id (a UUIDv7), and no field on the auth result exposes the real session id — so revokeSession is effectively uncallable from the auth result alone. Treat sessions-revoke as a known platform/SDK gap (auth result shape). Document it and move on — do not fight it, do not hand-craft a session id, and do not switch to SQL.
SDK-008
The template's app SDK snapshots headers at MODULE LOAD — so it runs ANONYMOUS after login, and a
200-but-0-rows on YOUR OWN app_public table is almost always THIS, not an RLS gap. The template's
AppProvider calls configureApp() at module load (before any login), and the generated
FetchAdapter snapshots the headers at construction. So the app namespace never carries the
post-login Authorization: Bearer — every @sdk/app CRUD op runs as the anonymous role and hits
permission denied for table <t>, which the API returns as HTTP 200 with 0 rows. This is
indistinguishable from an RLS/grant gap and burns cycles re-checking policies that are actually fine.
BlocksRuntime avoids this for auth/admin because it attaches the token per request via a
host-supplied getToken (TokenManager.getToken('admin').token?.accessToken). The template's app
namespace has no equivalent, and the Blocks on-ramp's Step 5a removes configureAuth/configureAdmin
while leaving configureApp intact — so the stale-anonymous app adapter survives the on-ramp
untouched.
Fix (one of two):
- Re-configure
appafter login with the live token — callconfigureApp({ adapter: createSdkAdapter('app') })again after sign-in completes (in the auth-success callback / a session effect), so the adapter is rebuilt with the post-loginAuthorizationheader; or - Wire the
appnamespace to read the token per request — give its adapter the same per-request seamBlocksRuntimeuses for auth/admin (() => TokenManager.getToken(...).token?.accessToken), so each request picks up the current token and never goes stale across login/refresh/logout.
🚨 Before suspecting RLS on a 200-but-0-rows from your own
app_publictable, CHECK THE REQUEST'SAuthorizationHEADER. If it is missing/anonymous, this is the stale-anonymous-header bug (re-configureapppost-login or wire it per-request) — not an RLS policy gap. Only chase RLS once you have confirmed the request actually carried the user's bearer token. Seereferences/blocks-onramp.mdStep 5a and SKILL.md S5.
HOOKS-001
Generated React Query hook mutations follow PostGraphile conventions:
- All
.mutate()calls take an object, never a bare value.deleteProject.mutate({ id }), notdeleteProject.mutate(id). - Update mutations use a table-prefixed patch field:
<tableName>Patch, notpatch. Example:updateProject.mutate({ id, projectPatch: { name: 'New Name' } }). - Delete mutations take
{ id }. Example:deleteProject.mutate({ id }).
If unsure about the exact shape, read the generated SDK docs:
sdk/sdk/src/generated/orm/AGENTS.md— best single-file ORM referencesdk/sdk/src/generated/hooks/README.md— best hooks reference- Per-table cheat sheets:
skills/orm-default/references/<table>.mdandskills/hooks-default/references/<table>.md
The ORM (db.project.update(...)) uses a different Prisma-like interface with data/where keys. Do not conflate hook variables with ORM arguments.
FRONTEND-001
When building a Next.js frontend from the Constructive sandbox template:
- preserve the template's root provider stack (
AppProvider,RouteGuard,AuthenticatedShell) - preserve the template's app shell and auth routes; add app-specific routes alongside them
- run the template's
pnpm codegen - register new app routes in the template route configuration
- configure app-specific generated SDK clients in shared integration code before hooks render
- do not call
configure()for a generated SDK inside a route-localuseEffect - app-specific routes must load after auth without runtime page errors
If the current task defines UI selectors or data-testid hooks, implement them where practical. They enable optional browser verification but are not required for the SDK/API acceptance gate.
FRONTEND-002
The Constructive sandbox template only manages platform (schema-builder) auth out of the box.
That means:
- the boilerplate token from
NEXT_PUBLIC_SCHEMA_BUILDER_GRAPHQL_ENDPOINTdoes not authenticate requests to the per-database data endpointapi-<sub> - per-database
auth-<sub>andapi-<sub>(data) endpoints need their own shared app-session integration - a browser signup/login flow is incomplete unless it also establishes the app-specific session needed by the app data CRUD route
The per-database data endpoint is
api-<sub>. Routing is byHostheader — see SUBDOMAIN-001. The lesson here is about authentication, not the host name: even with the rightapi-<sub>host, a platform/schema-buildertoken does not authenticate per-database data calls. You must establish a separate per-database app session viaauth-<sub>and send that token.
If the frontend uses both:
NEXT_PUBLIC_AUTH_ENDPOINTNEXT_PUBLIC_GRAPHQL_ENDPOINT
then implement a shared app-auth bridge:
- signup/login must also sign up/sign in against
auth-<sub> - logout must clear the app-specific token too
- app CRUD must use a shared client or adapter that sends the app-specific token to
api-<sub> - do not configure the app-specific generated SDK in a page-local
useEffect - do not assume the boilerplate's
TokenManager,AuthProvider, orschema-buildercontext automatically cover the per-database app endpoint
THRASH-001
If a verification step fails:
- Read the EXACT error message.
- Identify the ROOT CAUSE (not a symptom).
- Fix the root cause with ONE targeted change.
- Re-run verification ONCE.
Do NOT:
- Retry the same failing command without changing anything.
- Send SIGUSR2 to any process (restart it with SERVER-001 instead).
- ALTER TABLE to add column defaults (fix provision config instead).
- Regenerate SDK if the fix was in CLI code (regeneration clobbers manual fixes).
- Exceed the retry budget. The limit is 3 retries for the same issue (one initial attempt + up to 3
fix retries), matching
self-improvement.md"Retry Limits" ("Maximum 3 retries"). After the 3rd failed retry, STOP and report the exact error. Each retry must be a different, root-cause change — re-running an unchanged command does not count as progress and is forbidden.
Single source of truth for the retry limit:
self-improvement.md→ "Retry Limits" (max 3 retries for the same issue). This THRASH-001 rule and that section describe the same budget in the same unit (retries, not attempts); do not treat them as two separate allowances.
One restart per phase maximum. If the server needs restarting, use SERVER-001 exactly once and continue.
MISSING-001
If a template or file referenced by a phase doc does not exist:
- Verify
git submodule update --initwas run (templates may be missing). - If the file still does not exist after submodule init, STOP.
- Report the exact missing path as a blocking issue.
Do NOT:
- Search GitHub or external repos for the missing file.
- Read random skills looking for a replacement.
- Improvise a hand-written version from scratch.
- Use WebFetch or WebSearch to find the file online.
The templates in this repository are the only authoritative source. If they are missing, the setup is incomplete.
TS-001
ABSOLUTE PROHIBITION: as any, as unknown as T, // @ts-ignore, and // @ts-expect-error are FORBIDDEN in all agent-written code. There are ZERO exceptions for app code.
If you write as any anywhere outside of templates/, the build is a FAILURE regardless of whether it compiles.
Note: Use @constructive-io/sdk for all SDK access. It supports both Node.js and browser environments with clean subpath exports (auth, public_). If you find yourself needing // @ts-ignore for SDK imports, re-check your imports and package version.
If TypeScript types do not match in app code:
- Re-run codegen (stale types) — ONCE.
- Check if the provision script has a bug — fix the script, not app code.
- Use a narrow local wrapper function that encapsulates the type issue.
- If none of the above work, report as blocking issue — do NOT mask with casts.
This is not a style preference. as any defeats the type safety that Constructive's generated SDK provides. Any occurrence of as any in agent code means the agent bypassed the SDK contract, which is a critical failure.
TS-002
Generated query fields are typed T | null | undefined. Use nullish coalescing for defaults:
const name = project?.name ?? '';
const count = project?.taskCount ?? 0;
const items = data?.allProjects?.nodes ?? [];
Do not use as string or as any to strip nullability — that violates TS-001.
PROVISION-001
Never provision with modules: ['all']. Pass an explicit list of module names.
modules: ['all'] is the single most damaging mistake in the whole flow, because it fails silently. databaseProvisionModule feeds modules straight into metaschema_generators.provision_database_modules(v_modules => ...). The proc parses each array element (a plain string "users_module", or a tuple ["memberships_module", {"scope": "app"}]) into a {name, options} entry, then installs each module with a IF 'users_module' = ANY(v_module_names) THEN ... (unscoped) or a jsonb-containment IF v_module_entries @> '[{"name":"memberships_module","options":{"scope":"app"}}]' THEN ... (scoped) branch. There is no 'all' sentinel — not in the SQL proc, not in the BEFORE-INSERT trigger, not in the SDK, not in the CLI. So ['all'] matches zero branches and installs zero optional modules. You get only the ~4 base schemas. Then:
bootstrapUser: truefails withTARGET_USERS_NOT_FOUND(nousers_module).- Per-DB auth is empty —
signIn/signUp/currentUseragainstauth-<db>.localhostreturn nothing. - Every app-public query hits an RLS denial because no
rls_module/memberships_modulerows exist to authorize the caller.
The fix is an explicit module list — and the authority for which modules is references/flows.json, not a number you carry in your head. Pick the flow(s) the app needs (Step 4.0 of references/blocks-onramp.md) and provision exactly that flow's backend.modules. flows.json is generated from the node-type-registry presets and is machine-checked by check-flows.mjs (pnpm check:flows), so the module list a flow declares is guaranteed to be the real, resolvable preset — this closes the same silent-drift class as the ['all'] bug itself (a hand-maintained list rots; a generated, checked one cannot).
Read the list straight off the chosen flow instead of retyping it:
# The exact modules to provision for a flow (here: the basic email+password app):
node -e 'const f=require("./references/flows.json");const fl=f.flows.find(x=>x.id==="email-password");console.log(fl.backend.preset);console.log(JSON.stringify(fl.backend.modules,null,2))'
For a basic auth app (email + password sign-up/sign-in, app-level RLS — no orgs/SSO/MFA) the email-password flow carries its own backend.modules list. No shipped preset is that small — the smallest, auth:hardened, installs considerably more — so the flow's list is the authority and the block below is a copy for templates that cannot read a file at provision time. Keep it in sync with the flow.
Scoped modules are TUPLES, not colon strings. The proc takes
["name", { "scope": ".." }]tuples for scope-aware modules — aname:scopecolon string (e.g.'memberships_module:app') is read as a bare module name and throwsNOT_FOUND (memberships_module), installing the scoped module not at all.flows.jsonalready carries these as native tuples; pass them verbatim.
// The verified minimum for a basic auth app. Not a shipped preset — the
// smallest preset that covers it is `auth:hardened`, which installs far more.
// Scoped entries are ['name', { scope }] tuples — colon strings ('name:scope')
// throw NOT_FOUND in the provision proc.
const MODULES_EMAIL_PASSWORD = [
'users_module',
'membership_types_module',
['capabilities_module', { scope: 'app' }],
['limits_module', { scope: 'app' }],
['memberships_module', { scope: 'app' }],
'sessions_module',
'user_state_module',
'user_credentials_module',
'config_secrets_module',
'emails_module',
'rls_module',
'user_auth_module'
];
Re-provisioning with this exact list yields 18 schemas with working signIn / signUp / currentUser and a live RLS-governed createNote / query — versus the broken ~4-schema result from ['all'].
This list cannot earn a level. Levels, requirements, grants and rewards come from
events_module, and the ladder that fills them must be named:["events_module", { "scope": "app", "trust_ladder": "humanity" }]. Add both if the app gates onlevel.reachable; the shipped presets (auth:hardened,b2b:storage,full) already carry thehumanityladder. Seeconstructive-events→ trust-ladders.md.
For a fuller app, provision the richer flow's module list — again read from flows.json, not invented:
- Any
social-oauth/connected-accountsflow →auth:hardened(addsconnected_accounts_module+identity_providers_module+oauth_requests_module, plus passkeys, SMS and rate limits). - Any
org-*flow (organization,org-members,org-roles,org-invites,app-memberships) →b2b:storage(org-scoped memberships, invites, fine-grained capabilities, levels, profiles, hierarchy, storage, on top ofauth:hardened). There is no preset smaller thanb2b:storagefor org flows. Use when the app has workspaces / teams / tenants. full— installs every standard module (everything inb2b:storageplus billing/plans, notifications, crypto addresses, i18n, functions). Use for reference/demo DBs and open-ended greenfield apps. (Not flow-keyed; pull from the preset directly.)
The flow's backend.modules IS the exact set to pass to databaseProvisionModule; backend.preset is only the smallest covering shipped preset (advisory). If you need a preset not represented by a flow, pull its modules array from constructive/packages/node-type-registry/src/module-presets/<preset>.ts (the ModulePreset.modules field) or via getModulePreset('<preset>').modules — but for anything a flow covers, prefer flows.json so check-flows guards it. Presets are metadata only — what actually installs the modules is passing that flat string[] to databaseProvisionModule. Order does not matter; provisioning resolves dependencies.
NAMING-001
Never name an app table users. Every auth preset provisions a built-in users table (users_module).
BLOCKS-001
Blocks read _GRAPHQL_-named env vars, not the template's endpoint vars. blocks-runtime.tsx reads
process.env.NEXT_PUBLIC_AUTH_GRAPHQL_ENDPOINT and process.env.NEXT_PUBLIC_ADMIN_GRAPHQL_ENDPOINT
(literal references so Next can inline them). The sandbox template instead uses NEXT_PUBLIC_AUTH_ENDPOINT
/ NEXT_PUBLIC_ADMIN_ENDPOINT (or derives everything from NEXT_PUBLIC_DB_NAME). These are different
names. If you set only the template names, every block request no-ops and the console logs
Missing NEXT_PUBLIC_AUTH_GRAPHQL_ENDPOINT. In .env.local, set the _GRAPHQL_ names too (additive — keep
NEXT_PUBLIC_DB_NAME). See references/blocks-onramp.md Step 3.
BLOCKS-002
One configure() per namespace — resolve the AppProvider × BlocksRuntime collision. The template's
AppProvider (src/components/app-provider.tsx) calls configureAuth + configureAdmin + configureApp
at module load. BlocksRuntime also configures auth + admin. Two configurers for the same
namespace is last-writer-wins: block requests can silently go through the template's adapter (no
Authorization: Bearer), so authenticated block calls fail. Fix: remove configureAuth and
configureAdmin from AppProvider, keep configureApp; let BlocksRuntime own auth + admin. See
references/blocks-onramp.md Step 5a.
BLOCKS-003
@simplewebauthn/browser is a host dependency, not a block registry dependency. Passkey blocks
(passkey-sign-in, passkey-enroll) dynamic-import @simplewebauthn/browser, but it is not listed in
any block's registry dependencies — so shadcn add will not install it. If you install a passkey block
without adding it, the dynamic import fails at runtime. Run pnpm add @simplewebauthn/browser in the app
(Step 2). (Confirmed: the dashboard blocks app declares @simplewebauthn/browser as a top-level dep.)
BLOCKS-004
The Tailwind v4 @source trap — modals render unstyled without it. @constructive-io/ui/globals.css
declares its own @source "../components", but Tailwind v4 resolves an @source inside an @import-ed
sheet relative to that sheet, not your entry CSS — so the UI components are never scanned from your
app. The utilities that live only inside UI components (Dialog/DropdownMenu/Popover centering, backdrops,
enter/leave animations) are then never generated, and every block modal/menu renders unstyled (popup not
centered, no backdrop) even though the build passes. Fix: in globals.css, add a @source pointing at
the installed UI component source (e.g. @source "../../node_modules/@constructive-io/ui/dist"; from
src/app/). The exact path depends on pnpm hoisting — verify it resolves to a real directory before
trusting it. See references/blocks-onramp.md Step 2b.
BLOCKS-005
Published registry 404 → build + SERVE the local registry over HTTP, then add by name. The
published shadcn registry (https://constructive-io.github.io/dashboard/r/<name>.json, declared in the
template's components.json → registries.@constructive) returns 404 until the registry deploy
lands. A raw local-file install does NOT work around it: npx shadcn add /abs/path/.../<block>.json
(or add @constructive/<block>) still 404s, because shadcn resolves the block's @constructive/*
registryDependencies (blocks-runtime, cn, UI primitives) against that dead published URL. The
working path:
- Build + serve the
dashboard-blocksworktree's registry (consume-only) — not the plaindashboard/checkout, whose registry has UI primitives only (no auth/account/org flow blocks). One command does both, on the canonical port 4081:scripts/serve-registry.sh 4081(it runspnpm --filter @constructive-io/registry buildin.worktrees-v2/dashboard-blocks→apps/registry/public/r/*.json, then serves that dir so/r/<name>.jsonis fetchable). - Repoint
packages/app/components.json→registries.@constructiveto the local URLhttp://localhost:4081/r/{name}.json. cd packages/app && npx shadcn@latest add @constructive/<block>— now transitive deps resolve.- Tear the server down afterward with
scripts/stop-registry.sh.
This is a missing-artifact case, not MISSING-001 (the registry is buildable locally — build + serve
it; do not improvise block source). See references/blocks-onramp.md Step 4. Once published, drop the
local-serve + repoint steps. (Manual recovery if serve-registry.sh is unavailable: npx serve -l 4081 .worktrees-v2/dashboard-blocks/apps/registry/public after the registry build.)
BLOCKS-006
StepUpProvider is a required root provider for account/MFA/passkey blocks. Blocks that perform a
step-up (re-auth) — connected-accounts, danger-zone, TOTP-disable, backup-codes-regenerate, org settings,
etc. — call useStepUp(), which throws (useStepUp() must be called inside <StepUpProvider>) if
<StepUpProvider> is not mounted at the app root. Mount it inside BlocksRuntime (Step 5b). If you install
only the sign-in card you may not hit this, but install it as soon as any account/MFA block is added.
BLOCKS-007
The cn registry dep writes a NEW src/lib/utils.ts file that SILENTLY SHADOWS the template's
src/lib/utils/ directory — delete the stray file, do not "overwrite". The sandbox template does not
ship a single src/lib/utils.ts; it ships a src/lib/utils/ directory whose barrel
(src/lib/utils/index.ts) re-exports common.utils (which exports cn, clamp, …) and file.utils
(getImageUrl, …). (The directory also contains use-controllable-state.ts, available by subpath.)
57 files import @/lib/utils and rely on that barrel's full surface. The cn registry item, however,
writes a sibling file src/lib/utils.ts (its registry file resolves to @/lib/utils). Because the
template's tsconfig.json maps @/* → ./src/*, TS/Node resolves @/lib/utils to src/lib/utils.ts
(the file) before src/lib/utils/index.ts (the directory index) — file wins over dir-index. So the
freshly-written cn file silently shadows the whole barrel, dropping file.utils (and any
common.utils export beyond cn, e.g. clamp) from @/lib/utils and breaking those 57 importers
app-wide.
There is no overwrite prompt — the new file and the directory's files are different paths, so shadcn add writes src/lib/utils.ts additively without ever asking. The shadow is silent; the build may even
still pass for blocks while the rest of the app loses exports. Remediation: delete the stray file —
rm src/lib/utils.ts — to un-shadow the template's src/lib/utils/ directory (the directory and its files
are git-tracked; the stray src/lib/utils.ts is not tracked, so git checkout -- src/lib/utils.ts
does nothing here). The template's cn is compatible, so no block loses functionality. After deleting,
confirm @/lib/utils again resolves to the barrel and still exports everything its 57 importers use. See
references/blocks-onramp.md Step 4d.
BLOCKS-008
Add a graphql pnpm override pinned to ONE version — @constructive-io/ui drags graphile deps that
split graphql. Installing @constructive-io/ui pulls transitive graphile/postgraphile packages that
depend on graphql; they can resolve to a different graphql version than the template's generated
SDK, putting two copies of graphql in the tree. That throws the classic "Cannot use GraphQLObjectType
… from another module or realm" (dual-graphql) error. Prevent it with a workspace pnpm override pinned
to a single version, e.g. in the workspace root package.json:
{"pnpm":{"overrides":{"graphql":"^16.9.0"}}} (match whatever the template's SDK already uses), then
re-run pnpm install. Verify with pnpm why graphql → exactly one resolved copy. If the template
already declares a graphql override, reuse that exact version. See references/blocks-onramp.md
Step 2a-pre.
BLOCKS-009
Mount <BlocksRuntime> via a 'use client' wrapper — never directly in the server layout.tsx.
BlocksRuntime is a Client Component that takes a function prop (getToken). The sandbox template's
src/app/layout.tsx is a Server Component, and passing getToken={() => …} from a server file triggers
the Next build error "Functions cannot be passed directly to Client Components … mark it with 'use
client'" (getToken={function getToken}). Fix: create a small 'use client' BlocksProviders wrapper
that mounts BlocksRuntime + StepUpProvider and owns the getToken closure, then import that wrapper
into layout.tsx (only a component crosses the boundary, not a function). See
references/blocks-onramp.md Step 5b.
BLOCKS-010
Registry block names are PREFIXED — discover real names before add; guessing 404s. There is no
auth-sign-out (it is auth-sign-out-button), no account-profile (it is
auth-account-profile-card), etc. A wrong name produces a 404/"not found" that looks like a registry
outage but is just a bad name. flows.json is the authoritative answer to "which blocks?" — each
flow's blocks array is the exact prefixed install set (e.g. org-create-card, org-members-list,
org-roles-editor, org-settings-form for the org flows). To confirm a name resolves, ls the built
registry (the dashboard-blocks worktree — see BLOCKS-005 for the source path) and match it. See
references/blocks-onramp.md Step 4a.
BLOCKS-011
The @/generated/auth alias must point at a NON-EMPTY SDK that exports useSignInMutation — test -d
is not enough. The real failure mode is a per-DB auth-<subdomain> endpoint that is schema-empty
(no auth procedures wired): codegen still writes src/graphql/sdk/auth/, so the directory exists and
test -d passes, but it emits no useSignInMutation and every auth block no-ops. Verify the
content: both src/graphql/sdk/{auth,admin} must be non-empty AND
grep -rq 'useSignInMutation' src/graphql/sdk/auth must succeed. Fallback if it is schema-empty:
confirm the endpoint really lacks signIn (introspect it), and if so fix the backend provisioning
(Phase 1/2) — do not hand-write hooks (CODEGEN-001). If a schema-bearing auth endpoint exists for the
DB, point the template's codegen config at it and re-run pnpm codegen to regenerate
src/graphql/sdk/auth in place (never a second SDK under src/generated). A schema-empty auth SDK
passes check-sdk.mjs's directory check but fails its signIn → useSignInMutation manifest assertion.
See references/blocks-onramp.md Step 1.
BLOCKS-012
**`reference
…(truncated)