A module is a Postgres schema beside the socle. fixed_assets holds fixed
assets and their depreciation; budgets holds a plan and compares it to the ledger;
tax estimates the corporate income tax of a year from the books and from the
rules a country pack carries; einvoicing keeps the electronic invoice of a
posted sale and every time it was sent; crm keeps the record of a
relationship — cards, segments, pipelines, deals and activities — on the
socle's own contacts; contracts keeps what a company agreed with a party of
its books, and issues the invoices its schedules say are due as draft
documents of the socle; consolidation keeps groups of companies and who is
in each, for which period and at which percentage of interest. The socle lives
in its own schema, ekwo, and knows nothing about any of them.
The decision behind this, with what it rules out, is in decision 0051. This document is how to write one.
The shape
modules/fixed-assets/
├── module.json what it is, where it lives, what it needs
├── README.md what it does, and what an accountant should check
├── supabase/migrations/*.sql its own schema, its own row level security
└── tests/*.test.ts picked up by the root `npm test`
supabase/seed/modules/fixed_assets/ the country data it compiled, if it has any
packs/<cc>/fixed_assets.json the source of that data
modules/schema/module.1.json is the published shape of a manifest, and
ekwo module list refuses one that does not match it.
{
"code": "assets",
"name": "Fixed assets",
"schema": "fixed_assets",
"version": "2.0.0",
"status": "available",
"requires_socle_min": "20260913075903",
"posts": true,
"mcp": { "prefix": "fixed_assets" },
"pack": { "section": "fixed_assets" }
}
| Field | What it decides |
|---|---|
code |
The key of public.modules, and what is written on every entry the module posts. Immutable — which is why the fixed assets module is still assets after its schema became fixed_assets. |
schema |
The Postgres schema. One per module, never public. It may be renamed by a migration; the code may not. |
status |
draft is refused by enable_module(), available is enableable, deprecated keeps the companies it has and takes no new one. |
requires_socle_min |
The oldest socle migration it needs, by timestamp. ekwo module enable checks it against the history. |
posts |
Whether it writes to the ledger. A module that says false is held to it by a test. |
mcp.prefix |
Every MCP tool of the module is <prefix>_<verb>. |
pack.section |
The file a country pack carries for it, packs/<cc>/<section>.json, and the folder it compiles into, supabase/seed/modules/<section>/. |
The six that ship are six different shapes, and a new one is usually like one of them.
| Module | Folder | Country data | Posts | Written by |
|---|---|---|---|---|
assets |
fixed-assets |
its own section, fixed_assets |
yes | clients, under its policies, and post_module_entry() |
budgets |
budgets |
none | no | clients, under its policies |
tax |
corporate-tax |
its own section, corporate_tax — the most of the four |
the provision of a computation, since version 1.1.0 | security definer functions, and no role; its declarations, clients under its policies |
einvoicing |
einvoicing |
none of its own: the einvoicing section every pack already carries, read where the socle compiled it |
no | security definer functions, and no role; the file itself is written in TypeScript, by a brick |
crm |
crm |
none | no — winning a deal changes the socle's contact, never the ledger | clients, under its policies, directly or through its security invoker functions |
contracts |
contracts |
none | no — it writes draft documents of the socle, under the socle's policies, and asks post_document() to book one only where a schedule says auto_post |
clients, under its policies, directly or through its security invoker functions |
consolidation |
consolidation |
none | no — it reads the members' books, never writes in them | clients, under its policies, directly or through its security invoker functions; a trigger holds who may put a company in a group |
fixed-assets has country data and posts. budgets has neither.
corporate-tax — code tax — has the most country data and posts one
thing, the provision of a computation, on the accounts its pack names: every
table that holds a result is written by a
security definer function and by no role, which is how "only an owner calls
a computation final" holds for psql and PostgREST alike. einvoicing is the
same shape with one more side: what it keeps is produced outside the database
— a file a brick of packages/formats/ writes, sent through a transport the
host brings — so its functions check what can be checked without trusting
the caller (the document is a posted sale, the profile is the pack's, the
checksum is the text's) and its tables only ever move forward.
crm is the first module whose rows are about contacts rather than about
the books: it owns no contact, every row names one of ekwo.contacts by a
composite key with its company, and the socle's merge_contacts() moves its
rows with every other reference when two contacts turn out to be one party.
contracts has the shape of crm and one more reach: what it issues is a
document of the socle — a draft sales or purchase invoice written in
ekwo.documents as a person writes one, idempotent because the socle's
unique client_ref refuses a second document for one period.
consolidation is the first whose rows span companies: a group belongs to its
parent, and the books of every member are read with what the caller holds in
that member — the module grants nothing on them
(decision 0095).
The folder under modules/ is named after what the module is, lower case with
hyphens — fixed-assets — and need not be the code. ekwo module takes
either.
The six rules
1. One schema, and the socle is not it. A module creates its own schema and
everything it owns lives there. It depends on the socle by foreign key —
companies, accounts, contacts, products, fiscal_years — and the
dependency points one way: the socle has no hook, no callback and no way for a
module to change what posting means.
A module's SQL names the socle's schema. The files published before 0.23.0
say public.companies, public.is_company_member(…) and
set search_path = public, pg_temp, the socle's address of their day, and a
published migration is never edited; so whoever applies a module's file —
ekwo migrate, ekwo module install, the test image — reads it for the
socle's schema of the day (socleNamed() in @ekwo-ai/core): with the socle
in ekwo, public. before a name becomes ekwo., 'public' becomes
'ekwo' and a search_path = public names ekwo. A file written today names
ekwo. directly and passes unchanged. The move itself (20261028040000)
wrote the functions of the modules already installed again the same way, and
gave every module function that carried no path one of its own — its schema
first, then ekwo — so that what it calls and the triggers it fires find the
socle whatever path the request came in with. A new module function says the
same in its own file: set search_path = <schema>, ekwo, pg_temp.
2. The ledger only through post_module_entry(). A module hands over a
company, a date, a tag and a list of lines; the function builds the draft and
calls post_entry(), which is where sides, rounding, numbering and period
locks live. A module never names entries or entry_lines in a write
statement, and a test over modules/** refuses one that does. Reading the
ledger is another matter and is allowed — budgets.variance does exactly that.
select public.post_module_entry(
p_company_id => v_company,
p_module_code => 'assets',
p_ref => 'depreciation:2026-12-31',
p_date => date '2026-12-31',
p_description => 'Depreciation 2026',
p_lines => '[{"account_code":"630200","debit":"1200.00"},
{"account_code":"241900","credit":"1200.00"}]'::jsonb);
The tag (module_code, ref) is unique per company, so a module is idempotent
because the database refuses the duplicate, not because the module remembered
to look. That is what entries.module_code and entries.module_ref are for,
and it is why no entry_kind value is added per module: a depreciation entry is
an ordinary entry that a report may leave in.
3. Row level security on every table, through module_enabled(). A table
that carries company_id gets a policy of the shape
create policy fixed_assets_select on fixed_assets.fixed_assets
for select using (
public.module_enabled(company_id, 'assets')
and public.has_capability(company_id, 'assets.read')
);
create policy fixed_assets_write on fixed_assets.fixed_assets
for all using (
public.module_enabled(company_id, 'assets')
and public.has_capability(company_id, 'assets.write')
)
with check (
public.module_enabled(company_id, 'assets')
and public.has_capability(company_id, 'assets.write')
);
module_enabled() is the module being on for that company and the caller
being a member of it, so a stranger gets nothing before a capability is even
asked about. A table with no company_id is reference data of the
installation, like country_defaults, and gets the signed-in select policy. A
test refuses any other arrangement.
The capability is the module's own, declared in the module's own migration
with capabilities.area set to the module code — assets.read /
assets.write / assets.post, budgets.read / budgets.write — and added to
the presets there too, since a code that arrives after the socle has to name
itself. A .read goes to viewer and to client. A test refuses a module
policy that borrows the socle's can_write_company() instead.
4. A module does its own grants. public gets them from Supabase's default
privileges; a schema a migration created gets nothing at all. Every module
migration ends with the same block, and the last line is the one that matters:
grant usage on schema fixed_assets to anon, authenticated, service_role;
grant select on all tables in schema fixed_assets to anon;
grant select, insert, update, delete on all tables in schema fixed_assets to authenticated, service_role;
grant execute on all functions in schema fixed_assets to authenticated, service_role;
alter default privileges in schema fixed_assets revoke execute on functions from public, anon;
revoke execute on all functions in schema fixed_assets from public;
5. A country is data, here too. No module names a country. What a country
decides goes in packs/<cc>/<section>.json, is described in
packs/schema/pack.1.json, compiles into supabase/seed/modules/<section>/, and
lands in reference tables the module reads where they stand. A module whose
pack says nothing refuses by name — no_fixed_assets_country_rules — rather than
taking another country's answer. The accounts a module needs are roles of the
chart, in defaults.roles and country_defaults, because that is the one
place a pack says which account plays which part, and ekwo pack check already
proves every role code exists in every chart of the pack.
6. The registry is a table. The last statement of a module's first
migration inserts its row into public.modules. There is no plugin registry in
TypeScript: a module that is installed is a schema that exists and a row that
says so, and one query answers "what is here" for the CLI, the MCP server and a
human.
Migrations
They live in modules/<folder>/supabase/migrations/ and follow the socle's rules
— YYYYMMDDHHMMSS_subject.sql from the clock, never a round hour, additive
only, row level security in the file that creates the table, no transaction of
their own. Two rules are theirs alone:
- A module's timestamps sort after the socle migration its manifest
declares it needs (
requires_socle_min), which guarantees the objects it builds on already exist. A test refuses one that does not.ekwo migrateapplies the socle first and the modules after, so the applied order is right whatever the timestamps say. - They are recorded in the same history,
supabase_migrations.schema_migrations, with the plain timestamp asversionand the module in thename:assets/assets. That is what keepsekwo migrateandsupabase db pushinterchangeable for the socle.
ekwo migrate applies the socle's, then every module's, then the reference
seeds and the module seeds. By default, because a module is a schema whose
tables are empty and whose row level security is on until a company enables it
— there is nothing to ask before creating them, and a schema whose migrations
are half applied is the state nobody can reason about.
ekwo migrate --no-modules leaves them alone, which is what to pass before
running supabase db push: the Supabase CLI knows the socle's files and not a
module's, so it would report them as history it has no file for.
The commands
ekwo module list # what this release carries, and what the database holds
ekwo module migrate [<code>] # apply the migrations, and the country seeds
ekwo module enable assets --company "…" # turn it on for one company
ekwo module disable assets --company "…" # turn it off, unless the module says it holds data
enable and disable go through enable_module() and disable_module()
rather than writing company_modules, because the guard lives in the function:
the table has no write policy at all, so the rule applies to psql and PostgREST
alike and not only to clients that respect it. The CLI sets the request claim
for an owner of the company, the way ekwo register does.
can_disable, and the absence of it
disable_module() looks for <schema>.can_disable(uuid) and runs it if it is
there. It returns null when there is nothing in the way, and a sentence when
there is:
create or replace function fixed_assets.can_disable(p_company_id uuid)
returns text language sql stable as $$
select case when exists (select 1 from fixed_assets.depreciation_lines l
where l.company_id = p_company_id and l.posted_at is not null)
then 'depreciation has been booked from it' end;
$$;
A module that holds nothing a company would lose writes none at all, which is
what budgets and tax do — turning it off hides the rows and turning it back on
gives them back. Nothing a module wrote is ever deleted by a disable.
archive_tables, and why it is not optional
A company leaves an installation with its books
(company-archive.md), and a module's rows are part of
them. The socle finds every table that belongs to a company in the catalogue,
and refuses to export anything while one of them is unclassified — so a module
says what happens to each of its tables, in a function the socle looks up the
way it looks up can_disable:
create or replace function fixed_assets.archive_tables()
returns table (table_name text, disposition text, reason text,
via_column text, via_table text, load_order integer)
language sql immutable as $$
values ('fixed_assets'::text, 'exported'::text, null::text, null::text, null::text, 1),
('depreciation_lines', 'exported', null, null, null, 2),
('disposals', 'exported', null, null, null, 3);
$$;
exported, or excluded with a reason of a sentence. load_order is
relative to the module — the socle loads every module after its own tables —
and a parent comes before its children. Reference data of the installation,
with no company_id and no foreign key to a company, is not listed at all. A
function in the module's schema rather than rows in a table of the socle,
because a module migration may be applied on a socle that does not have that
table yet.
Renaming what a module owns
A schema, a table or a function of a module may be renamed; its code may
not, because the code is written on every entry the module posted and a posted
entry does not move. The fixed assets module is the worked example: its schema
was assets and is fixed_assets since version 2.0.0, its code is assets
still.
The rename is a migration that renames in place (rows, keys, policies and
privileges follow the objects), re-creates every function whose body names the
old objects, and is idempotent, so a fresh installation and an upgraded one
reach the same catalogue. An archive written before the rename is read under
today's names through <schema>.archive_former_names(), returning
(former_name, current_name).
What no migration can do is the API setting: the exposed schemas of the project have to name the new schema, and the old one taken out.
PostgREST, and the one thing no migration can do
A schema other than public is served only once the project lists it under its
exposed schemas. No migration can set that: it is a setting of the API, not
of the database. So:
- Supabase dashboard → Project Settings → API → Exposed schemas: add
fixed_assets. - Self-hosted: add it to
[api] schemasinsupabase/config.tomland restart.
ekwo module enable prints those two lines every time, and the MCP server turns
the profile error PostgREST answers with into the same sentence.
MCP
A module's tools are declared as data in
packages/mcp/src/tools/modules.ts and registered by a loader that reads
public.modules. A module that is not installed is not offered, because a tool
a model cannot use is worse than a tool it cannot see. Adding a module to the
server is adding a toolset to that file and nothing else — the same shape the
registry takes in the database.
Writing one in a day
mkdir -p modules/<folder>/{supabase/migrations,tests}and writemodule.jsonandREADME.md.- One migration:
create schema <code>, the enums, the tables with their foreign keys ontopublic, the functions —archive_tables()among them — the policies, the grants, and theinsert into public.moduleslast. - If the module has a country rule, add
<code>topacks/schema/pack.1.jsonas a$defsdocument, read it inpackages/cli/src/pack/read.ts, compile it inpackages/cli/src/pack/compile.ts, and runekwo pack build --all. - Tests in
modules/<folder>/tests/. The rootnpm testpicks them up. npm run docs:schema— the generator covers module schemas, one section each.- Tools in
packages/mcp/src/tools/modules.ts, under your prefix. What a tool needs that is not in the database — the transport an electronic invoice leaves through — reaches it in the context the server passes beside the backend, set by whoever runs the server and never by a model.
The guards in tests/modules.test.ts are what will tell you if you got it
wrong: row level security, module_enabled() in the policies, company_id on
every table of a company, no country literal, no write to the ledger, the
manifest against its schema, and the migration timestamps in order.
tests/company_archive.test.ts adds one: a table of yours that belongs to a
company and that archive_tables() does not classify.