Skip to content

Backend Core Specification

Related documentation: Kisum System · Backend Core API contract · Backend Auth · Backend implementation · Backend modules · Data ownership · Infrastructure tasks · Error contract

Detailed Implementation Specification for Platform Core Backend

Section titled “Detailed Implementation Specification for Platform Core Backend”

Audience: backend engineers, platform engineers, DevOps, architecture leads, platform-admin feature owners
Status: implementation specification
Scope: this document defines the Platform Core backend in full detail:

  • what the Core service owns
  • what it does not own
  • PostgreSQL structure
  • internal authentication
  • request/response contracts
  • how Auth calls it
  • how Platform Admin writes to it
  • how subscription/add-on/catalog changes are stored
  • how entitlement versions are computed and invalidated
  • how other services should consume it
  • operational and security rules
  • Implemented now: Backend-Kisum-Core is an internal-only Chi service protected by X-Internal-API-Key matching CORE_INTERNAL_API_KEY.
  • Implemented now: the live router exposes health/readiness, internal catalog routes for modules/packages/add-ons, and internal company routes for company admin list, company master records, business-unit master records, profile, addresses, social links, documents, entitlements, subscription summary, history, Basic subscription upsert, and add-on upsert.
  • Implemented now: Core now stores company master data directly in PostgreSQL through companies, company_addresses, company_profiles, company_social_links, and company_documents.
  • Implemented now: existing entitlement-bearing company IDs are backfilled into companies during migration, and the commercial tables now foreign-key to the Core company master table.
  • Implemented now: the generated OpenAPI in this docs site reflects those current internal routes and should be treated as the runtime contract for other backends.
  • Implemented now: Core can send commercial lifecycle email via AWS SES only for Core-owned state changes such as company creation, company status changes, base subscription updates, and add-on updates. Email sending is asynchronous and best-effort.
  • Still target-state: longer-term billing/provider automation, richer lifecycle orchestration, trials/renewals/grace periods, and external billing sync remain architectural direction rather than implemented runtime behavior.
  • Docs rule for this page: sections below describe the intended Core ownership boundary, but any route, request, or response details must match the current Backend-Kisum-Core router and OpenAPI.

Platform Core exists to answer one question:

What does this company commercially own right now?

That means Platform Core is the commercial entitlement authority for:

  • Basic subscription
  • add-on modules
  • package catalog
  • add-on catalog
  • module catalog
  • package-to-module mappings
  • add-on-to-module mappings
  • company subscription state
  • company add-on state
  • entitlement versioning

Platform Core does not answer:

  • who the user is
  • what permissions the user has
  • what tenant/company role the user has
  • what a user can grant to another user
  • whether a specific route is allowed for a specific user

Those belong to Auth.


1. Core service role in the full architecture

Section titled “1. Core service role in the full architecture”
Auth = identity + memberships + module grants + permissions + delegation
Core = company master + commercial entitlements
Backends = business execution
Frontend = UI consumer only

Auth computes final access as:

effective_access = company_entitlements ∩ membership_grants

Core only provides the left-hand side:

company_entitlements

Platform Core is consumed by:

  1. Auth Backend
    • to resolve company entitlements during /auth/me/access
  2. Platform Admin Backend
    • to manage packages, add-ons, modules, company subscription state
  3. Optional internal jobs
    • if later you introduce reconciliation, billing sync, catalog sync, or entitlement repair jobs

1.4 Platform Core is not public browser API

Section titled “1.4 Platform Core is not public browser API”

Platform Core should be internal-only.

It should not be called directly by:

  • browser frontend
  • public clients
  • mobile clients

Public flows should go through:

  • Auth
  • Platform Admin Backend
  • business backends where appropriate

Platform Core owns the following domains.

  • modules
  • packages
  • add-ons
  • mappings between packages/add-ons and modules
  • company master row
  • company addresses
  • company profile metadata
  • company social links
  • company documents
  • company lifecycle state
  • company creation source
  • company billing/customer linkage metadata
  • whether Basic subscription is active
  • whether add-ons are active
  • add-on start/end dates
  • subscription start/end dates
  • subscription status
  • company entitlement version
  • entitlement history / audit trail
  • commercial lifecycle notifications related to Core-owned truth

Core may send email only for events where Core is the commercial source of truth.

Current Core-owned email category:

  • company created
  • company status changed
  • base subscription updated
  • add-on updated

Core should not own:

  • password reset email
  • invitation email
  • user activation email
  • membership-change email
  • login/security/session email

Those remain Auth-owned identity and access notifications.

  • companies
    • the canonical Core company/commercial identity row
    • stores the company id, lifecycle state, creation source, and billing/commercial linkage fields
  • business_units
    • the canonical Core business-unit master table
    • stores business-unit identity, company linkage, code, slug, active state, and metadata
    • does not store business-unit memberships
  • company_profiles
    • stores descriptive business metadata such as website, phone, timezone, industry, and description
    • exists so descriptive data does not bloat the root company row
  • company_addresses
    • stores structured address records that may be legal, billing, mailing, or office addresses
    • exists because a company may need more than one address
  • company_social_links
    • stores repeatable external/public links for the company
    • exists because these are one-to-many normalized records, not a single profile field
  • company_documents
    • stores company-level document references and file metadata
    • exists because document records have their own lifecycle and may point to external storage

