Repository navigation
Development Strategy
Audience: AI agents and engineers who build or extend an application on Apostol CRM — Apostol CSMS, DEBT Master, CopyFrog, Campus CORS, TalkingToAI, PlugMe and every next one. This page is the main line of development for all of them. The procedure for moving an existing application's API is API-v1-to-v2-Migration.
The decision behind this page (the platform owner, 17 September 2026): the Go layer built on go-platform is the main thread of further development of every application in the Apostol ecosystem; business logic may and must be implemented in the Go layer. It extends the earlier decision of the Graftio track, where Go was allowed to be a transport over api.* only. It does not replace Platform 2.0 (the clean-slate line): it is the road from the applications that exist to it.
Revision of 26 September 2026 (the platform owner): the Go process connects to PostgreSQL as daemon, not apibot, and reaches api.* only through helpers in schema daemon (see § 2 and API-v1-to-v2-Migration § 0). The helper set is being added to db-platform; until a deployment runs a db-platform version that carries it, the Go process stays on the transitional apibot path described in the previous revision. New work targets the daemon path.
Apostol CRM of the first generation had exactly two places for logic: C++ (libapostol, in-process modules) or PL/pgSQL (db-platform). C++ was chosen to be a thin transport, so the whole application — data model, workflow, access, and also HTTP routing, field checks and JSON envelopes — lived in PostgreSQL. One HTTP request was one api.run; composing several operations meant writing another PL/pgSQL function. The REST layer alone (rest.*) grew to ~27 000 lines of SQL.
go-platform adds a third place. A Go process behind Gateway API opens one transaction per request, has the database validate the token and establish the session through a daemon.* helper, and can then call any number of api.* functions — through that helper — in sequence, branch on their results, and commit or roll back as one action — something no v1 request could do. Around that transaction the process has everything a real application layer has: types, tests, generators, HTTP clients, concurrency, N instances. PL/pgSQL keeps what it is good at: the model, constraints, the workflow catalogue, the access rules and the atomic primitives. C++ keeps what it is good at: the gateway, OAuth2, WebSocket events, OCPP and file transfer.
That is the whole strategy in one sentence: primitives and rules in PostgreSQL, composition and integration in Go, transport in C++ until Platform 2.0 removes the need for it.
clients ──► C++ worker (libapostol) ──► Go process (go-platform + project packages) ──► PostgreSQL (db-platform + project configuration)
AuthServer /oauth2/* JWT out one request = one transaction kernel.* data, catalogue, workflow, access
GatewayAPI /api/v2/* ─ HTTP ─────────────────────► platform-service: api.* composition, api.* SECURITY DEFINER primitives, views
/gateway/* ◄─ WebSocket ── control ─── integrations, adapters, jobs daemon.* what C++ and Go need (fetch, login, observer; the gateway helpers)
AppServer /api/v1/* (until migrated) role daemon (transitional: apibot) gateway.* routing mirror
WebSocketAPI events, FileServer, PGHTTP, OCPP CS role daemon
| Layer | Owns | Must not |
|---|---|---|
PostgreSQL — db-platform + configuration/<project>
|
the data model (db.*, one table per class), the workflow catalogue (entities, classes, states, the closed list of actions, methods, transitions), access decisions (current_userid(), Access* views, three dimensions of access), integrity (CHECK, UNIQUE, FK, guards in triggers), the api.* primitives (get/list/count/set/delete/<action> per entity, authorize, parse_message), the event log, the API log, the outbox tables when a project has them |
parse HTTP, format envelopes, call external services synchronously, hold orchestration that spans several primitives when that orchestration is new — that is § 3 |
Go — go-platform + configuration/<project> packages |
the request path (lib/pgtx), /api/v2 resources (lib/rest, lib/query), business logic that composes primitives, integrations with external systems (payment providers, roaming hubs, LLM providers, maps, SMS), adapters behind one interface, background work (jobs, timers, outbox dispatch), computations that are awkward in SQL, typed OpenAPI, the parity and integration tests |
authenticate (AuthServer issues tokens; Go verifies them), bypass the session the database establishes or the Access* views, call api.* other than through the daemon.* helpers, touch kernel/db tables directly (the role cannot), open a second transaction for one request, keep state between requests (sessions live in db.session), serve /api/v2 over WebSocket |
| C++ — libapostol worker | OAuth2 (AuthServer), the gateway (GatewayAPI), events over WebSocket (WebSocketAPI), files (FileServer), outgoing HTTP continuations (PGHTTP/http.*), OCPP Central System, AppServer for /api/v1 while consumers remain |
grow new business logic; every new capability that is not transport goes to Go |
The database trusts the session code and nothing else. The signature of a JWT is verified in Go (and again in the gateway); the database validates the token itself in the daemon.* helper, takes sub and decides who the user is and what they may see. The daemon.* functions are SECURITY DEFINER (owner kernel) — that is how daemon reaches api.* without any grant on schema api, exactly as AppServer does through daemon.fetch. Go never carries an identity the database did not establish inside the current transaction.
Apply top to bottom; the first row that matches decides.
| The logic is… | Goes to | Why |
|---|---|---|
| a constraint on data (uniqueness, referential integrity, a value range, a state guard that must hold under concurrency) |
SQL — CHECK/UNIQUE/FK/EXCLUDE, trigger guards, FOR UPDATE in the primitive |
only the database can enforce it under N processes |
| a decision about access (who sees which rows, who may run which method) |
SQL — Access* views, object_grant, the three dimensions in kernel
|
the access model is data; Go calls it, never re-implements it |
| the lifecycle of an object (states, actions, transitions, the reactions the catalogue already describes) |
SQL — the workflow catalogue and api.execute_object_action
|
the catalogue is the product; Go triggers it through POST …/{id}/actions/<verb>
|
a primitive on one entity (get/list/count/set/delete, one method) |
SQL — api.*, generated where possible |
one function = one api.* = one resource; the generator knows the shape |
| composition: several primitives that must succeed or fail together, with branching on intermediate results |
Go — one handler, one Runner.Do, several api.* calls |
this is what v1 could not do; writing another PL/pgSQL function for it is the anti-pattern this strategy removes |
| an integration with an external system (payment, roaming, LLM, SMS, maps, a customer's ERP) | Go — an adapter behind an interface in the project's package, called from a handler or a job | HTTP clients, retries, typed payloads, secrets from the environment, tests with a fake |
| a computation that is awkward in SQL (pricing across several tariffs, a report assembled from several sources, a diff of a local list) |
Go — a function with unit tests, fed by api.* reads, writing back through api.*
|
testable, versioned, profiled |
| work that outlives the request (send, retry, poll, reconcile, expire) |
Go — a job reading an outbox/queue table through api.*, SKIP LOCKED lease, idempotent by key |
the request stays short (the gateway's 30 s); the job is an instance of the same binary or the same package |
| an event to a connected client |
C++ — WebSocketAPI through the database's publisher/listener tables |
decision 15: HTTP for requests, WS for events |
| a new protocol endpoint (a station, a hub, a file) | C++ until Platform 2.0; a Go adapter only when the C++ module does not exist and the owner says so | the C++ modules are finished and proven; do not rewrite them for symmetry |
Two rules that override the table:
-
A row of the model is never written from two places. If a Go handler writes it through
api.set_<x>, no PL/pgSQL reaction writes the same row behind its back, and vice versa. When in doubt the primitive owns the row, and Go composes primitives. - What exists in SQL is not moved to Go for its own sake. Migration of existing logic is a card with a reason (a defect, a performance wall, an integration that needs it). New logic goes to Go by default.
The Go tree mirrors the SQL tree; a package is the Go side of an SQL module or entity, at the path of its rest.sql.
<project>/
├── db/ SQL: sql/platform (db-platform, submodule) + sql/configuration/<project>
├── go/ Go: module github.com/<org>/<project>/go
│ ├── platform/ go-platform, submodule, public, MIT — the mirror of db/sql/platform
│ ├── configuration/<project>/ the project's packages — the mirror of db/sql/configuration/<project>
│ │ ├── entity/object/document/<x>/ one package per entity: resources, handlers, adapters, jobs, tests
│ │ ├── <module>/ one package per project SQL module (ocpp/, ocpi/, …)
│ │ └── cmd/platform-service/ THE ONE binary: modules() is the manifest, in the order of create.psql
│ ├── Dockerfile static binary, FROM scratch, healthcheck = the binary itself
│ └── CLAUDE.md the Go agent's charter: reading list, boundaries, commands
├── backend/ C++ worker: AuthServer, GatewayAPI, WebSocketAPI, AppServer (v1), project modules
├── frontend/ SPAs: v1 client and a separate v2 client until the last page moves
├── tests/ the cross-layer harness (pytest): calls every layer from outside, fixes none
└── docker-compose.yaml backend, platform-service, postgres/pgbouncer, nginx, …
Rules of the tree (recorded 16.09.2026, T298 in CSMS):
- Package = folder of the SQL module/entity, package name = its name (
client, notclients). The library folder islib(gocannot end an import path). -
platform.ModuleisName · Prefixes · Routes(mux)and nothing else until stage 2. - One process per project: one
platform.New, one pool, one gateway socket, one image. Split by prefix when load shows it — same binary, another manifest. The process name isplatform-service(the generic name agreed for the Graftio delivery; earlier projects may still carry a project-specific name and rename by a card). - Versions are linked by
platform.DBPlatform: the db-platform version the integration tests last ran against, checked by the run, bumped only in the commit that re-runs them. - The catalogue generator emits a package into the entity's folder, not into a separate tree.
-
go/platformis public: no secrets, no brand names, no hosts, no internal addresses, no references to a project's cards or wiki — in code, tests, README and commit messages. Project specifics live inconfiguration/<project>. English only.
A new project on Apostol CRM starts with both submodules, its SQL configuration (runme.sh --init) and its Go configuration with one entity package and cmd/platform-service; the first /api/v2 resource must answer through the gateway before the second entity is written.
| Stage | What | State |
|---|---|---|
| 1 — mirror | the Go tree mirrors the SQL tree; /api/v2 over api.*; full coverage of the platform's InitAPI() (19 dispatchers, 233 branches → 13 platform packages); the first consumer (the admin panel) moves page by page |
done in CSMS, September 2026; the reference implementation |
| 1.5 — business logic in Go (this page) | new logic by the decision table of § 3; integrations as adapters; jobs; the parity tool and the catalogue generator as everyday tools; every application of the ecosystem adopts go-platform as its application layer | starts now; the Graftio delivery (platform-service) is its first external instance |
| 2 — SQL inside the package | the SQL of a module travels into its Go package (embed), per-package migrations replace P000000NN, a Requires() graph replaces "platform first, then configuration"; go-platform becomes the platform's application and runme.sh/create.psql/patch/ become its commands |
a direction recorded 16.09.2026, not a card; nothing of it is built before the owner's word |
| Platform 2.0 | the clean-slate line: the core as a Go library, PostgreSQL the only stateful dependency, no C++, a package as the unit of deployment and of transaction, one Package service (connect-go: gRPC + HTTP/JSON), reactions as code by reference, outbox between packages, ledger/Stream/jobs as primitives; reference project SERP 2.0 |
its own specification (v4) and route; applications of the first generation are not rewritten onto it — go-platform is how they converge |
What go-platform borrows from 2.0 now, so that convergence is a merge and not a rewrite: problem+json with the error catalogue; Idempotency-Key on every POST; ETag/If-Match on every PATCH; actions on objects as POST …/{id}/actions/<verb>; one action = one transaction; typed OpenAPI from Go types; the gateway registering by what a process serves; instance states ready/draining/overloaded. What it does not borrow ahead of the owner's word: the Package gRPC service, reactions by reference, per-package schemas and roles, outbox as a platform primitive, the removal of C++.
The team is split by language and by repository; each agent owns one layer and asks the neighbour for the seam.
| Agent | Owns | Asks |
|---|---|---|
database (*-db) |
all SQL: db-platform and the project configuration; every api.* signature; the daemon.* helpers Go calls (transitional: grants to apibot); patches |
nothing before changing a signature Go calls — the Go agent is told first |
Go (*-go) |
go/: platform submodule and project packages, their tests, the wiki section on Go |
the database agent for signatures (grep in api.sql before calling; invent nothing); the C++ agent for the gateway seam; devops for how the binary reaches a deployment |
C++ (*-backend, *-apostol) |
the worker and libapostol: gateway, auth, WS, OCPP, file server | — |
QA (*-qa) |
the cross-layer harness; tests every layer, fixes none — a red test is a card to the layer | — |
| frontend | the SPAs: the v2 client beside the v1 client, one page at a time | the Go agent for a resource that does not exist yet |
| devops | images, compose/k8s, brands, releases; GATEWAY_ENABLE per deployment |
— |
| coordinator / owner | cards, state, decisions; writes no code | — |
Non-negotiable practices:
- A card per task, one in-flight card per agent, idle is a correct state. No "preparing" the next stage; stage 2 and 2.0 features are not built without the owner's word.
- Review before commit, started by the author (a code-review subagent with an entry point). The review reads code; a doubt about the premise under the code goes to the neighbouring agent.
-
Commit by path, never
git add -A; push, tag and release only on the owner's word, recorded and handed with the line of the record. The platform submodule is committed first, so the pin the project commits exists. -
Framework changes are made inside the project's submodule, tested with the project's modules, then pushed upstream — mirrors are
git pullonly. -
"It works" is accepted only with the command and its output:
go build ./... && go vet ./... && gofmt -l . && go test ./... -count=1 -racein both modules; the integration run under the role the process uses in the deployment (daemon;apiboton the transitional path) naming the db-platform version; the parity tool for a migrated resource. - Production databases are never rebuilt. Every schema change is a patch; the Go layer follows the database, never the reverse.
- Public repositories are documentation-complete in themselves: a reader with only the repository must understand it; contract numbers, card ids and internal wiki paths do not appear there.
| Anti-pattern | What to do instead |
|---|---|
A new PL/pgSQL function that only calls three api.* functions in a row |
a Go handler with one Runner.Do
|
A Go handler that writes db.* through a view or a kernel function |
api.set_<x> / the action; if no primitive exists, a card to the database agent |
| Re-checking access in Go ("if the user is an operator then…") | the Access* view decides; Go handles the 401/403 the database returns |
| A second transaction in one request "to log the error" |
daemon path: the helper that closes the request records the outcome inside the same transaction; transitional apibot path: ROLLBACK, then api.parse_message on a separate connection — the library does this |
A /api/v2 route that returns 200 with an error inside |
HTTP status + problem+json
|
| Ignoring the part of a query string the handler did not understand |
400; the missing filter that returned "everything" deleted 96 users once |
| A background job that keeps state in the process | state in the database, lease with SKIP LOCKED, idempotent by key; the process is disposable |
Subscriptions or long-polling over /api/v2
|
WebSocketAPI |
| "Let us also move this old SQL to Go while we are here" | a card with a reason, or leave it |
| A package in go-platform that knows a project's entity | it belongs to configuration/<project>; the platform holds only what its C++ or SQL twin holds in a public repository |