Problem
Someone has just said "we want a CRM" — or a general ledger, or an ITSM desk. One sentence, and it hides the decision enterprise software has been losing to for thirty years.
Buy the product and it arrives carrying somebody else's model of your business: deals with
stages you don't run, a qualified flag that means nothing here, an approval gate on a number
nobody at your company approves. So you configure it. Then you customize it. Then you pay people
to carry those customizations across every upgrade, forever. The bend-it-to-fit work outlives the
purchase, and the vendor's release schedule quietly becomes yours.
Build it instead and the business model finally fits — while everything underneath it is wrong, because the hard parts of an enterprise system were never the objects. They are idempotent writes, who-may-see-which-row, a period you must not be able to post into, a login that survives an audit. Every team re-learns those, and usually learns them in production.
Both options are expensive in the same place: the gap between a general system and this customer's actual business. One side pays integrators to close it; the other pays engineers to rediscover the platform.
There is a third shape, and it is what the DODIL packages are for. It starts by not doing the
obvious thing. Handed crm-core, the move most agents reach for is deploy it — clone, point at
a bucket, ship, hand over a URL. That hands the customer a demo of somebody else's CRM and starts
the fork-and-patch treadmill on day one.
Instead: code/crm-*, code/gl-* and code/itsm-* are reference
implementations, not product. You read one to learn how a correct system on this platform is
built, you interview the customer to find out what their business actually is, and then you
generate their system: their objects, their stages, their approvals, their org chart, their
branding. Every customer gets their own pool, bucket, repo and apps. Nothing is shared. There is
no upgrade treadmill, and the reference improving never disturbs a running deployment.
The customization is the product; the reference is how you learn to do it correctly.
The payoff, concretely: a customer-specific ERP where a sales opportunity JOINs a finance invoice with no ETL (one bucket), where staff sign in once for every module (one pool), where adding the next module is one more router rather than a second system — and where the parts that are about the platform rather than the business are copied verbatim, because every one of them is a scar from a live failure that cost somebody an afternoon.
This post is the map. It is written for agents as much as humans.
The seven steps
1 DISCOVER /llms.txt → /library/<id> (the WHY) + /skills/<module>/<workflow> (contract)
2 FETCH /code/<id> → the reference package + PLATFORM.md
3 DERIVE interview the customer → THEIR model ← the only creative step
4 GENERATE their models/routes/UI; platform layer COPIED verbatim
5 PROVISION bucket · pool + roles · service account · repo · CI secret
6 DEPLOY git push → CI → registry → CD
7 VERIFY ignite invoke + pod logs — NEVER the front doorSteps 1, 2, 5, 6 and 7 are mechanical. Step 4 is disciplined copying plus code generation. Step 3 is the only place judgement is required, and it is the whole value of the engagement. An agent that spends its effort anywhere else is spending it in the wrong place.
1 — DISCOVER: the index, the why, the contract
Everything an outside agent needs is published and reachable without this repo:
| Surface | What it is |
|---|---|
https://blog.dodil.io/llms.txt | the index — every post, generated from the live catalog |
https://blog.dodil.io/library/<id> | the narrative: why the module is shaped this way |
https://blog.dodil.io/skills/<module>/<workflow> | the machine-readable YAML contract (/skills/crm/core, /skills/gl/core) |
https://blog.dodil.io/code/<id> | the code package tar |
Read the post before the code. The post carries the reasoning — why the CMDB is a graph, why the
closed-period lock is a sole-writer engine and not a database constraint — and the reasoning is
what transfers to a customer whose objects are different. The contract under /skills/ is what
you check your generated system against; note its id is two segments (crm/core), not the post's
one (crm-core).
Start points: crm-core · gl-core ·
itsm-core, and the suite posts that compose them
(CRM · GL · ITSM).
2 — FETCH: the package, and the file that ships with it
curl -L https://blog.dodil.io/code/gl-core -o gl-core.tar
tar xf gl-core.tar # → gl-core-v1/
ls gl-core-v1
# models.py routes.py posting.py db.py auth.py sa_token.py PLATFORM.md
# requirements.txt .env.example README.md EXTENDING.mdPLATFORM.md is byte-identical in every DODIL reference package. It is the half of the
instruction the code cannot carry on its own: the lines you copy rather than regenerate, and the
DataK3 semantics the code depends on. Read it before you write a line of the customer's system.
Everything in §"The invariants" below is in it.
The package is source only — no Dockerfile, no build file. Deploy is its own story (Ship a DODIL app), deliberately not baked into the teaching artifact.
3 — DERIVE: interview the customer (the only creative step)
Do not skip to code. The reference gives you the shape of a correct system; the customer gives
you the content. Ask, and write the answers down — they become §"What was customized, and why"
in the engagement's own CLAUDE.md:
- Objects. What do they actually track? Not "accounts and contacts" — their nouns. A recruiter has candidates and placements. A broker has policies and renewals. If their word for a thing differs from the reference's, their word wins, in the schema and the UI.
- Stages. Their pipeline or lifecycle, in order, and precisely what moves a record from one stage to the next. This is where most reference schemas are wrong for a given customer: the stage list is a business fact, never a default.
- Approvals. Which actions spend money, move a reported number, or are irreversible? Those — and only those — get a role gate. Everything else rides on being signed in. An approval gate on a harmless route is friction the customer will route around.
- Org chart. Their real roles, and for each: what can they see (row-level ownership) and what can they do (permissions). Roles are the org chart, not a security abstraction invented at build time.
- The money. What does this system change about their week? If nobody can answer that, the module is not ready to be built yet.
4 — GENERATE: their logic, our platform layer
Now write their system. Two piles, and the line between them is not negotiable:
| Generate | Copy verbatim |
|---|---|
models.py — their tables, their columns, natural PKs | db.py — lazy engine + the filtered ON CONFLICT upsert |
modules/*.py — their workflows and routes | auth.py — the gateway header-trust role gate |
web/ — their branding, their fields | sa_token.py — service-account token mint/refresh |
.dodil/deploy.yaml — templated with their names | .github/workflows/ci.yml |
CLAUDE.md — what you customized, and why | PLATFORM.md — unchanged, so it stays comparable |
Default to one FastAPI app with one router per module, over one canonical models.py —
every table defined once, the superset of what the modules need. Plain imports
(from modules import core, quotes, …), never an importlib loader: a dynamic loader shrugs at
a broken module, a plain import fails loudly at startup, and a pod about to serve a general ledger
should fail loudly.
Ship the engagement's own CLAUDE.md in the repo, next to PLATFORM.md. The next agent to touch
that customer's code inherits the model from the repo instead of guessing at it — what this system
is (generated, not a fork), the customer's topology, the invariants (by pointer, not copied), the
verify rule, and the diff-with-reasons between the reference and this customer.
5 — PROVISION: pool, bucket, service account, repo
customer = pool + bucket + Ignite group + repo + SA. Create exactly that, once, per customer.
Provision a new customer engagement for northwind: create the DataK3 bucket northwind, create a service account cli-northwind-sa and grant it k3.editor on the k3 authorization service plus ignite.app-developer on the ignite authorization service, and create a git repo northwind-erp under org IHDIASH.
data_bucket_create→auth_service-account_create→auth_service-account_grant-role→auth_service-account_list-roles→git_repo_createBucket northwind created (region uk-lon-1). Service account cli-northwind-sa created — client id and secret returned once; store the secret in the repo's CI secret store, not in git. Roles granted: k3-authorization-service k3.editor (read/write the bucket) and ignite-authorization-service ignite.app-developer (deploy/invoke the app). I did not grant ignite.model-user — none of the workflows you described call Models; add it later if that changes. Repo IHDIASH/northwind-erp created, default branch main, visibility private.
export CUSTOMER=northwind # bucket names must be 3+ chars — DataK3 rejects shorter
export ORG=IHDIASH
# 1. ONE bucket = their whole ERP database (masters + every module's transactions)
dodil data bucket create "$CUSTOMER"
# 2. ONE appid pool = their staff, signing in once for every module.
# Roles ARE their org chart; permissions are namespaced <module>:<object>:<verb>.
dodil appid pool create "$CUSTOMER" --display-name "Northwind" --with-local
dodil appid roles set "$CUSTOMER" \
sales="sales:quotes:approve,sales:deals:read" \
finance-manager="gl:journal:post,gl:period:close" \
admin="sales:quotes:approve,gl:journal:post,gl:period:close"
# 3. The app's own identity — least privilege, per service it actually calls.
dodil auth service-account create "cli-$CUSTOMER-sa" -o json # secret prints ONCE
export SA_UUID=...
dodil auth service-account grant-role $SA_UUID k3-authorization-service k3.editor
dodil auth service-account grant-role $SA_UUID ignite-authorization-service ignite.app-developer
# ...and ignite.model-user ONLY if their workflows call Models. Check the live catalog
# rather than assuming: dodil auth service-account list-roles
# 4. The engagement repo, and the SA secret as a CI secret — never a value in git.
dodil git repo create "$CUSTOMER-erp" --org-name "$ORG"
dodil git secret set "$CUSTOMER-erp" NORTHWIND_SA # referenced from .dodil/deploy.yaml
# (equivalently, and what CI scripts use:
# PUT https://git.dodil.io/api/v1/repos/$REPO_ID/ci-secrets/NORTHWIND_SA {"value":"..."}
# -> 204. Writing a secret needs git.admin; git.editor can push code but not this.)The two-plane rule holds throughout: the pool identifies the business user, the service account reaches the bucket. An app-user is never a bucket principal.
6 — DEPLOY: git push
.github/workflows/ci.yml builds and pushes the image; .dodil/deploy.yaml declares the app and
CD applies it on every green build, resolving the runtime credential from the repo's CI secret
store. git push is the deploy command. Two details from the manifest that are specific to this
model: user_pool: <customer> attaches the pool to the ingress, and public_invoke: false makes
login required — so the app ships no auth code. The full schema, the tri-state keys and the
13-minute cold build are in Ship a DODIL app.
7 — VERIFY: ignite invoke and pod logs, never the front door
northwind-erp just deployed. Tell me whether the APP is actually serving — not whether the URL responds. Check the active version, invoke the app directly, and show me the pod logs.
ignite_app_get→ignite_invoke→ignite_app_logs→ignite_version_listnorthwind-erp: active_version 4, deployment_state deployed, public_invoke false. Invoke reached the app: it answered 405 Method Not Allowed — invoke always POSTs to /, and the router has no POST / route, so that 405 is FastAPI itself talking. Pod logs confirm it: 'Application startup complete', 'Uvicorn running on http://0.0.0.0:8080', the probe's GET /healthz 200 OK, and my invoke as POST / 405. No import errors. For contrast, a curl of the public FQDN returns 401 end-user login required — that is the gateway, and it says the same thing whether the pod is healthy, crash-looping or absent.
export APP=northwind-erp
dodil ignite app get "$APP" --output json # read active_version — a green deploy check
# reports that the apply RAN, not that it changed
# `invoke` always POSTs to "/" and the platform mints the token. What you are reading is
# WHOSE answer it is: a 200/404/405 with your framework's body = the APP is up and routing.
dodil ignite invoke "$APP" --output json
# -> {"status_code": 405, "body": "{\"detail\":\"Method Not Allowed\"}", "duration_ms": 12290}
# FastAPI answered. That is a live app.
dodil ignite app logs "$APP" --since 10m --limit 50
# -> INFO: Application startup complete.
# -> INFO: Uvicorn running on http://0.0.0.0:8080
# -> INFO: 10.1.67.201 - "GET /healthz HTTP/1.1" 200 OK <- the probe
# -> INFO: 10.100.55.4 - "POST / HTTP/1.1" 405 Method Not Allowed <- the invoke above
# What NOT to trust:
curl -s https://northwind-erp-ihdiash-8080.ignite.dodil.cloud/
# -> 401 {"error":"end-user login required","login_url":"/.dodil/auth/login?return_to=%2F"}
# That is the GATEWAY. It answers identically whether the app is healthy, crash-looping,
# or absent. It is not a deploy check.Give the app a route the invoke contract can land on (a POST / that returns the same payload as
/healthz) and the check becomes a plain 200 — but read the body, not the status of the front
door.
The per-customer topology
Isolation is not a feature here; it falls out of the topology for free.
appid pool <customer> ONE pool. Every module, one sign-in.
roles = their real org chart
perms = <module>:<object>:<verb> sales:quotes:approve
Ignite group <customer>
app <customer>-erp ONE app, a router per module — the default
split ONLY for a stated reason: a public surface vs a
private engine, independent scaling, a distinct trust boundary
every app: user_pool=<customer> · public_invoke=false · numeric non-root USER
DataK3 bucket <customer> ONE bucket = their ERP database
master tables (single documented owner) + per-module
transaction tables → a sales opportunity JOINs a finance
invoice with NO ETL
SA cli-<customer>-sa k3.editor (+ ignite.model-user only if they call Models)
customer = pool + bucket + group + repo + SA. Nothing crosses that line.Why one pool and not one per module. A pool per module means the customer's staff re-authenticate walking from Sales to Finance, and their admin maintains N user lists. That is the fragmentation to avoid. One pool per customer, roles spanning modules, permissions namespaced so they cannot collide.
Why one bucket. Three modules on three databases is three copies and an ETL job between them. One bucket is one copy of the rows, queryable as SQL, as vectors and as a graph — which is the entire reason an ERP belongs on DataK3. Drive the same rows from any standard client when you need to (Connect your tools).
Why one app by default. Pragmatism, not dogma. Don't force everything into one binary and don't shard for its own sake. Split when there is a real reason and say the reason in the repo. One more: an application control that must actually hold — "this period is closed", "this record is locked" — can only be enforced by making one code path the sole writer. Six apps are six writers, and the lock becomes six conventions that happen to agree.
NOTE
One thing here is unverified and load-bearing: the gateway session cookie is host-only, so each app holds its own session. Whether a second app signs the user in silently depends on appid keeping its own IdP session. If it re-prompts, that is a further argument for one app with routers. Two-minute test; state it as unknown rather than assuming.
Adding a module to an existing ERP
This is the question customers ask second, and the answer is short.
Same pool. Same bucket. One more router. That is it.
- Pool: no new pool. Add the module's permissions to the roles that need them
(
dodil appid roles set), namespaced<module>:<object>:<verb>so they cannot collide with an existing module's. - Bucket: no new bucket. Add the module's tables to the same one, next to the masters they will JOIN against. That JOIN working with no ETL is the payoff for having done this correctly.
- App: no new app by default. Add
modules/<new>.pyexposing anAPIRouter, mount it under its prefix inmain.py, extend the one canonicalmodels.py. Push. - Repo: the same repo, and one more entry in the engagement
CLAUDE.md's customization table.
Northwind already runs sales on bucket northwind. Add the finance module's tables to the SAME bucket — a gl_journals table keyed on journal_id and gl_journal_lines keyed on line_id — then show me that a finance row can JOIN a sales row with no ETL.
data_table_create→data_table_upsert→data_pg→data_table_listCreated gl_journals (merge-key journal_id) and gl_journal_lines (merge-key line_id) in bucket northwind, alongside the existing sales tables. Upserted one journal against an existing opportunity. The JOIN of gl_journal_lines to opportunities on account_id returns the invoiced amount beside the pipeline amount — one bucket, one copy of the rows, no pipeline between them. Tables in northwind are now: accounts, contacts, opportunities, quotes, gl_journals, gl_journal_lines.
# The SAME bucket the sales module already writes to.
dodil data table create gl_journals -b "$CUSTOMER" \
--columns-json '[
{"name":"journal_id","type":"string"},
{"name":"period","type":"string"},
{"name":"source","type":"string"},
{"name":"posted_at","type":"string"},
{"name":"account_id","type":"string"}
]' \
--merge-key journal_id
dodil data table create gl_journal_lines -b "$CUSTOMER" \
--columns-json '[
{"name":"line_id","type":"string"},
{"name":"journal_id","type":"string"},
{"name":"account_id","type":"string"},
{"name":"amount","type":"double"}
]' \
--merge-key line_id
# ...and the reason the whole model is one bucket: this JOIN needs no ETL.
dodil data pg -b "$CUSTOMER" \
"SELECT o.account_id, SUM(o.amount) AS pipeline, SUM(l.amount) AS invoiced
FROM opportunities o
JOIN gl_journal_lines l ON l.account_id = o.account_id
GROUP BY o.account_id"
dodil data table list -b "$CUSTOMER"Money columns are DECIMAL(18,2) in a real ledger and are written over the pg wire — the
example above is deliberately minimal. Writes need a PRIMARY KEY, and a bare re-INSERT of a
committed key is rejected: use data_table_upsert or INSERT … ON CONFLICT (<pk>) DO UPDATE.
The invariants — generate the business logic, COPY these
Every row is a scar from a real failure on this platform. Not preferences.
| Invariant | Why |
|---|---|
No auth code in the app — the gateway does PKCE, auth.py is a header-trust role gate only | auth written per customer is auth written wrong |
db.py / sa_token.py verbatim — lazy engine, filtered ON CONFLICT, no token read at import | app.openapi() must build with no credentials or CI cannot generate the client |
Natural PKs · idempotent upserts · DELETE needs a WHERE · commit before read | DataK3 semantics, not preferences |
COPY *.py in the Dockerfile — never a hand-maintained module list | a dropped module crash-loops the pod behind a healthy gateway; it looks deployed |
Numeric non-root USER 10001 | runAsNonRoot rejects a username — it fails after a successful image pull |
Secrets by reference (${{ secrets.X }}), never in git | |
owner_scope() on every owned read | row-level visibility is a security property, not a feature |
Verify with ignite invoke + pod logs | a 401 is the gateway answering, not your app |
| Four-fact explanations on actions and metrics | an unexplained AI button that writes data destroys trust |
The rule, stated plainly: if a line is about the platform, copy it. If it is about the business, generate it. When unsure, it is platform — ask.
The honest bit: where each of those came from
They read like fussy style rules until you know what they cost.
The Dockerfile that looked deployed. A hand-maintained COPY list in a Dockerfile missed one
module. The build was green, the image pushed, the pull succeeded — and the pod crash-looped on an
ImportError at startup. The gateway in front of it was perfectly healthy and kept answering, so
from outside, the app looked deployed. COPY *.py is not tidiness; it is the difference between
a loud failure and a silent one.
The 401 that was mistaken for "live". curl the app's public FQDN and you get
401 end-user login required. It is easy to read that as "the app is up and asking me to log in".
It is not — it is the gateway, and it returns exactly the same thing when the pod behind it is
dead. A deploy was reported as verified on the strength of that 401. Verification is
ignite invoke plus pod logs, always.
The five bugs that only existed together. The GL's six components each passed validation on their own bucket. Run together on one bucket, they produced five integration bugs — including an FX revaluation that returned a different gain on every run while the trial balance sat serenely at 0.00, because an aggregate was re-derived inside the same transaction that wrote the rows it summed (there is no read-your-writes inside an open transaction on DataK3). The CRM's version of the same lesson: an untyped graph walk pulled a supplier into a customer's corporate family and inflated a forecast by $18k the moment two components' edges shared one graph. Components validated separately hide integration bugs. Validate the suite on one bucket, which is how the customer will run it.
Verify
This post is a map, so what is verifiable about it is that each destination exists and each
command resolves. Checked on 2026-09-08 against the live org ihdiash and the published blog:
# 1 — DISCOVER: the surfaces are live, and the skill id is NOT the post id
curl -sL -o /dev/null -w '%{http_code}\n' https://blog.dodil.io/llms.txt # 200
curl -sL -o /dev/null -w '%{http_code}\n' https://blog.dodil.io/library/crm-core # 200
curl -sL -o /dev/null -w '%{http_code}\n' https://blog.dodil.io/skills/crm/core # 200
curl -sL -o /dev/null -w '%{http_code}\n' https://blog.dodil.io/skills/crm-core # 404 <-
# contracts live at /skills/<module>/<workflow>, two segments.
# 2 — FETCH: the tar streams, and PLATFORM.md rides along inside it
curl -sL https://blog.dodil.io/code/gl-core -o /tmp/gl-core.tar
tar tf /tmp/gl-core.tar | grep PLATFORM.md # -> gl-core-v1/PLATFORM.md
tar xf /tmp/gl-core.tar gl-core-v1/PLATFORM.md && shasum -a 256 gl-core-v1/PLATFORM.md
# -> 478c21dac361bcbda0290eddd66f3240e6d9da79b4611279f267af1f4f280d0d
# — the same sha256 as the copy in all 22 reference packages in the source tree
# (7 gl-* + 7 crm-* + crm-suite-app + 7 itsm-*). The gl-* tars carry it today; the
# crm-*/itsm-* tars pick it up at their next publish.
# 5 — PROVISION: the shapes above are the ones the platform actually accepts
dodil data bucket list # bucket names are 3+ chars
dodil auth service-account list-roles # the LIVE role catalog — there is
# no ignite.developer; the deploy role is ignite.app-developer, the bucket role is k3.editor,
# and ignite.model-user is needed only when the handler calls Models
# 7 — VERIFY: the distinction this post insists on, run on a live app (crm-suite)
dodil ignite app get crm-suite --output json
# -> active_version 8, deployment_state deployed, public_invoke false,
# public_urls ["crm-suite-ihdiash-8080.ignite.dodil.cloud"]
dodil ignite invoke crm-suite --output json
# -> {"status_code":405,"body":"{\"detail\":\"Method Not Allowed\"}","duration_ms":12290}
# invoke POSTs to "/"; FastAPI answered. THE APP IS UP.
dodil ignite app logs crm-suite --since 10m
# -> Application startup complete. / Uvicorn running on http://0.0.0.0:8080
# -> "GET /healthz HTTP/1.1" 200 OK (the probe)
# -> "POST / HTTP/1.1" 405 Method Not Allowed (the invoke above, same second)
curl -s https://crm-suite-ihdiash-8080.ignite.dodil.cloud/
# -> {"error":"end-user login required","login_url":"/.dodil/auth/login?return_to=%2F"}
# <- the GATEWAY, on a demonstrably healthy app. A crash-looping pod returns the same
# thing. That is the whole point.What is asserted from live evidence rather than re-run here: the topology (one pool / one
bucket / one app per module by default) is the shape crm-suite v8 runs in today on bucket crm;
the five GL integration bugs, the $18k CRM forecast inflation, the runAsNonRoot numeric-USER
failure and the crash-loop-behind-a-healthy-gateway were each hit during real builds on this
platform and are recorded in PLATFORM.md. The provisioning commands in step 5 are the shapes the
CLI takes; a fresh northwind customer was not stood up for this post. The one thing
deliberately not claimed: whether a second app in the same group signs a user in silently
(host-only session cookie — untested, and stated as untested above).
One-shot
Take a customer from "I want a <module>" to a running system on DODIL. The code packages are
REFERENCE implementations, not product: read them, derive THIS customer's model, GENERATE their
system. The customization is the product.
1 DISCOVER https://blog.dodil.io/llms.txt -> /library/<id> for the WHY (read it before the
code) and /skills/<module>/<workflow> for the machine-readable contract
(two segments: /skills/crm/core, NOT /skills/crm-core).
2 FETCH curl -L https://blog.dodil.io/code/<id> -o <id>.tar ; tar xf it -> <id>-v1/.
Read PLATFORM.md FIRST — byte-identical in every package; it holds the invariants
and the DataK3 semantics the code depends on. Source only, no Dockerfile.
3 DERIVE Interview the customer. THE ONLY CREATIVE STEP. Get: their objects (their nouns
win over the reference's), their stages IN ORDER and what moves a record between
them, which actions spend money / move a reported number / are irreversible (only
those get a role gate), their org chart as roles + row-level ownership, and what
this changes about their week. Write the answers into the repo's CLAUDE.md.
4 GENERATE models.py (their tables, natural PKs) + modules/*.py (one APIRouter each, plain
imports, NEVER importlib) + web/ (their branding) + templated .dodil/deploy.yaml.
COPY VERBATIM: db.py, auth.py, sa_token.py, .github/workflows/ci.yml, PLATFORM.md.
One FastAPI, ONE canonical models.py, a router per module.
5 PROVISION customer = pool + bucket + Ignite group + repo + SA. ONE pool (all modules, one
sign-in; roles = their org chart; perms namespaced <module>:<object>:<verb>).
ONE bucket (masters + every module's transactions; cross-module JOINs, no ETL;
3+ char name). SA cli-<customer>-sa with k3.editor (+ ignite.app-developer to
deploy, + ignite.model-user ONLY if they call Models — check
`auth service-account list-roles`, there is no ignite.developer). Repo
<customer>-erp; SA secret into the repo CI secret store, referenced from the
manifest, never in git.
6 DEPLOY git push -> CI builds+pushes the image -> CD applies .dodil/deploy.yaml
(user_pool: <customer>, public_invoke: false, COPY *.py, numeric USER 10001).
Budget 12-13 min cold. Debug locally against the same bucket, not through CI.
7 VERIFY dodil ignite app get <app> (read active_version — a green deploy check means the
apply ran, not that anything changed); dodil ignite invoke <app> (it always POSTs
to "/" — a status+body from YOUR framework, even a 405, proves the app is up);
dodil ignite app logs <app> --since 10m (startup line + the probe's GET /healthz
200 + your invoke). NEVER the public FQDN: a 401 is the GATEWAY and is identical
whether the pod is healthy, crash-looping or absent.
ADDING A MODULE to an existing ERP: same pool (add namespaced perms to the roles that need
them), same bucket (add its tables next to the masters they JOIN), one more router in the same
app. Not a new app, not a new bucket, not a second sign-in — unless there is a STATED reason:
a public surface vs a private engine, independent scaling, or a distinct trust boundary.
THE RULE: if a line is about the platform, COPY it. If it is about the business, GENERATE it.
When unsure, it is platform — ASK. Validate the suite on ONE bucket, together: components
validated separately hide integration bugs (GL: five, one returning a different FX gain every
run while the trial balance sat at 0.00; CRM: an untyped graph walk inflating a forecast $18k).Where to go next
- The invariants in full, with the DataK3 facts behind them:
PLATFORM.md, inside every package tar —crm-core·gl-core·itsm-core. - The deploy last mile: Ship a DODIL app.
- Identity: App authentication · App roles and permissions.
- Driving the same rows from your own stack: Connect your tools.
- Worked examples of a whole suite on one bucket: CRM · GL · ITSM.
The short version, if you carry away three things: generate the business, copy the platform; one pool, one bucket, one app per module unless you can name the reason; and a 401 is not a deploy check.