Nested create/edit behavior in the current implementation

Section titled “Nested create/edit behavior in the current implementation”

The current Backend-Kisum-Core implementation supports two ways to write company sub-domain data:

  • direct sub-resource endpoints such as:
    • PUT /internal/companies/{companyId}/profile
    • POST /internal/companies/{companyId}/addresses
    • POST /internal/companies/{companyId}/social-links
    • POST /internal/companies/{companyId}/documents
  • nested company payloads sent to:
    • POST /internal/companies
    • PATCH /internal/companies/{companyId}

Current nested payload behavior:

  • profile
    • optional
    • upserted into company_profiles
  • addresses
    • optional
    • when present on edit, Core synchronizes the full list against company_addresses
    • rows with id are updated
    • rows without id are inserted
    • rows omitted from the submitted list are deleted
  • socialLinks
    • optional
    • synchronized with the same list-replacement behavior against company_social_links
  • documents
    • optional
    • synchronized with the same list-replacement behavior against company_documents

This means frontend can send the full company structure in one JSON payload during create or edit, while Core still preserves the normalized tables internally.

For PATCH /internal/companies/{companyId}, the safest frontend/backend contract is:

  • omit addresses to leave addresses unchanged
  • omit socialLinks to leave social links unchanged
  • omit documents to leave documents unchanged
  • send [] only when the caller intentionally wants to clear that collection

This matches the current implementation:

  • omitted collection keys decode as nil and are skipped
  • present empty arrays decode as non-nil empty slices and trigger full collection clear/sync

Therefore frontend should send only the sections it actually intends to modify.

Company admin list route in the current implementation

Section titled “Company admin list route in the current implementation”

The current Core runtime also exposes:

  • GET /internal/companies

Purpose:

  • paginated internal admin listing of companies
  • returns a lightweight bundle per row so platform-admin tools can browse companies without calling every sub-resource separately

Current response contents per company row:

  • company
  • optional profile
  • addresses

Current response does not include:

  • socialLinks
  • documents
  • entitlements
  • subscription summary
  • history

Current pagination behavior:

  • page defaults to 1 when omitted or invalid
  • limit defaults to 20 when omitted or invalid
  • limit is capped to 100

Business-unit master routes in the current implementation

Section titled “Business-unit master routes in the current implementation”

The current Core runtime also exposes:

  • GET /internal/companies/{companyId}/business-units
  • POST /internal/companies/{companyId}/business-units
  • PATCH /internal/business-units/{businessUnitId}

Purpose:

  • manage Core-owned business-unit master data
  • keep organizational structure in Core instead of Finance
  • provide the canonical business-unit ids referenced by Auth memberships and module backends

Important ownership rule:

  • Core owns business-unit master rows
  • Auth owns business-unit memberships

So Core exposes business-unit master endpoints, but it does not expose business-unit membership endpoints.

  • Stripe product references
  • Stripe price ids
  • plan migrations
  • trials
  • renewals
  • grace periods
  • cancellations
  • invoicing references
  • self-service billing flags

2.2 What Platform Core explicitly does NOT own

Section titled “2.2 What Platform Core explicitly does NOT own”
  • users
  • passwords
  • sessions
  • JWT
  • refresh token lifecycle
  • company memberships
  • business unit memberships
  • tenant roles
  • membership module grants
  • membership permissions
  • delegation policies
  • invitations
  • invitation tokens
  • invitation acceptance
  • onboarding flows
  • membership onboarding
  • events
  • vendors
  • venue business records
  • AI outputs
  • finance transactions
  • market/touring documents

Platform Core is the source of truth for:

  • whether the company exists
  • what lifecycle/commercial state the company is in
  • whether the company has Basic
  • which add-ons are active
  • which modules are enabled by those products

Platform Core is not allowed to become a second source of truth for:

  • user permissions
  • user roles
  • user module grants

That remains in Auth.


Invitations do not belong to Platform Core.

Invitations are part of:

  • user onboarding
  • identity lifecycle
  • company membership creation
  • tenant role assignment

Those responsibilities belong to Auth Backend.

  • invitation creation
  • invitation storage
  • invitation email sending
  • invitation acceptance
  • invitation token validation
  • membership creation through invitation flow
  • Auth Backend → invitations, onboarding, membership creation
  • Platform Core Backend → packages, add-ons, modules, subscriptions, entitlements

If a company is invited, approved, or onboarded through invitation flow, the invitation logic still belongs to Auth.

Core only becomes relevant after that for commercial entitlement state.

Invitations may result in a user joining a company that already has commercial entitlements.

However:

  • Core does not participate in invitation flow
  • Core does not validate invitations
  • Core does not create memberships

After invitation acceptance:

  • Auth creates membership
  • Auth later calls Core (via /auth/me/access) to resolve entitlements

Core remains unaware of invitation lifecycle.


Commercial product that enables:

  • Core App UI
  • Basic module
  • Basic business experience

Commercial add-ons:

  • finance
  • artist
  • vendor
  • venue
  • finance
  • touring
  • ai
  • venue_marketplace

