# HRM: attendance and payroll

A payroll app that uses only Astra's public SPI and follows the Frappe HRMS model: attendance sets the working days, a tenant's Wasm module computes the courier bonus, one Payroll Entry computes every salary slip in…

Company: AstraCMS Docs
API version: Không áp dụng
Requested language: en
Content language: en
Source: https://zerohost-b69e460a-fdedfaced775e42578cf55c1346b15d0.zhst.app/astra-cms/en/hrm

▶ [English](https://media.zerohostcms.com/astra-cms/astra-video-19-hrm-en.mp4) · [Tiếng Việt](https://media.zerohostcms.com/astra-cms/astra-video-19-hrm-vi.mp4) · 2:40 · Script: [demo-video-series.en.md](/astra-cms/en/video-scripts)

> **In one sentence:** A payroll app that uses only Astra's public SPI and follows the Frappe HRMS model: attendance sets the working days, a tenant's Wasm module computes the courier bonus, one Payroll Entry computes every salary slip in parallel, and submitting it writes exactly one balanced accrual journal entry to the append-only ledger.
>
> **Read this if:** you want to see how a real business module (eight DocTypes, five hooks, one MCP tool) is built on the SPI without touching the kernel (developer angle) / you are judging whether a metadata platform can carry money-sensitive batch work such as month-end payroll, and what is still missing before it is payroll software (tech-lead angle).

## The problem

Astra Express pays couriers a base salary plus a bonus per parcel delivered. At month end, HR at the Saigon tenant has to
turn attendance into working days, apply the parcel bonus tariff, deduct the employee's social, health and unemployment
insurance, and hand accounting a journal entry that matches the slips to the đồng. The usual failure modes are familiar:

- **Payroll lives in a spreadsheet.** Working days are typed by hand, the bonus formula is copied between tabs, and the
  accountant re-keys totals into the ledger. Nobody can show which input produced which line.
- **The bonus rule changes per region and per quarter.** In a classic ERP that means a code change or a
  vendor customisation. Each tenant wants its own tariff without a release of the whole product.
- **Slips and ledger drift apart.** If slips are saved in one step and the journal entry is posted in another, a crash
  in between leaves a month that is half accrued. Cancelling a run later has to undo both, without deleting history.

Frappe HRMS (on top of ERPNext) solves this with a well-known set of concepts: Employee, Salary Component, Salary Structure,
Salary Structure Assignment, Attendance, Additional Salary, Payroll Entry and Salary Slip. A Payroll Entry creates the
slips for a period and books an accrual journal entry. Salary component formulas there are Python expressions evaluated
by the framework.

Astra's HRM app takes **the concept names only**. The script states it plainly: no code, schema or UI is copied from
ERPNext or HRMS. What differs is where each piece runs: the formulas that vary per tenant run in the Tier 2 Wasm sandbox,
the slips are computed on virtual threads, and the accrual goes through the same `GeneralLedger` SPI the Express app uses
for freight revenue.

## What the video shows

The recording runs `make demo-hrm` against the k3d stack after `make demo-seed`, which loads September 2026 (26 working
days, Monday to Saturday) for `saigon-express`. The script signs in as `hanh` (role `hr`).

1. **The app** (scene 1). App `io.astra.hrm`, eight DocTypes in module `hrm`, depending only on `astra-spi`. The
   narration: "Frappe HRMS concept names, no code."
2. **Components** (scene 1). Eight Salary Components: base pay, lunch and fuel allowances, a KPI bonus, a one-off bonus,
   and the employee's insurance: BHXH 8 %, BHYT 1.5 %, BHTN 1 %, posted to accounts 3383, 3384 and 3386. Rates are demo
   seed values.
3. **The courier bonus in Wasm** (scene 2). Rust code in `astra-apps/hrm/wasm/courier-kpi`: 1,500 ₫ per parcel up to 600,
   2,000 ₫ up to 1,000, 2,500 ₫ beyond, plus 500,000 ₫ for full attendance. On save the kernel test-runs it:
   `kpi(1000 pieces, 26/26 days) = 2200000 VND`. An infinite-loop module is stopped at 50 ms and rejected with 422.
4. **Attendance** (scene 3). NV002 is absent on 15/9 and on a half day on 22/9, so 24.5 payment days. NV004 is on unpaid
   leave on 29/9. A day with no record counts as worked. `quang` (ops) can submit attendance but gets 403 on Salary
   Slip; `tuan` (sales) gets 403 on Payroll Entry.
5. **One save, every slip** (scene 4). Saving a draft Payroll Entry for 1 to 30 September creates four draft slips,
   computed in parallel with one virtual thread each, in the same transaction as the entry. In the recorded run the
   entry is `PAY-2610-002` (the series uses the creation month) and the slips are `SAL-2610-00005` to `SAL-2610-00008`.
6. **A slip, line by line** (scene 5). NV002: `100% of 6500000 (base) × 24.5/26 days` = 6,125,000; KPI
   `Wasm kpi(1007 pieces, 24.5/26 days)` = 1,717,500; BHXH `8% of 6500000 (base)` = 520,000; gross 9,001,539, net
   8,319,039. NV003 joined on 14/9, so 15 working days, plus a 500,000 ₫ Additional Salary. A client that sends
   `net_pay` gets 422 `field 'net_pay' is read-only`.
7. **Submit** (scene 6). Four employees, gross 35,837,501, deductions 3,517,500, net 32,320,001. One accrual voucher with
   five lines: Debit 642 35,837,501; Credit 3383 2,680,000; Credit 3384 502,500; Credit 3386 335,000; Credit 334
   32,320,001. Each slip's `accrual_voucher` is set to the entry name.
8. **Three refusals** (scene 6). Cancelling one slip of a submitted run returns 422 "cancel the payroll entry instead".
   A second September run returns 422 "NV001 already has submitted salary slip …". Setting pay from the client was
   already refused in scene 5.
9. **MCP** (scene 7). `payroll_summary` returns totals and slip lines with masked names (`N*** V*** A***`). `tuan`
   calling the same tool gets `isError: true`, "not permitted: read PayrollEntry".
10. **Cancel** (scene 8). The slips move to docstatus 2, the voucher gets five more lines with `is_reversal: true`, and
    the trial balance of 334, 3383, 3384, 3386 and 642 returns to 0. No row is deleted.

![Figure 3: Cancel adds rows, never deletes](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-19-figure-3.png)
*Cancelling a payroll run posts mirror reversals to astra_ledger, preserving historical payroll audit trails.*

## How it works

### The shape of the app

`HrmApp` is a Tier 0 app: a CDI bean that lists its DocType YAML files. Everything else is hooks and one MCP tool.

*`astra-apps/hrm/src/main/java/io/astra/apps/hrm/HrmApp.java`*
```java
@ApplicationScoped
public class HrmApp implements AstraApp {

    @Override
    public String id() { return "io.astra.hrm"; }
    // …
    @Override
    public List<String> doctypeResources() {
        return List.of(
                "META-INF/astra/doctypes/employee.doctype.yaml",
                "META-INF/astra/doctypes/salary-component.doctype.yaml",
                "META-INF/astra/doctypes/salary-structure.doctype.yaml",
                "META-INF/astra/doctypes/salary-structure-assignment.doctype.yaml",
                "META-INF/astra/doctypes/attendance.doctype.yaml",
                "META-INF/astra/doctypes/additional-salary.doctype.yaml",
                "META-INF/astra/doctypes/payroll-entry.doctype.yaml",
                "META-INF/astra/doctypes/salary-slip.doctype.yaml");
    }
}
```

The Payroll Entry is an ordinary submittable DocType. Its totals are `read-only`, so only the hook can set them, and
the permission table is where "sales gets 403" comes from:

*`astra-apps/hrm/src/main/resources/META-INF/astra/doctypes/payroll-entry.doctype.yaml`*
```yaml
name: PayrollEntry
module: hrm
kind: transaction
submittable: true
naming: { series: "PAY-.YYMM.-.###" }
# …
fields:
  - { name: start_date,      type: Date,     required: true, in-list: true }
  # …
  - { name: total_net,       type: Currency, read-only: true, in-list: true }
# …
permissions:
  - { role: admin,      read: true, create: true, write: true, submit: true, cancel: true }
  - { role: hr,         read: true, create: true, write: true, submit: true, cancel: true }
  - { role: accountant, read: true }
```

![Figure 1: One save, four slips, one transaction](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-19-figure-1.png)
*One payroll entry save computes four employee salary slips in parallel within a single database transaction.*

### Save, submit, cancel

```mermaid
sequenceDiagram
    participant HR as hanh (hr)
    participant K as Kernel (DocumentStore)
    participant PE as PayrollEntryHook
    participant SC as SalaryCalculator
    participant W as Wasm sandbox
    participant SS as SalarySlipHook
    participant GL as GeneralLedger SPI
    HR->>K: POST /api/doc/PayrollEntry (1–30 Sep)
    K->>PE: afterSave (same transaction)
    PE->>PE: PayrollInputs.load per employee
    PE->>SC: computeAll(batch)
    par one virtual thread per slip
        SC->>W: kpi(pieces, days×10, working)
    end
    SC-->>PE: computed slips
    PE->>K: insert SalarySlip ×4 (PRECOMPUTED bound)
    K->>SS: beforeSave takes precomputed values
    HR->>K: POST …/PAY-2610-002/submit
    K->>PE: onSubmit
    PE->>K: submit each draft slip
    PE->>GL: post one voucher (Dr 642 · Cr 3383/3384/3386 · Cr 334)
    K-->>HR: 200, docstatus 1 (one commit)
```

Saving a draft entry rebuilds the draft slips. The hook reads every input inside the entry's transaction first, then
hands the pure calculation to `SalaryCalculator`, then writes the slips back in the same transaction:

*`astra-apps/hrm/src/main/java/io/astra/apps/hrm/PayrollEntryHook.java`*
```java
    @Override
    public void afterSave(Document entry) {
        if (entry.docstatus() != 0) return;
        // …
        List<SalaryInput> batch = new ArrayList<>();
        List<String> unassigned = new ArrayList<>();
        for (Document e : employees) {
            Document slip = existing.get(e.name());
            if (slip != null && slip.docstatus() != 0) continue;   // submitted or cancelled on its own: leave it
            inputs.load(e, start, end).ifPresentOrElse(batch::add, () -> unassigned.add(e.name()));
        }
        // …
        List<Map<String, Object>> computed = calculator.computeAll(batch);
        // …
        ScopedValue.where(SalarySlipHook.PRECOMPUTED, byEmployee).run(() -> {
            for (SalaryInput in : batch) {
                // …
                    docs.insert("SalarySlip", period, entry.owner());
                // …
            }
        });
    }
```

**Why split read, compute and write.** Database access stays on the request's thread and its single transaction.
The calculation touches no storage, so it can safely fan out. The Wasm KPI call dominates the cost, which is why
`computeAll` uses one virtual thread per slip:

*`astra-apps/hrm/src/main/java/io/astra/apps/hrm/SalaryCalculator.java`*
```java
    List<Map<String, Object>> computeAll(List<SalaryInput> inputs) {
        try (ExecutorService threads = Executors.newVirtualThreadPerTaskExecutor()) {
            List<Future<Map<String, Object>>> futures = inputs.stream().map(in -> threads.submit(() -> compute(in))).toList();
            // …
        }
    }
```

**Why a `ScopedValue` for precomputed results.** `SalarySlipHook.beforeSave` computes a slip whenever a draft slip is
saved. When the entry writes its own slips, it binds `PRECOMPUTED` so the slip hook reuses the batch result instead of
computing twice. The binding is in-process (see `PRECOMPUTED` in `SalarySlipHook`), so a REST client cannot smuggle
computed values in through it.

### Working days and the four calculation types

Payment days are working days minus days outside employment, minus Absent, minus half of each Half Day, minus Leave
Without Pay. Unmarked days count as worked. This is the rule that gives NV002 24.5 of 26:

*`astra-apps/hrm/src/main/java/io/astra/apps/hrm/SalaryCalculator.java`*
```java
            lost += switch (att.getString("status")) {
                case "Absent" -> 1;
                case "Half Day" -> 0.5;
                case "On Leave" -> "Leave Without Pay".equals(att.getString("leave_type")) ? 1 : 0;
                default -> 0;
            };
        }
        double paymentDays = Math.max(0, working - outside - lost);
```

Each component has one of four calculations: Fixed, Percent of Base (optionally capped), Percent of Gross (deductions
only) and Wasm. Every line records a human-readable `basis`, which is what the video shows as "traces every line":

*`astra-apps/hrm/src/main/java/io/astra/apps/hrm/SalaryCalculator.java`*
```java
            case "Wasm" -> {
                long v;
                try {
                    v = kpi(scripts, c, ctx.pieces(), Math.round(ctx.paymentDays() * 10), ctx.workingDays());
                } catch (ScriptException e) {
                    throw new DocValidationException("component " + c.name() + ": formula failed (" + e.reason() + "): " + e.getMessage());
                }
                // …
                basis = "Wasm kpi(" + ctx.pieces() + " pieces, " + Values.plain(ctx.paymentDays()) + "/" + ctx.workingDays() + " days)";
            }
```

Payment days are passed to Wasm as `payment_days × 10`, an integer, because the sandbox exchanges only `i64` values.

### The tenant's bonus formula

*`astra-apps/hrm/wasm/courier-kpi/src/lib.rs`*
```rust
const TIERS: [(i64, i64); 3] = [(600, 1_500), (1_000, 2_000), (i64::MAX, 2_500)]; // (up to pieces, VND per piece)
const FULL_ATTENDANCE_BONUS: i64 = 500_000;

#[unsafe(no_mangle)]
pub extern "C" fn kpi(pieces: i64, payment_days_x10: i64, working_days: i64) -> i64 {
    let pieces = pieces.max(0);
    let mut bonus = 0;
    let mut floor = 0;
    for (ceiling, rate) in TIERS {
        if pieces <= floor {
            break;
        }
        bonus += (pieces.min(ceiling) - floor) * rate;
        floor = ceiling;
    }
    if working_days > 0 && payment_days_x10 >= working_days * 10 {
        bonus += FULL_ATTENDANCE_BONUS;
    }
    bonus
}
```

NV002's 1,007 parcels: 600 × 1,500 + 400 × 2,000 + 7 × 2,500 = 1,717,500 ₫, with no attendance bonus because 24.5 < 26.

A module cannot reach payroll unless it passes a self-test on save. A module that loops forever is stopped at its
deadline and the save is rejected:

*`astra-apps/hrm/src/main/java/io/astra/apps/hrm/SalaryComponentHook.java`*
```java
        try {
            bonus = SalaryCalculator.kpi(scripts, c, 1_000, 260, 26);
        } catch (ScriptException e) {
            throw new DocValidationException("module rejected (" + e.reason() + "): " + e.getMessage());
        }
        if (bonus < 0 || bonus > 1_000_000_000L) throw new DocValidationException("module rejected: kpi(1000 pieces, 26/26 days) returned " + bonus);
        // …
        c.set("self_test", "kpi(1000 pieces, 26/26 days) = " + bonus + " VND in " + micros + " µs");
```

![Figure 4: The courier bonus tiers](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-19-figure-4.png)
*Courier performance bonuses evaluated dynamically via WebAssembly pricing formulas per tenant.*

### One accrual voucher per run

On submit the entry submits every draft slip, totals earnings and deductions per GL account, and posts one voucher
through the `GeneralLedger` SPI, inside the entry's transaction. If any step fails, the whole run rolls back:

*`astra-apps/hrm/src/main/java/io/astra/apps/hrm/PayrollEntryHook.java`*
```java
        List<Entry> lines = new ArrayList<>();
        byAccount(submitted, "earnings").forEach((account, amount) -> lines.add(Entry.debit(account, null, amount)));
        byAccount(submitted, "deductions").forEach((account, amount) -> lines.add(Entry.credit(account, null, amount)));
        lines.add(Entry.credit(SALARY_PAYABLE, null, BigDecimal.valueOf(sum(submitted, "net_pay"))));
        ledger.post("PayrollEntry", entry.name(), Values.requireDate(entry, "posting_date"), lines,
                "Payroll accrual " + entry.getString("start_date") + " – " + entry.getString("end_date")
                        + " (" + submitted.size() + " slips)");
```

The voucher balances by construction: gross (debit 642) equals deductions (credits to 3383, 3384, 3386) plus net
(credit 334). In the recorded run: 2,680,000 + 502,500 + 335,000 + 32,320,001 = 35,837,501.

**Why the slip does not post itself.** One voucher per run keeps the ledger readable for the accountant and makes
cancellation a single reversal. The cost is that 334 holds a total, not a balance per employee (see "To be accurate").

Cancel is the mirror image: the entry cancels its own slips (binding `CANCELLING_ENTRY` so the slip guard lets them
through) and asks the ledger for one reversal. The ledger table itself refuses UPDATE and DELETE (see
[Video 12](/astra-cms/en/ledger)).

*`astra-apps/hrm/src/main/java/io/astra/apps/hrm/PayrollEntryHook.java`*
```java
    @Override
    public void onCancel(Document entry) {
        ScopedValue.where(SalarySlipHook.CANCELLING_ENTRY, entry.name()).run(() -> {
            for (Document slip : slips(entry)) {
                if (slip.docstatus() == 1) docs.cancel("SalarySlip", slip.name());
            }
        });
        ledger.reverse("PayrollEntry", entry.name());
    }
```

![Figure 2: The accrual voucher balances](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-19-figure-2.png)
*Submitting a payroll run posts a balanced general ledger accrual voucher across salary expense and payable accounts.*

### The AI agent's view

`PayrollSummaryTool` (`payroll_summary`) is an app-contributed MCP tool. Its `reads()` returns `PayrollEntry` and
`SalarySlip`, so the MCP server checks the caller's read permission on both DocTypes before the call; that is why
`tuan` gets "not permitted: read PayrollEntry". `employee_name` is marked `pii: true` in the DocTypes, so names leave
masked through the kernel's MCP masking (see [Video 15](/astra-cms/en/mcp)).

## Real-world example

A courier company in Ho Chi Minh City runs payroll for its delivery staff on the seeded COURIER structure. Take one
courier for September 2026 (26 working days) with a contract base of 7,000,000 ₫, one absent day and 820 parcels
delivered. The values below are computed by hand from the code's rules and the seed components; they are illustrative,
not output from a run.

**Inputs:** Attendance: one `Absent` record on a working day. Parcels entered with attendance: 820. Assignment: COURIER,
base 7,000,000.

**What Astra does:**

| Line | Rule | Amount (₫) |
| --- | --- | --- |
| BASIC | 100 % of 7,000,000 × 25/26 days | 6,730,769 |
| LUNCH | 730,000 fixed × 25/26 days | 701,923 |
| FUEL | 500,000 fixed × 25/26 days | 480,769 |
| KPI (Wasm) | 600 × 1,500 + 220 × 2,000; no attendance bonus (25 < 26) | 1,340,000 |
| **Gross** | | **9,253,461** |
| BHXH | 8 % of 7,000,000 (base, not prorated) | 560,000 |
| BHYT | 1.5 % of 7,000,000 | 105,000 |
| BHTN | 1 % of 7,000,000 | 70,000 |
| **Net** | gross − 735,000 | **8,518,461** |

**Outputs:** the slip carries every line with its `basis` string, so an employee query ("why is my lunch allowance
701,923?") is answered from the document. When HR submits the month's Payroll Entry, this courier's amounts are folded
into one voucher for the run: 9,253,461 into Debit 642, 560,000 into Credit 3383, and so on. If the regional manager
changes the parcel tariff next quarter, the tenant uploads a new Wasm module to the KPI component; the self-test runs on
save and no Java code changes.

A run is limited to 1,000 employees (`MAX_EMPLOYEES` in `PayrollEntryHook`). A company above that size would have to
split runs, for example by `designation` (the entry's filter field: All, Courier, Driver, Hub Staff, Office).

## For technical leaders

- **What this proves.** A money-sensitive, multi-document batch can be written as a Tier 0 app on the public SPI: the
  entry, its slips and its ledger voucher commit or roll back together, and an append-only ledger keeps cancellation
  auditable.
- **What it does not prove.** It is a demo payroll app, not payroll software. Personal income tax, the employer's
  insurance share, salary payout and bank files are absent (see below). Do not compare it feature by feature with
  Frappe HRMS.
- **Per-tenant formulas without code releases.** Wasm components move tariff changes out of the release train. The
  trade-off is that formulas are compiled modules, not a formula editor; HR cannot change them without a developer.
- **Run size and latency.** The batch runs inside the HTTP request that saves the entry, up to 1,000 employees. Measure
  request time and transaction length at your headcount before relying on it; a month-end background job with leader
  election is on the roadmap, not built.
- **Ledger granularity.** One voucher per run, totals per account. If your auditors need 334 per employee or cost-centre
  allocation, that is not there yet.
- **Data protection.** Employee names reach an AI agent masked, and the agent inherits the caller's permissions. Employees
  cannot yet read their own slips (`if-owner` applies to writes only).
- **When not to use it today.** Any real payroll that must file tax or pay staff. Use it to evaluate the platform's
  shape: SPI, hooks, Wasm, ledger and MCP working together on a real business flow.

## Try it yourself

Prerequisites: JDK 25, Docker with about 8 GB of RAM, `kubectl`, `k3d`, `jq`, Bash 5 (`brew install bash` on macOS).
The `hanh` user must exist in Keycloak; if the realm changed since your cluster was created, run
`scripts/keycloak-sync-realm.sh`.

```bash
make k3d-up                          # cluster: kernel pods, PostgreSQL, Redis, Keycloak, Seata, CDC worker
make demo-seed                       # loads September 2026 for saigon-express (and hanoi-express)
make demo-hrm                        # all scenes, Enter between steps
make demo-hrm SCENES="4 5 6" PAUSE=0 # open, inspect and submit a payroll run without pausing
```

Expected output (numbers from the recorded run; entry and slip names depend on how many runs you have opened):

- Scene 4: `HTTP 201` with `"docstatus": 0`, then four draft slips with `payment_days` 26.0, 24.5, 15.0 and 25.0.
- Scene 6: `"employees": 4`, `"gross": 35837501`, `"net": 32320001`, and five ledger lines for the voucher.
- Scene 8: the trial balance shows `"balance": 0.00` for 334, 3383, 3384, 3386 and 642.

Then open Desk at `http://localhost:8080/desk/` as `hanh` (password `Astra@123`, local stack only) to see the Payroll
Entry and Salary Slip forms. The integration test is `astra-server/src/test/java/io/astra/server/HrmPayrollTest.java`.

## To be accurate

From the script's "To be accurate" section for Video 19:

- **No personal income tax** is computed; net is gross minus the employee share of insurance. The employer share (BHXH,
  BHYT, BHTN, KPCĐ) is not computed. Insurance rates and caps are seed configuration for the demo and have not been
  checked against current regulations; the rule that 14 or more days of leave means no BHXH contribution is not handled.
- **Minimal attendance:** no holiday calendar, shifts, check-in, or leave requests with balances. The working week is
  fixed Monday to Saturday, and days without an attendance record count as worked. Parcel counts are entered by ops
  with attendance, not pulled from Waybill or STMS through CDC as the use case describes.
- **Only four calculation types** (Fixed, % of base, % of gross, Wasm), no formula editor. No salary payout journal entry
  (Debit 334 / Credit 112) and no bank file export. The accrual records totals per account, not per employee on 334;
  all cost goes to 642, with no cost-centre allocation.
- **The batch runs inside the request** that saves the entry (up to 1,000 employees). There is no month-end background
  job or leader election. Slips and the ledger share one PostgreSQL transaction, without Seata.
- **Employees cannot view their own slips:** the `if-owner` permission applies to writes only, not to reads.
- **No dedicated payroll Desk:** the generic Desk shows slip lines as "N item(s)".
- The figures in the script were computed from the hook's logic; the numbers in this article's "What the video shows"
  come from the recorded run, and the "Real-world example" values are hand-computed and illustrative.

## Figures

- [Figure 1 · One save, four slips, one transaction](#how-it-works)
- [Figure 2 · The accrual voucher balances](#one-accrual-voucher-per-run)
- [Figure 3 · Cancel adds rows, never deletes](#what-the-video-shows)
- [Figure 4 · The courier bonus tiers](#the-tenants-bonus-formula)

## Related

- Previous: [Video 17 · Seata: one submit, three resources](/astra-cms/en/seata). Next: [Video 20 · STMS: vehicle dispatch with Timefold + AI copilot](/astra-cms/en/stms). Roadmap: [Video 18](/astra-cms/en/roadmap).
- Mechanisms reused here: [Video 4 · lifecycle](/astra-cms/en/lifecycle), [Video 11 · permissions](/astra-cms/en/keycloak), [Video 12 · append-only ledger](/astra-cms/en/ledger), [Video 14 · Wasm Tier 2](/astra-cms/en/wasm), [Video 15 · MCP](/astra-cms/en/mcp).
- Design: [use-case-01.md](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/use-case-01.md) (module 4, `astra-app-hrm`, with a status note on what differs from the running slice).
- Source: [`HrmApp`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/astra-apps/hrm/src/main/java/io/astra/apps/hrm/HrmApp.java), [`PayrollEntryHook`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/astra-apps/hrm/src/main/java/io/astra/apps/hrm/PayrollEntryHook.java), [`SalaryCalculator`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/astra-apps/hrm/src/main/java/io/astra/apps/hrm/SalaryCalculator.java), [`SalarySlipHook`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/astra-apps/hrm/src/main/java/io/astra/apps/hrm/SalarySlipHook.java), [`PayrollSummaryTool`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/astra-apps/hrm/src/main/java/io/astra/apps/hrm/PayrollSummaryTool.java), [`courier-kpi`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/astra-apps/hrm/wasm/courier-kpi/src/lib.rs), [`demo-hrm.sh`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/scripts/demo-hrm.sh), test [`HrmPayrollTest`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/astra-server/src/test/java/io/astra/server/HrmPayrollTest.java).

