Astent — Product Documentation

The architecture

How the two repositories, 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 explains the shape. It does not restate the specifications, which stay authoritative in their own documents; each section points at the one it draws from.

The shape of the program

Astent system architectureThe astent-architecture repository holds documents and evidence. The astent-lab repository holds everything executable: the harness core, case packages and Frappe apps. The two couple by case and baseline identifier. Inside astent-lab, the harness reaches the ERP only through the ERPAdapter, which crosses a process boundary over HTTP to one Frappe site per case.astent-architecturedocuments and evidencethesis · roadmapharness methodologyPhase 0 contractuse casesby name+ versionastent-labeverything executableharness/runner · evaluatorcases/case packagesapps/Frappe appsadapters/erpnext — ERPAdapterthe only door to the ERP: REST, instrumented, nothing in-processprocess boundary · HTTP onlyERPNext instancesone Frappe site per case · separate database and sites volumeFenestrafenestra_v1orderability · priceBOM · routingKlerkler_v0composition validityzone covering · delivery sets
The two repositories couple by case and baseline identifierfenestra_v1, kler_v0 — never by file path. Research documents reference those identifiers; cases/<name>/release.yaml declares them. Findings flow back the other way as documents.

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 the repositories merged:

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.

The runtime layer stackFour runtime layers in dependency order, from ERPNext core at the bottom through astent_core and the client app to client policy at the top. A seam between the client app and client policy separates code shipped in an image from records a practitioner can edit. Reference models sit beside the stack, not in it.4Client policyrecords in their site · declared in cases/<client>/configurationclient's clockno releasethe seam3Client appapps/<client> — this client's product model, owned by them aloneengineering clockrelease2astent_corethe substrate every client runs · no domain contentplatform clockevery client1ERPNext coreupstream, unmodified, pinned by digestplatform clockevery clientReference modelsrunnable synthetic starting points · forked at onboardingbeside the stacknever a runtime dependency
Everything from the client app downward is code shipped in an image; client policy is records a practitioner can edit. The seam is physically located at 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:

Policy — a record edit, no releaseApp — a release
A surcharge moving from €110 to €150Applying the colour percentage before the flat surcharges instead of after
Maximum width 3,000 → 3,200 mmA rule spanning two dimensions, which no constraint kind expresses
A channel factor 0.85 → 0.82A fifth configurable axis — the four axes are a module constant
Ceasing to enforce a constraint entirelyA 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. A case brings five things:

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:

CommandGuarantee
startstart the pinned ERPNext environment
healthwait for and report usable application state
seedinstall apps and create or update the case fixture
testrun the unit, guard and live behavioral checks
inventorysnapshot the normalized baseline and report drift
statusshow the latest recorded state of the case
resetrecreate the known baseline, discarding experimental mutations
stopstop 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.

DigestAnswersStatus
Filewhich version of the policy file was this instance seeded fromin use
Inventorydo two independent rebuilds of the same baseline produce identical business statein use — the load-bearing one
Imageis every client provably on the same corenot 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:

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

This page is a layer over the following documents, which remain authoritative where they disagree with anything above.