A company can have add-ons without the promoter base module, as long as it still has a compatible base module active.

So these are all valid:

  • promoter only
  • artist + finance
  • venue + ai
  • promoter + venue_marketplace
  • Basic + Finance + Market
  • Venue + AI only
  • no active Basic, but active add-ons

Core must be able to return:

  • hasBasic
  • basePackage
  • active add-ons
  • enabled modules
  • entitlement version

4. Service location and deployment identity

Section titled “4. Service location and deployment identity”
core.kisum.io

Optional internal alias:

platform-core.kisum.io

Use one canonical service name in logs/metrics:

platform-core-backend

The service should:

  • run as independent backend
  • have its own PostgreSQL database
  • be private/internal
  • have health and readiness checks
  • use structured logs
  • use request IDs
  • use strict config validation

  • Auth Backend
  • Platform Admin Backend
  • approved internal jobs/services
  • browser frontend
  • public clients
  • direct mobile apps
  • arbitrary third-party clients

Every internal route must require service-to-service authentication.

Allowed patterns:

  • internal API key
  • signed internal service JWT
  • mTLS
  • private network + internal header validation

For first version:

  • private network
  • internal API key or signed internal service token
  • clear allowlist of callers
X-Internal-API-Key: <secret>

Or, if using service JWT:

Authorization: Bearer <internal-service-token>

Can call:

  • entitlement reads
  • catalog reads if needed

Can call:

  • catalog reads
  • catalog writes
  • company subscription writes
  • company add-on writes
  • company subscription summary reads

Can call:

  • read routes
  • write routes only if explicitly allowed

6.0 Mandatory physical schema (non-negotiable)

Section titled “6.0 Mandatory physical schema (non-negotiable)”

A compliant Platform Core deployment MUST provision PostgreSQL with every object defined in this specification for Core:

  • Section 7 — every CREATE TABLE MUST be applied (all tables listed there MUST exist in the Core database).
  • Section 8 — every index MUST be applied.
  • Section 9 — seed data MUST be loaded (or equivalent) so catalog rows exist as described.

Migrations are required: the service MUST NOT run against an empty or partial schema in production. “Optional” in this document refers to future tables (for example billing_products), not to skipping tables that are already specified with DDL.

Important company rule: Platform Core MUST define a full company structure. Company creation, lifecycle, profile/contact structure, and commercial identity are Core responsibilities. Everything else in sections 7–9 that has DDL MUST also exist—there is no optional subset of companies, company_addresses, company_profiles, company_social_links, company_documents, modules, packages, company_subscriptions, etc.


platform_core_db

This database must be separate from:

  • auth_db
  • finance DB
  • market/touring DB
  • main/basic DB

Same PostgreSQL cluster is acceptable, but separate database is preferred.


Recommended:

  • pgcrypto if using gen_random_uuid()
  • timezone support as standard
  • JSONB support (native in Postgres)

Recommended schema:

public

You can later split into:

  • catalog
  • entitlements
  • billing

But first version can stay in public.


6.4 Company identity: companies table in Core

Section titled “6.4 Company identity: companies table in Core”

Platform Core is the source of truth for the company master row.

This is mandatory because the platform already supports two creation paths:

  • Platform Admin creates a company
  • self-serve checkout creates a company before payment is finalized

Both paths must land in the same authoritative system.

  • companies lives in Platform Core
  • company_id remains the stable UUID shared across Auth, Core, Base, and module backends
  • Auth references company_id for memberships and access grants
  • Base and modules reference company_id for business/domain records

The company row is not only an access concept. It is also not only profile data.

It is a tenant + commercial object that needs an authoritative place for:

  • company existence
  • addresses and contact structure
  • profile/social/document metadata
  • company lifecycle state
  • creation source (admin, self_serve, migration)
  • pending-payment state
  • activation/suspension/archive state
  • billing/customer linkage metadata

The following Core tables must reference companies(id):

  • company_subscriptions.company_id
  • company_addons.company_id
  • company_entitlement_versions.company_id
  • entitlement_history.company_id
  • Prefer company_subscriptions (not a generic table name subscriptions alone)
  • “Entitlements” in APIs means normalized response + computed enabledModules + version fields, not a requirement for one physical table named entitlements

Every subsection below includes normative DDL. Implementations MUST create these tables (and columns, constraints, and defaults) via migrations. The example starting with modules is required, not illustrative only.

Purpose:

  • tenant/company master row
  • company lifecycle and commercial identity
  • entrypoint for admin-created and self-serve-created companies
