Architecture decisions
Historical design rationale and trade-offs; current contracts live in the topic guides.
These are historical decision records, including earlier consumer integrations and runtime shapes. Use the topic guides and current public source for the supported API. Historical performance claims without a maintained fixture have been removed; Benchmarks records the current measurements and their limits.
Each entry records what was chosen, why, and what it costs. Reversibility is stated because two of these cannot be undone later.
D1 — Extension points are published, not discovered#
Chosen: a module declares joints; other modules may only fill those.
Why: the domain contract composes brilliantly because any module can patch anything, and it
cannot be upgraded safely for exactly the same reason: with no public API, nothing
is safe to change. Its ecosystem is a debt taken on purpose that now cannot be repaid.
Cost: you will sometimes need a joint the author did not publish. The answer is
a PR, not a patch.
Reversible: no. Tightening later breaks every installed module. Loose-then-strict
is impossible; strict-then-loose is not.
D2 — An escape hatch exists but must be declared#
Chosen: patches[] in the manifest, surfaced by every upgrade diff.
Why: absolute prohibition gets routed around. A declared escape hatch keeps the
debt visible, which is the one thing the domain contract lacks.
D3 — KTL server expressions and browser code have separate boundaries#
Chosen: server-side KTL expressions use declared data and rendering contracts, compiled to
closures without eval or new Function. Theme assets may contain browser JavaScript.
Why: server template evaluation and browser presentation have different capabilities. KTL
scope does not expose the process environment or datastore; browser modules implement client-side
interactions using the application's existing HTTP contracts.
Cost: KTL retains a constrained expression grammar. Interactive presentation uses browser
JavaScript, islands or registered module behaviors rather than expanding that server grammar.
Reversible: no, same argument as D1.
D3a — A website theme may run JavaScript in the browser (2026-10-05)#
Chosen: D3 constrains KTL server evaluation; it does not prohibit browser JavaScript. A Website Studio theme
may additionally ship a stylesheet and one browser module, kept in the website_theme registry and
served by /_theme/{versionId}/{file} from the tenant's own storage.
Why: real sites need carousels, lightboxes and menus that a template cannot express, and D3's
server boundary keeps process capabilities outside KTL. Browser requests still pass through the
application's permissions and tenant isolation.
Consumer-specific historical policy: only private themes, reviewed before an operator installs them; install rejects a
stylesheet that reaches outside the site root; the page carries a CSP with script-src 'self'; the
module is left out of previews, out of any page served with a staff session, and off hosts that are not
a site's own domain. Uploaded third-party themes would need an isolated origin and are not allowed.
Reversible: yes. Removing the registry leaves every page complete, because a theme enhances markup
the server already rendered.
D4 — SQLite first, Postgres second, adapter shape fixed on day one#
Why: node:sqlite ships with Node and proves the whole stack end to end, so the
riskiest work does not sit on the critical path.
Reversible: yes — that is why it was safe to decide without waiting.
D4a — Keep the PostgreSQL dependency in its own adapter package#
Current boundary: @ketvietlab/ketjs-postgres owns the external SQL driver. The core framework uses Node built-ins plus @ketvietlab/ketjs-view; standalone ketjs-view has no dependencies. Importing the PostgreSQL adapter is an explicit application choice.
Why: the adapter contract isolates the driver from the rest of the framework. The dependency audit enforces public package boundaries. The original driver comparison is historical and was not rerun; current installation measurements are in Benchmarks.
D5 — Reactivity at runtime, not compile time#
Chosen: signals; the renderer diffs holes, never a tree. Why: Svelte 5 abandoned compile-time dependency analysis for the same reason. Runtime reactivity is what allows a template compiler with no JavaScript parser — which is what makes D3 affordable at all. Measured: 1 host operation to change one row of a thousand; 0 for a no-op re-render; 2 moves for a swap. These operation counts were rechecked in the current benchmark fixture.
D6 — Source is built; Node only runs artifacts#
Verified: Node's type stripping neither checks types nor transforms TSX. It is
therefore not a compilation boundary suitable for a framework.
Chosen: tsc emits workspace JavaScript into .build, package JavaScript and
declarations into dist, and rewrites relative TypeScript extensions. Every
production serve, test, benchmark and audit command builds first and executes only
.js artifacts. Development is the explicit exception: tsx watch transforms
source modules in memory while tsc --noEmit --watch checks them independently.
It writes neither .build nor dist, eliminating output races during editing. The
CLI rejects a source workspace unless the dev orchestrator sets its private source
mode. Artifact builds remain serialized and fingerprinted, so concurrent production
or verification commands reuse the same revision instead of deleting each other's
outputs.
Why: TSX lets UI components be authored structurally while the custom JSX
runtime still produces Ket's existing TemplateResult; there is no React or VDOM.
The explicit build also makes published package contents identical to what was
tested locally.
Browser islands follow the same rule. Their typed TS or TSX source is compiled by a scoped esbuild step
into ESM under the owning module's asset root; those generated .mjs files are not authoring sources.
The browser build keeps @ketvietlab/ketjs-view external and resolves it to the framework copy served at
/_ket/view/, so islands share one renderer instead of bundling private copies.
Cost: a compiler is a required development dependency and startup waits for an
initial build. It remains absent from runtime dependencies.
D7 — Destructive migrations are generated but refused#
Why: the domain contract alters schema silently at install time, which is a root cause of its upgrade failures. Here "never drop a field" stops being discipline someone has to remember and becomes something the tool enforces.
D8 — The agent's write surface is data, not code#
Chosen: composition JSON validated against a schema; server functions exposed as
tools only when marked agent: true; dry-run and idempotency keys mandatory once a
database exists.
Why: an agent that writes React cannot be trusted against production. An agent
that writes schema-checked composition data can be trusted today.
D9 — Umbrella layout#
Chosen: many apps in one codebase; apps sharing a datastore get one union schema,
computed and checked at build.
Why: depends between modules was already in_umbrella by another name. The
classic umbrella failure is the shared database becoming invisible coupling — here
two apps disagreeing about a column is a build error.
D12 — The adapter contract is asynchronous, and transactions are scoped#
What happened: the day-one contract was synchronous, because node:sqlite is.
Postgres over a socket is not, and no amount of API design makes it so. The contract
moved to async: SQLite resolves immediately and pays nothing measurable, while the
harder case becomes expressible at all.
The second correction was subtler. tx(fn) originally gave the callback no
handle, so the body would have run on whatever pooled connection came next rather
than the one that issued BEGIN — a transaction that silently is not one. It now
passes an adapter scoped to a reserved connection. SQLite could never have surfaced
this bug, which is the argument for building the second adapter earlier rather than
later.
Cost: every ctx.db call and every handler is now async, and the test suite
had to follow. Paid once, at the cheapest possible moment.
D41 — Backend screens open up, by name, and only by name#
The gap, found by being asked about it. Backend screens use ketjs-view TSX and
joints only existed in KTL, so backend published none and nothing could reach
them. A module could not add a column, a button or a menu item. For a framework
whose first pillar is lego, that was a hole rather than a style choice.
What actually hurts in the domain contract is not extension. It is two specific things, and naming them is what settled the design:
- XPath into unpromised anchors.
<xpath expr="//field[@name='partner_id']" position="after">addresses a node upstream never promised would exist or stay put. Rename it and every extension breaks. position="replace"severs the link. Upstream fixes and improvements never arrive, and nobody is told.
So the axis is not insert versus modify. It is declared anchor versus undeclared, and link kept versus link cut.
Fills are KTL, not functions — and this reversed my first proposal. A
function-valued fill would have been typed, and it would have been arbitrary
JavaScript from another module running in this process, which is the thing KTL
exists to prevent. It would also have made fills the first part of the manifest
that is not data: unprintable by ket manifest, undiffable by ket diff,
unsnapshotable. A fill is text. One extension language for the storefront and the
backend, sandboxed by construction rather than by promise, and a fill that wants
behaviour places an island the way a theme does.
What that took in the view: a value kind for markup a trusted compiler already escaped. Inserting it as a plain string double-escapes — the tags render as text — so it is its own kind, branded so it cannot be made from an arbitrary string. SSR writes it verbatim; hydration, which cannot count nodes it did not produce, runs to the closing marker instead. That worked because a hole has been fenced on both sides since the SSR text-merge fix, which was done for an unrelated reason and paid for itself here.
Omit removes rather than hides. CSS hides at the wrong layer: the data still travels and the tab order still walks through what nobody can see. An omitted joint renders nothing at all, server-side. An omission by a module that is switched off is not an omission — the joint returns, exactly as its fills would.
A fill that will never render is recorded, not silently dropped. Omitting a
joint someone else fills is not an error — both modules may be deliberate — but it
is exactly what gets discovered six months later, so it lands in manifest.patches
where ket check and the upgrade diff show it.
Screens still receive data, not a runtime. The card joint takes the app as a prop, so the route renders one per card and hands the screen a map keyed by name. Passing a function would have been shorter and would have made a screen depend on a server — and the design catalogue renders these same screens with no server at all.
A module does not depend on the admin just to add a button to it. The fill
went into product first, which made product depend on backend — so every
composition without an admin failed, and a headless API could not have a catalogue.
It belongs in a bridge that installs itself once both sides are present, which is
what install: 'auto' was built for and what the domain contract does with sales/inventory bridge. CI found
this; running only the tests I had touched did not, because I had touched
product without thinking of it as touched.
Cut, deliberately: replace and reorder. Replace is the one that severs the upstream link, and the vertical-rewrite case it serves is better answered by a different screen on a different route than by replacing slots in a generic one. Reorder needs the joint list to become ordered data, which is a bigger declaration change worth making once a real screen has slots in it. Both are additive later; neither is blocked by this.
D39 — The screens that make the enforcement usable#
D38 closed the door. This gives it a handle: a browser arriving uninvited gets a sign-in page rather than a bare 401, and lands where it was going once it signs in.
A browser gets a page, anything else gets a status. The signal is
Accept: text/html, because a redirect to an HTML form is a useless answer to a
fetch(), and a 401 with no body is a useless answer to a person. The same route
serves both without either pretending to be the other — it accepts a form-encoded
body and a JSON one, and answers in kind.
The sign-in page runs without JavaScript, and has to keep doing so. A login that breaks when a script fails breaks in the one situation where getting in matters most. There is nothing on it a form element does not already do.
next is carried through and is only ever a path on this site. Landing back
where you were going is the difference between a guard and an obstacle; accepting
//attacker.example as a destination would make the sign-in page an open redirect,
so anything not starting with a single / becomes /admin.
A cross-site POST to /login is refused. SameSite protects the session cookie
once it exists, not the request that creates it — an attacker who can make your
browser sign in as their account then watches what you do in it. Browsers send
Origin on any cross-site POST and nothing else sends one at all, so absence is
not suspicious and presence has to match.
Already signed in, the form is skipped. Showing someone a login they do not need is how they sign in twice and cannot tell which one took.
The login screen belongs to user, not to backend. It was written in the
backend module first, which meant user importing backend without declaring a
dependency on it — a module borrowing another's code and strings, and breaking
quietly the day that other one is uninstalled. It moved, and its messages moved
with it.
The topbar says who and offers the way out. The company appears only when the
account belongs to more than one, because a label that is always the same value
teaches nobody anything. New data-ui selectors are in HANDOFF.md: the markup is
here, the look is the design team's, and both the wrong-password and
several-companies states need drawing rather than only the happy one.
D38 — A stranger is not an unrestricted caller#
The hole, as it was found. With sessions on and no cookie at all:
# File: packages/docs/content/docs/architecture-decisions.md
TẠO người dùng : {"ok": true, "id": "hack"}
TẠO vai trò : {"ok": true, "id": "r"}
/admin : 200, rendering the app listAnyone reaching the port could create an account and then log in as it. The whole of D35 and D36 was in place and none of it applied.
The cause was a rule from D36, defended in its own pull request. allow: null
means unrestricted, and allowFor returned null when there was no session — under
the reasoning that migrations, internal calls, tests and the public storefront all
arrive without an identity, so restriction should begin where identity does. That
is correct about in-process calls and catastrophically wrong about an HTTP
request that simply has not logged in. Absence of identity is not absence of
constraint; it is the constraint.
Chosen: a request with no session gets the anonymous set, not the full one, and
membership is declared per function (anonymous: true) and per route. Two
functions need it and they are the two that make sense — user.authenticate,
because there is no session until it runs, and website.getPageByPath, because a
public storefront is public. Three routes: login, logout, whoami.
The terse form is the closed one. A route written as a bare factory is
protected; opening it takes an object with anonymous: true. A default of open is
a default nobody notices until it is on the internet.
The route check is central, not per screen. Every module route passes through one place that refuses a session-less request. Putting it in each screen is how it ends up missing from one.
Fixing it immediately broke the permission resolver, which is the honest kind of
consequence: the app resolves what a user may do by calling user.permitted, and
that call went through the check it was trying to answer — asking permission to
find out whether it has permission. So there is now ctx.callUnchecked, one word
to grep for, like raw. Deciding what a caller may do is the one question someone
has to be allowed to ask; nothing else has that excuse.
What this did not change: an app that has not turned sessions on is unaffected, because there is no login to be outside of — the header shim is still the identity there, and the banner still says so.
D37 — One deployment, many databases, and one door into each#
Chosen: the domain contract's model — the code ships with the deployment, the decision about
what is switched on lives in each database. ket_app per database is
ir_module_module per database, and D7 makes it cheaper here: every schema exists
everywhere, so enabling a module for a tenant is one UPDATE rather than a
migration. The former PostgreSQL table-size and DDL timings have not been remeasured and are no longer presented as current performance evidence.
What this closes is not a missing feature but a wrong answer. bootDeployment opened
one adapter and built one AppRegistry, so the restricted manifest was computed
once. Serving two tenants through that would not have crashed — it would have
shown tenant B the module set of tenant A. The registry is per datastore now and
the manifest is resolved per request.
One datastore is the degenerate case of the same interface, not a second path.
Two code paths through the thing that decides whose data a request sees is exactly
how one of them rots; every existing test exercises singleTenant, and the pooled
implementation differs only in where the adapter comes from.
The bug that proves the point. The HTTP layer was handed the raw pool, which
looked equivalent and was not: /_ket/fn leased a datastore that had never been
migrated, because migration happens the first time the tenant runtime touches
one. The first API call to a new tenant failed with "no such table" while a page
request to the same tenant worked. It now leases through the tenant runtime, so
there is one place preparation can be forgotten rather than two.
Leases are scoped, never handed out. The pool is bounded — Postgres has a hard
connection ceiling, which is where the domain contract model hurts most in practice — so
ServeContext exposes live(req) and appsOf(req) rather than an adapter or a
registry. An adapter that escapes its lease is a connection nobody gives back.
The theme is per tenant too, keyed by the installed set. It compiles every template, so it is cached — but cached against what is installed, so switching an app on rebuilds it rather than serving a stale one until restart.
A host this deployment does not serve is refused, not defaulted. A default tenant is how one customer's request quietly reads another's data.
Sessions follow the tenant, and which way depends on how the tenant is named. With subdomains the Host says which tenant before any cookie is read, so each keeps its own sessions in its own database — and that is the isolation: a session id from one tenant is not a row in another's table, even though the signature is valid, because it is the same secret. An app serving every tenant from one domain must resolve the tenant before the cookie, from a trusted gateway assertion, path, or another explicit request key. It may use one shared identity store, but every record is tenant-bound and a session never selects a datastore. Both storage forms are expressible; neither is assumed.
The cookie carries no Domain, which is what makes the subdomain case safe:
Domain=.example.com would hand acme.example.com the cookie set for
globex.example.com. It was already absent; it is now deliberate and tested.
Also still single: the stream store falls back to memory when there is no single adapter, so resumable streams are not yet durably stored per tenant. The HTTP endpoint now requires an explicit resolver that authorizes a public id and maps it to a tenant-namespaced topic; that closes cross-tenant exposure, but it does not decide which tenant database should own the durable log.
The single-tenant path: a deployment without a tenant resolver uses the same context contract. Historical request and manifest-cache timings have been removed because that fixture was not rerun. Current identity and runtime ownership are documented in Sessions and tenants; this record explains why one interface must represent both cases.
D36 — A role is a list of functions, enforced where every call already passes#
Chosen: the framework enforces an allow-list, the app decides what is on it — the same split as the datastore driver, and for the same reason. Roles are the app's model; a framework that knew their shape would be a framework every app had to agree with.
A role is a named list of function keys, additive across roles, which is
Salesforce's permission sets rather than the domain contract's model-level CRUD grants. Model-plus-CRUD
is what makes the domain contract's permissions unanswerable: granting read on product.template
grants it in the form, the list, the export, XML-RPC and every search() any
module makes. Here the unit is the action, so the role is the list of actions,
and ket permissions --role kho prints exactly what it reaches — including which
fields, since D33.
The check runs before input validation. A caller who may not call this at all should learn that and nothing else: validating first answers with the signature, which is a map of the surface handed to someone who may not touch it.
Absent means unrestricted, and that reads backwards until you see the alternative. Migrations, internal calls, tests and the public storefront all arrive with no identity. Narrowing them by one would break every path that has no user. The restriction begins where identity does — and an empty list is a real restriction, distinct from a missing one.
Resolved per request, not cached in the session. A cached list is a revoked role that keeps working until someone logs out, and "why can they still do that" is a worse conversation than one extra query. Tested: unassigning a role takes effect on the next call. Cache it when a measurement says to.
A superuser column rather than a magic id. Something has to be exempt or a deployment that turns roles on can never grant the first one — the functions that manage roles are themselves behind the check. the domain contract solves this with user id 2 and a group that everyone learns about by being told. A declared boolean is the same escape hatch with its name written on it, and it shows up in a query.
A grant for a function nobody ships is refused rather than stored. It would sit in the table looking like access, and become access again the day the name comes back.
ket permissions --role is the only part that reads the database, because what
a role grants is a fact about a deployment rather than about the code. It looks for
user_role and user_grant by convention and says so plainly when they are not
there — the framework ships no role model, so it cannot import one.
D35 — Sessions, with the store an interface because pods are the point#
The seam was built for this. resolveScope has been one function since D27
precisely so that replacing headers with a login would be one change, and D32
settled the shape a session has to produce: a set of companies to read and one to
write. sessions.scopeOf(record) returns exactly that, so nothing downstream
learned a new concept.
With sessions on, the header shim is gone — not kept as a fallback. A system
where X-Ket-Company can stand in for a login is a system with no login. An app
that has not turned sessions on keeps the shim and keeps the banner apologising for
it; the banner now names which of the two is in force.
The store is an interface, and that is the whole reason it exists as one.
Sessions in memory behind three pods means a login lands on one and the next
request is anonymous on another — a bug that only appears once you scale, which is
the worst moment to find it. dbSessionStore solves it with no extra
infrastructure, because the database is already shared; verified on live Postgres
with two runtimes over one store. Redis would be faster and belongs in its own
package the way the Postgres driver does — the framework cannot depend on a client
without spending an exception it has already spent.
The secret is the part that fails quietly, so it is said out loud. A cookie
signed by one pod and rejected by another is a login that works until the load
balancer sends you elsewhere. Absent KET_SECRET, one is generated and the banner
prints two lines saying sessions will not survive a restart and will not work
across pods. Tested by signing on one instance and reading on another with a
different secret.
Signing at all, given the id is 32 random bytes: it makes a forged cookie cheap to reject — no store lookup at all, asserted by counting reads.
Refresh is capped. A session extends while in use, never past
createdAt + absoluteTtl. A session that renews forever is a session that never
ends. Expiry is enforced on read as well as by the sweep, because a record that
reads back after its expiry has outlived it for however long nothing swept — and
the UPDATE that refreshes carries its own expiry guard, so a session that lapsed
between the read and the write is not quietly revived.
Both stores take an injectable clock. A store that reaches for Date.now
cannot be tested for expiry without sleeping, and a test that sleeps is a test that
flakes.
Login is a route, not a server function. Ctx is data and nothing else — no
request, no response, no cookie — and that boundary is what keeps handlers testable
and HTTP out of the data layer. So user.authenticate decides whether the password
is right and what the account may see, and the route decides what to do about it.
It also means /login arrived through the module-route mechanism from D30 rather
than needing anything new.
One answer for every failure. A wrong password, an unknown login and an account with no company are three different reasons and one 401: telling them apart is how someone learns which of the three they hit.
RouteResult gained headers, and a withHeaders helper rather than a spread —
spreading would produce a plain object that only looks like a RouteResult, which is
the hole the brand exists to close.
D34 — Parties, legal entities and users, with the three things the domain contract folds together kept apart#
Parties are shared, and addresses are their own model. res.partner is one
table for customer, supplier, contact, delivery address, invoice address and legal
entity, and the cost is visible in the model itself: is_company to ask whether
this is a legal entity, type to ask whether it is an address, and a computed
computed commercial-party pointer to answer the only question invoicing cares about — who do
we bill.
That third field is the tell. It exists because addresses are parties: when a
delivery address is itself a partner, the system has lost the ability to say who
the counterparty is, so it walks up the parent chain to recover it. SAP, Tryton and
ERPNext all keep addresses separate. Doing the same removes computed commercial-party pointer
and type outright — the party on a document is the party.
Roles are rows, as in SAP's BUT100. A supplier who is also a customer is one party with two rows. the domain contract uses customer and supplier role counters counters, so a new role is a new column on a table that already has about 120.
property side table becomes an ordinary company-scoped model. The party is shared, its
payment terms are not: the same customer may be on 30 days with one company and
prepayment with another. the domain contract keeps that in a side table keyed by
(field, company, record) — EAV, invisible to SQL, untyped, and the reason "it is
blank in company B" is a recurring ticket. partner.CompanyTerms is SAP's KNB1: a
real table with real columns, scoped by machinery that already existed.
The scope guard caught the first draft of it. A Partner.terms hasMany was
refused at compose with E_RELATION_WIDENS_SCOPE — preloading it from a shared row
would hand back every company's terms at once. SAP reads KNB1 by (customer, company
code) for the same reason: the segment is reached from the scoped side, never from
the shared one. The relation is one-directional now because the framework insisted.
A user has a party rather than being one. the domain contract is alone in making the user a
delegated subclass (_inherits), which puts every user in the address book and
leaks through archiving, deletion and sudo. Salesforce keeps User separate and
links a Contact only for external users; SAP keeps SU01 separate from the business
partner. The link is optional here for the same reason — an operator account is not
someone you invoice.
Memberships are rows too, so granting a company is an insert and revoking it a
delete: traceable, and not a read-modify-write two requests can race on. What
authenticate returns is exactly the shape D32 defined — a set to read and one to
write — so the session that replaces the headers has nothing new to invent.
Passwords need no dependency. node:crypto ships scrypt, which is memory-hard;
the encoded value carries its own parameters, because a hash that cannot say how it
was made cannot be moved on without reading every row. needsRehash reports when a
stored one is behind. Node's default 32MB cap rejects N=2^15, so the limit is passed
rather than assumed — a detail that costs an afternoon if it is met at deploy time.
Nothing hands back the hash, and that is structural rather than careful. Every
function here declares its output, so the projection from D33 picks the named
fields — a handler that returned the whole row would still hand back only what was
declared. createUser and authenticate are deliberately not agent tools: an agent
that can mint logins can mint itself one.
Found while building: db.del had never worked. website_menu.removeMenuItem
built its query with from() rather than deleteFrom(), so it rendered as a SELECT
— and the effect check saw read, refusing a function that had correctly declared
write. It threw on every call, and no test had ever called it. db.del now
refuses anything that is not a delete, naming the fix, so the trap is closed rather
than the instance repaired.
D33 — output becomes a projection, enforced when declared#
Chosen: enforce where it is declared; leave an undeclared output meaning "hands
everything back", and let ket permissions count what is still open. Making it
mandatory is a migration across every function at once, and a gap that is visible
beats a migration nobody finishes. The count is the progress bar: 16 of 17
unprojected when the report landed, 5 after this change.
What it was before: a comment. output was composed into the manifest and read
by nothing, so a function could declare three fields and return eight. Measured on
a handler that preloads a relation to show a product's name:
# File: packages/docs/content/docs/architecture-decisions.md
output đã khai : {"id","qty","productName"}
thực tế trả về : { companyId, id, productId, qty,
product: { id, name, cost: 12000, price: 30000 } }The cost of goods left the building, and so did the scope column, which is machinery and nobody's business.
Two properties, deliberately told apart. Nothing undeclared escapes is picking, so it holds for every value and for an empty result — it cannot depend on the data, which is the mistake the preload check made and #10 fixed. Everything declared is present can only be checked where a row exists; an empty result proves nothing about it. That is a bug in the handler rather than a hole in the boundary, and it is reported as one, with the fix in the hint.
? means the same as it does everywhere else. Enforcing the declaration
immediately caught the only function that had one: uom.convert declared
{ qty: 'float' } and actually answers {ok:true, qty} or {ok:false, errors}.
A flat record cannot describe a union without optionality, and that union is the
convention every write function here uses. The declaration was simply wrong, and
nothing said so until something read it.
It is one level deep, and that is a limit rather than an oversight. Naming a nested object hands it over whole; a narrower slice is what view models are, and they already exist. Asserted in a test so the limit is a fact rather than a surprise.
The report now answers the field-level question, which was the half that was missing from D31:
# File: packages/docs/content/docs/architecture-decisions.md
product.listTemplates reads
returns active, categoryId, description, id, name, …
website_menu.listMenu reads
returns WHOLE ROWS — output undeclaredD32 — Reads span a set of companies, writes go to exactly one#
Chosen: the domain contract's split, deliberately. scope.companies is what a read may see;
scope.company is what a new row is stamped with. The split is right because the
two questions genuinely differ: a report may span three legal entities, but an
invoice belongs to one.
Absent, the set is just the company being written to. Widening what a request
can see should take saying so — the default that looks convenient is the one that
leaks. Which is also why nothing existing had to change: 23 call sites already
passed { company, branches } and kept working, with the narrow meaning.
The guard that earns its place: writing to a company you cannot read back is
silent corruption. The row lands, every later query filters it out, and nothing
anywhere says why. So scope.company must be in scope.companies, checked before
the first query runs, with the error naming both sides.
A readable set and nothing to write to is its own error, listing the companies it does have — because the fix is to pick one, and the message may as well say which ones are available.
The convenience path had to part company with its column = ? map. db.select
builds equality pairs, which cannot express a set, so it now appends an explicit
IN with its own placeholders. Two ways to read must not disagree about who you
are; both are tested, on SQLite and on live Postgres, where placeholder rendering
differs.
crossCompany is unchanged and still means all of them. It remains the
declared, descriptor-visible exception rather than the ambient one.
Over HTTP: X-Ket-Company names the write target, X-Ket-Companies widens the
read, and the active company is folded into the set without the caller having to
repeat it. Still headers, still not authentication — but the shape a session will
produce is now the shape that exists.
D31 — Permissions are reported before they are enforced#
Chosen: ket permissions first, the role model second. Deciding how roles
should be shaped is much easier while looking at what the functions already imply,
and the report costs a fraction of what guessing wrong would.
The question that prompted it: a user needs orders but must not have products.
In most systems answering that takes reading every module, because permission is
granted on a table and a table is used everywhere — grant read on
product.template in the domain contract and you have granted it in the order form, the list
view, the export, XML-RPC, and every search() any module makes.
Here the unit is the action, so the answer is arithmetic rather than
investigation. A function cannot touch a model it did not declare — not through
a relation (D10, and the hole that let an undeclared preload through on an empty
table is closed), and not by calling another function, because Ctx has no way to
call one. So the reach of a set of functions is the union of their effects. There
is nothing to traverse, which is why the report is thirty lines and exact.
# File: packages/docs/content/docs/architecture-decisions.md
ket permissions --grant product.listTemplates
models reachable:
product.Product read via product.listTemplates
product.Template read via product.listTemplatesEvery model names the function that granted it, because a surprise has to be traceable to its cause.
What the report immediately showed, which is the real value: of KetSuite's 17 functions, 16 return an undeclared shape. Model-level reach is exact; field-level reach is not stated at all. So the honest answer to "may they see the product name but not its cost" is currently no, they see the whole row.
FnSpec.output already exists and is already composed into the manifest — and is
read by nothing. Meanwhile the mechanism that would enforce it exists and runs:
view models (views) build a null-prototype frozen projection of exactly the
declared fields, which is the boundary that keeps a third-party theme from touching
anything else. The two have simply never been connected.
Not enforced in this change, deliberately. Making output mandatory is a
migration across every function; making it enforced-when-present is a decision
about defaults that is better made with the count in front of you. The report
flags every unprojected function so the field-level gap is visible rather than
remembered — which is the same move as the banner saying auto-install is off.
Cost: granting by function means more functions than the domain contract has rules — two audiences needing two slices of one model is two functions, not one function with a flag. In exchange the reach is computable, which the domain contract cannot do at all.
D30 — A module contributes to the served surface, not just to the database#
The hole, stated as a measurement. With backend uninstalled, /admin still
answered 200 and its stylesheet was still linked. "Enable at run" reached the
database and stopped there. The cause was that the app assembled the surface by
hand: it reached into packages/ketsuite/src/modules/backend/design/ by filesystem
path, named two stylesheets, and declared four routes belonging to that module —
so it went on serving all of it after the module was switched off, and it also
walked straight past the rule the dependency audit enforces for imports.
Chosen: a module declares assets, styles and routes, compose aggregates
them, and restrictManifest drops them with the rest of the module's behaviour.
Paths are data, handlers are factories. routes is one factory per path, not
one factory returning many, because compose has to settle ownership — two modules
claiming a path is a build error naming both — while a handler needs the running
server, which does not exist at compose time. Dispatch then checks the live
manifest per request, so uninstalling stops a route answering without a restart,
and reinstalling brings it back the same way. /_ket/ is refused to modules: it is
where health, the agent descriptor, streams and assets live.
Stylesheets come out in dependency order, which is the point of composing them
rather than gathering them: a module that extends another loads after it and can
override it. ctx.styles() returns every installed module's, so no module names
even its own file, let alone another's.
Assets are namespaced /_ket/asset/<module>/, so two modules may both ship
tokens.css, and an uninstalled one can be refused by name before any file system
call happens.
A theme may ship assets and styles — that is most of what a theme is — but not routes. A route is code running on the server, which is the line themes exist on the far side of.
The static handler kept its own file reading. An asset body cannot go through
the Html constructors from D29: a PNG is not markup and a string-typed body would
corrupt it. So ServeOpts.assets became a list of mounts, each either a fixed
directory or a resolver answering per request — the resolver is what lets a
module's files disappear when the module does. Traversal is refused in both encoded
and unencoded forms, tested.
Performance evidence: the historical PostgreSQL schema-size and DDL measurements are not a current benchmark. A fresh isolated PostgreSQL run is still needed. The architectural benefit is that tenant module activation changes application state without a migration or data loss when it is disabled.
D29 — HTML is a value, templates are files, and removal has a boundary too#
The premise was wrong in an interesting way. "We cannot write HTML as strings"
sounded like KetJS had no template engine. It has two: TSX through ketjs-view for first-party
screens, KTL for third-party themes — and that split is right, because the two
audiences differ in exactly one way that matters, whether their code may run. What
was actually string concatenation was narrower and worse placed: the document
shell, the thing every page passes through.
A route's body was string, so escaped and hand-built were the same type.
Nothing could tell them apart — not the compiler, not a reviewer reading a diff —
and the shell was concatenation because concatenation was allowed. RouteResult is
branded now with a type-only symbol, so an object literal is not assignable and the
only ways to make one are page, fragment, json, text and raw. raw is the
single hatch, and it is one word to grep for. The type-level claim is asserted in
tools/type-proof.ts, because a rule nothing checks is a rule that decays.
Two bugs fell out of building it, both found by probing rather than by tests:
localeOfsplitAccept-Languageand handed the result to Intl. Node's own fetch sendsAccept-Language: *by default, so any client that did not set the header got a 500. Every earlier check used curl, which does not send it. The locale is now resolved against the catalogues the deployment actually ships, which fixes the crash and closes the wider hole at once: the value reaches thelangattribute of every page, and one drawn from a fixed set carries nothing.apps/**was never in tsconfig. The application entry point had never been type-checked. It is now, and it needed fixing when it was.
Templates are .ktl files. The file name is the template name, so an error can
name a place without a second map. Themes gained comments ({# … #}) at the same
time — a language a theme author cannot annotate is one they will annotate in the
markup — and KTL errors now carry the line.
{% render %}, with no inheritance. Shopify made the same call and the reason
holds here twice: a partial that sees only what it was passed is a partial you can
read on its own, and a theme is a stranger's code, so one that silently saw the page
scope would leak whatever the page was carrying. The callee's scope is a
null-prototype object holding the passed arguments and nothing else. A depth cap
turns a self-rendering template into an error naming the template and the line
rather than a stack overflow.
removable: false, which the install policy had no answer for. D28 drew the
boundary on the way in; there was none on the way out, and the backend — the screen
you would use to put something back — could be uninstalled. A deployment that lets
you remove that lets you remove your way out of ever fixing it. Default is true:
refusing removal is the exception and has to be argued for.
Still open, and now visible: a module cannot contribute assets, stylesheets or
routes. The app reaches into packages/ketsuite/src/modules/backend/design/ by
filesystem path and names two stylesheets by hand, which is why uninstalling a
module leaves its routes mounted and its CSS linked. That is the next change, and
it is the same defect class as this one: the app assembling by hand what the
manifest should carry.
D28 — The framework boots the app, and a module says whether it may arrive#
Chosen: ket serve and ket dev, with the boot sequence as a function
(bootDeployment) rather than a script. What moved into the framework is everything that
was app-agnostic and would have been copied by the second app: opening a datastore,
migrating, registering functions, installing a bootstrap set, deciding who the
request is, mounting the theme, mounting /_ket/health and /_ket/agent, printing
a banner, shutting down on a signal. KetSuite's entry went from 162 lines to 4.
What stayed with the app is what only the app knows, and it arrives as data on
defineDeployment: which modules ship, which function turns a path into a page, which
extra routes it serves, where its assets are. The page resolver is a name
(website.getPageByPath), not a closure — so the framework never learns which
module provides pages, and a missing resolver is caught at boot rather than at the
first request that happens to hit it.
The framework cannot open Postgres, and that is the fence working. ketjs ships
SQLite, which it owns; the Postgres adapter is a separate package that depends on
ketjs, so a framework that reached for it would be a cycle. An app that wants it
hands serve.openStore in. The dependency audit is what keeps this true rather
than a comment — it failed this change twice and was right both times, once for
importing the driver and once for scaffold templates whose string literals
contained from '@ketvietlab/ketjs'. The templates are files now, which makes "this is data,
not code" a shape instead of an exception.
install replaced autoInstall, and gained the case that was missing.
A boolean could say "come along by yourself" but not "never arrive by yourself":
'manual'(default) — installed only when someone asks for it by name'auto'— installs itself once everything it depends on is installed'never'— refuses direct install entirely; it arrives only by being depended on, which is how machinery stays out of reach without also being hidden
That is the boundary the module author draws. Whether 'auto' actually fires is
a separate decision belonging to the deployment: KET_AUTO_INSTALL=0, or
ket dev --no-auto-install, holds it back — which is what a developer wants when
they are changing one module and an app that installs itself is a surprise rather
than a service. Held back is not forbidden: installing by name still works. The
banner says when the switch is off, because a module that declared 'auto' and did
not arrive should explain itself rather than look broken.
autoInstall: true still parses and still means 'auto'; defineModule
normalises it away, so the manifest has one spelling.
ket new writes an app that runs unedited — verified by booting the output and
calling its route, not by reading it. It refuses to overwrite: a scaffold that can
eat work is not a scaffold.
Cut: hot module replacement. The project compiler watches source and emits
JavaScript; ket dev watches only that emitted graph and restarts it. Source is
never handed to Node as an executable input.
D27 — One command that starts the thing#
There was a design entry point and a CLI that could check and migrate, but nothing
that ran KetSuite. npm start now opens a database, migrates to the manifest,
installs a starter set of apps if none are installed, and serves — storefront,
backend and API on one port.
Configuration is a value, read once. A misspelt variable is then a visible
default rather than an undefined that surfaces three layers down as something
else. DATABASE_URL is what switches SQLite for Postgres; the adapter contract
being fixed on day one is why nothing else changes.
The storefront goes through callFn like everything else. A path becomes a
page through website.getPageByPath, so the company filter and the app-installed
check apply to a public page exactly as they do to an API call. The front of the
site is not a second door with different rules — verified by two companies serving
different pages on the same path.
resolveScope joins resolveDatastore and resolveLocale. Building this
found that the HTTP layer had no way to say which company a request was: every
company-scoped function would have failed over HTTP. Resolution now happens in one
place for the same reason as the others — a handler that had to remember would
eventually forget, and forgetting means answering with another company's rows.
The authentication gap is stated in the banner the server prints, not buried in a document. Until there is a session, the company comes from a header, which is fine for development and not for production.
D26 — Units of measure: the domain contract's model, and the rounding it depends on#
Chosen: the domain contract's shape, deliberately. A category groups units that convert between
one another; exactly one is the reference; every other records factor — how many
of itself make one reference unit — and rounding, the precision it is meaningful
to. Conversion runs through the reference, both ways.
Crossing a category is refused, not approximated. There is no number of kilograms in a litre, and a framework that guessed one would be worse than one that stopped.
Quantities are floats, as in the domain contract, and that has teeth. 0.1 + 0.2 is not 0.3, and
a figure that drifts by 1e-16 per movement eventually compares unequal to zero. The
defence is that every value crossing a boundary is rounded to its unit's precision,
and that comparisons go through compareQty and isZero — never ===.
Writing the tests found two bugs in the rounding, both of the quiet kind:
Math.roundsends .5 toward positive infinity, so-0.5becomes-0. A quantity half a unit below a threshold compared equal to it while half a unit above compared greater — an asymmetry in one direction only, which is exactly the sort that hides for months in a stock ledger. Now spelt out as half-away-from-zero.- Multiplying back by the precision reintroduced the error: three times 0.1 is
0.30000000000000004, so
roundToreturned a value it would not itself consider rounded. A test now asserts every result is stable under a second rounding.
Product depends on uom, as in the domain contract: a template counts in a unit, optional so a service needs none and so existing rows survive the module arriving.
decimal is a separate type from float, with an exact storage and query contract.
the domain contract splits these and the split is right: a quantity or a price is stored as exact
decimal and computed as a binary float, with the rounding helpers standing between.
The first version here copied the arithmetic and missed the storage — quantities
went into DOUBLE PRECISION, where 0.1 comes back as 0.1000000000000000055 and
every trip through the database puts back the error the rounding just took out.
- Postgres:
NUMERIC; KetJS applies the same 4096-character public value budget as SQLite. - SQLite:
TEXT. SQLite has no exact decimal at all —NUMERICaffinity silently becomesREAL— so text is the only storage that returns what it was given. - Both adapters hand ordinary selected values back as exact strings. Arithmetic stays an explicit caller choice, as in the domain contract.
- SQLite predicates and ordering compare normalized decimal parts exactly rather
than casting through
REAL. Grouping andcountDistinctuse canonical numeric equivalence;sum,min, andmaxuse string/BigIntaggregates. An unrounded decimalavgfails explicitly on both adapters because a rational result may not have a finite decimal spelling. Callers can supply a finite scale and the explicit half-away-from-zero rule for portable SQLite/PostgreSQL execution, or keep another rounding policy in the domain by requestingsumandcount. - Computed decimal keys and aggregates are canonicalized after either adapter, while ordinary selected fields retain their stored spelling. Every order explicitly follows PostgreSQL's null convention: ascending last, descending first.
Tested both ways: awkward values round-trip unchanged through SQLite and through a
live Postgres, and the raw column is confirmed to hold "0.1" rather than a binary
approximation of it.
Cut: purchase units, and the reference-changing migration the domain contract needs when a category's reference moves. Both wait for a real case.
D25 — Product: template and variant, with the stock concern left to stock#
Naming follows the domain contract — product.Template and product.Product, where Product is
the variant — so the migration map stays one to one. The name reads oddly and a
comment says so; a comment costs less than a translation table.
Where it deliberately does not follow the domain contract: product.template.type there takes
three values, one of which — product, meaning storable — is a stock concept
living in a module that must not know stock exists. Uninstall stock and the value
means nothing while still sitting in the data.
Here product says only goods or service, and stock extends the template with
tracked. the domain contract's three states still map one to one — service · goods+untracked ·
goods+tracked — and a template keeps its meaning when stock is switched off. A test
asserts both halves.
Attribute values reach a variant through an explicit join model, not a hidden many-to-many. The framework has no magic for it, and a join you can see is one you can query, scope and migrate.
Master data is shared, per the decision that products, partners and price lists are tenant-wide: no company column exists on these tables at all, and a second company sees the same catalogue.
Archived, never deleted — the same reason a field is never dropped. Rows elsewhere point at this one.
Cut on purpose: packaging, dynamic variant generation, attribute exclusions, and units of measure. UoM in particular is deferred until its rounding behaviour has been discussed on its own; getting a carton-to-piece conversion wrong is the kind of bug that quietly skews stock for months.
D24 — Relations, with no lazy side at all#
Deferred since D11, opened now because product cannot be modelled without it: a template has variants, a variant belongs to a template.
Chosen: belongsTo and hasMany, declared by a module that depends on both
sides, checked at compose against the models and the key they travel on. A typo is a
build error rather than a query that quietly returns nothing.
There is no lazy loading, and that is the point. Nothing populates itself when
touched; a caller asks with preload() or does not get the rows. The N+1 that makes
ORMs slow in ways nobody can see is not expressible here. A preload costs two
queries — the parents, then the children by id — and a test asserts exactly that
count rather than trusting the shape of the code.
A relation is not a way around the company boundary. Preloaded children go through the same scoped path as any other read, so a child row belonging to another company does not arrive through its parent. There is a test that deliberately re-parents a row across companies to prove it.
And a relation may not widen scope. A hasMany from a shared model to a
company-scoped one would expose every company's rows through a row that belongs to
none of them, so the composer refuses it — E_RELATION_WIDENS_SCOPE. Only the
narrowing direction is allowed. This surfaced immediately: the obvious
catalog.Product.orders was rejected the first time it was written.
Reading the far side needs its own declared effect. A preload is a read, and it is checked like one.
Generated types mark relations optional, because they are optional in fact — present only if the query asked for them. The type says so instead of pretending every row arrives complete.
D23 — Company is a row-level boundary; branch is a dimension#
The situation being replaced: the domain contract has no branch concept, so a business with
several branches of one legal entity models each branch as a res.company. That
forces a chart of accounts per branch, turns internal transfers into inter-company
invoices that then have to be eliminated, and fragments master data. It is not the
right model — it is the model the domain contract leaves you with.
Chosen: two dimensions with different semantics.
company |
branch |
|
|---|---|---|
| is | a legal entity | an operational unit inside one |
| filter | mandatory, a module cannot widen it | default, widenable within the company |
| crossing it | needs crossCompany: true, visible in manifest, diff and agent descriptor |
ordinary; aggregating branches needs no permission |
Every model must declare its scope — shared, company or company+branch.
Omitting it is a build error, because the safe-looking default is the one that leaks.
The scope columns are added by the composer, never by the module: a module that spelt
them itself could spell them differently and the filter would silently stop matching.
Extending them is refused for the same reason.
Why this had to come before any vertical: isolation used to be a database boundary, where a miss fails loudly. It is now a WHERE clause, where a miss quietly returns another legal entity's rows. Every query written before the filter existed would have been unscoped, and nothing would have caught it. This is a D1-class decision — tightening later is not possible.
Writes are stamped from the request. A module cannot set companyId itself;
attempting it is E_SCOPE_FIELD_WRITTEN. Otherwise a write could be aimed at another
company.
A request with no company cannot touch company data at all — it fails rather than falling back to everything, which is the failure mode that would have gone unnoticed.
ctx.tx() arrives with it. Stock reservation is unsafe without a transaction
spanning several writes, and the transaction hands the body a ctx bound to the
transaction's connection — carrying the same company, on the same session that issued
BEGIN.
Shared master data, by decision: products, partners and price lists are tenant-wide; warehouses and branches are scoped.
D22 — Translations belong to the module that owns the strings#
Chosen: a module declares messages per locale; the composer prefixes every key
with the module name and merges them. Same rule as models, joints and sections — a
module declares what it contributes and nothing reaches in from outside.
Prefixing is what stops the collision that rots a flat global catalogue: two modules may both own a "title" without either knowing the other exists.
Bound to _, after gettext. It appears often enough in markup that a longer
name would cost more than it explains. In KTL it is a filter, not a function in
scope — {{ 'website.page.title' | _ }} — because server template scope holds data only.
This scope contract is separate from the JavaScript a theme may ship for the browser.
Missing translations never break a build. They fall back to the default locale,
then to the key itself, which is visible and greppable rather than blank.
missingMessages() reports the gaps for whoever is filling them in. A build that
fails because one Danish string is absent is a build nobody translates into Danish.
Plural rules come from Intl.PluralRules, not from a hand-written table.
Vietnamese has one form, English has two, and neither is our business to encode.
The pseudo-locale earns its place. qps returns every string longer and
bracketed, so a layout tuned to short Vietnamese shows its seams before a real
translation exists. It found two things immediately: app titles, summaries and
categories were not translatable at all, and label() was asking has() — which is
about this locale — when it needed resolves(), which is about whether a
translation exists anywhere. Using the wrong one made the pseudo-locale silently stop
expanding, which is the one thing it is for.
Module metadata translates by convention, not by new syntax. title, summary
and category stay plain strings so a module reads without a catalogue; a module
that wants them translated adds app.title and friends to its own messages. No
module has to change, and the pseudo-locale shows which ones have not been done.
D21 — Apps install at build, switch on at run#
KetSuite needs what the domain contract has: a list of apps, installed or not, with install and remove. the domain contract gets it by letting each database hold a different set of modules — the exact thing D16 refused, because it is why a fleet upgrade there is N unknown migrations instead of one known one.
The split: a deployment decides at BUILD time which modules exist, and every database it serves has the same schema. A database decides at RUN time which of them are ON. Installing changes behaviour — which functions answer, which sections may be placed, which fills appear — and never the shape of the database. Verified: the table list is byte-identical before and after installing and removing an app.
Uninstalling deletes nothing. The columns stay, the rows stay, re-installing finds the data where it was. the domain contract drops columns on uninstall and people lose data to a misclick. Refusing that is the same rule as D7, applied one level up.
The cost, stated: a database carries columns for apps it does not use. Nullable columns are nearly free, and this is precisely the price that buys a fleet where the upgrade diff runs once.
What "installed" gates: restrictManifest filters behaviour — functions,
sections, islands, joints, fills, regions — and never models, because rows outlive an
install. A call into a switched-off app answers E_APP_NOT_INSTALLED naming the app,
not "no such function", which would send the reader hunting for a typo.
One thing this surfaced: a theme is written against what the deployment ships, not against what one database has on. So the strict check — a template naming an island nobody provides — belongs to the full manifest, where it is a build error, while a restricted manifest renders nothing instead. A page saved while an app was installed still names its sections afterwards; those sections are skipped and come back with their data when the app returns. Uninstalling must not take the theme down.
D20 — Sections: placement by data, not by code#
Chosen: a page's body is an ordered list of section placements, each with settings validated against a schema the providing module declared. A joint is placed by code; a section is placed by data.
This is where pillar 3 stops being a slogan. An agent composing a page writes JSON that is checked against the sections that actually exist, before anything is stored, and gets back a list of what is wrong and where — a bad section type, a missing required setting, a setting of the wrong shape. A list, not an exception, because a list is what an agent can act on.
And where pillar 4 pays off: the theme owns how a section looks, the data owns which sections exist and in what order, and neither writes the other's half. The same declaration serves both — no second schema to drift.
A module is one file per concern. index.ts assembles; models, joints,
sections, views, functions and tokens each live in their own file. The alternative
is the giant central model file that every mature the domain contract module turns into.
Writing a real module found a real gap: parentId: 'ref:MenuItem' declared
required is a contradiction — the first row can never satisfy it — and nothing
caught it until SQLite refused the insert. The composer now rejects a required
self-reference at build time. This is the argument for writing a vertical rather
than more framework.
D19 — Monorepo, so the fences become shapes#
Chosen: four packages — @ketvietlab/ketjs-view, @ketvietlab/ketjs, @ketvietlab/ketjs-postgres, @ketvietlab/ketsuite —
in one repository under npm workspaces.
Each boundary earns its place:
@ketvietlab/ketjs-viewis browser-safe and depends on nothing, so a client that never touches the server half can install it alone.@ketvietlab/ketjs-postgresexists because of D4a. The driver is no longer "allowed in one file" — it lives in the one package that declares it, and every other package is structurally unable to reach it.@ketvietlab/ketsuitemay only import@ketvietlab/ketjs's public entry. If the suite needs something deeper, so does every third-party module, and it should be exported rather than smuggled. This is the rule that keeps the framework honest, and it is now checked rather than promised.
One cycle had to be cut first: view/island.ts imported KetError from the
kernel, while the theme layer imported the view. A single line, and the only thing
that would have made the two packages mutually dependent. The view layer now carries
its own errors, which is better layering regardless of packaging.
Verified by trying to break it: a suite file importing @ketvietlab/ketjs/src/kernel/... is
rejected; a core file importing postgres is rejected.
D18 — Module-owned islands and theme browser assets#
Current contract: modules register islands and disposable browser behaviors. A theme places
an island with {% island "name" %} and may also ship browser JavaScript through its assets.
defineTheme refuses islands and behaviors declarations; that registration boundary does not
prohibit JavaScript assets or client-side theme interactions.
Separate execution paths: KTL evaluates server templates; JavaScript enhances browser markup. The earlier blanket claim that a theme never writes behavior is superseded. See Browser JavaScript in themes for the current authoring boundary.
The joint was already the seam. Nothing new was needed to connect the two halves: a theme names a place, a module fills it. Placing an island nobody provides is a build error, exactly like filling a joint nobody publishes.
Hydration and enhancement: islands hydrate their own rendered regions. Other server-rendered markup may be progressively enhanced by theme browser assets or module-owned browser behaviors.
Two client contracts, not one overloaded component. An island owns one local
server-rendered tree. Its controller may mount({ root, lifetime }) only after
hydration and must release browser resources when that lifetime ends. Delegated
shell actions and form interception span server-owned DOM, so modules declare
them as browser behaviors with an optional selector instead of hiding them in an
empty island. Behaviors receive the public navigation service directly; a shared
mutable global is not an application contract.
A fragment and its runtime must be one build. Responses carry X-Ket-Build.
The browser refuses to combine a fragment from another build with its current
controller code and performs a full navigation. This is part of correctness, not
only cache invalidation: a new server prop shape cannot safely update an old
client factory.
An API note worth keeping: hydrateIslands first took the mount function as a
parameter, and its first caller passed the one that BUILDS instead of the one that
ADOPTS — so an island quietly rendered a second copy of itself beside the server's.
The parameter is gone. An API that makes the wrong choice expressible will
eventually have it chosen.
D17 — A framework table appears only when something uses it#
Asked why the framework owns tables at all, the honest audit found that
createKetServer created ket_stream at boot — so an app that never streamed still
found a stream table in its database. Framework tables now arrive with the runtime
that owns them: ket_stream on the first stream, ket_idem on the first
idempotency key, and ket_job when a queue producer or worker first prepares the
database. Queue preparation happens before user transactions so rolling back a
first enqueue cannot also roll back its system schema.
The same audit found two gaps in idempotency, both the kind that only bite during an incident:
- A caller that died between claiming a key and finishing it blocked that key
forever. A claim older than
staleMs(default 5 minutes) is now treated as abandoned and taken over. The trade is stated rather than hidden: too short and a slow call runs twice, too long and a stuck key blocks retries. - Records never expired, so the table grew without bound — the quiet way a
correctness feature turns into an operational problem.
sweep()drops records past the window in which a client could still retry.
D15 — SSR marks only what it cannot describe#
Chosen: the server walks the same parsed template the client does and emits one comment marker per hole — nothing else. Everything but a hole has a length the template already knows, so the hydration walk can count nodes instead of reading markers for them.
Static output is a separate contract. renderToStaticString() walks the same
escaped template structure but omits markers outside explicit island hosts. It
keeps them below either <ket-island> or <div data-ket-island> because only those
subtrees are later adopted. This is decided from declared hydration boundaries,
not guessed from whether a current interpolation happens to contain a signal.
Hydration adopts, it does not rebuild: verified in a real browser, hydrating twenty server-rendered rows creates zero nodes and keeps the same node objects, and the first update afterwards also creates zero.
A mismatch throws. A hydration that half-works is worse than one that fails,
because the failure is then silent and permanent. E_HYDRATION_MISMATCH names the
node it expected and the one it found, so the caller can fall back to a clean client
render.
D16 — One manifest, many databases; never many manifests#
Chosen: a database per tenant, all migrating to the same target schema, each recording the schema it is actually on.
Why not the domain contract's version: the domain contract lets every database install a different module set, so there is no single schema to reason about and a fleet upgrade is N unknown migrations. That is the root of the upgrade failures, not a detail of them. Giving up per-tenant module sets buys a fleet where the upgrade diff runs once.
The pool is the part that bites in production: a database per tenant multiplies connections by tenant against a cluster with a hard ceiling. So the pool caps how many databases stay open, evicts least-recently-used, and refuses to evict one a request still holds — it fails loudly instead of quietly serving the wrong tenant.
Resolution happens in exactly one place. ctx was already the only thing that
touches data, so a request cannot reach the wrong tenant by forgetting to thread
something through. Verified: two tenants, same product id, no bleed, and an
unresolvable request gets E_UNKNOWN_TENANT rather than a default.
One failure does not stop the fleet. migrateFleet reports which databases
moved and which did not; a half-migrated fleet you cannot see is worse than one you
can.
D14 — One log was the wrong abstraction; split by heat, not by shape#
Retracted: the claim that folding streams, jobs, the outbox and idempotency into a single append-only table was somewhere fullstack "gives some back". They were grouped by shape — all append-only — while differing completely in how hot they run and how long they live. The hottest rows in the system shared a table and an index with the coldest.
The original bottleneck: per-chunk database writes and frequent polling made the stream log compete with colder work. The earlier synthetic rates are not current measurements and have been removed.
What changed:
- A stream has exactly one producer, so the sequence belongs to the writer. It is recovered once at open and never read again.
- Chunks are batched (50 ms / 32 chunks). "Resumable" means no gap and no duplicate, not one transaction per token.
- Readers on the same instance are woken by the writer; the poll fell from 10 ms to 250 ms and exists only as the cross-instance fallback.
- Finished streams are swept after a grace period. Previously nothing expired.
- Three tables instead of one:
ket_stream,ket_job,ket_idem. - Postgres claims jobs with
FOR UPDATE SKIP LOCKED; twenty concurrent workers, twenty distinct jobs, verified against a live server.
Result: sequence ownership, batching and notifications reduced the required database work. No current throughput or reduction ratio is claimed here; a maintained stream fixture is needed to measure it again.
What did not change: streams still have to be durable somewhere. An in-memory buffer loses the stream on restart, which is the exact failure the feature exists to prevent, and a reader reconnecting to a second instance would find nothing. The answer was never "take it out of the database" — it was that the database must not be the default for the hottest rows, and that when it is used the writes must be batched, notified and expired.
D13 — Idempotency records live in the log, not in memory#
A process-local Map loses every record on restart and is invisible to a second
instance, which makes the guarantee false exactly when it matters. Records now sit
in ket_log under idem:<fn>:<key> at seq 0, claimed with
INSERT ... ON CONFLICT DO NOTHING so the primary key settles the race rather than a
check-then-insert. A key that is claimed but unfinished returns
E_IDEMPOTENCY_IN_FLIGHT instead of quietly running the work twice.
D11 — SQL layer: Ecto's architecture, Drizzle's surface, no ORM#
Chosen: hand-written, not a dependency. A query is an immutable value, built
by a chainable typed builder and rendered per dialect. Casting and validation live in
changesets, separate from persistence. ctx is the Repo — the only thing that
touches the database.
Why a value and not a SQL string: this is the decisive point, and it is forced by
pillars 3 and 5 rather than being a matter of taste. A query you can inspect can be
checked against a function's declared effects before it runs, handed to an agent
as data, and rendered for SQLite and Postgres from one shape. A tagged SQL literal
gives up all three.
// File: packages/ketjs/src/data/query.ts
const q = from(P).where(eq(P.active, true)).orderBy(desc(P.priceCents)).limit(20)
q.touches // ['catalog.Product'] -> effect check happens here
q.toSQL('postgres') // { text, params } -- values always parameterisedWhy changesets rather than reaching for a validation library:
- Casting rules come from the manifest, so field types are declared once, not twice.
- Errors are structured data; an agent cannot act on a thrown string.
changesis a real diff against the existing row — exactly what dry-run reports.cast()is an explicit allow-list, so mass assignment is not possible by default.
Deliberately absent — this is what "no ORM" means here: identity map, lazy loading, dirty tracking on objects, Active Record, automatic association loading, unit of work. Rows are plain objects. Nothing loads itself, so N+1 cannot happen by accident.
Deferred: relations. ref:module.Model exists in the type system and is unused.
The hard part is specific to Ket: module B must be able to relate A's model to B's
own, because that is the lego pillar, and Ecto never faces this. Deferred on purpose
until the query shape has run against real data.
D10 — Name#
Ket, from kết — to join. npm @ketvietlab/ketjs is free; ket is held by a dead 2022
package. Flagged: ket is UK slang for ketamine and bra-ket notation in physics.
D42 — Menus are declared in the module, and hidden by permission#
Where a menu lives. the domain contract keeps menus as rows: ir.ui.menu, created by XML data
files, edited in the database, surviving the code that put them there. That is why
an upgrade can leave an entry pointing at an action nobody ships any more, and why
"who put this here" is answered by an XML id rather than by a file.
Here a menu is menus on the module, beside models, functions and joints —
data in the manifest, so it arrives and leaves with the code that serves it, and
ket check can refuse a broken one at build time rather than at click time.
// File: packages/ketsuite/src/modules/product_backend/menus.ts
menus: {
product: { label: 'menu.app', icon: '📦', sequence: 20 },
'product.templates': { parent: 'product', label: 'menu.templates',
path: '/admin/product/templates', needs: 'product.listTemplates' },
}Ids are global, like joint keys. Two modules claiming one is E_MENU_DUPLICATE
naming both, not one of them quietly winning. Hanging an entry under somebody else's
needs the same declared dependency filling their joint would
(E_MENU_NOT_DEPENDED) — otherwise a module could rearrange a sidebar it never
admitted to knowing about.
Three filters, in this order: what the deployment shipped, what this database has
switched on (restrictManifest drops the entries of an uninstalled module), and what
this request may call. The last is needs, naming a function key. An entry the
viewer cannot use does not appear — the 401 arrives instead of the click, not
after it. A heading left with no children goes too: an empty section reads as
"broken", not as "not for you".
needs on a module that is not in the build is a soft dependency, not an error.
If the named module is installed and the function is missing, somebody mistyped and
hiding it would hide the mistake — E_MENU_UNKNOWN_FUNCTION. If the module is
absent, the entry is gated on something this deployment simply does not ship, and it
is dropped silently. That is what let backend gate its Pages entry on
website.listPages without depending on website.
Cost and reversibility. Menus can no longer be reordered by an administrator at
runtime, which the domain contract allows; sequence is the only lever and it is in code. Cheap to
reverse — a database table read after the manifest would layer on top without
changing any of this. Deferred until somebody actually asks.
What this replaced. product_backend used to add its sidebar link by filling
backend:nav.items with a KTL string that re-implemented the active check. The
joint stays for genuinely arbitrary additions; a menu entry is no longer one.
D43 — The URL is the list's state#
Which page, which search, which view — all in the query string. No store, no hook, no component that has to be told what page it is on. The consequences are the point: the back button works, a bookmark works, and a link somebody pastes into chat opens the same list they were looking at. None of that needed code.
Every applied control in the chrome is a link or a method="get" form. A custom
filter editor may use buttons and temporary draft state, but Apply must navigate to
a canonical URL before the table changes. The table never owns a second client-side
copy of search, filter, grouping, sorting, paging, or open-group state.
Filter and Group By tokens name only fields declared by a per-list allowlist. Same-group preset filters are OR-ed, different groups are AND-ed, and a custom filter is a bounded nested AND/OR tree. Group headers and their counts come from database grouping, not from loading a page and grouping it in memory. Open paths and group pages are URL values, so an expanded grouped list remains reloadable and shareable.
What that cost, and where it bit. A GET form replaces the whole query string, so
searching while looking at the cards threw you back to the list. The fix is
keep — the rest of the URL as hidden fields. Found by clicking, not by a test;
the test came after.
Paging is on the function, not on a generic list endpoint. listTemplates grew
limit, offset and search, and countTemplates appeared beside it — the domain contract's
search_read / search_count pair, for the same reason: a page needs a total it
cannot compute from the page. Both are built from one shared query expression,
because a count that filters differently from the list it counts is the bug you find
on page four, not on page one.
Controls that have nothing to say are not rendered. No pager when everything fits on one page. No view switcher when there is one view. No search box unless the list can search. A toolbar full of dead controls teaches people to stop reading it.
But an exhausted arrow stays, disabled. That is the exception, and it is deliberate: a pager that changes width as you page is a pager you cannot aim at.
Deferred: the "Mới" button. ListChrome carries create and the catalogue shows
it, but no live screen sets it, because no create screen exists yet. A button that
404s is worse than no button.
D44 — Columns are data, and one bar holds the chrome#
A module says what its columns ARE. Key, label, how to read one out of a row,
whether it is on by default. It does not write a <table>. Three things follow at
once: every list in the product has the same row height, the same sticky header and
the same overflow behaviour; a column can be turned off without touching markup; and
a module extending another module's list has something to name.
// File: packages/ketsuite/src/modules/product_backend/screens.tsx
export const templateColumns = (_: Translator): Array<Column<TemplateRow>> => [
{ key: 'name', label: _('…col.name'), cell: (r) => r.name },
{ key: 'variants', label: _('…col.variants'), cell: (r) => String(r.variants), align: 'end' },
{ key: 'id', label: _('backend.table.id'), cell: (r) => <code>{r.id}</code>, optional: true },
]An optional column that is off is absent from the HTML. Not hidden by CSS — hiding it would still ship the data to the browser, and a column a viewer cannot see but can read in view-source is a column that leaked. Which ones are on lives in the URL, like everything else about a list (D43), so a colleague can be sent the list with the id column showing.
The column menu is links, not checkboxes. A checkbox needs a handler to mean
anything, and a handler is client state. Each entry is the same list with one column
more, or one fewer. Toggling one keeps the page you are on — showing another column
is not a new filter, so withParam grew a resetPage argument to say so.
One bar, and no breadcrumb in it. The chrome was a breadcrumb row under the topbar at first, the way a lot of admin UIs do it. Two bars cost 3rem of every screen to say what fits in one, and the title above and the breadcrumb below were the same sentence twice — so they became one bar: title, then the search in the middle, then the pager and view switcher.
Then the breadcrumb went entirely. The sidebar already says which app you are in and which entry is open, in two places you are looking at anyway; a trail across the top repeats both, and earns its line back only on screens nested deeper than this product goes. Both corrections came from looking at the thing, not from planning it.
Who you are moved to the foot of the sidebar, with the counters for what is
waiting. In the topbar it competed with the title and the search for the one line
that changes on every screen; at the foot of the sidebar it sits with the one thing
that never changes. The counters are Indicator data plus a sidebar.foot joint —
the shell does not know what an activity is, so a module with a queue of anything
says so and it appears. A count of zero renders the icon with no number: a badge that
says nothing is a badge people stop reading.
Three labels came out of the sidebar. The active app was named at the top, again as the highlighted row in the app list, and a third time above its menu; "ỨNG DỤNG" labelled a column of application icons. Each cost a line of the one column that has to hold forty menu entries, and the highlighting had already said it.
A list runs to both edges of the pane. The border, the radius and the drop
shadow were drawing a card around something that is not a card, and the reading
measure that suits prose is wrong for a table: the eye follows a row to a column that
is not there, and the space it was centred in was the space the row needed. Screens
that are read rather than scanned — the app grid, the settings list — keep the
measure, which is what :has() selects on.
Tone, not colour. badge(label, 'positive') rather than data-published="true".
A design team that wants "draft" amber changes one rule and every draft in the product
follows. data-value carries the raw state as well, so a stylesheet can still be more
specific where it must be.
Initials, not photographs. Nothing stores an avatar image yet, and a broken image in every row is worse than none. When images arrive this stays as the fallback, which it would have had to be anyway. Vietnamese puts the given name last, so that is the letter that comes first.
D45 — The shell is ported from vidoo_backend_theme, icons included#
Why port rather than design. KétViệt already runs an the domain contract backend with a theme
the team built and uses daily — vidoo_backend_theme, 228px sidebar, no desktop
application bar, systray at the foot. A framework that ships a different good admin
gives the company two products to learn. So the sidebar here is that sidebar: the
same widths, the same 13px/500 rows, the same --kv-* palette the tokens were
already derived from, the same brand row, menu search, app list, section tree with a
rule down its left and a dot on each leaf, and the same footer.
Icons are vendored, not installed. Twelve Lucide glyphs (ISC) as strings in
icons.ts, which is what the domain contract theme does too. An icon set is data — a few
hundred bytes of path each — and taking a package for it would put a dependency, a
build step and a supply chain between this repo and twelve strings. audit:zero-dep
is not a slogan to work around; it is the reason a Ket app is one node away from
running.
MenuDef.icon therefore became a name, not a glyph. Every entry may name one: the
declaring module owns the semantic choice and the theme owns the drawing. An app
name this build does not carry falls back to a monogram; a nested entry falls back
to the existing dot. A module naming an icon we never vendored loses its icon, not
its row.
A glyph with no size is the size of its container, which the search icon
demonstrated at about 300px tall. [data-ui="icon"] now defaults to 1em, and the
fixed boxes opt into filling.
Two searches, and they are different things. The one in the sidebar narrows the menu; the one in the control panel narrows the list. Both are GET forms with their state in the URL (D43), so a filtered sidebar is also a link. A filter that matches nothing says so — the label and silence under it read as broken rather than as "nothing here".
D46 — Dynamic routes belong to the engine, not to website modules#
A parameter is one whole path segment. /products/{slug} is a route;
/products/product-{slug} is not. Parameters use identifier names, may not repeat
inside one pattern, are URL-decoded once by the engine, and arrive as the third
argument of the route handler. Existing handlers that only need the URL and request
remain valid.
Specificity, not registration order, chooses the route. The pattern with more
static segments wins, so /products/new beats /products/{slug}. Two patterns that
can match the same pathname with the same specificity are a composition error. An
application must not change behaviour because two modules happened to be listed in
a different order.
The rest of the route contract still applies. A dynamic route belongs to its
module, disappears when that module is disabled, and is closed to strangers unless
it declares anonymous: true. Framework paths under /_ket/ remain reserved.
Declared routes run before the theme page resolver, exactly as static routes already
did; the resolver is the fallback for paths no route owns. Website modules therefore
declare the public URL they own rather than teaching their own handlers to parse a
path the engine claimed not to understand.
D47 — Durable jobs live with tenant data; notification is only a bell#
Chosen: PostgreSQL or SQLite owns every job state. Redis is not required.
LISTEN/NOTIFY carries only the queue name and only shortens wake-up latency;
adaptive polling, due-time checks and expired-lease rescue are the correctness
path. PostgreSQL sends pg_notify on the enqueue transaction's reserved
connection, so rollback leaves neither business data, job nor notification.
One app, two process roles. ket serve and ket worker both start with
bootRuntime, compose the same DeploymentSpec and register the same emitted module
artifact. Production runs them as separate processes; ket dev --all runs both
loops under the existing single source watcher. There is no second build watcher
and no execution of source TypeScript in production.
Delivery is at least once. A claim is a short transaction, the handler runs
outside it, and completion is another short transaction. A process can therefore
die after a business write and before completion; every job must explicitly state
idempotent: true. Leases, heartbeat and exponential full-jitter retry recover the
other crash positions. A handler receives an AbortSignal, but Node cannot
forcibly stop a Promise that ignores it. Heartbeat continues while that handler is
alive to avoid manufacturing an overlapping retry; if it eventually returns after
abort, the worker records a structured handler_ignored_abort warning.
Scheduling is itself an effect. A function or another job may enqueue only a
qualified job named by an enqueue:module.job effect. Composition checks that the
target exists and that the producer depends on its module; runtime checks the same
effect before a durable row is inserted. Otherwise asynchronous work would be a
way to bypass the model effects enforced on the request.
Queue uniqueness coalesces active delivery, not business history. A
(job, unique_key) constraint covers available, scheduled, executing and retryable
rows. Terminal rows release the key immediately, so whether the prune command ran
cannot decide whether new work executes. A handler that must apply a business
operation only once still enforces that invariant in business data.
Expired leases are rescued in bounded batches. Queue DDL uses explicit PostgreSQL timestamp types, and legacy table migration is serialized by a transaction-scoped PostgreSQL advisory lock so replicas may start concurrently without racing a rename.
Tenant fairness is scheduler state, not central queue state. Jobs stay in each tenant database so enqueue and business writes share one transaction. The worker refreshes the tenant list, claims one bounded batch per turn and rotates the first tenant. It does not reserve one PostgreSQL listener connection per tenant. If a future fleet needs sub-100ms wake-up across thousands of databases, that is a separate wake-up plane; durable ownership remains with the tenant database.
D48 — Blob bytes are outside SQL; their authority is not#
Metadata and bytes have different jobs. storage.Attachment is a
company-scoped row in each tenant database: ownership, target record, media type,
size, checksum and visibility remain queryable and transactional. The byte stream
lives behind one Storage contract on local disk or an S3-compatible service.
Putting large opaque bodies in SQL would make ordinary backups, replication and
table scans pay for data they cannot inspect.
Tenant isolation is applied before a key reaches an adapter. HTTP and worker roles open the same configured storage and receive a namespace derived from the resolved tenant key. A module never supplies that namespace, just as it never supplies a database connection. Inside it, stored attachments are content addressed by company and SHA-256; duplicate metadata rows share bytes safely.
The contract streams. put and get use async byte iterables. Local writes go
to a unique temporary file, sync, validate their declared length and rename into
place. S3 requests use Signature V4 with no SDK dependency; live MinIO tests cover
PUT, HEAD, GET, ListObjectsV2, presigned GET and DELETE. Multipart parsing is
bounded by total, part and header limits and keeps boundary fragments across
network chunks rather than buffering the upload in memory.
A filename and media type are data, not trust. Responses add nosniff and
force unknown or active content to application/octet-stream plus attachment
disposition. Only a small inline-safe set may use a short-lived S3 redirect. Public
download is a separately declared anonymous function that still passes through
company scope and the attachment's public predicate.
Deletion is asynchronous and conservative. Removing metadata does not delete
a content-addressed blob another row may share. storage.sweep runs on the durable
maintenance queue, lists only the captured company prefix and removes only
unreferenced objects older than a grace period. Its blob reads/removals are declared
effects, so adding storage to a job does not become a new way around the operation
boundary.
A public bucket is optional, not a change to the private bucket's ACL. The
existing backend remains the default and stores canonical originals. A second
Storage.public backend uses explicit configuration and its own credentials; both
backends retain the same tenant namespace and effect boundary. For eligible public
attachments, HTTP upload commits a storage.publish job with the authorized row.
The worker copies bytes to an attachment-specific, checksum-qualified key and
records publicStoreKey only after rechecking visibility and company. A private
attachment sharing the canonical checksum never inherits that public key. Direct
function callers explicitly request publishCopy; single-backend callers remain
compatible without a publication worker. Enabling split storage requires an
additive schema migration; per-company sweeps backfill old public attachments.
Public delivery is deliberately not revocable authorization. Only the existing inline media allowlist can be projected, with no format conversion or byte-level content validation implied. Downloads use the public backend only after resolving a public attachment; pending publications fall back to the original path. A configured public URL is unsigned and bypasses the app after issuance. Metadata deletion therefore relies on asynchronous object cleanup plus the operator's CDN retention/invalidation policy, not instant revocation. Turning off the second backend restores original-source delivery but does not erase old public objects. Assets requiring revocation must remain private. Bucket provisioning, access policies, and CDN response headers remain deployment responsibilities.
D49 — Product and stock follow the domain contract where the subset is real#
Names and codes are compatibility boundaries. UoM is a relative tree with one root, product variants have a stable combination key, pricelist applicability keeps the domain contract's selection codes, and stock operations keep their warehouse and procurement codes. Unsupported accounting, purchasing, selling and valuation behaviour returns an explicit error; it is not approximated behind a familiar name.
A reservation has one source of truth. Demand belongs to stock.Move, reserved
detail belongs to stock.MoveLine, and stock.Quant.reservedQuantity is only a
query-friendly mirror. Updating that mirror uses compare-and-set, so two workers can
compete for the same quant without both winning. Inventory counts create completed
moves instead of rewriting a quant directly, preserving the trail that explains the
balance.
Warehouse is a boundary, not a label. Locations, quants, picking types and warehouse routes determine which physical stock a flow may see. Forecast and reservation operate on a requested location, while replenishment selects product, category, then warehouse routes. Stock in one warehouse therefore cannot silently satisfy another warehouse's move.
Product media is metadata over storage, not a second blob system. Product
screens retain the named template and variant media joints and the unavailable,
loading, ready and error states. The installed product_media bridge adds only
company-scoped ordering, alt text and primary-image metadata; bytes, checksums,
delivery and garbage collection stay with storage.Attachment and the Storage
contract from D48. Upload, primary selection, reordering and removal use native
forms, and the neutral UI component receives URLs and action endpoints instead of
depending on a schema or object-store convention. Product and stock still own no
blob column, resize pipeline, CDN rule or file-processing implementation.
D50 — Module paths discover packages; they do not decide the deployment#
Chosen: a workspace may declare several filesystem roots whose direct children
contain ket.module.json. An app selects a module by its declared name; resolution
loads that module and its dependency closure into the ordinary object-only
DeploymentSpec before composition. Imported KetModule objects remain valid in the same
list, so existing workspaces need no migration.
Why: the domain contract's module search paths makes private and vendor modules operationally easy to place, but scanning a root and installing everything found are different decisions. Ket keeps them different. A file appearing on disk makes a module discoverable, not shipped; the workspace remains the reviewable statement of what the deployment contains, and the database still only switches that build-time set on or off.
The fences: roots are canonicalized, duplicate names across roots are errors rather than order-dependent overrides, descriptor and executable identities must match, and an entry may not escape its module directory. Discovery reads every small descriptor but executes only selected modules. Production accepts emitted JavaScript artifacts, preserving D6; TypeScript entries are admitted only through the explicit development loader.
Where it lives: resolution is asynchronous and belongs between loading the
workspace and calling composeWorkspace. Composition, migrations, HTTP and workers
continue to know only KetModule[]. Module location therefore cannot become a
second registration mechanism or leak into business runtime code.
D51 — Hospitality is two business modules, and language is not business data#
The fourteen vidoo_hospitality* the domain contract addons are a packaging history, not fourteen
bounded contexts. KetSuite consolidates property, content, rooms, reservations,
inventory restrictions, housekeeping, services and Vietnamese lodging operations
under hospitality_core; provider-neutral channel work and provider adapters live
under hospitality_ota. The public names carry no vidoo_ prefix. Accounting and
legal e-invoicing remain outside both modules until their own contracts exist.
Operational codes are stable data. available, out_of_order, hotel,
non_refundable and their peers are stored and exchanged; labels are resolved by
the module catalogue. Every visible key and validation code ships in Vietnamese
and English from its first PR. Business names and authored descriptions remain the
user's data and are not copied into message catalogues.
A property is company-scoped and is itself the operational accommodation boundary.
Buildings, floors, room types and rooms repeat propertyId deliberately so every
write can prove the full structure belongs to one property before committing. The
database indexes enforce company and property uniqueness; APIs return translated
message keys for validation failures rather than embedding one locale in business
logic.
Hospitality screens are owned by the same module as their functions because the
agreed deployment has only two hospitality modules. They still compose the shared
KetSuite UI kit and write no private markup. Media remains storage.Attachment
metadata plus the storage backend; hospitality does not invent another binary
table or object-key convention.
D52 — Reservation intent, physical stay and operational folio are separate records#
A reservation is the commercial promise, a stay is the physical visit, and a
folio is the operational account for room and service charges. They are created in
one transaction but have independent state machines: cancelling before arrival
does not delete audit rows, checking in assigns a physical room, and checkout
closes the stay and folio. The Charge table is deliberately not an invoice or
accounting entry; accounting will consume this boundary in a later stack.
Room assignment history is append-only. Check-in claims a room with compare-and-set, moving closes the current assignment and appends another, and checkout closes the last assignment while marking the room dirty. A PostgreSQL benchmark opens two connections against the same room and requires exactly one winner. SQLite keeps the same transition contract for development but is only a single-writer target.
The tape chart reads stays and assignment history rather than becoming a second availability source. Confirmed stays without a physical room have dedicated rows, so overlapping unassigned bookings remain legible instead of painting over each other. The browser acceptance path runs every hospitality route with seeded data in Vietnamese and English, plus the calendar at a narrow viewport; this is part of the feature definition, not a release-only visual pass.
Housekeeping tasks are durable operational records, not ephemeral UI cards. Checkout creates exactly one urgent task in the same transaction that marks the room dirty; manual daily cleaning, inspection and maintenance use the same state machine. Starting, completing or cancelling a task uses compare-and-set and recalculates room state in the same transaction. A company-scoped detail read preloads only its property, room and stay, and archived rooms remain visible in history but cannot receive new work from the UI. The room-status board reads sellable inventory and physical-room state as separate concepts. Occupied and cleaning states remain lifecycle-owned; the housekeeping workflow also owns the transition back to sellable. A supervisor may take an active, unoccupied ready/dirty room out of service, or release a repaired room back to dirty. Out-of-service transitions require a reason, reject open housekeeping work, and compare against the status the operator viewed so concurrent changes cannot be silently overwritten.
D53 — Collaboration keeps one polymorphic boundary and external I/O behind jobs#
A date is not a datetime with the clock hidden. Activity deadlines and all-day
event boundaries use the date scalar and canonical YYYY-MM-DD values. SQLite
stores it as text and PostgreSQL as DATE; changesets, function/job inputs, layout
contracts, generated declarations and agent JSON schemas all retain that meaning.
Impossible and normalized dates such as 2026-02-30 are refused at every input
boundary rather than left for a database or timezone conversion to reinterpret.
Record access stays with the record owner. mail.Thread is the sole
resModel/resId boundary. Mail has no public generic function that accepts an
arbitrary model and id. Product, Stock and later business modules publish typed
joints; a bridge depending on both sides verifies the target row under its normal
company scope, then calls Mail operations while declaring both effect sets. The
small bridge cost is intentional: a reusable generic Chatter endpoint would be a
cross-domain record-rule bypass in a permission system whose unit is the function.
The first message document is plain text. Chatter and inbound bodies are stored as text and escaped by the rendering layer. the domain contract HTML is not copied into a trusted backend surface before a sanitizer or restricted document format exists. Internal notes exclude external followers; an external mention is rejected unless the UI passes a confirmation that represents an explicit user decision.
A delivery request is business data; ket_job is not. Message, recipient
Notification and the later rendered Delivery snapshot commit with the enqueue.
The queue row owns attempts, leases and scheduling and may be pruned. A worker gets
an OutboundTransport from the deployment through serve.openTransport; the job
must declare transport:send before the provider sees anything. Provider secrets
therefore remain deployment secrets rather than plaintext company rows or module
globals.
Every send carries a stable idempotency key. The in-memory provider double proves retry and provider-side deduplication, while the contract records whether a receipt was deduplicated. This yields exactly-once external acceptance only for providers that honor the key. Raw SMTP can reuse a stable RFC Message-ID but cannot close the crash-after-acceptance window, so no UI or runbook may claim that it can.
D54 — Inbound email enters through signed, concrete routes#
Anonymous does not mean unauthenticated. Provider callbacks use a dedicated
KET_WEBHOOK_SECRET, not the session cookie key. The HMAC covers timestamp, path
and exact body bytes; a five-minute window rejects replay and binding the path stops
a valid reply callback from being replayed against an alias bridge. Only the route
may call the closed receive function without a session. The generic function HTTP
surface stays unavailable to strangers.
Dedupe precedes business work. A company-scoped (provider, providerEventId)
identity owns the receive attempt. The message, attachment metadata and event state
commit together. Repeating a callback returns the existing outcome, while provider
References resolve only through a recorded outbound provider message id. A supplied
but invalid reply token is terminal and never falls back to a guessed Reference.
HTML is input, never a document. A conservative converter removes active blocks
and tags and stores only plain text in Chatter. Inbound files pass the same upload
limit, content-addressed company key and storage.Attachment contract as browser
uploads. A blob written before a failed database transaction is an unreferenced
object and is collected by the storage sweep.
Aliases are bridges, not model names. Core Mail records alias configuration but
does not dynamically open a table or call a string-named model. The first concrete
stock.receipt bridge depends on Stock and Mail, validates a configured picking
type, creates one draft receipt, then posts to its ordinary Chatter thread. Unknown
or uninstalled bridges remain bounded diagnostics. A maintenance job prunes failed
diagnostics and expired token digests; processed provider identities remain compact
dedupe tombstones.
D56 — OAuth belongs to KetSuite; provider policy belongs to the deployment#
KetJS remains an application-neutral framework. Its signed sessions, actor,
function allow-list, transaction and compare-and-set primitives are enough for an
application to build identity, but the framework does not own issuer discovery,
external subjects or account provisioning. The open-source oauth and
oauth_backend modules live in KetSuite because they map verified identities into
user.User, company/branch context and KetSuite Role/Grant rows.
The protocol path is generic OpenID Connect Authorization Code with PKCE. Provider configuration contains issuer, client id, exact callback, scopes, client auth method and signature algorithm allow-list; there is no ZITADEL branch in protocol code. ZITADEL organization binding, KétViệt tenant policy and provisioning credentials remain deployment adapters. Keycloak, Auth0, Okta, Entra or another conforming issuer use the same module.
An external identity is (provider, issuer, subject), never email. State and nonce
are digest-only, the short-lived verifier is server-side, and claiming a callback
is single-use CAS before code exchange. ID tokens must pass signature, key,
algorithm, issuer, audience, authorized-party, nonce and time validation. Provider
claims cannot grant function permissions; they resolve one local User, after which
the existing live session and Role/Grant rules remain authoritative.
D57 — Address catalogs are versioned, lazy reference data#
KetSuite owns one address module, not one source module per country and not a
country registry inside KetJS. Bundled data is organized by ISO 3166-1 alpha-2 and
catalog version. The server reads no administrative dataset at boot: a small index
is opened on first discovery and complete chunks only on explicit installation.
Every manifest, policy and chunk is checksum-verified before a transaction can move
the country's single active-catalog pointer.
Country and Division are shared reference rows; a Partner address remains the business-owned row. Company refers to the Partner that represents the legal entity instead of copying address columns. Canonical addresses store the terminal Division reference and derive the complete parent path. A policy declares required levels, allowed kinds, postal-code validation and formatting, so a new country is data plus policy rather than new application code.
Catalog rows are immutable after verification. New writes must resolve against the
active catalog, while business modules that freeze a document must retain an address
snapshot with catalog id, named division path and formatted lines. DivisionTransition records
explicit splits, merges and replacements when a future source provides them; the
system never guesses a replacement by string similarity. Catalog installation is
an internal function owned by a trusted administration route and uses unique
indexes, insertIfAbsent and CAS so concurrent pods converge.
D58 — Hospitality services are intentions plus immutable occurrences#
A service intention (ExtraLine) attaches one sellable Product variant to exactly
one reservation or physical stay. It snapshots the description, unit, quantity,
price and recurrence used by hotel operations. once, per_night and per_unit
are explicit policies: a night must fall inside the property-timezone occupancy
range, while each quantity-based post carries a caller request key. Every policy
produces a stable Charge source key, so a retry or two concurrent PostgreSQL
connections converge on one operational occurrence.
Posting a service and advancing the open Folio total share one transaction. Once any occurrence exists, the intention cannot be repriced or retargeted; corrections will be represented by later operational adjustments rather than rewriting audit history. The Charge remains an operational folio record, not an accounting move or invoice line. Those systems may consume the stable Product, UoM, ExtraLine and Charge references later without changing the hotel source of truth.
Property fees are separate provider-visible content. Their create/update writes a ContentChange in the same transaction so each private OTA connection can rebuild its current payload independently. Storage is not involved: services and fees are structured database records, while Storage continues to own only binary media.
D59 — Hospitality closes a business date on its own worker#
A night audit is an operational close for one Property and one local calendar
date, not an accounting period close. The web process only validates and enqueues
hospitality_core.nightAudit on the maintenance queue. Posting per-night service
occurrences and recurring weekly/monthly room rent happens on the hospitality
worker, so a failing hotel job cannot hold an HTTP request open. Deployment owns
the daily trigger; KetSuite does not add a distributed cron scheduler.
NightAuditRun is unique by company, property and business date and records the
attempt count plus the cumulative occurrences attached to that run. Each Charge still
has its own stable source key, so a crash after one folio write, two workers racing
on PostgreSQL or an operator rerunning a completed date converges without duplicate
money. A run may therefore catch up several missed rent periods and can be retried
after late service intentions are added.
Long-stay schedules use fixed seven- and thirty-day periods to preserve the source
system's weekly/monthly contract. Check-in can post the first period immediately,
or leave it for night audit through Property.longStayBillOnCheckIn. Calendar
dates are normalized at the adapter boundary because SQLite returns date text while
PostgreSQL may return Date objects. Charges and folio totals remain operational
records; invoices, tax documents and accounting moves consume them in a later
integration rather than being created by night audit.
D60 — Vietnam stay notices are an evidence workflow, not a simulated connector#
The first Vietnam compliance slice follows the current Ministry of Public Security Circular 116/2026/TT-BCA and its stay-notice procedure. For a normal arrival, the property must notify by 23:00 on the arrival date; an arrival from 23:00 onward is due by 08:00 the following date. Deadlines are computed in the Property timezone. The durable preparation job runs after check-in and again when a checked-in stay gains a guest or updated document, with enqueue committed in the same transaction as the triggering business write.
Readiness covers the current statutory content: guest name and birth date, identity or passport number, an explicitly chosen reason, stay period and property address. A single notice is refused when the stay exceeds the current 30-day limit. Supported evidence channels mirror Circular 116: registered telephone, email or website, National Public Service Portal, VNeID, or stay-notice software.
The public procedure exposes human-facing online channels but no stable machine API
contract that this module can safely claim to implement. KetSuite therefore never
marks a record submitted merely because a worker ran. A signed-in operator records
the actual official channel and a required verifiable evidence reference; a later
confirmation retains its actor and timestamp. Adding a real connector requires a
separate reviewed contract and encrypted deployment credentials. No portal account
or password is stored by hospitality_core.
StayNotice is an operational checklist, not a copy of the declaration. It stores
the guest name already needed by hotel operations, document type, only the final
four document digits, readiness issues, actors, timestamps, evidence reference and
a SHA-256 comparison hash. The full statutory package is reconstructed from live
Property, Stay, StayGuest and GuestDocument rows only in memory while submission is
recorded; neither the package nor the full document number is copied into the notice
row, queue payload or default logs. Foreign-guest temporary-residence integration
and accounting/e-invoice behavior remain later private slices.
D61 — Property settings preserve canonical location and publish one content change#
The Property workspace edits the accommodation identity, operating timezone,
check-in/out clock, long-stay behavior, guest policy and public copy. Timezones must
be valid IANA names and a default cancellation policy must resolve inside the same
company scope. A successful create or update writes one ContentChange in the same
transaction so private channel connections independently rebuild current content.
Canonical address fields are intentionally read-only in this first workspace slice. Saving operational settings copies their existing structured country/division and coordinate values back through the public function; it never replaces them with a free-text approximation. A dedicated address-picker slice may edit those references later. Property charges remain provider content, and invoices or accounting entries remain outside this boundary.
D62 — Room types are sellable products, while physical rooms remain operational assets#
The Room Type workspace owns the public accommodation product: stable code and names, guest limits, room size and view, bathroom mode, fallback rate, identity colour, cancellation policy, publication state and marketing description. A room type belongs to one property for its lifetime; moving an existing record to another property is rejected because its rooms, inventory, rates and channel mappings are already property-scoped. Operators create a new room type when a product must move.
Every successful create or update appends one ContentChange in the same transaction.
Private OTA connections consume that durable signal independently and rebuild the
current public record; the open-source module stores no provider credentials or
provider-specific identifiers. The fallback rate is not an invoice or accounting
entry and never replaces a matching Rate Plan. Physical location, room lifecycle and
archive controls belong to the following Room configuration slice so sellable content
cannot directly bypass operational room transitions.
D63 — Physical room configuration cannot mutate the operational lifecycle#
The Room workspace owns property-scoped buildings, floors and physical room records. A building cannot move to another property, and an existing floor cannot move to another building because rooms already reference that location. A selected floor canonically supplies its building when the room form omits it. Room type, building, floor and capacity remain editable inside the same property so operators can correct physical setup without recreating the asset.
saveRoom never writes status or the operational note. New rooms start as
available; later transitions continue through stay and housekeeping functions with
their occupancy, open-task and compare-and-set guards. An explicit status passed to
the configuration function is rejected when it differs from the live state. Physical
room count is not sellable availability and does not emit an OTA content mutation:
availability remains durable in AvailabilityLedger, exactly as in the legacy
hospitality inventory boundary. No payment, invoice or accounting behavior is opened
by this workspace.
Buildings and floors use reversible archive rather than deletion. A building cannot be archived while it still owns an active floor or room, and a floor cannot be archived while it still owns an active room. Restoration proceeds from the outside in: the property must be active before a building, and the building before a floor. The archive functions and location-changing room writes lock the same active rows inside their transactions, so a concurrent archive and room/floor move cannot leave an inactive container with an active child. Archived locations remain readable in room history but are excluded from choices for new operational work.
Physical rooms follow the same reversible rule. A room may be archived only from
available, with no checked-in stay and no todo/in_progress housekeeping task.
Archiving, check-in, manual status changes and task creation all compare the same
active + status row version, so only one concurrent operation can win. Restoration
requires the property, room type, building and floor to be active and returns the room
to the front-desk candidate set without deleting its assignment, stay or task history.
D64 — Accounting core never selects a jurisdiction#
The public account module owns double-entry posting, exact money, reconciliation,
period controls and jurisdiction-neutral books. Its first initialization freezes the
ledger currency and accounting timezone but does not install a chart, taxes, statutory
forms or legal metadata. Those belong to a separately deployed localization.
account.Setup.standard records which localization is active. A non-custom standard
makes the public close engine require checksummed account.CloseEvidence from that
localization, so direct use of the core close command cannot bypass statutory checks.
Core neither knows the provider's form codes nor stores its filing artifacts.
D65 — Front-desk identity intake is stay-scoped and exposes only a safe projection#
An identity document entered from a Stay belongs permanently to the selected Contact
and that Stay. The Contact must already be a registered StayGuest; an arbitrary
Contact cannot be attached through a forged form post. Reusing an existing document ID
with another guest or Stay is rejected instead of silently moving protected data.
Operational lists expose the document type, holder name, nationality, OCR state, date- of-birth readiness and only the final four number characters. They do not expose the full number, date of birth, address, OCR payload or attachment references. The complete record remains available only inside effect-checked business functions that need it, such as statutory stay-notice preparation. Default logs, screenshots and browser acceptance fixtures use synthetic values and must never contain real guest PII.
Manual entry is the complete first front-desk path. Storage-backed front/back images and OCR state remain supported by the domain model, but automated scanning is a later adapter slice and is not required to receive or operate a stay.
D66 — A declared boundary that nothing enforces is not a boundary#
The view layer's contracts were all named and only some were kept. views said
which fields a theme may read and no render path ever projected one: makeDrop() had
exactly one caller in the whole repository, a test. A joint declaring
product: 'catalog.product' checked that the value was an object and passed the row
through with every column on it. tokens had the same shape of problem one layer up —
every theme declared them, the manifest composed them, tokensToCss() turned them into
--ket-* inside a declared cascade layer, and nothing ever put the result on a page, so
themes hard-coded a second palette in their own stylesheet. A rule nothing checks decays;
a rule nothing runs was never true to begin with.
Chosen: the framework enforces at the one choke point it owns. A view-typed prop is
projected before it crosses into a joint or an island. A section placement is projected to
the settings its section declared, so a layout stored before a schema change still hands a
theme declared keys only. sealScope() refuses a function at any depth rather than at the
top level, which is what its documentation had always claimed. And the tokens a theme
declares are served at /_ket/tokens.css and linked into the document it renders — by the
framework, because the framework is what publishes the cascade order they live in.
The projection is per reader, not per joint. The first cut projected to the view named
in the contract and broke the example the framework leads with: inventory adds
leadTimeDays to catalog.Product with extend, declares its own view over it, and reads
it back through a joint catalog published. Refusing that would mean the extension pillar
and the view boundary could not both be true. So a module reads the fields the view's owner
declared plus the fields it declared itself over the same model. A field nobody declared
anywhere still cannot cross.
Cost: a fill that was reading an undeclared field now reads empty, and finds out at
render rather than at build — the projection cannot tell an undeclared field from a typo.
Tokens now reach pages that never had them, so a theme carrying a hard-coded palette may
show two sources of truth until it moves its colours into tokens.
Reversible: yes, but pointlessly. Loosening restores a leak nobody asked for.
One behaviour, one implementation. Rendering a joint existed twice — once for the
storefront, once for first-party screens — and the copies had drifted: the same missing
joint reported two different error codes depending on which half of the product you stood
in, and one of them took a tenant's whole theme down when an island's module was switched
off while the other degraded to nothing. Both now go through theme/joint-runtime.ts. The
storefront kept the degradation, because that is the behaviour a restricted manifest is for.
D67 — Deployment composition replaces the module application lifecycle#
Supersedes the lifecycle portions of D28, D50, and D57. Module groups and database-owned install state were designed for customers choosing capabilities at runtime. KetSuite deployments are now selected by the system, and product variants can require different implementations of a similarly named capability. A partial group such as “11 of 12 installed” therefore exposed an operator mechanism without expressing the product decision that actually matters.
Chosen: DeploymentSpec.modules is the only composition list. Every selected module is composed,
migrated, served, and run by workers. Every tenant of that deployment receives the same immutable
manifest. KetJS no longer defines application/group metadata, bootstrap modules, install policies,
runtime install state, or an App List management screen.
Product-specific arrays may still be ordinary TypeScript constants in a private repository. They are authoring conveniences, not framework concepts, discoverable catalogues, or mutable database state.
Cost: changing product composition requires a source change and deployment release. Database schemas no longer vary independently from behavior, and operators cannot enable a module without shipping a new artifact. This is intentional: the deployment owner, not an end user, chooses the product shape.
Reversible: by adding a separate plugin/lifecycle product later, without weakening the deployment contract or restoring hidden state to every KetJS runtime.
D68 — Staff identity is transport-neutral; native Bearer is a server session#
Chosen: browser staff calls keep the signed HttpOnly session cookie, while native staff calls use a
short-lived opaque Bearer access token backed by a deployment-owned server session and a rotating refresh
token. Both presentations resolve to the same live StaffIdentity before a Channel route runs.
The native authorization ceremony remains OpenID Connect Authorization Code with PKCE. The identity edge may verify the provider result during exchange, but the provider access token is not the credential accepted by staff business routes. Company, branch, role, and membership are re-resolved from current server data; a raw claim or client-supplied tenant hint never becomes business scope.
Why: the POS identity foundation proved the reusable framework boundary: an identity declares its presentation, CSRF follows that presentation, OpenAPI publishes credentials per profile, and a deployment resolver turns an opaque token into live scope. Native staff needs that boundary without inheriting POS device grants or making a generic Bearer string trustworthy.
Security consequences: cookie-authenticated unsafe requests still require same-origin and CSRF proof. Bearer-authenticated requests do not require CSRF, but remain subject to rate limits, idempotency, authorization, live security-version checks, expiry, refresh reuse detection, and revocation. Credential precedence must fail closed when multiple presentations are supplied.
Ownership: KetJS owns the StaffIdentity presentation union, resolver-composition seam, CSRF behavior,
and OpenAPI schemes. The private deployment owns token persistence, exchange, refresh, revocation, IdP
integration, and membership resolution. Mobile owns PKCE and secure token storage.
Cost: the framework now publishes both staff schemes and provides deterministic presentation resolver composition. A deployment still has to implement the private Bearer session resolver; this decision does not authorize accepting raw IdP JWTs or reusing POS tokens.
D69 — Operational logging is a driver contract, and it is not the audit trail#
The gap. The framework wrote nothing. A 500 was turned into a JSON body for the client and its
stack was discarded by the handler's own catch; a caller invoking a function it may not call threw
E_FN_NOT_PERMITTED and left no trace anywhere; no function had a latency or an error rate. The one
structured stream that existed, WorkerLog, went to console.log on stdout and repeated six fields
at ten call sites.
Chosen: a LogDriver contract shaped like Storage and OutboundTransport — name, write,
optional flush/close — with built-in console, pretty, file, memory and null sinks, and
multiLog / leveledLog / bufferedLog / isolatedLog / redactLog as combinators. Anything
needing a client library arrives through serve.openLog, the same fence as openStore. Zero
dependencies added.
The distinction that settled the design. An audit trail is durable business evidence: it lives in
the tenant's database, the application queries it, it belongs to a domain contract, and it rolls back
with the transaction that wrote it — pos.AuditEvent already is one. A log is none of that, and the
sharp edge is the rollback: a record written inside a transaction that later rolls back must still
be emitted, because the attempt was real and knowing it failed is the point. Two opposite
requirements, so they cannot share a mechanism. Nothing in this decision writes to a database.
write is synchronous. An await inside the request path changes interleaving and produces bugs
that appear only under load, and a logging call that can be forgotten with a missing await will be.
Drivers that need I/O buffer internally. Cost: a driver cannot apply backpressure to its caller,
which is the intended trade — dropping records beats stalling the work being described — so every
buffering driver has a bound, drops the newest while reserving room for errors, and announces the
gap as log_dropped. A silent gap cannot be told apart from quiet.
Logging is not an effect. Effects exist to make a side effect that would corrupt data impossible
to perform undeclared; a forgotten log corrupts nothing, and log:write on every function would add
noise to every manifest while discriminating nothing. The one real objection — an HTTP sink is
undeclared network egress from inside a function — is answered by the fence: a module cannot choose a
destination, only the deployment can.
Context is bound, not ambient. AsyncLocalStorage is a Node built-in and would have worked, and
was rejected: ctx exists so that "the call forgot its context" cannot be written down, and a
module-scope logger reading its tenant from ambient state is that pattern returning by another door.
Everything here is already handed a ctx, so ambient propagation buys nothing.
Redaction is structural before it is defensive. LogFields accepts scalars only, so
log.info('saved', { input }) does not compile — that being the ordinary way a customer payload
reaches an aggregator. redactLog still runs, mandatorily and around a deployment's own sink,
because catch (e) and JavaScript callers have no types. trace and actor are HMAC-SHA256 keyed by
KET_SECRET and truncated to 64 bits rather than the bare namespaced digest POS uses for its audit
rows: a correlation id is often a client-chosen command key, so a bare digest of order-42 is
recovered by guessing — tolerable inside one tenant's own database, not in an aggregator shared by
every tenant. Keying it also reuses an invariant that already holds, that KET_SECRET is identical on
every pod, which is the only reason a web record and a worker record can be recognised as one request.
http_request records the route pattern, never the pathname, because a raw path carries record
identifiers and a query string. An unmatched path is (unmatched); the status already says what
happened.
Reversible: the driver contract, yes — Storage and OutboundTransport have already proved that
shape survives. The event vocabulary, no. Once an alert keys on fn_error, renaming it breaks
that alert silently and nobody is told, which is the same failure mode as renaming a permission. The
catalogue is therefore a closed list in code, exported as a value, and a test checks the documented
table against it rather than trusting that both were updated.
D70 — A field may say what it holds, and the vocabulary is closed#
The gap, found by surveying rather than by needing it. A field was Record<string, string> — a type
string and nothing else. There was nowhere to record the two questions any deployment holding customer
data eventually has to answer: which columns are personal data, and which must never leave the system.
Both were being answered by convention. user.passwordHash carried the comment "Never in any declared
output" — a promise the type system could not hold, and which the write record stored in the durable
idempotency row was quietly breaking.
Chosen: fields: Record<string, string | FieldDef>, where FieldDef is { type, personal?, sensitive? }. The string form stays and stays canonical; the two forms compose to the same field, so
nothing downstream learns the difference.
Why the container, not the keys, is the decision. Adding a key to FieldDef later is additive and
safe. Changing Record<string, string> into a union is not: it touches every model declaration in every
module and the fifteen core files that read .fields. That asymmetry is the whole reason to land the
slot now with a deliberately small vocabulary rather than wait until a compliance deadline picks the
shape under time pressure. The vocabulary is closed and an unknown key is a composition error, for the
same reason D1 gives about joints: strict-then-loose is possible, loose-then-strict is not.
Classification is not storage, and the schema snapshot proves it. schemaFromManifest names the
columns it cares about rather than spreading the field, so classifying a column plans no ALTER and
needs no downtime. A test asserts the two snapshots are identical, because if tagging a field ever
planned a migration nobody would tag anything.
sensitive is enforced or it is decoration. A write record travels further than it looks: returned
to the caller, shown by a dry-run, and stored verbatim in the durable idempotency row. The value is
masked before the record is kept. The field is also withheld from ket agent entirely rather than
annotated — the descriptor is the agent's map of what it may do (D8), and a value it must never write
does not belong on that map. An insert that omits a required sensitive field then fails at validation,
which is a loud way to find out and the right one.
personal is recorded, not restricted, and this is stated rather than hidden. The application still
has to serve the person the data is about. What the declaration buys is enumerability: ket classification answers "which columns are in scope" for an export or erasure request, and it also names
every model that classifies nothing, because an inventory of only what somebody remembered to tag is
worse than none — it looks complete. Automatic export and erasure need one more thing, which person a
row belongs to, and that is a relationship rather than a field property. Not built, deliberately.
A reclassification is visible in the upgrade diff, risky in both directions: dropping sensitive
silently starts letting values through, and dropping personal silently ends an obligation.
Cost: one more shape for a module author to know, and a KetSuite whose models are almost entirely unclassified on day one. The inventory reports that honestly instead of implying coverage.
Reversible: the keys, yes — additive. The container, no.
D71 — A schedule is manifest data, and it claims before it runs#
The gap. There was no way to say "this runs every night". Five modules already did periodic work and the hospitality plan needed a catch-up sweep to cover dropped webhooks, so the alternatives on the table were a Kubernetes CronJob or a job that re-enqueues itself. Both are worse than they look. A CronJob runs once, and a database-per-tenant deployment needs the work once per tenant — the worker already iterates tenants and the cluster does not know they exist. A self-re-enqueueing job stops forever the first time an attempt is discarded, and tells nobody.
Chosen: defineJob({ schedule: { every: '15m' } | { dailyAt: '03:00', timezone } }). The schedule
is part of the manifest, so ket manifest prints it, ket diff compares it and ket check rejects
one that does not parse — the same argument D41 makes for fills being data rather than functions.
A schedule runs nothing. It decides a tick is due and enqueues the ordinary job, which then goes through the leases, retries, timeouts, abort signals and records that already exist. That reuse is why the whole feature is small; the only genuinely new problem is mutual exclusion.
Exactly once without a leader. The queue's uniqueKey was the obvious tool and is not enough: it
holds only while a job is live and is released the moment one completes, so a tick that finished an
hour ago would be enqueued again. Instead one row per job per tenant holds the last tick anybody
claimed, moved forward with a compare-and-set. Every replica sweeps, one update changes a row, the
rest get nothing, and there is no election to get wrong. The claim happens before the enqueue, so a
crash between them loses a tick rather than running it twice — the right way round for anything that
touches money.
Missed ticks are skipped and counted, never replayed. Three days of downtime produce one run. A
job that needs to know what it missed can read its own ledger, which is more truthful than three
identical runs, and the schedule_fired record carries the count so the gap is visible. A schedule
seen for the first time does not fire for the tick it was installed inside, because nobody asked for a
run at deploy time.
Interval and wall clock, not cron. every has no clock in it and cannot be wrong about a
timezone. dailyAt names one, because "after the shop closes" cannot be said any other way and
computing it in the server's timezone is how nightly jobs run at the wrong hour. Cron expressions were
rejected: the parser is the easy half, and the day-of-week and DST edges are where every
implementation gets subtly wrong. Both forms are additive to; a cron form can be added later without
breaking either.
The company problem, and what it forced. A schedule fires once per tenant with no company, because
the framework knows what a tenant is and does not know what a company is. That left a scheduled job
unable to do the per-company work an ERP actually schedules, so jobs.enqueue gained a company,
refused unless the enqueuing operation declares crossCompany. The gate is the declaration that
already let the operation see more than one company, and it is in the manifest where a diff shows it.
Cost: one statement per scheduled job per tenant every sweep, and a deployment with many tenants
has to raise scheduleSweepMs. Also one more thing a module can turn on that the deployment does not
see coming — mitigated only by the schedule being in the manifest and in the upgrade diff.
Reversible: the schedule forms, yes — additive. The two events added to the log catalogue, no, by D69's argument.
D72 — One durable ceiling, applied where repetition is the abuse#
The gap, found by counting. Seventeen modules touched rate limiting and there were three
incompatible implementations: a declarative { action, limit, windowMs } on channel routes backed by
website.claimChannelRateSlot, a lockedUntil column on the customer account, and a blockedUntil
plus failure counter on the user. The first is the right shape and lives in the wrong place — it makes
the API facade depend on the storefront module for a purely infrastructural concern.
Chosen: claimRateSlot(adapter, { action, key, limit, windowMs }) over a framework ket_rate
table in the tenant's own database, plus a serve.rateLimit resolver the HTTP boundary enforces
before a route is chosen.
Durable, because the alternative is a lie. A Map in one process means two replicas enforce two independent limits and the real ceiling is whatever the load balancer decides.
Per-route rather than global, and this is the important part. A durable check costs a database round trip. Applying it to all traffic would hand an attacker a cheaper lever than the one it removes, so the resolver returns null for almost everything. This limiter bounds what one identified caller may repeat; volume belongs in front of the process. Saying so plainly is better than shipping something that looks like DDoS protection and is not.
Fixed window, and the cost is stated rather than hidden: a caller can spend a full allowance at
the end of one window and another at the start of the next, so the worst case across a boundary is
twice the limit. A token bucket removes that and quietly stops meaning limit per windowMs, which is
what the existing contract says and what a person reading it expects.
The row is a counter, not a location record. key — an account, a hashed address, a device — is
hashed with the action before storage. The existing implementation stored it raw. A rate-limit table
has no reason to also answer "who was here", and the same argument was already made for trace in D69.
Refusing under contention. The read-then-compare-and-set retries, because every replica checking one counter at once is the situation the limiter exists for rather than a fault. After eight lost races it refuses: allowing would make contention a way through the limit.
What was deliberately left alone. Account lockout — user.blockedUntil, website.lockedUntil —
stays domain state. It is business data: an administrator unlocks it, a screen shows it, it belongs to
the security model rather than to infrastructure. Moving it into a framework table would take it out of
the data the domain can query. The line drawn here is that a ceiling on repetition is infrastructure
and a locked account is a fact about an account.
Cost: a database round trip on every limited route, one table per tenant that uses it, and a
counter that has to be pruned — the worker does it hourly on a pass it already holds the lease for, so
a deployment with no worker has to call pruneRateSlots itself.
Reversible: yes. Nothing here is in the manifest, and the two log events are additive.
D73 — Document numbers are a primitive, and the format is not#
The gap, found by counting. Two modules had already written the same allocator: a counter row, a
read, a compare-and-set, a retry loop. sale.nextName retries 64 times with jittered backoff and a
comment explaining that a bulk import is a stampede by design. pos.nextOrderNumber retries 32 times
with no backoff at all, so it spins hottest exactly when the counter is contended. Both are
correct; only one is careful, and the next module to need a number would have picked one at random.
Chosen: ctx.sequence(name) over a framework ket_sequence table, with the careful retry.
It returns a number. Turning 42 into S00042 or POS/00042 is the domain's, and a framework
that chose would be choosing an invoice format for a tax authority it has never heard of.
Per company by default, shared only when asked. Both existing implementations keyed by something the caller passed in — a company key in one, a session id in the other. That works until somebody forgets, and what a forgotten scope produces is two legal entities issuing the same invoice number, which nobody notices until an auditor does. The default is therefore the safe one and the wide one costs a parameter.
Gaplessness is the caller's, and the docs say so. The counter moves in whatever transaction the caller is already in, so a number taken inside the transaction that writes the document rolls back with it, and one taken outside leaves a gap when a later step fails. No counter can know which happened, so promising "gapless" unconditionally would be a promise the framework cannot keep.
A dry-run reads without consuming. The alternative — allocating during a preview — makes the real command that follows skip a number, and shows the caller a number nobody will ever use. It follows that a preview number is not reserved, which is stated rather than left to be discovered.
Cost: one more framework table, and a sequence hot enough to lose 64 races raises rather than blocks. That error names the fix (split the sequence) because a single row is a real ceiling and pretending otherwise would just move the failure somewhere less legible.
Reversible: yes. Nothing is in the manifest, and the existing module allocators keep working untouched — this PR does not migrate them.
D74 — Append-only is a model property; what an event means is not#
The gap. Two modules keep an audit timeline and both promised the same thing in prose —
pos.AuditEvent "never edits the first event", account.AuditEvent the same by convention. Nothing
held either to it. A promise a reviewer has to remember is one a refactor eventually breaks, and both
timelines exist precisely because somebody will later want to know that a row did not change.
Chosen: append: true on a model. ctx.db.update, del, compareAndSet and an updating
changeset are refused with E_APPEND_ONLY; insert and insertIfAbsent are untouched, so a replay is
still answered by landing on the row it already wrote.
Where the line falls, and why here. The alternative was a framework-owned ket_audit table that
modules write into. That was rejected: pos.listAuditEvents is a public contract with filters,
paging and a clamped window, and POS stores configId and sessionId as immutable dimensions
captured with the command — none of which a framework knows about. Taking that into a framework table
would repeat, in the other direction, the mistake D69 was written to avoid. The framework owns the
integrity of the record and the domain owns its meaning.
The two helpers are the rest of that line. auditId derives an identity from the command so a
retry is the same command. auditHash produces a digest namespaced by the owning module, so two
timelines cannot be joined by accident and a digest from one cannot be tested against a guess made in
another. Both were written by hand in POS; neither is about what an event means.
Hashing is offered, not imposed. The two existing timelines disagree: POS hashes actor, subject
and related identities, account stores them raw. Both can be right — POS exports a sample beyond
the tenant, account needs to join back. A framework that forced either would be deciding a question
it cannot see the inputs to. auditHash's documentation says plainly that it is pseudonymisation and
not secrecy, because a low-entropy value stays guessable.
Declaring it plans no migration, by the same argument as D70: it describes what may happen to a
row rather than how the row is stored, so the schema snapshot ignores it and a test asserts the two
snapshots are identical. ket diff does report it, risky in both directions.
Cost: a model that later genuinely needs a correction has to model the correction as a new row,
which is what an append-only timeline means and is more work than an UPDATE. Both KetSuite
timelines were already written that way, so nothing had to change to adopt it.
Reversible: yes — removing the flag restores the previous behaviour, and ket diff says so out
loud when it happens.
D75 — A reader is told, and the poll is what happens when nobody told it#
streams.tail had one way of finding out that a chunk had been written: read again in 250ms. The
store had a notification bus, but it was an EventEmitter created inside the store, so it reached
exactly the process that owned it. A job runs in a worker and the reader tailing it is in a web
process, which is the case the feature exists for, and it was the case the bus could not serve.
So the read was not a fallback. It was the mechanism, and it cost four queries a second per open
connection — each of which read the whole topic, because the cursor was applied to the rows in
JavaScript after they arrived rather than in the WHERE clause. A stream that had written a thousand
chunks re-read and re-parsed all thousand, four times a second, to learn that nothing had changed.
The primitive was already there. Adapter.notifications is LISTEN/NOTIFY, implemented in the
PostgreSQL driver with a dedicated listener connection and reconnect handling. Nothing used it here.
dbStreamStore now publishes on write and listens on first read, and says so through notifies —
which is what lets tail choose its fallback interval instead of being told one. Five seconds when
the store can reach every writer, 250ms when it cannot.
One channel, the topic in the payload. A PostgreSQL channel is an identifier — sixty-three bytes,
with quoting rules — and a topic is chosen by the application. One LISTEN per process is also
cheaper than one per stream. The cost is that a process wakes for every stream and filters, which is
the right trade while streams are counted in tens and the wrong one at ten thousand.
Correctness does not depend on any of it. A missed notification costs latency: the fallback read
still finds the chunk, and the cursor still guarantees no gap and no duplicate. That is why the
publish is fire-and-forget — a failed announcement must not fail the write it describes — and why a
failed LISTEN clears itself so a later reader can try again rather than leaving a process
permanently deaf.
What it is not. There is still no per-connection state on the server, so a client learning that something changed is a client that then asks for it. Compared with a framework that keeps a process per viewer and computes a diff, this delivers a signal and not an update; the second round trip is the price of a stateless server, and it is a different decision from this one.
Cost: one connection per process that reads, and only after something reads. A deployment on SQLite gets none of it and keeps the behaviour it had, which is a real difference between the test lanes and has to be covered on both.
Reversible: yes — a store that reports no notifies restores the old interval, and removing the
publish restores the old mechanism, at the old price.
D76 — A stream belongs to the database it is about#
D75 made a stream reader something that can be told rather than something that polls. It could not be used. With a database per tenant there is no single adapter, so the store fell back to memory — per process — and the process that knows when a job finished is the worker, not the one holding the reader. Two web instances shared nothing either.
The gap was already written down, in the future tense: resumable streams were not yet durably stored per tenant, and a database-per-tenant deployment still needed to choose the backing-store ownership model. This is that choice.
A stream describes something that lives in a record, and that record lives in exactly one
database. So the log goes there. streamsOf(adapter) makes one Streams per adapter and hands the
same one back, so a job and a reader given the same tenant adapter meet — in the database, and in the
in-process bus when they happen to share a process.
ctx.streams follows the call. A function or a job opens a writer in the database it is already
working in, which is the only database it is allowed to be working in. Nothing new is granted: a call
that could not reach a tenant's data cannot reach its streams.
The namespace stays the caller's, as it already was for resolveStream. The framework does not
derive a topic from an actor or a header, because the writer and the reader have to agree and only
the module knows what they are agreeing about. Per-tenant storage removes the cross-tenant hazard
that made this sharp; it does not remove the need to agree.
A dry run opens a writer that discards. A rehearsal reports what a command would do; announcing it to everyone watching would be doing it.
The endpoint holds a lease for the length of the tail. It leases the datastore, not a connection:
reads inside take one and give it back. The lease is what stops the pool evicting a tenant's adapter
out from under a reader, and it is bounded by streamTimeoutMs.
Cost: a tenant that streams gets a ket_stream table in its own database, created on first use —
a tenant that never streams still gets none. A deployment with many tenants and many watchers holds
one pool lease per watched tenant, which is a real ceiling worth knowing before pointing this at
something every viewer opens.
What it is still not. There is no per-connection state on the server, so a client told that something changed is a client that then asks for it. That is D75's line, and it has not moved.
Reversible: yes — serve.streamStore still overrides everything with one store, which is what a
deployment that wants the old shape passes.