WBSP Project Guidelines
These guidelines are normative. Every project that deploys on the WBSP platform should reference this document from its constitution, and should follow the conventions below. They exist to make deployment smooth, predictable, and boring — the platform can only hide infrastructure details from you if your project is laid out the way the platform expects.
If a rule here ever conflicts with a more specific platform document (for example application-creator-guide.md or parallel-services-guide.md), follow the more specific document and raise the discrepancy.
1. File locations
A project repository has a small number of well-known directories. Keeping to them means tooling, reviewers, and the platform always know where to look.
/initial-research
Early project planning, specifications, and research live here and stay here. These are working documents that informed the project; they are not the project itself. Typical files:
data-model-suggestion-*.mddevelopment-plan.mdfeatures.mdlegal.mdresearch.mdstandards.md
/target
Place all application code and files under /target. This is the only
directory whose application changes the platform tracks and deploys. Treat
everything outside /target as supporting material (research, docs, tooling)
rather than the shippable product.
/user-docs
All user-facing documentation lives here. See §3 Pre-deployment documents for the specific files required before a milestone.
/user-docs/website/index.html
A project may publish a single web page describing itself. If it does, it must be:
- a single page,
- static and self-contained (no build step, no external assets),
- inline CSS (no linked stylesheets),
- openable directly in a browser by double-clicking the file.
2. Websites and services
If your project exposes a web interface or an API, it needs to be deployable on the cloud. Provide the two platform files at the root of your project:
| File | Purpose |
|---|---|
wbsp.yaml | Tells the platform what to deploy, where to route traffic, and what resources/backing stores to provision. |
wbsp.Docker | The Dockerfile the platform uses to build your application image. |
Your application should follow the platform's basic contract — be stateless,
listen on the port the platform tells it to, and take all configuration from
environment variables. See wbsp-yaml-reference.md for
the full wbsp.yaml field reference and worked examples, and
application-creator-guide.md for the end-to-end
deploy walkthrough.
Preferred web + API stack: Next.js
Next.js is the platform's preferred (but not mandatory) web + API stack. If you choose Next.js, write your APIs following nextJS-api-guidelines.md, unless there is a good reason not to do so — in which case ask the user for permission to deviate and document the decision and reason clearly (in your project's constitution or an ADR). The guidelines exist so that an API written today in Next.js can be ported to a different server framework (most likely NestJS) with minimal rework if the project ever outgrows Next's API surface.
If your project uses services (rather than providing them), start those
services for local development — preferably with a docker-compose.yaml file —
so a developer can run the whole project on their own machine.
Avoiding port clashes in docker-compose
Common ports (3000, 4000, 5432, 6379, …) collide when several WBSP projects run
their docker-compose stacks on the same machine at once. To keep stacks
runnable side by side, derive the publicly-published ports from the project's
numeric prefix whenever the project name / directory has one.
For a project directory with a numeric prefix (e.g. 41-ai-native-crm), publish
each service on a port formed by the first two digits of the service's
conventional port as the prefix and the three-digit, zero-padded project
number as the suffix:
| Service | Conventional port | First two digits | Project suffix | Published port |
|---|---|---|---|---|
| Web app | 3000 | 30 | 041 | 30041 |
| PostgreSQL | 5432 | 54 | 041 | 54041 |
| Redis | 6379 | 63 | 041 | 63041 |
Only the host-published (left-hand) port changes; the container-internal port stays conventional, so application code and service-to-service URLs inside the Compose network are unchanged:
services:
app:
ports:
- "30041:3000" # host 30041 → container 3000
postgres:
ports:
- "54041:5432"This rule applies to docker-compose (local development) only — it does not
affect AWS or other cloud targets, where the platform assigns ports and routes
traffic itself (your container still listens on the platform-specified port,
e.g. 8080). If a derived value would exceed the maximum TCP port (65535), pick a
nearby free high port instead.
For apps deployed via the platform's local-machine types — compose,
dev, and standalone (features 055 / 063) — the above derivation is
automatic. The operator declares projectNo: <N> at the top level of
wbsp.yaml (or omits it and lets the platform parse the leading numeric prefix
of the app directory's name), and the platform host-publishes app/postgres/redis
on <two-digits>NNN ports per this rule. A derived value that would exceed
65535 (e.g. an app on port 8080 → 80NNN) folds deterministically into the
IANA dynamic range [49152, 65535], and a host-port clash is reported by the
deploy's pre-flight. No hand-written docker-compose.yaml is required. See
docs/migration-from-local-to-compose.md.
Labelling docker-compose services for the platform
So that wbsp-app list --provider compose can see your locally-running stack and
report it accurately, label every service in your docker-compose file with
the platform identity from your wbsp.yaml:
| Label | Value | Source |
|---|---|---|
wbsp.tenant | your tenant | tenant: in wbsp.yaml |
wbsp.app | your app name | name: in wbsp.yaml |
wbsp.managed-by | wbsp-platform | constant |
services:
app:
labels:
- "wbsp.tenant=test"
- "wbsp.app=music-rights-management"
- "wbsp.managed-by=wbsp-platform"
postgres:
labels:
- "wbsp.tenant=test"
- "wbsp.app=music-rights-management"
- "wbsp.managed-by=wbsp-platform"Apply the same wbsp.tenant/wbsp.app pair to every service in the stack
(web, database, redis, worker, …). The platform groups them into a single
application row, derives running/stopped from whether the containers are
actually up, and excludes unrelated Compose stacks that carry no wbsp.app
label. Without these labels the platform cannot tell your stack apart from any
other Compose project, so it will not appear in the application list.
3. Pre-deployment documents
The following documents must be created and kept up to date prior to any major milestone. They are written for people who are not on your team and may have no access to our platform, so favour plain language over jargon.
/user-docs/api-reference.md and /user-docs/openapi.yaml
Required if the project provides an API.
api-reference.mdmust contain everything an unrelated project would need to consume this project's services — endpoints, authentication, request and response shapes, error handling, and examples.openapi.yamlmust describe the same API in valid OpenAPI Specification (OAS) format.
/user-docs/testing.md
Explain, in layman's terms, the testing this project has been put through — what is covered and why it gives confidence. Then enumerate the specific individual tests as an appendix at the end of the file.
/user-docs/useful-commands.md
A summary of the commands and configuration an application developer needs: relevant CLI commands, configuration, and any AWS / EKS / database commands.
- This document is only for app developers, who may not have control of or access to our actual platform.
- Do not include anything about setting up the platform itself.
- Do show the useful commands for
docker compose, for deploying to AWS, and for running the parallel-services environment.
/user-docs/getting-started.md
Written in layman's terms, this is the front door to the project. It must:
- Describe what the project does, including short explanations of things that might be assumed obvious (e.g. what a monorepo is) and any jargon or acronyms used.
- Explain how a reader can experience the application — either for a real purpose or via a demonstration scenario.
- Finish with a list of useful commands and pointers to other documentation in the project that might help.
4. Data stores: Postgres and Redis
-
The platform provides PostgreSQL and Redis when required. Applications should use these in preference to standing up their own data stores. Enable them through
wbsp.yaml:- a dedicated database via
database.enabled: true, and - a Redis store via
redis.enabled: true.
Choosing a Redis mode. Default to cache mode (
redis.enabled: truewithdurable: false, the default) — it is provisioned on ElastiCache and is the right choice for caching, rate-limiting, sessions, and retryable/periodic job queues. Only setdurable: truewhen your data must survive a node failure or restart (a persistent store rather than a cache); it is provisioned on MemoryDB and takes longer to create.Treat cache-mode Redis as ephemeral. Anything in it — including queued jobs — can be lost on a failover or restart. If you run a job queue (e.g. BullMQ) on cache mode, design jobs to be idempotent and safely retried, and do not use Redis as the system of record. If you cannot tolerate that, use
durable: true(or keep the source of truth in Postgres).When enabled, the platform injects connection details as environment variables (
DATABASE_*andREDIS_*— includingREDIS_NAMESPACE, the key prefix your app should use, andREDIS_TLS). Build your client from these discrete variables (there is no singleREDIS_URL/DATABASE_URL). See application-creator-guide.md and wbsp-yaml-reference.md.This contract is identical across every deployment type. Whether your app runs on
awsor on any of the local-machine types —compose(whole stack in containers),dev(data services in containers, your app run from your IDE), orstandalone(everything in one container) — it receives the sameDATABASE_*/REDIS_*variables. Read those and your code needs no change between local and the cloud. (Local types reach the stores atlocalhostor the in-stack service name on derived ports; you never hard-code that — it comes from the variables.) - a dedicated database via
-
If your project needs other backing services (for example Forgejo, ClickHouse, and similar), provision them through the platform's Parallel Namespace facility rather than embedding them in your app. See parallel-services-guide.md.
-
Evolve schema through migrations — never hand-edit a deployed database. Manage your schema with versioned migrations checked into the repository (e.g. Prisma Migrate) and apply them as part of your deploy/release process. Never make ad-hoc, out-of-band changes to a deployed database — no manual
CREATE/ALTER/DROPDDL, no hand-edited rows, no one-off SQL data fixes run by hand. Direct connections (e.g.psqlvia a port-forward) are for inspection and debugging; every schema or data change must flow through a migration or the application's own code so it is reviewed, versioned, and reproducible across all targets.ORM choice. Prisma is preferred as the default ORM and migration tool. Drizzle is acceptable where it is required for performance reasons — either at startup (e.g. cold-start-sensitive environments such as Lambda) or at runtime (where specific optimised or customised SQL is required and Prisma's query layer is a poor fit). TypeORM is discouraged. If these guidelines cannot be followed for a given project, ask permission and document the decision and reasons (in the project's constitution, an ADR, or equivalent) so the choice is visible to reviewers and future maintainers.
5. Environment configuration and secrets
The platform delivers all configuration to your container as environment variables. Split configuration into three tiers — non-secret config, secrets, and per-destination overrides — so the same image runs everywhere and no secret is ever committed.
Configuration is delivered per destination. Each destination reads one environment file —
.env.<destination>— and the per-destination, never-layer rules below apply to every destination. See wbsp-yaml-reference.md for the fullwbsp.yamlfield reference and worked examples.
Non-secret config → wbsp.yaml env:
Plain runtime settings (feature flags, log levels, public URLs) go in the env:
map of wbsp.yaml. These are committed. You may override per destination under
destination.<name>.env: (the destination wins over the base env:).
Secrets → wbsp.yaml secrets: + a git-ignored .env
Never put secret values in wbsp.yaml. Declare the secret env vars your app
needs in a secrets: block by reference (the key to read), and put the
actual values in a git-ignored .env beside wbsp.yaml:
# wbsp.yaml — declares WHICH secrets the app needs, never their values
secrets:
HAP_SMTP_PASSWORD: SES_SMTP_PASSWORD # env var the app receives : .env key holding the value
HAP_ADMIN_API_KEY: HAP_ADMIN_API_KEY # (a key may reference itself)# .env (git-ignored — never committed)
SES_SMTP_PASSWORD=BICoEXAMPLE...realtoken
HAP_ADMIN_API_KEY=…At deploy the platform resolves each reference, stores the values in a protected secret object (not in the pod spec, not in git), and surfaces them to your container as the named environment variables. A declared secret with no value fails the deploy (fail-closed) — it never deploys silently unset.
Per-destination values → .env.<destination>
Each destination reads one environment file: .env.<destination> (e.g.
.env.prod, .env.dev). When a value differs by destination — e.g. a real SES
password on AWS but a dummy locally — supply the right value in each destination's
file, keeping a single secrets: declaration:
# .env.dev
SES_SMTP_PASSWORD=dev-dummy
# .env.prod
SES_SMTP_PASSWORD=BICoEXAMPLE...realtokenDestinations never layer — each stays in its own lane. A .env.<destination>
is the only file consulted for that destination; it does not fall back to a
bare .env or borrow from another destination's file. Every key a destination
needs must live in that destination's own file.
Precedence (highest first):
- A real OS environment variable (operator override)
.env.<destination>(the single file for the selected--destination)
Rules
- Add
.env*to.gitignoreso real.env.<destination>files are never committed — but do commit.env.<destination>.exampletemplates (see below). Because.gitignore.env*also matches.env.dev.example, add an un-ignore rule:!.env.*.example. - No secret value may appear in
wbsp.yamlor any committed file — with the one allowed exception below (the shared test-HAP credentials in the committed local.exampletemplates). - Read every value (secret or not) from the environment; never bake config into the image.
- This is a stopgap: today secret values come from your
.env.<destination>. A future platform secrets manager will resolve the samesecrets:references from a managed vault with no change to your app — so always declare secrets by reference, never inline.
Ready-to-run local environment files
Every project following these guidelines generates the local-destination environment files pre-wired to the shared test HAP server, so a developer can run and sign in immediately. Concretely, for each destination provide two files:
- a committed
.env.<destination>.exampletemplate — no secret values, except the shared test-HAP credentials on the local destinations (dev,prod-parity,standalone), which are safe to share; and - a git-ignored
.env.<destination>that is as ready-to-run as possible — for the local destinations, fully populated (including the test-HAP values) so the app runs with no manual edits; for cloud destinations (stage,prod), populated except for the managed secrets only your operator can supply, which are clearly marked.
The three local destinations point at the shared test HAP server (issuer
https://wbsp-auth.wbsp-demo.com, redirect http://localhost:3000/api/auth/callback).
See wbsp-yaml-reference.md for the worked wbsp.yaml
and the five per-destination .env examples.
6. Authentication
If your project requires user authentication, prefer the platform's Headless Auth Platform (HAP) — an API-first, multi-tenant OAuth 2.0 / OpenID Connect identity service. Your app delegates login to it and receives standard OIDC tokens, so you do not build or operate your own credential store, password hashing, MFA, or session issuance. See also single-tenant-guidelines.md for single-tenancy and shared sign-on (SSO) across a user's apps.
Integrate over standard OIDC, through a well-supported OIDC client (e.g. Auth.js / NextAuth, or your language's OIDC library) rather than bespoke calls:
- Discover endpoints from
${AUTH_ISSUER}/.well-known/openid-configuration. - Sign in with the Authorization-Code + PKCE flow. One client covers every redirect-based mode — standard sign-in, SSO between sibling apps (so a user already signed into one app isn't asked again), and the optional Hosted Login mode where HAP hosts the login UI and your app ships none.
- Register your callback as
${YOUR_APP_URL}/api/auth/callback. - Resolve identity after the round-trip: HAP access tokens carry only the
subject (
sub), so your app runs its own "who am I" step (typically aGET /me) mapping the subject to your app's role/tenancy — see "Make identity resolution resilient" below.
HAP owns the authoritative client guidelines (SSO-client, client cookbook,
integration guide, hosted-login, and SSO-broker how-tos). They are the source of
truth and may evolve, so obtain the current versions from your platform operator
(or the hap-docs index page on this wiki, which links every HAP guide)
and follow them rather than any copy here. For
a ready-to-run local setup, the .env.<destination> examples in
wbsp-yaml-reference.md are pre-wired to the shared test
HAP server.
If those guidelines cannot be implemented, or are not appropriate for your project, do not silently diverge: tell the user the specific problem and document the proposed alternative (what you will do instead, and why).
Integrate through a replaceable adaptor. Authentication must sit behind a thin, swappable layer so the project can move to a different identity provider — or a different auth model entirely — with minimal work. Concretely:
- Use the standard OIDC interface, not HAP-specific code. Talk to HAP via
OIDC discovery (
${ISSUER}/.well-known/openid-configuration) and the authorization-code + PKCE flow, using a well-supported auth library configured with a provider entry (e.g. Auth.js / NextAuth, or your language's OIDC client) rather than bespoke calls. - Keep all provider specifics in configuration/secrets (see §5), never hard-coded: issuer URL, client id/secret, scopes, claim mapping. Switching IdP should be an env/secret change plus a provider swap, not a rewrite.
- Confine sign-in/out, token validation, and session handling to one
authmodule that the rest of the app depends on through a small, stable interface. An alternate model (a different OIDC IdP, a managed auth service, or SAML) must be a drop-in replacement behind that interface. - Inject HAP's connection details (issuer, client credentials) as platform
config/secrets at deploy time; register your callback as
${YOUR_APP_URL}/<auth-callback-path>.
This applies the platform's Adaptor-Based Integration principle to identity: depend on the capability ("authenticate a user"), not on a specific vendor.
Make identity resolution resilient — never let optional state break login.
HAP access tokens carry only the subject (sub), so your app has its own
"who am I" step after the OIDC round-trip (typically a GET /me that maps the
subject to the app's role/tenancy). That call is the gate to the whole app:
if it fails, the user is bounced back to sign-in, and if it fails repeatedly
they are stuck in a sign-in loop. Therefore:
- Identity resolution must depend only on the verified token, never on optional request headers, query params, or persisted client state (e.g. a saved "view as"/impersonation selection, a remembered tenant/role, a feature flag). If such extra input is present but invalid or stale, degrade to the base identity — ignore it and resolve the real user — rather than returning an error from the identity call.
- Persisted client state can outlive the server. Ids in
localStorage/sessionStorage/cookies survive redeploys and data reseeds; treat any stored id as possibly dangling. The server should tolerate it (fall back), and the client should self-heal (drop a selection the server reports as not applied) so it can't be re-sent and wedge the session. - Diagnose loops by the identity call's status. A sign-in page that just refreshes is almost always the who-am-I call failing. Check its status in the API logs: a repeating non-2xx on that one endpoint is the loop. Keep a short per-app login-troubleshooting note listing the known causes and their fixes.
7. Logging
The platform aggregates application logs and runs anomaly detection over them (the LogWatch platform). That analysis is only as good as the structure of the logs it receives, and unstructured logs degrade detection quality for every tenant sharing a service baseline — so log structure is a shared responsibility, not a per-team preference.
The single most important rule:
Emit machine-readable, structured logs whose fields map cleanly onto the OpenTelemetry Logs Data Model — either as OTLP, or as JSON on
stdout.
In practice, applications should:
- Log to
stdout/stderr, not to files on disk. On both EKS and Lambda the platform's collector tails your container output and forwards it — do not manage log files, rotation, or direct network shippers from your app. - Emit one-line JSON per event, with at least
severity_textandbody, plusservice.name,service.version, anddeployment.environmentwhere available. - Keep the message (
body) stable and put variables in attributes (e.g.order.id,http.response.status_code) so anomaly detection can group by message template. Use OpenTelemetry Semantic Convention names where one exists. - Use UTC, RFC 3339 timestamps, and include W3C
trace_id/span_idwhen emitting inside a request context. - Never log secrets, credentials, tokens, or unnecessary PII — ingestion-time redaction is a backstop, not a licence.
- Prefer the platform's OpenTelemetry SDK/agent and your language's recommended structured logger over a bespoke logging mechanism.
These are recommendations; deviation is permitted (for sources that genuinely cannot emit structured output, vendored components, local development, and extreme hot paths) and should be documented in the project's constitution or an ADR.
The same logging contract applies to any backing services you run through the
platform's Parallel Namespace facility — their stdout/stderr is collected
the same way, so emit structured logs there too. See
parallel-services-guide.md.
For the full field reference, per-language library recommendations, ingestion paths, and the conformance checklist, follow the platform's Client Logging Guidelines — owned by the LogWatch log-aggregation project, which is the source of truth. Obtain the current version from your platform operator (or the LogWatch section of this wiki).
8. Testing and test-driven development
Projects on this platform are expected to follow test-driven development (TDD). Write the test first: express the intended behaviour as a test, run it and watch it fail, then write the minimum code to make it pass, and refactor with the test still green (Red-Green-Refactor). The test exists before the implementation, not after it.
TDD is mandatory for the parts of a project where a silent defect is costly — anything handling money, identity, authorization, data integrity, or other business-critical logic. Auth, in particular, must be developed test-first: if your project integrates the Headless Auth Platform (§6) or implements any credential, token, session, or access-control logic of its own, hold it to the stronger standard (HAP's own constitution makes test-first non-negotiable for that surface).
In practice:
- Test behaviour, not implementation. A test should describe what the code must do for a caller, so it survives refactoring.
- Integration tests run against real backing services, not in-memory substitutes — use the platform's PostgreSQL and Redis (§4), or ephemeral containers (e.g. testcontainers), so tests exercise the same engines you deploy on. Mock only third-party HTTP you do not own.
- Keep tests fast and runnable with one command, and wire them into your build so they run on every change.
- Record what is covered in
/user-docs/testing.md(§3) — the layman's summary plus the enumerated test appendix described there.
It is the project's call whether TDD fits a given piece of work. If TDD cannot be applied or is not appropriate for some part of your project, the project must not silently skip it: notify the user clearly (which code, and why test-first does not fit — for example a spike, a generated client, a UI-layout concern, or an extreme hot path), explain why, and document the proposed alternative (what you will do instead — characterization tests written immediately after, manual verification with evidence, contract tests, etc. — and why it gives comparable confidence). The escape hatch is "explain and substitute", never "skip quietly".
9. Respect other projects' boundaries
Each project owns its own repository. Stay strictly inside your own project and never reach into another one:
- Do not change files that belong to another project. If you need to send a
message, reply, or hand something off to another project, write it into your
own
/docsdirectory (not/user-docs), from where the operator can copy it across. Treat another project's repository as read-only. - Never run git operations on another project, and never deploy another project. Committing, pushing, branching, or deploying outside your own repository is the operator's responsibility, not yours.
Quick checklist
Before a milestone, confirm:
- Application code is under
/targetonly. - Research/planning docs are under
/initial-research. - User docs are under
/user-docs. -
wbsp.yamlandwbsp.Dockerexist (if the project is a website/service). - If the project uses Next.js, APIs follow nextJS-api-guidelines.md (or any deviation has been approved by the user and documented).
-
docker-compose.yamlprovided for any services the project consumes. -
docker-composehost ports derived from the project's numeric prefix (e.g.30041/54041) to avoid local clashes; internal ports unchanged. - Every
docker-composeservice labelled withwbsp.tenant/wbsp.app(fromwbsp.yaml) andwbsp.managed-by=wbsp-platform, sowbsp-app list --provider composecan discover and reconcile the stack. -
getting-started.md,testing.md, anduseful-commands.mdare current. -
api-reference.md+openapi.yamlexist (if the project provides an API). - Postgres/Redis enabled via
wbsp.yaml; other services via parallel namespaces. - Database schema is managed by versioned migrations; no ad-hoc DDL or manual SQL is applied to deployed databases.
- Secrets declared by reference in
wbsp.yamlsecrets:; values only in git-ignored.env.<destination>(never committed);.env*is in.gitignore. - Committed
.env.<destination>.exampletemplates exist (no secrets except the shared test-HAP credentials on local destinations);.gitignoreun-ignores them via!.env.*.example; local destinations (dev,prod-parity,standalone) are pre-wired to the test HAP server. - If the project authenticates users, it uses the Headless Auth Platform via standard OIDC behind a replaceable
authadaptor (provider specifics in config/secrets, not hard-coded). - Code is developed test-first (TDD), mandatory for money/identity/authorization/data-integrity logic; where TDD is not appropriate the project has notified the user, explained why, and documented the alternative.
- Logs are structured JSON on
stdout/stderr, mapped to the OTel Logs Data Model, with no secrets. -
user-docs/website/index.htmlis single-page, static, self-contained (if present). - No files in other projects were changed; messages to other projects went into this project's
/docs(not/user-docs), and no git or deploy operations were run on another project.