create table if not exists companies (
id uuid primary key default gen_random_uuid(),
old_id text unique,
name text not null,
slug text unique,
legal_name text,
business_id text,
email text,
phone text,
website text,
status text not null check (status in ('draft', 'pending_payment', 'active', 'suspended', 'rejected', 'archived')),
created_via text not null check (created_via in ('admin', 'self_serve', 'migration')),
created_by_user_id uuid,
billing_email text,
stripe_customer_id text,
country_iso2 text,
is_active boolean not null default false,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
  • id is the canonical company_id used platform-wide
  • old_id stores legacy Mongo/company identifiers during migration
  • status is the authoritative lifecycle field
  • is_active should remain consistent with lifecycle state
  • a company may exist before any active subscription exists
  • self-serve checkout should create the company row first, typically with status = 'pending_payment'

The following tables are part of the mandatory Core company structure and MUST exist:

  • company_addresses
  • company_profiles
  • company_social_links
  • company_documents

Purpose:

  • normalized address storage for company identity and billing/legal use
  • used by POST /internal/companies/{companyId}/addresses
  • used when a company needs structured address records instead of free-text contact info
create table if not exists company_addresses (
id uuid primary key default gen_random_uuid(),
company_id uuid not null references companies(id) on delete cascade,
type text not null default 'primary' check (type in ('primary', 'billing', 'legal', 'office')),
address1 text,
address2 text,
city text,
region text,
postal_code text,
country text,
country_iso2 text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
  • every company address submitted by frontend must be stored in company_addresses
  • first version may use one primary address only
  • schema allows billing/legal separation without redesign
  • use this table for real address records, not for descriptive company profile text

Purpose:

  • store non-commercial company profile metadata separate from the master row
  • used by PUT /internal/companies/{companyId}/profile
  • used when the system needs descriptive business information that is not entitlement state or billing state
create table if not exists company_profiles (
company_id uuid primary key references companies(id) on delete cascade,
logo_url text,
references_agents text,
references_artists text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
  • one profile row per company
  • optional record; a company can exist before profile data is filled in
  • use this table for descriptive business fields, not subscription or access-control data

Purpose:

  • normalized company social link storage
  • used by POST /internal/companies/{companyId}/social-links
  • used when the company needs one or more external/public branded links
create table if not exists company_social_links (
id uuid primary key default gen_random_uuid(),
company_id uuid not null references companies(id) on delete cascade,
platform text not null,
url text not null,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
  • one company can have many social links
  • each row is one platform/url entry
  • this table is for public/external links, not internal file references

Purpose:

  • store company-level files and reference documents
  • used by POST /internal/companies/{companyId}/documents
  • used when Core must register company-owned legal, compliance, or reference documents
create table if not exists company_documents (
id uuid primary key default gen_random_uuid(),
company_id uuid not null references companies(id) on delete cascade,
name text not null,
file_type text,
url text not null,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
  • one company can have many documents
  • document rows may point to external object storage via url or future storage-key metadata
  • this table is for company-owned reference documents, not arbitrary business-domain entities unless Core is the owner

Purpose:

  • canonical list of modules the platform understands
create table if not exists modules (
id uuid primary key default gen_random_uuid(),
key text not null unique,
name text not null,
type text not null check (type in ('base', 'addon')),
description text,
is_active boolean not null default true,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);

key values:

  • promoter
  • artist
  • vendor
  • venue
  • finance
  • touring
  • ai
  • venue_marketplace

type:

  • base → module delivered by Basic subscription
  • addon → module delivered by add-on

Purpose:

  • commercial package catalog
create table if not exists packages (
id uuid primary key default gen_random_uuid(),
key text not null unique,
name text not null,
description text,
is_active boolean not null default true,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);

Initially expected:

  • basic

Future expansion possible:

  • basic_plus
  • enterprise
  • etc.

Purpose:

  • commercial add-on catalog
create table if not exists addons (
id uuid primary key default gen_random_uuid(),
key text not null unique,
name text not null,
description text,
is_active boolean not null default true,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
  • finance
  • market
  • touring
  • venue
  • ai

Purpose:

  • maps package → modules
create table if not exists package_modules (
package_id uuid not null references packages(id) on delete cascade,
module_id uuid not null references modules(id) on delete cascade,
created_at timestamptz not null default now(),
primary key (package_id, module_id)
);

Initially:

  • package basic → module basic

Purpose:

  • maps addon → modules
create table if not exists addon_modules (
addon_id uuid not null references addons(id) on delete cascade,
module_id uuid not null references modules(id) on delete cascade,
created_at timestamptz not null default now(),
primary key (addon_id, module_id)
);

Initially:

  • addon finance → module finance
  • addon touring → module touring
  • addon ai → module ai
  • addon venue_marketplace → module venue_marketplace

Purpose:

  • stores company Basic subscription state
create table if not exists company_subscriptions (
id uuid primary key default gen_random_uuid(),
company_id uuid not null references companies(id) on delete cascade,
package_id uuid not null references packages(id),
status text not null check (status in ('active', 'inactive', 'cancelled', 'expired', 'trial', 'paused')),
starts_at timestamptz,
ends_at timestamptz,
source text,
external_reference text,
entitlement_version integer not null default 1,
created_by text,
updated_by text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (company_id, package_id)
);
  • company_id references companies.id
  • one company may have 0 or 1 active Basic package in first version
  • if future multi-package model is needed, schema already supports it
  • source examples:
    • platform_admin
    • self_service
    • billing_sync
  • external_reference may store Stripe subscription id or invoice ref later

Purpose:

  • stores company add-on state
create table if not exists company_addons (
id uuid primary key default gen_random_uuid(),
company_id uuid not null references companies(id) on delete cascade,
addon_id uuid not null references addons(id),
status text not null check (status in ('active', 'inactive', 'cancelled', 'expired', 'trial', 'paused')),
starts_at timestamptz,
ends_at timestamptz,
source text,
external_reference text,
created_by text,
updated_by text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (company_id, addon_id)
);

Supports:

  • one company with many add-ons
  • add-ons active on different compatible base modules, not just promoter

Purpose:

  • explicit version tracker for entitlement invalidation

This table is mandatory. company_subscriptions.entitlement_version alone is not sufficient for a clean cross-row invalidation model.

create table if not exists company_entitlement_versions (
company_id uuid primary key references companies(id) on delete cascade,
entitlement_version integer not null default 1,
updated_at timestamptz not null default now(),
updated_by text
);

Because any of the following must bump the version:

  • Basic activated/deactivated
  • add-on activated/deactivated
  • catalog mapping changes that affect a company’s enabled modules
  • manual repair of entitlement state

Using a dedicated table keeps invalidation and response generation consistent.


Purpose:

  • audit trail of entitlement changes
create table if not exists entitlement_history (
id uuid primary key default gen_random_uuid(),
company_id uuid not null references companies(id) on delete cascade,
change_type text not null,
entity_type text not null,
entity_key text,
previous_status text,
new_status text,
payload_json jsonb not null default '{}'::jsonb,
source text,
changed_by text,
created_at timestamptz not null default now()
);

change_type:

  • basic_activated
  • basic_deactivated
  • addon_activated
  • addon_deactivated
  • catalog_updated
  • entitlement_repaired

entity_type:

  • package
  • addon
  • mapping
  • company

Purpose:

  • hold external billing references
  • provide a normalized place for Stripe/provider product and price mapping
create table if not exists billing_products (
id uuid primary key default gen_random_uuid(),
entity_type text not null check (entity_type in ('package', 'addon')),
entity_id uuid not null,
provider text not null,
provider_product_id text,
provider_price_id text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
  • this table exists even if Stripe integration is not active yet
  • provider reference columns may remain null until billing integration is enabled

These index definitions MUST be applied (same migration discipline as section 7). A deployment that lacks them is not a complete Core schema.

create index if not exists idx_modules_key on modules(key);
create index if not exists idx_packages_key on packages(key);
create index if not exists idx_addons_key on addons(key);
create index if not exists idx_package_modules_package_id on package_modules(package_id);
create index if not exists idx_package_modules_module_id on package_modules(module_id);
create index if not exists idx_addon_modules_addon_id on addon_modules(addon_id);
create index if not exists idx_addon_modules_module_id on addon_modules(module_id);
create index if not exists idx_company_subscriptions_company_id on company_subscriptions(company_id);
create index if not exists idx_company_subscriptions_status on company_subscriptions(status);
create index if not exists idx_company_addons_company_id on company_addons(company_id);
create index if not exists idx_company_addons_status on company_addons(status);
create index if not exists idx_entitlement_history_company_id on entitlement_history(company_id);
create index if not exists idx_entitlement_history_created_at on entitlement_history(created_at);

The inserts below MUST be executed (or produce the same catalog rows) so module/package/add-on keys exist before serving traffic.

insert into modules (key, name, type, description) values
('basic', 'Core App', 'base', 'Core App / Basic product module'),
('finance', 'Finance', 'addon', 'Finance module'),
('market', 'Market', 'addon', 'Market module'),
('touring', 'Touring', 'addon', 'Touring module'),
('venue', 'Venue', 'addon', 'Venue module'),
('ai', 'AI', 'addon', 'AI module')
on conflict (key) do nothing;
insert into packages (key, name, description) values
('basic', 'Basic', 'Basic subscription that enables Core App')
on conflict (key) do nothing;
insert into addons (key, name, description) values
('finance', 'Finance', 'Finance add-on'),
('market', 'Market', 'Market add-on'),
('touring', 'Touring', 'Touring add-on'),
('venue', 'Venue', 'Venue add-on'),
('ai', 'AI', 'AI add-on')
on conflict (key) do nothing;
insert into package_modules (package_id, module_id)
select p.id, m.id
from packages p
join modules m on m.key = 'basic'
where p.key = 'basic'
on conflict do nothing;
insert into addon_modules (addon_id, module_id)
select a.id, m.id
from addons a
join modules m on m.key = a.key
on conflict do nothing;

10.1 Company can have a base module without add-ons

Section titled “10.1 Company can have a base module without add-ons”

Valid.

10.2 Company can have add-ons without the promoter base module

Section titled “10.2 Company can have add-ons without the promoter base module”

Valid.

10.3 Company can have a base module plus add-ons

Section titled “10.3 Company can have a base module plus add-ons”

Valid.

10.4 Company may have no active promoter base module and still have active add-ons

Section titled “10.4 Company may have no active promoter base module and still have active add-ons”

Valid and must be supported everywhere.

Core computes:

enabledModules =
package modules from active Basic subscription
+
add-on modules from active company add-ons
  • Basic inactive
  • Add-ons active: finance, touring

Result:

{
"hasBasic": false,
"enabledModules": ["finance", "market"]
}
  • Basic active
  • Add-ons active: finance

Result:

{
"hasBasic": true,
"enabledModules": ["basic", "finance"]
}

There are 3 groups of endpoints:

  1. Catalog read
  2. Entitlement read
  3. Catalog/company write
  4. Admin/audit/support read

All are internal-only.

Platform Core must not expose any endpoints related to:

  • invitations
  • onboarding
  • user creation
  • membership creation

If such endpoints are required, they must be implemented in Auth.


{
"success": true,
"data": {}
}
{
"success": false,
"error": {
"code": "string_code",
"message": "Human readable message"
}
}
  • unauthorized
  • forbidden
  • validation_error
  • not_found
  • conflict
  • internal_error
  • service_unavailable

Purpose:

  • simple liveness probe
{
"success": true,
"data": {
"status": "ok"
}
}

Purpose:

  • readiness check

Must verify:

  • Core DB reachable
  • internal config loaded
{
"success": false,
"error": {
"code": "not_ready",
"message": "core database unavailable"
}
}

  • Platform Admin Backend
  • Auth Backend (optional)
  • internal jobs

Return full module catalog.

{
"success": true,
"data": {
"modules": [
{
"id": "uuid",
"key": "basic",
"name": "Core App",
"type": "base",
"description": "Core App / Basic product module",
"isActive": true
},
{
"id": "uuid",
"key": "finance",
"name": "Finance",
"type": "addon",
"description": "Finance module",
"isActive": true
}
]
}
}

  • Platform Admin Backend
  • internal jobs
{
"success": true,
"data": {
"packages": [
{
"id": "uuid",
"key": "basic",
"name": "Basic",
"description": "Basic subscription that enables Core App",
"isActive": true,
"modules": ["basic"]
}
]
}
}

  • Platform Admin Backend
  • internal jobs
{
"success": true,
"data": {
"addons": [
{
"id": "uuid",
"key": "finance",
"name": "Finance",
"description": "Finance add-on",
"isActive": true,
"modules": ["finance"]
},
{
"id": "uuid",
"key": "market",
"name": "Market",
"description": "Market add-on",
"isActive": true,
"modules": ["market"]
}
]
}
}

15.1 GET /internal/companies/{companyId}/entitlements

Section titled “15.1 GET /internal/companies/{companyId}/entitlements”
  • Auth Backend (main consumer)
  • Platform Admin Backend
  • internal jobs

Return the current commercial entitlement state for a company.

  • companyId: canonical UUID
{
"success": true,
"data": {
"companyId": "cmp_001",
"hasBasic": false,
"basePackage": null,
"addons": [
{
"key": "finance",
"status": "active",
"startsAt": "2026-04-16T00:00:00Z",
"endsAt": "2026-05-16T00:00:00Z"
},
{
"key": "market",
"status": "active",
"startsAt": "2026-04-16T00:00:00Z",
"endsAt": "2026-05-16T00:00:00Z"
}
],
"enabledModules": ["finance", "market"],
"entitlementVersion": 7,
"updatedAt": "2026-04-16T05:00:00Z"
}
}
  • if Basic inactive, hasBasic = false
  • basePackage may be null
  • enabledModules still may include add-ons
  • output must always be normalized

15.2 GET /internal/companies/{companyId}/subscription-summary

Section titled “15.2 GET /internal/companies/{companyId}/subscription-summary”
  • Platform Admin Backend
  • internal jobs
  • Auth Backend (optional)

Admin-facing summary of commercial state.

{
"success": true,
"data": {
"companyId": "cmp_001",
"hasBasic": true,
"basePackage": "basic",
"addons": ["finance", "market"],
"status": "active",
"entitlementVersion": 7
}
}

15.3 GET /internal/companies/{companyId}/history

Section titled “15.3 GET /internal/companies/{companyId}/history”
  • Platform Admin Backend
  • internal audit/reporting jobs

Return entitlement history.

{
"success": true,
"data": {
"companyId": "cmp_001",
"history": [
{
"id": "uuid",
"changeType": "addon_activated",
"entityType": "addon",
"entityKey": "finance",
"previousStatus": "inactive",
"newStatus": "active",
"source": "platform_admin",
"changedBy": "admin_user_uuid",
"createdAt": "2026-04-16T05:00:00Z"
}
]
}
}

These are mainly consumed through the Platform Admin Backend.

  • Platform Admin Backend only
{
"key": "finance",
"name": "Finance",
"type": "addon",
"description": "Finance module",
"isActive": true
}
{
"success": true,
"data": {
"id": "uuid",
"key": "finance",
"name": "Finance",
"type": "addon",
"description": "Finance module",
"isActive": true
}
}
  • key unique
  • type must be base or addon

16.2 PATCH /internal/catalog/modules/{moduleId}

Section titled “16.2 PATCH /internal/catalog/modules/{moduleId}”
{
"name": "Finance",
"description": "Finance module updated",
"isActive": true
}
  • key should generally be immutable once used in production

{
"key": "basic",
"name": "Basic",
"description": "Basic subscription that enables Core App",
"isActive": true,
"moduleKeys": ["basic"]
}
  • create package row
  • map module keys in package_modules

16.4 PATCH /internal/catalog/packages/{packageId}

Section titled “16.4 PATCH /internal/catalog/packages/{packageId}”
{
"name": "Basic",
"description": "Updated Basic description",
"isActive": true,
"moduleKeys": ["basic"]
}
  • update package
  • replace mapping set if moduleKeys present
  • if mapping change impacts active companies, entitlement version repair may be needed

{
"key": "finance",
"name": "Finance",
"description": "Finance add-on",
"isActive": true,
"moduleKeys": ["finance"]
}

16.6 PATCH /internal/catalog/addons/{addonId}

Section titled “16.6 PATCH /internal/catalog/addons/{addonId}”
{
"name": "Finance",
"description": "Finance updated",
"isActive": true,
"moduleKeys": ["finance"]
}

These are the most important write endpoints.

17.1 POST /internal/companies/{companyId}/basic

Section titled “17.1 POST /internal/companies/{companyId}/basic”
  • Platform Admin Backend
  • optional internal billing/reconciliation jobs

Activate, update, pause, or deactivate Basic subscription.

{
"status": "active",
"startsAt": "2026-04-16T00:00:00Z",
"endsAt": "2026-05-16T00:00:00Z",
"source": "platform_admin",
"externalReference": "sub_123"
}
  • find package basic
  • upsert company subscription row
  • update status/dates/source/external ref
  • bump entitlement version
  • write entitlement history
{
"success": true,
"data": {
"companyId": "cmp_001",
"hasBasic": true,
"basePackage": "basic",
"entitlementVersion": 8
}
}
  • active
  • inactive
  • cancelled
  • expired
  • trial
  • paused

17.2 POST /internal/companies/{companyId}/addons

Section titled “17.2 POST /internal/companies/{companyId}/addons”
  • Platform Admin Backend
  • optional internal billing jobs

Activate or update one add-on for a company.

{
"addonKey": "finance",
"status": "active",
"startsAt": "2026-04-16T00:00:00Z",
"endsAt": "2026-05-16T00:00:00Z",
"source": "platform_admin",
"externalReference": "addon_sub_123"
}
  • resolve addon by addonKey
  • upsert company_addons
  • bump entitlement version
  • write history
{
"success": true,
"data": {
"companyId": "cmp_001",
"addonKey": "finance",
"status": "active",
"entitlementVersion": 9
}
}

17.3 POST /internal/companies/{companyId}/deactivate-addon

Section titled “17.3 POST /internal/companies/{companyId}/deactivate-addon”

You may choose not to create a separate endpoint and instead use the same POST /addons route with status: inactive.

That is actually preferred for simplicity.

Use:

POST /internal/companies/{companyId}/addons

for both activate and deactivate.


Auth caches merged access.
When Core changes company entitlements, Auth must know access is stale.

So Core must bump:

entitlementVersion

on every meaningful entitlement change.


18.2 Events that MUST bump entitlement version

Section titled “18.2 Events that MUST bump entitlement version”
  • Basic activated
  • Basic deactivated
  • Basic expired
  • add-on activated
  • add-on deactivated
  • add-on expired
  • package mapping changed in a way that affects active companies
  • add-on mapping changed in a way that affects active companies
  • manual entitlement repair

Preferred:

  • company_entitlement_versions table

Fallback:

  • version stored on company_subscriptions

Preferred is cleaner.


Auth receives:

  • entitlementVersion from Core response and uses it in cache key or stale-check logic.

Example cache key:

access:{companyId}:{membershipId}:{accessVersion}:{entitlementVersion}

Auth calls:

GET /internal/companies/{companyId}/entitlements
  • hasBasic
  • basePackage
  • addons
  • enabledModules
  • entitlementVersion
  • membership grants
  • permissions
  • delegation

Core does not perform the merge.


If Core is unavailable:

  • Auth should fail /auth/me/access
  • return 503
  • do not guess entitlements
  • do not fallback to stale business assumptions

Frontend admin UI should not call Core directly.
It calls:

  • Platform Admin Backend

Platform Admin Backend then calls Core internal routes.


  1. Platform Admin UI → Admin Backend
  2. Admin Backend validates platform-admin access
  3. Admin Backend calls:
    • POST /internal/catalog/addons
  4. Core writes addons
  5. Core returns created addon

  1. Platform Admin UI → Admin Backend
  2. Admin Backend validates admin permissions
  3. Admin Backend calls:
    • POST /internal/companies/{companyId}/basic
  4. Core upserts subscription
  5. Core bumps entitlement version
  6. Core returns updated state
  7. Auth access cache must become stale

  1. Platform Admin UI → Admin Backend
  2. Admin Backend validates platform admin
  3. Admin Backend calls:
    • POST /internal/companies/{companyId}/addons
  4. Core upserts addon status
  5. Core bumps entitlement version
  6. Core writes history
  7. Auth invalidates / rebuilds access next time

  • companyId, moduleId, packageId, addonId must be valid UUIDs where path expects UUID
  • module.key, package.key, addon.key should be lowercase slug format
  • no spaces
  • immutable after production use if possible

Allowed status values:

  • active
  • inactive
  • cancelled
  • expired
  • trial
  • paused
  • startsAt <= endsAt if both present
  • dates optional for manually controlled states
  • server should normalize timezone to UTC

{
"success": false,
"error": {
"code": "validation_error",
"message": "addonKey is required"
}
}
{
"success": false,
"error": {
"code": "conflict",
"message": "addon key already exists"
}
}
{
"success": false,
"error": {
"code": "unauthorized",
"message": "missing or invalid internal credentials"
}
}
{
"success": false,
"error": {
"code": "not_found",
"message": "company not found"
}
}

Recommended Go project structure:

/backend-kisum-core
/cmd/api
/internal/config
/internal/http
/handlers
/middleware
/internal/catalog
/internal/entitlements
/internal/history
/internal/repository
/postgres
/internal/types
/internal/errors
/migrations
  • modules CRUD
  • packages CRUD
  • addons CRUD
  • package/addon mappings
  • company Basic state
  • company add-on state
  • enabled modules computation
  • entitlement version bumping
  • write/read entitlement history
  • all SQL access

Must log:

  • entitlement reads
  • catalog writes
  • company subscription writes
  • add-on writes
  • entitlement version bumps
  • invalid internal auth attempts

Track:

  • entitlement lookup count
  • entitlement lookup latency
  • catalog mutation count
  • company mutation count
  • 4xx/5xx rate
  • history insert failures

/ready must verify:

  • DB connectivity
  • config loaded

Core DB must be backed up independently.


  • Core must not be public browser API
  • internal routes must require service auth
  • DB credentials must be unique to Core
  • no cross-service DB writes from Auth to Core DB
  • no user permissions stored in Core
  • no JWT generation in Core
  • no tenant role interpretation in Core

Auth must not expect Core to return:

  • permissions
  • delegation
  • tenant roles
  • user grants
  • membership objects

Core returns only:

  • company commercial state
  • enabled modules
  • entitlement version
  • product catalog state if asked

  • do not read Auth DB directly
  • do not validate frontend user JWTs as if it were public API
  • do not compute user-level access
  • do not duplicate user grants
  • do not become tenant-authorization service
  • do not manage invitations or onboarding flows

  • create platform_core_db
  • create all tables
  • create indexes
  • seed catalog
  • bootstrap service
  • add health/ready
  • add internal auth middleware
  • add structured logging
  • GET /internal/catalog/modules
  • GET /internal/catalog/packages
  • GET /internal/catalog/addons
  • POST /internal/catalog/modules
  • PATCH /internal/catalog/modules/{id}
  • POST /internal/catalog/packages
  • PATCH /internal/catalog/packages/{id}
  • POST /internal/catalog/addons
  • PATCH /internal/catalog/addons/{id}
  • GET /internal/companies/{companyId}/entitlements
  • GET /internal/companies/{companyId}/subscription-summary
  • GET /internal/companies/{companyId}/history
  • POST /internal/companies/{companyId}/basic
  • POST /internal/companies/{companyId}/addons
  • entitlement version table or equivalent
  • version bump logic
  • history writing
  • invalidation strategy documented

Platform Core is the commercial entitlement backend.
It owns packages, add-ons, modules, and company product state.
It does not own users, permissions, or membership access.
Auth calls Core to get company entitlements.
Platform Admin writes to Core to change commercial state.
Core returns normalized enabled modules and entitlementVersion.

30. Access model layers (REFERENCE — DO NOT IMPLEMENT HERE)

Section titled “30. Access model layers (REFERENCE — DO NOT IMPLEMENT HERE)”

Platform Core participates in a 3-layer access model but only owns Level 1.

Level 1 — Company entitlements (OWNED BY CORE)

Section titled “Level 1 — Company entitlements (OWNED BY CORE)”

Defines what the company has purchased:

  • Basic
  • Finance
  • Market
  • Touring
  • Venue
  • AI

Source of truth: Platform Core DB


Level 2 — Membership module grants (OWNED BY AUTH)

Section titled “Level 2 — Membership module grants (OWNED BY AUTH)”

Defines which modules a user can access.

Core MUST NOT store or evaluate this.


Defines actions inside modules.

Core MUST NOT store or evaluate this.


Core returns entitlements ONLY
Core never computes user access

Platform Core is a dependency of Auth.

Auth → Core → Auth → Frontend

Core:

  • returns company entitlements
  • returns enabled modules
  • returns entitlement version

Auth:

  • merges entitlements with user grants
  • computes effective access
  • returns final access model

Core must NEVER:

  • return user-level access
  • validate permissions
  • validate roles
  • decide if a request is allowed

Core MUST NOT implement:

effective_access = entitlements ∩ grants

This logic belongs ONLY to Auth.


33. Delegation awareness (READ-ONLY CONTEXT)

Section titled “33. Delegation awareness (READ-ONLY CONTEXT)”

Core is aware that delegation exists, but:

  • does not enforce delegation
  • does not validate delegation
  • does not store delegation rules

Delegation is owned by Auth.


Frontend must NOT call Platform Core directly for access.

Allowed frontend calls:

  • Auth
  • Base backend
  • module backends
  • admin backend

Business backends must NOT:

  • call Core for user access decisions
  • use Core as authorization service

They must rely on:

Auth → /auth/me/access

Core = commercial truth
Auth = access truth
Backend = enforcement
Frontend = UX only