The architecture
How the harness, the adapter, the ERPNext substrate and the case packages fit together — and where the boundaries are that the whole product argument rests on.
This page is the current statement of the architecture. The decision documents behind it live in Linear and are listed under Sources; where a decision is still in flight, that is said explicitly.
The shape of the program
fenestra_v1, kler_v0 — never by file path. Documents reference those identifiers; cases/<name>/release.yaml declares them. Findings flow back the other way as documents.The shape is mid-move (decided 2026-08-16). The former astent-architecture repository is being retired — its documents move into Linear and this site (AST-43).astent-lab narrows to the experimentation harness (AST-45), and deployment moves to two dedicated repos with near-identical mechanics: astent-prototype(AST-44) — demos, testing and trying new things, fictional companies only, no live customer data — where instances run today, carrying every mechanic production software will have, and where the harness connects to run experiments; and astent-production(AST-47), stood up once the first client onboards, since that moment forces a re-think and the two deserve separate lives. Until those land, the diagram above shows what exists today.
Why the split is structural
The separation is not filing tidiness. Four reasons are recorded for it, and each is a property that would be lost if documents and executable code shared a repository:
- The public-content boundary is structural. The executable repository contains no customer research or strategy material, so a public site or benchmark build cannot leak it — the guarantee holds by construction rather than by review.
- Benchmark holdout control. Eventual public release of the benchmark needs a repository publishable independently of the product's internal documents.
- Frappe apps are installable units. They are fetched with
bench get-app; code and CI churn stays out of the document history. - The licensing boundary stays visible. App code under
apps/runs in-process with GPLv3 ERPNext; the harness talks REST from outside the process, which keeps benchmark content separable.
One invariant survives the split: site source, canonical case package and ERP seed all stay versioned together, so a single change updates them atomically.
The adapter is the only door
The harness never runs inside ERPNext. Every read and write crosses the process boundary as REST through a single class, ERPAdapter, whose surface is deliberately small and generic — list and get doctypes, list, get, create, update, delete, submit and cancel documents. No domain verbs.
An instrumentation decorator wraps the adapter's methods so that no ERP call can go unlogged. That is what makes a run record usable as evidence rather than as a summary: the trace is a by-product of the only available path, not something a caller has to remember to emit.
Two more rules keep measurement honest. Domain knowledge — what “orderable”, “price”, “BOM” or “routing” mean for a given case — enters as a per-case evaluator vocabulary, never as branches inside the core. And the agent and the evaluator hold separate credentials, so the party being measured is not the party doing the measuring.
Inside one instance: four layers
Each running case is a stack of four layers in dependency order. This is decision D13, taken 2026-08-15.
frappe_loader.load_active_policy() — the pure policy layer cannot import frappe, which is enforced by a test. That purity is what makes the boundary a seam rather than a convention.No client instance depends on a shared domain artifact. An industry reference model is where a new client's app comes from, not something it imports or upgrades against. Two clients in one industry share ancestry, not code. That was chosen on a reversibility argument: a library can be extracted from five client apps once the common part is visible; a library five clients already depend on cannot be un-shared — and with zero pairs of clients in the same industry, the evidence to locate that common part does not exist yet.
The cost is accepted knowingly: a domain defect found in one client is N fixes across N clients, with nobody notified. It is bounded by keeping astent_core genuinely generic, and it makes fork provenance — which reference model, which version — mandatory at fork time. Recording it is cheap then and impossible to reconstruct later, and it is the only thing that turns “the same improvement was needed five times” into a visible signal rather than five unrelated tickets.
The boundary that carries the thesis
Layer 4 changes on the client's clock with no engineering involved, and how much of a client's world fits there is the entire commercial argument. If onboarding needs layer-3 work every time, this is consulting with extra steps.
Two tests place any change:
- Can the existing case schema already express it? If the YAML has a field for it, it is policy.
- Is it a value, or a kind? The app owns kinds and vocabulary; the policy owns values and instances of those kinds.
| Policy — a record edit, no release | App — a release |
|---|---|
| A surcharge moving from €110 to €150 | Applying the colour percentage before the flat surcharges instead of after |
| Maximum width 3,000 → 3,200 mm | A rule spanning two dimensions, which no constraint kind expresses |
| A channel factor 0.85 → 0.82 | A fifth configurable axis — the four axes are a module constant |
| Ceasing to enforce a constraint entirely | A new field on the transaction line |
The tell is consistent: policy changes are things the schema already has a slot for; app changes are things it does not. Adding the slot is the release; filling it is not.
There is live evidence for the left-hand column. A surcharge edited as a single record moved a real quotation from €412.40 to €452.40 with no code change and no deploy.
Two operational notes keep that honest. A Desk edit and the case YAML can diverge, so configuration_version records the digest of the file an instance was seeded from and the lifecycle detects the mismatch. And a policy version is stamped on every transaction line, so reopening an old document does not silently re-price it against today's rates.
This same boundary is the agent's authority ceiling. A change is safe to delegate to the ERP agent precisely when it is type-checked by the loader, reversible as a record, and behaviorally provable — true of values and instances of kinds, and of nothing else.
What a case contributes
One harness core, N cases. Each demo on the demos page is one case here. A case brings five things:
- a case package under
cases/<name>/— declared business state, walkthroughs, semantic ERP links; - a benchmark task set under
benchmark/<name>/; - an assertion vocabulary for the evaluator;
- optionally a Frappe app, when the domain needs custom doctypes;
- an entry in the case registry, which is what makes it addressable by the lifecycle.
Adding a third case is an entry in that registry, not a fork of the lifecycle. Fixture isolation is one Frappe site per case, in practice one service per case with its own database and volume — and the isolation is measured rather than asserted: a full destructive rebuild of one case left the other's baseline digest unchanged.
The two existing cases share the harness core and share no domain code. No shared core app has been extracted yet, and the reason is recorded: their two policy models share no domain vocabulary. That finding is about the domain layer only — it does not cover the substrate, which is already identical and domain-free.
The lifecycle
One CLI over N cases; an environment variable selects which case is active, so a single shell can hold both instances. Eight verbs, each with a stated guarantee:
| Command | Guarantee |
|---|---|
start | start the pinned ERPNext environment |
health | wait for and report usable application state |
seed | install apps and create or update the case fixture |
test | run the unit, guard and live behavioral checks |
inventory | snapshot the normalized baseline and report drift |
status | show the latest recorded state of the case |
reset | recreate the known baseline, discarding experimental mutations |
stop | stop services without deleting repository files |
Every command except status writes a durable run record, on success and on failure.
The site name is not sufficient identification. Every stack calls its Frappe site frontend, so naming the site is equally true of either instance. The service UUID is what makes a destructive command case-safe: reset resolves and validates it before the first remote call, and refuses one belonging to another case or to a protected sandbox.
Without that guard, a stale UUID copied between configurations would drop the wrong database with every other guard agreeing.
How change stays detectable
A digest is a short hash standing in for content: change one byte and the hash changes completely. It turns “did anything change?” from an inspection into a string comparison. Three kinds are in play.
| Digest | Answers | Status |
|---|---|---|
| File | which version of the policy file was this instance seeded from | in use |
| Inventory | do two independent rebuilds of the same baseline produce identical business state | in use — the load-bearing one |
| Image | is every client provably on the same core | not recorded today |
The inventory digest is load-bearing because it collects business fields only, dropping generated ids, timestamps and per-order records. That is what made a reset-equivalence claim checkable rather than impressionistic: the same digest before and after a full destructive rebuild. Not “looks right” — identical.
Two files sit either side of the same question. The release manifest is the hand-authored declaration of what a case should be; the committed state summary is the machine observation of what was last measured. When they disagree, the run records say when it stopped being true. The lifecycle reports drift in both directions — configuration drift when the canonical file has been edited since seeding, ERP drift when the inventory digest moves between runs.
One caveat governs all of it: a digest is only meaningful against a fixed spec. Adding or removing a doctype changes the payload and therefore every digest, so changing a spec starts a new baseline and snapshots taken across the change are not comparable.
Deliberately unsettled
Two architectural decisions are open by choice, because they are expensive to reverse. Leanings recorded in the source documents are arguments, not rulings — nothing here should be implemented against them or cited as settled.
Tenancy. Multi-entity is the stated default for the target segment, and the code refuses it today: exactly one active policy per site, and no company dimension anywhere in the policy layer.
Image topology. Downstream of tenancy. The file layout of astent_core depends on it, which is why the extraction work is blocked rather than merely unscheduled.
What is settled and safe to build on:
- Do not fork ERPNext. Zero patches are carried; everything added is a Frappe app.
- Pin by digest, never a moving tag.
- Reference models, not a shared domain library — the decision above.
Two claims in the source documents are explicitly flagged as unverified: how far ERPNext user permissions actually scope, and the multi-site cost figures. Both should be tested before anything leans on them.
Sources
The decision records behind this page. This page is the source of truth; if one of them disagrees with what stands above, the disagreement itself is the defect to file.
- The layer stack: ERPNext core → Astent core → client app → client policy — decision D13; the runtime stack and the app-versus-policy boundary.
- Layer vocabulary: core, domain, case, instance — the four terms, and the three kinds of digest.
- Architecture decisions in flight — map and open questions — the entry point for what is open and in what dependency order.
- The agent's place in the stack — where the agent acts, and the escalation ladder above its ceiling.
- Separation audit: what is and isn't shared today — the code-evidenced state of the repository.
- In
astent-lab: the repositoryREADMEfor the split and the multi-case pattern,harness/READMEfor the measurement boundary,infra/READMEfor the lifecycle contract and drift reporting, andinfra/DECISIONS.mdas the standing decision record.