# Overview Astra CMS documentation: why it exists, how it is built, 22 demo videos and 21 deep-dive articles. 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/overview Astra is a Frappe/ERPNext-style, metadata-driven platform rebuilt in Java, designed to change continuously without downtime and to be AI-native. These docs cover the idea, the architecture, a series of short demo videos and one deep-dive article per video. ## Where to start - [Why Astra](/astra-cms/en/why-astra): the long-form talk behind the project. - [Architecture](/astra-cms/en/architecture): the one-page picture of how a write flows through the system. - [Demo videos](/astra-cms/en/videos): 22 short videos you can watch in order. - [Articles](/astra-cms/en/quickstart): one article per video, with code quoted from the repository. ## Source Repository: [ZeroHostCMS/astra-cms](https://github.com/ZeroHostCMS/astra-cms). The project is a running thin slice, not a finished product; every article ends with what is not built yet. --- # Why Astra English · Tiếng Việt · Back to the README 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/why-astra **English** · [Tiếng Việt](/astra-cms/en/why-astra) · Back to the [README](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/README.md) > A document for a public talk (meetup, conference talk, article), delivered as one continuous 40-minute talk. Each section has a **key point**, **reference platforms**, **rationale**, and **trade-offs**. Astra's performance figures are **design targets**, not measurements; other platforms' figures are their own claims. --- ## 0. Opening: what ERPNext proved **Key point:** ERPNext proved something the enterprise software industry had mostly given up on: **one open platform can run a whole company's content, commerce, accounting, inventory, manufacturing, HR and projects**, and small teams can customise it without forking it. The reason is not any single module but Frappe's core idea: **the application is metadata**. A DocType defines a document type's fields, validation, permissions, form, list, API and workflow as data; everything else (REST, UI, reports, print, notifications) is generated from it. Astra takes that idea and rebuilds it for four constraints Frappe was never designed for: 1. **Throughput and latency at cluster scale.** Java 25 virtual threads, Generational ZGC, Quarkus build-time wiring, in-process plugin isolation. Thousands of tenants and their customisations on one pod, instead of one process per site. 2. **Change every day without breaking yesterday.** Zero-DDL DocTypes (customising a form never locks a table), side-by-side app versions, per-tenant pinning, canary rollout with auto-rollback, an SPI with a deprecation policy, and tenant customisations stored as overlays that survive every upgrade. 3. **AI both builds apps and uses them.** Agents build apps against a small typed SPI with a sandbox and a test kit; agents use the platform through MCP tools generated from the same DocTypes, under the same permissions and audit. 4. **Compliance as a property of the metadata.** Every DocType field carries classification, retention, legal basis and residency. Records of processing, data-subject requests, retention schedules, audit trails and access reviews are all generated from that metadata. An operator can reach SOC 2 Type II, ISO 27001, GDPR and Vietnam's Personal Data Protection Law (Luật Bảo vệ dữ liệu cá nhân) without reworking the data model. > **Say two things up front:** (1) Astra is in the design phase; there is a **running thin slice** (CMS DocTypes, tenancy via ScopedValue + row-level security, generated REST, a Tier 0 hook, running on k3d with two pods), and the rest is blueprint. (2) **Clean-room:** Frappe is MIT, ERPNext is GPL-3.0, Astra is MIT. Astra uses no code, schema or assets from either; it borrows only concepts and vocabulary ("DocType"). Say this first so nobody mistakes it for a port. **One-sentence summary:** *Astra is a Frappe-style metadata-driven application platform, rewritten from scratch in Java 25, where everything is a plugin; the first app is a CMS, the second is an ERP; it changes every day with no downtime, and every DocType is also a tool for AI agents.* --- ## 1. Who we learn from | Platform | What they do | What Astra borrows | | --- | --- | --- | | **Frappe / ERPNext** | DocType metadata drives schema, form, API, permissions, workflow. Apps are installable units with hooks. Custom Fields and Property Setters let you customise core DocTypes without forking, and survive upgrades. Submittable documents (draft → submitted → cancelled; amend creates a new revision) give accounting-grade immutability. Bench runs many sites. | The DocType model, app-as-plugin, customisation-as-overlay, the submittable lifecycle, and the "everything generated from metadata" discipline. Rebuilt in Java with zero-DDL storage, cluster rollout, and tiers of isolation Frappe does not have. | | **Linux kernel** | Never break userspace. | `astra-spi` is the userspace ABI. | | **Kubernetes** | Alpha → beta → GA APIs; deprecated GA APIs keep working 12 months or 3 releases; rolling updates under PDBs. | SPI maturity levels, the same deprecation window, kernel rolling upgrades that drain transactions. | | **Stripe** | Dated API versions pinned per account. | App and API versions pinned per tenant. | | **Erlang / OTP** | Old and new code coexist; running calls finish on old code. | Side-by-side plugin versions, drain before release. | | **Shopify Functions** | Merchant logic as Wasm with deterministic instruction limits. | Tier 2 Wasm with budgets and per-invocation metrics. | | **Cloudflare Workers** | In-process isolates instead of containers; claims ~100x faster start. | In-process isolation instead of a microservice per customisation. | | **Envoy / Proxy-Wasm** | Control plane distributes Wasm filters to every proxy. | Plugin control plane delivering the same version to every pod. | | **VS Code** | Separate extension host, stable typed API, tens of thousands of extensions. | Strongly typed extension points + isolation. | | **Google SRE / Netflix** | Canary, error budgets, automated rollback. | Health gates, auto-rollback, DORA metrics per app. | | **Alibaba → Apache Seata** | Transaction coordination at Singles' Day scale. | AT on PostgreSQL, TCC on Redis Cluster, smart bypass. | | **MCP (Linux Foundation / AAIF)** | Open agent-tool standard; adopted by ChatGPT, Gemini, Copilot, Cursor, VS Code; 10,000+ servers. | DocTypes and extension points projected as MCP tools under the same permissions. | **How to tell it:** "We didn't ask 'how do you build an ERP'. We asked 'what does Frappe get right, what is Frappe missing, and how did Erlang, Stripe and Kubernetes solve the missing parts', then put it together for Java 25." --- ## 2. The DocType engine: the heart of the platform **Key point:** The DocType engine is what turns Astra from "a CMS with plugins" into a Frappe-class platform. A DocType fully describes a kind of business record. Everything a user or an agent touches is a DocType: a blog post, a customer, a sales invoice, a stock ledger entry, a workflow definition, even a DocType itself. ### 2.1 What a DocType declares Name, module, kind (master, transaction, child, setup, ledger, virtual), storage strategy, whether it is submittable, naming rules, change tracking, the field list (Link, Table, Currency, Select, fetch-from…), indexes on projected columns, permissions by role and perm level with row-level conditions, lifecycle hooks (validate, on_submit, on_cancel…), workflow, and the generated API config (REST, GraphQL, MCP, dated versions). ### 2.2 Storage: zero-DDL by default, ledgers where it matters Frappe creates one table per DocType and runs DDL when you add a Custom Field. Astra never runs DDL for a form change in production, and still gives accounting-grade storage where ERP needs it. | `storage` | Used for | How it works | DDL | | --- | --- | --- | --- | | `jsonb` (default) | Masters, transactions, settings, content | Core metadata in indexed relational columns; fields in JSONB with GIN. Fields marked `projected` get an **expression index** (`CREATE INDEX CONCURRENTLY` on `(data->>'field')`), which is genuinely online; generated queries use the same expression. Heavy sort/aggregate workloads use `projected: materialised`: add a nullable column (metadata only), backfill in batches with a job, the kernel keeps it in sync on write, then index `CONCURRENTLY`. **No stored generated columns** on tables with data, because PostgreSQL rewrites the whole table. | Never for fields; expression indexes are online, additive, reversible; materialised columns are online but take time to backfill | | `ledger` | GL Entry, Stock Ledger Entry, payroll postings, audit | Append-only relational tables, partitioned by period, fixed columns. Never updated; corrections are reversing entries. Written only inside `@AstraGlobalTransactional` by the owning app. | Owned by the app, evolved with expand/contract | | `virtual` | External systems | A `DocTypeAdapter` maps the DocType onto a legacy ERP/CRM/DB. Same API, permissions, tools. | n/a | **Line to say:** "The invoice lives in JSONB so it can be customised freely; its postings live in a ledger so they are immutable and auditable. Two strategies, one DocType engine." **Two rules so flexibility doesn't eat performance:** * **Projection and index quotas.** Each DocType has a projection and index quota (default 8 and 12), each tenant has an overlay projection quota, and materialised projections on large tables need an approval step. Going over quota is blocked during metadata validation, before it reaches the production database. * **Hot DocTypes need materialised columns.** A PostgreSQL `UPDATE` rewrites the whole row, so a DocType with a few fields that change many times a day (stock levels, outstanding amounts, counters, status) will bloat and overload vacuum if every field lives in JSONB. The rule: keep hot fields in materialised columns so the large JSONB stays untouched in TOAST; leave hot fields unindexed where possible so updates are HOT, and set `fillfactor` below 100; put counters and balances in `ledger` or Redis; split extremely churny status into a narrow side DocType. `jsonb` is the flexible default, not the choice for every case. ### 2.3 Document lifecycle, versions, amend * **Submittable** DocTypes carry `docstatus`: draft, submitted, cancelled. Submitted documents are immutable except for `allow-on-submit` fields. Cancelling reverses postings; **amend** creates a new revision (`INV-0001-1`) linked to the original. That is why an invoice is handled as a financial record, not a blog post. * **Track changes** stores a diff per save; versions are queryable. * **Links** are validated on write, protecting referential integrity across JSONB and ledgers; `fetch-from` copies denormalised values at write time, the way ERP documents expect. * **Child tables** are ordered sub-documents. * **Naming series** are cluster-safe (Redis counters, gap-free mode for fiscal documents). ### 2.4 Customisation as overlays that survive upgrades A tenant customises an app's DocType without forking it, and the customisation **survives every app upgrade**: add fields, change field properties, add permissions, attach a Wasm server script, attach a client script for Desk. Overlays are data, versioned, pinned per tenant, and applied at metadata-load time. An app upgrade that removes a field an overlay depends on is caught by the **conformance check** before rollout. This is the biggest lesson from Frappe: Custom Fields and Property Setters are why ERPNext customers can upgrade. Astra keeps the idea and tightens it with automated checks. ### 2.5 Everything generated from metadata REST `/api/doc/`, GraphQL, MCP tools, Desk UI (form, list, kanban, calendar, tree, report builder, dashboard, workspace), print/PDF, notification/email/webhook, search and vector index, OpenAPI/JSON Schema/client types. Write the DocType once, get all of it. ### 2.6 Reports and scripts Report Builder runs on projected columns. Query reports are parameterised SQL over projections and ledgers, reviewed like code. Script reports and server scripts run as Tier 2 Wasm with budgets, or Tier 1 Java for trusted apps. This is Frappe's Server Script idea with a real sandbox instead of a restricted interpreter. --- ## 3. Everything is a plugin ("app") **Key point:** An app is a plugin that ships DocTypes, lifecycle hooks, extension-point implementations, fixtures, patches, and Desk assets. Astra CMS and the Astra ERP modules are apps; a tenant's own module is an app too. The kernel has **no privileged shortcuts**. * An application = kernel + a list of apps in `astra.yaml`; `astra build` produces an immutable image. * An app can **define new extension points** for other apps, like the sales module's `PricingRule`, which a tenant implements in Wasm. * Extension points are **typed Java interfaces** with JSON Schema, validated at load, dispatched without reflection: DocType hooks, workflow, report, print, Desk, route, event, job, TCC branch, ledger, adapter, auth, tool, agent, model provider, CLI. ### Three isolation tiers, one SPI | | Tier 0: Shared (build-time) | Tier 1: Isolated JVM (runtime) | Tier 2: Wasm sandbox (runtime) | | --- | --- | --- | --- | | Loading | Compiled into the image, Quarkus `@BuildStep` + Jandex | Separate ClassLoader per app version | Chicory module, Extism-compatible ABI | | Dispatch cost | Direct call | `MethodHandle` bound at load | Wasm compiled to bytecode; interpreter on native | | Change requires | Image rebuild, rolling deploy | Hot-swap | Hot-swap | | Capabilities | Everything, including ledgers and new extension points | DocTypes, hooks, workflows, reports, routes, events, jobs, TCC, tools, agents | Server scripts, script reports, enrichers, validators, tools | | Failure blast radius | Whole pod | Bulkhead: timeout, concurrency limit, circuit breaker | Hard sandbox: memory, deadline, interrupt, no ungranted host calls | | Languages | Java/Kotlin | Java/Kotlin | Rust, Go, C/C++, C#, Zig, AssemblyScript, JS, Python (Extism PDK) | | Default for | Core apps (accounts, stock), anything on every request | Tenant modules, integrations, localisation | Tenant scripts, marketplace, **AI-generated code** | **How to tell it:** "Accounts and stock are Tier 0 because every document goes through them. Vietnamese localisation is Tier 1 because it changes with every new government circular. A tenant's credit-check script is Tier 2 because we don't trust that code yet but want it running now." Moving between tiers is a build flag. Chicory: pure Java, no JNI, with a Wasm → bytecode compiler. Extism ABI: ready-made PDKs for many languages; Astra implements the ABI on Chicory itself because Extism's official Chicory SDK is still experimental. Trade-offs: Chicory has no fuel metering yet (we use deadline + memory); native image is interpreter-only; Tier 1 is ClassLoader "leak-resistant", not leak-proof. ### App manifest: least privilege, with provenance `astra-app.yaml`: id, version, SPI range, tier, dependencies, DocType list, overlays, fixtures, patches, **capabilities** (hosts, secrets, DocTypes, read-only ledgers, LLM budget), tenancy, runtime limits, compat, rollout, and **provenance** (which person or tool generated it). Security review = manifest review. Sigstore signing, SBOM, publisher allow-lists, a policy that forces AI-generated code onto the Wasm tier. ### Cluster-wide hot-swap Publish → verify (signature, SPI, provenance) → announce via Redis Pub/Sub → canary by pod percentage with health gates → side-by-side load, warm-up, atomic dispatch re-point, drain, leak check → automatic cluster-wide rollback on health failure → new HPA pods pull current state at boot. --- ## 4. Continuous change without breaking: the compatibility contract **Key point:** Enterprise architects should hear this part first. *If we ship every day, does yesterday's feature still work, and how do we keep downtime at zero?* ### 4.1 The SPI is the ABI Apps depend only on `astra-spi`. Maturity levels are `alpha`/`beta`/`stable`. A deprecated `stable` element keeps working for at least **12 months or 3 minor releases**, with a structured warning. **Kernel N runs SPI N-1 apps** through a shim. **Conformance suite:** golden apps from every supported SPI version, **plus every registered tenant overlay**, run in CI; break one and it does not ship. ### 4.2 Versions coexist; tenants pin N app versions run side by side; an old version stays as long as a tenant pins it or a call is still in flight. Per-tenant pins for apps and APIs. Generated APIs are date-versioned and served side by side. ### 4.3 Data evolves without locks | Data | Mechanism | DDL | Rollback | | --- | --- | --- | --- | | DocType fields, overlays | Zero-DDL JSONB; upcasters on read, lazy rewrite; online projections | Never for fields | Instant | | Ledgers | Expand/contract enforced by CI; append-only | Additive, online | Previous version still reads | | Data patches | Idempotent, versioned, run per tenant on rollout, resumable | n/a | Compensating patch | | Events | `schemaVersion`; consumers declare ranges; upcasters | n/a | Old consumers keep working | | Cache | Keys include app and schema version; cluster-wide invalidation | n/a | No stale reads | ### 4.4 Zero-downtime matrix (one-slide summary) | Change | Mechanism | Downtime | Rollback | | --- | --- | --- | --- | | Tier 1/2 app, overlay, server script | Hot-swap, canary, drain | 0 | Seconds, automatic | | Tier 0 app | New image, rolling under PDB, transactions drained | 0 | Minutes | | DocType field, form, permission, workflow | Metadata version, zero-DDL | 0 | Instant | | Ledger schema | Expand/contract | 0 | Previous app still runs | | Prompt, model, agent policy | Versioned artifacts, same rollout path | 0 | Seconds | | Kernel minor / major | Rolling with shim / blue-green, tenants migrate by pin | 0 | Minutes / switch traffic back | | PostgreSQL / Redis / Seata TC | Standard HA; retries with idempotent TCC | 0 for reads, sub-second failover for writes | n/a | ### 4.5 Change is observable Every rollout is an event correlated with metrics. **DORA metrics per app** are built in. --- ## 4A. Multi-tenancy: a kernel property, not an app feature **Key point:** Every kernel service applies the tenant boundary itself. An app or agent cannot cross tenants even if it tries, because no API accepts a tenant from the caller. ### 4A.1 Model `Tenant` is a kernel `setup` DocType, so onboarding, suspension and offboarding are ordinary, audited document lifecycles. A Tenant declares: status, parent organisation (optional), isolation profile, region, resolution and identity providers (OIDC, SAML, SCIM), keys (BYOK, rotation), enabled apps with pins and configuration, overlays, API version, plan, quotas, feature flags. ### 4A.2 Resolution and context Resolution order: custom domain or subdomain, OIDC token claim, header for service accounts, path prefix for the Desk. The result is bound once per request in a `ScopedValue` and inherited by every virtual thread, subtask, hook, job and agent. A user can belong to several tenants; switching tenant is a new session with a new scope. ### 4A.3 Three isolation profiles | Profile | Data | Keys | Compute | Use for | | --- | --- | --- | --- | --- | | `shared-rls` (default) | One PostgreSQL cluster; `tenant_id` column + row-level security on every table, including ledgers and audit; partitioning by tenant hash for large tables | Per-tenant data keys under the tenant's KMS | Shared pods, bulkheads per tenant and per app | Most tenants; thousands per pod | | `database-per-tenant` | Own database, same schema, RLS still on | BYOK | Shared pods | Regulated customers, large tenants | | `cluster-per-tenant` | Dedicated PostgreSQL, Redis, storage, Astra pods | HYOK / HSM | Dedicated | Data sovereignty, air-gapped | Tenants move between profiles with `astra tenant migrate`: snapshot, dual-write window, cutover, verify; no interruption for reads and a sub-second write pause at cutover. ### 4A.4 What is scoped per tenant Data and ledgers (RLS, partitioning), metadata (base DocTypes global; overlays, pins, feature flags per tenant; effective metadata cached per tenant and version), apps (installed globally; enabled, pinned, configured per tenant), cache (keys include tenant), events and jobs (fair scheduling, per-tenant concurrency caps), transactions (Seata keys and Redis hash tags include tenant), search and vectors, object storage, identity (per-tenant IdP, MFA, SCIM), AI (per-tenant model, region, budgets, PII redaction, approval gates, opt-out), compliance (per-tenant RoPA, DSAR, retention, residency), observability (every span, metric and log tagged with tenant; per-tenant dashboards and SLOs). ### 4A.5 Organisations and sharing An `Organisation` groups tenants (a holding company and subsidiaries, a franchise network, an agency and its clients). Within an organisation, DocTypes can be declared `shareable` with an explicit policy (read-only masters such as Item or Chart of Accounts templates; consolidated reporting). Sharing is always opt-in, logged, and expressed as metadata; the default remains strict isolation. Multi-company within one tenant is handled by the `Company` DocType and is unrelated to tenancy. ### 4A.6 Fairness, quotas and metering Bulkheads per tenant and per app: virtual-thread limits, rate limits, job concurrency, agent token budgets. A noisy tenant degrades itself, not its neighbours. Quotas come from the plan, can be overridden per tenant, with soft limits before hard limits. Metering emits usage records (requests, storage, jobs, agent tokens, seats) as a `ledger` DocType for billing integrations. Astra does not ship billing; it ships the meter. ### 4A.7 Tenant lifecycle Provision (create the Tenant, keys, region placement, enable apps, run fixtures and patches, verify with the conformance suite) → customise (overlays, pins, configuration, IdP; versioned and audited) → suspend (requests rejected with a tenant-specific status, jobs paused, data retained) → export (all DocTypes, files, audit, metadata, overlays in an open format) → offboard (retention applied, erasure by key destruction, ledgers retained under legal obligation with pseudonymised references, deletion evidence recorded) → migrate (between profiles or regions). ### 4A.8 Single-tenant mode `tenancy.mode: single` keeps every mechanism but fixes one tenant: a self-hosted deployment for one company pays no tenancy overhead, and can later switch to `multi` without a data migration. **Line to say:** "One self-hosted company today, a SaaS serving thousands of companies tomorrow: same codebase, same data model, no migration." --- ## 4B. Compliance by Design SOC 2 Type II and ISO 27001 certify an *operator* (the organisation running the service) over an observation period, not software. GDPR and equivalent laws bind *controllers and processors*. No open-source codebase is "SOC 2 certified". What a platform can do is **make those outcomes cheaper**: ship the controls, generate the evidence, and keep the data model compliant by construction. A hosted Astra Cloud would pursue the certifications itself; self-hosters inherit the controls and the evidence pipeline. **Key point:** Most compliance effort is collecting evidence and knowing what data lives where, why, and for how long. If the DocType already answers those four questions for every field, then RoPA, DSAR, retention and residency are **generated**, not **done by hand**. ### 4B.1 Frameworks in scope | Framework | Scope | What Astra provides | | --- | --- | --- | | **SOC 2 Type II** | Security, Availability, Processing Integrity, Confidentiality, Privacy | Controls for criteria CC6 (access), CC7 (monitoring), CC8 (change management), A1, PI1, P-series; continuous evidence export; control matrix in `docs/compliance/` | | **ISO/IEC 27001:2022** + 27017/27018/27701 | ISMS, cloud, PII in cloud, privacy management | Annex A mapping, asset inventory from DocType metadata, access review workflows, sub-processor register | | **GDPR / UK GDPR** | EU/UK personal data | Classification, legal basis, RoPA generation, DSAR automation, retention and erasure, 72-hour breach workflow, DPIA templates, processing records, EU residency profile | | **Vietnam PDPL** (Law 91/2025/QH15, Decree 356/2025, effective 1 January 2026, replaces Decree 13/2023) | Personal data of Vietnamese data subjects | Basic vs sensitive data categories, consent records, impact assessment dossiers, cross-border transfer records, request-response timelines, VN residency profile | | **CCPA/CPRA, LGPD, PDPA (SG), APPI, PIPL** | Regional privacy laws | Same privacy engine with jurisdiction profiles; localisation rules (PIPL, VN) enforced by `ResidencyPolicy` | | **SOX ITGC** (ERP customers) | Change management, access, segregation of duties, audit trail | Signed, approved, audited rollouts; SoD rules on DocType actions; immutable ledgers; period locks; access reviews | | **PCI DSS 4.0.1** | Card data | Designed to keep card data **out of scope**: tokenisation via payment providers, no PAN storage | | **HIPAA** (optional profile) | US health data | Field-level encryption, access logging, BAA-ready controls | | **EU AI Act** (phased 2025-2027; high-risk dates being deferred by the Digital Omnibus) + **ISO/IEC 42001**, **NIST AI RMF** | AI systems and governance | Agent action logging, human-in-the-loop gates, model and prompt versioning, transparency notices, PII redaction before external model calls, tenant opt-out, AI inventory | | **EU DORA** (financial entities, since Jan 2025), **NIS2** | Operational resilience, ICT third-party risk | Resilience testing hooks, incident reporting workflow, RTO/RPO evidence, sub-processor register | | **EU Cyber Resilience Act** (reporting from Sept 2026, full application Dec 2027) | Products with digital elements | SBOM per release, vulnerability handling process, coordinated disclosure, security update policy; the Astra project operates as an open-source steward | | **WCAG 2.2 AA**, **EU Accessibility Act** (June 2025), **Section 508** | Accessibility | Desk UI built to WCAG 2.2 AA; automated and manual checks in CI | | **OWASP ASVS, NIST SSDF, SLSA, OpenSSF Scorecard, CIS Kubernetes** | Secure engineering and supply chain | ASVS Level 2 baseline, Level 3 for auth, payments, ledgers; SSDF-aligned SDLC; SLSA Level 3 builds; Scorecard in CI; hardened Helm defaults | ### 4B.2 Compliance metadata on DocTypes Compliance is declared where the data is declared. Each field can carry: `classification` (public → restricted), `pii` / `sensitive` (including special categories: health, biometric), `legal-basis` (consent, contract, legal-obligation…), `retention` (period, anchor, reason, then delete / anonymise / archive), `residency` (tenant-region, eu-only, vn-only), `encrypt` (field, deterministic for searchability), `mask` and `dsar` (export / redact / exclude). From this the platform **generates**: the Record of Processing Activities, a data map per tenant, DSAR packages, retention schedules, DPIA skeletons and Vietnam PDPL impact-assessment dossiers, and the data inventory ISO 27001 asks for. **Line to say:** "In Astra, the question 'where does our personal data live?' is answered by a metadata query, not a three-month project." ### 4B.3 Controls in the kernel * **Identity and access.** OIDC and SAML SSO, MFA enforcement, SCIM provisioning and deprovisioning, RBAC plus attribute rules, least-privilege defaults, periodic **access reviews** generated as workflows, break-glass accounts with mandatory justification and auto-expiry. * **Segregation of duties (SoD).** DocType-level rules: the user who creates a Payment Entry cannot submit it; approval chains are workflow states; violations are blocked and logged. * **Tamper-evident audit trail.** Every DocType change, permission change, rollout, agent action, admin action and export is written to an append-only `ledger` DocType with a hash chain, exportable to WORM and SIEM (`AuditSink`: OpenTelemetry, syslog, OCSF). Retention and legal hold apply to audit too. * **Encryption and keys.** TLS 1.3, encryption at rest, field-level encryption, keys from `KeyProvider` (AWS KMS, GCP KMS, Azure Key Vault, Vault, HSM), **BYOK per tenant**, rotation, crypto-shredding for erasure where records must stay immutable. * **Privacy operations.** DSAR portal and API: access, export (JSON/CSV), rectification, erasure, restriction, portability, objection, with deadlines tracked per jurisdiction. Erasure on ledgers: pseudonymise the subject reference and shred the key, preserving the financial record under the legal-obligation basis. Consent is a DocType with versioned purposes. Breach workflow with a 72-hour clock. * **Data residency and sovereignty.** Tenants pin a region; `ResidencyPolicy` enforces it on primary storage, caches, backups, search, object storage, **and LLM providers**. Profiles `eu`, `us`, `vn`, `sg`, `custom`. No cross-region replication of PII unless the transfer record permits it. * **Change management.** Signed artifacts with SBOM and provenance; four-eyes approval for production; environment separation; every rollout audited; canary and rollback as compensating controls; DORA metrics as evidence. * **Availability and resilience.** HA on every tier, encrypted backups with PITR, **restore tests as scheduled jobs with evidence**, published RPO/RTO, chaos and failover testing hooks, incident workflow. * **Vulnerability and supply chain.** SCA, SAST, DAST and container scanning in CI; CVE response SLAs; `SECURITY.md`; CycloneDX SBOM; SLSA Level 3 provenance; OpenSSF Scorecard; annual third-party penetration test for the reference deployment. * **Tenant isolation.** Row-level security, per-tenant keys, bulkheads against noisy neighbours, and **DB-per-tenant** or **cluster-per-tenant** profiles for regulated customers. * **AI governance.** Every agent action logged with model, prompt version, inputs hash and outputs; **human approval gates** for high-impact actions (posting ledgers, changing permissions, bulk operations) configurable per tenant; PII redaction before external model calls; region-pinned or local models; AI inventory and transparency notices generated from metadata. * **Accessibility.** Desk built to WCAG 2.2 AA, automated axe checks and manual audits. ### 4B.4 Evidence automation `astra compliance evidence --framework soc2 --period 2026-Q3` exports access-review records, change records with approvals, backup and restore results, vulnerability SLAs, incident logs, and audit-trail integrity proofs. Connectors push the same evidence to compliance automation platforms (Vanta, Drata, Secureframe…). Control catalogues are kept in machine-readable form (OSCAL) under `docs/compliance/`. ### 4B.5 Shared-responsibility matrix | Area | Astra platform | Operator / tenant | | --- | --- | --- | | Controls implemented in software | Provides and tests | Configures, reviews | | Evidence generation | Automates and exports | Retains, presents to auditors | | Policies, risk assessment, training, vendor management | Templates and registers | Owns | | Certification (SOC 2 report, ISO certificate) | Astra Cloud pursues for itself | Self-hosters pursue for their own scope | | Legal roles (controller, processor, DPO) | Supports both roles | Decides and documents | --- ## 5. Toward an ERP: Astra ERP on Astra **Key point:** Astra ERP is the proof that the platform is general. It is built the way ERPNext is built on Frappe: **every module is an app, every record is a DocType, every posting is a ledger entry**. It copies ERPNext's *shape*, not its code. | Module (app) | Core DocTypes | Ledgers | Notes | | --- | --- | --- | --- | | `erp.core` | Company, Fiscal Year, Currency, Exchange Rate, UoM, Address, Contact | | Multi-company and multi-currency from day one | | `erp.accounts` | Account (tree), Journal Entry, Payment Entry, Sales/Purchase Invoice, Tax Template, Cost Center, Period Closing | **GL Entry** | Double-entry; postings are reversed, never edited; period locks | | `erp.stock` | Item, Warehouse (tree), Stock Entry, Delivery Note, Purchase Receipt, Batch, Serial No | **Stock Ledger Entry** | FIFO / moving average; backdated repost as a job; reservation via Redis TCC for flash sales | | `erp.selling` | Customer, Quotation, Sales Order, Pricing Rule | | Pricing Rule is an extension point tenants implement | | `erp.buying` | Supplier, RFQ, Purchase Order | | | | `erp.manufacturing` | BOM, Work Order, Job Card, Routing | | | | `erp.hr` | Employee, Leave, Attendance, Payroll Entry, Salary Slip | **Payroll Ledger** | | | `erp.projects` / `erp.crm` / `erp.assets` | Project, Task, Timesheet / Lead, Opportunity / Asset, Depreciation | GL via `erp.accounts` | | Platform features that ERP needs and the kernel already provides: * **Posting a Sales Invoice** touches GL Entry, Stock Ledger Entry, customer outstanding, and inventory reservations in Redis. That is **one** `@AstraGlobalTransactional` method: Postgres via Seata AT, Redis via TCC, all-or-nothing. * **Month-end close** on 50 pods: period locks are DocType state, enforced by the kernel in lifecycle hooks, not by application code remembering to check. * **Localisation** (e-invoice, VAT, Vietnamese chart of accounts) ships as a Tier 1 app with overlays and fixtures, pinned per tenant, upgraded independently of `erp.accounts`. * **Agents in the back office:** "create a journal entry for this bank statement line", "explain this variance" run as `AgentContributor` with role-scoped MCP tools over DocTypes, with budgets and audit. **Compared with Frappe/ERPNext (fair, not dismissive):** Frappe uses Python and MariaDB/Postgres, one table per DocType, DDL when a Custom Field is added, Gunicorn workers and RQ queues, one database per site. That is a sound choice for SMEs and a broad community. Astra targets cluster scale, high-density multi-tenancy, zero-DDL, multi-tier isolation, cross-store transactions, and an AI surface. Two philosophies, one core idea. --- ## 6. Performance: principles and targets No reflection on the hot path; DocType reads served from L1/L2 cache keyed by metadata version; writes go through projected columns so reports never scan JSONB; bulkheads per app; zero cost when idle; in-process isolation; structured concurrency for fan-out. Every target is stated with the **conditions under which it is measured**. | Metric | Condition | Target (to be validated by public benchmarks) | | --- | --- | --- | | DocType get by id | metadata in L1, document in L2 or PostgreSQL by primary key | p99 < 2 ms | | DocType list, 1M rows per tenant | equality or range filters on at most 2 indexed projections, no joins, page of 50 | p99 < 20 ms | | Document save, single store | smart bypass, no global transaction, 1 hook | p99 < 15 ms | | Sales Invoice submit, global transaction | Seata AT on PostgreSQL + TCC on Redis, Raft coordinator in region, 2 branches | p50 < 40 ms, p99 < 150 ms | | Tier 1 hook, dispatch only | `MethodHandle` vs direct call | < 1 µs | | Tier 1 hook, end to end | dispatch + capability check + context propagation + audit span | < 50 µs | | Tier 2 script, warm | pooled, already-compiled module, small input | p99 < 5 ms | | Tier 2 script, cold compile | Chicory Wasm→bytecode, done at rollout warm-up, off the request path | < 500 ms per module | | App rollout, propagation | announce → every pod has the new version loaded and warmed in standby, 50 pods | < 2 s | | App rollout, cutover | atomic dispatch re-point on one pod | < 10 ms | | App rollout, drain | in-flight calls finish on the old version | bounded by request timeout | | Kernel pod ready, JVM | Tier 0 wired at build time, Flyway no-op, warm registry | < 5 s | | Kernel pod ready, native | same | < 1 s | Three more things, said plainly: a density of "thousands of tenants per pod" is a **hypothesis until benchmarked** with real overlay, cache and ClassLoader footprints; the Seata TC is a central component, kept off the hot path by smart bypass and **sharded by transaction service group** (each group a Raft cluster), so TC throughput scales with the number of groups, not the number of pods; JVM heap per Tier 1 app is **observable but not enforceable**, which is one more reason untrusted code belongs in Tier 2. **Java 25 and Quarkus (briefly):** unpinned virtual threads (JEP 491), Scoped Values final (JEP 506), Structured Concurrency fifth preview (JEP 505, wrapped behind an executor), Generational ZGC by default (JEP 490, `-XX:+UseZGC`). Quarkus build-time augmentation is the Tier 0 mechanism; full JDK 25 support from 3.31, LTS 3.27. Java 21 is not enough; Spring Boot relies on runtime reflection, which works against Tier 0; the price is writing the Seata extension ourselves. --- ## 7. AI-native: built by AI, used by AI **For agents that build apps:** the SPI as a whole is large: twenty-plus extension points, the DocType model, manifests, overlays and compliance metadata. What makes it workable for an agent is that each task is narrow: a hook touches two interfaces, a DocType is one YAML file, a Wasm script is one exported function. `AGENTS.md` is organised per extension point so an agent loads only the slice it needs, and `astra mcp --dev` answers "what exists" from the live kernel. The test kit is what makes agent-written apps trustworthy. Two more disciplines for the tool surface: namespacing (`doc..`, `app..`), exposing only DocTypes the tenant declares, tool packs by role, paginated `tools/list`; and **grounding**: answers about business data must cite the DocTypes and ledger entry ids they used, and the gateway rejects unsourced answers for financial DocTypes. The test kit gives a pass/fail loop (contract tests, capability assertions, budgets, **assertions that overlays survive upgrades**). AI-generated code defaults to Tier 2 Wasm; `provenance.generator` is recorded; policy can require the sandbox. Canary + auto-rollback catch failures under real traffic. The repository ships `AGENTS.md`, `CLAUDE.md` and `llms.txt`. **For agents that use an application:** every DocType (search, get, create, submit, cancel), event, job and `ToolContributor` is projected as an MCP tool with the DocType's JSON Schema, scoped by role and tenant, audited like any call. `AgentContributor` runs budgeted agents with scoped memory (DocTypes + pgvector). `ModelProvider` abstracts Claude, OpenAI, Gemini and local models. Prompts and evals are versioned artifacts on the same rollout path. **Line to say:** "AI writes, the platform verifies, the cluster rolls out, and if it is wrong the cluster rolls itself back. And the AI accountant works on the same DocTypes, with the same permissions and the same audit, as the human accountant." --- ## 8. Comparison table (one-slide summary) | Platform | Metadata-driven | Typed extension points | Hot-swap without restart | Side-by-side versions, tenant pins | Isolation | Zero-DDL customisation | Cross-store transactions | Agent tool surface | | --- | --- | --- | --- | --- | --- | --- | --- | --- | | Frappe / ERPNext | **Yes** | Python hooks | Partial (bench restart) | No | No | No (DDL) | No | Bolt-on | | WordPress / Strapi | No | No | Yes | No | No | No | No | Bolt-on | | Keycloak SPI | No | Yes | No | No | No | n/a | No | No | | OSGi / Liferay | Partial | Yes | Yes | Partial | ClassLoader | n/a | No | No | | Shopify Functions | n/a | Yes | Yes | Per app | Wasm | n/a | n/a | n/a | | **Astra** | **Yes** | **Yes** | **Tier 1 & 2** | **Yes, per tenant** | **Shared / JVM / Wasm** | **Yes** | **Seata AT + TCC** | **MCP, capability-scoped** | --- ## 9. Risks and trade-offs (for Q&A) | Risk | Level | Mitigation | | --- | --- | --- | | Very large scope (platform + CMS + ERP) | High | 9-phase roadmap; kernel and DocType engine first, ERP later; each ERP module is an independent app | | Overall complexity | High | Everything can be turned off; a small project runs one pod, single Postgres + Redis, no Seata, no MCP | | Self-written Seata extension for Quarkus; Seata still incubating | High | Prioritised in Phase 4; public benchmarks; Seata is widely used in Chinese e-commerce | | Is JSONB enough for ERP | Medium | Projected columns for filters/reports; append-only relational ledgers for GL/SLE; 1M rows per tenant benchmark | | Chicory lacks fuel metering; Extism Chicory SDK is experimental | Medium | Deadline + memory cap; own ABI implementation | | Structured Concurrency in preview | Medium | Wrapped behind an executor | | Tier 1 ClassLoader leaks | Low to medium | Lifecycle tracking, Tier 2 recommended | | SPI and DocType model must stabilise early | Medium | Maturity levels, deprecation, shims, conformance suite including overlays | | Performance targets not yet measured | Medium | Benchmarks in Phase 2, revised against real numbers | | AI-generated code in production | Medium | Sandbox by default, provenance, contract tests, canary | | Licence confusion with ERPNext | Medium | Clean-room stated in README and CONTRIBUTING; no PRs that copy GPL code | | Overpromising "compliance" | Medium | Say it plainly: the platform provides controls and evidence; certification belongs to the operator; public shared-responsibility matrix | | Changing laws (AI Act deadlines deferred, PDPL has new decrees) | Medium | Jurisdiction profiles are data, updated like apps; no law hard-coded into the kernel | | Erasing personal data on immutable ledgers | Medium | Pseudonymised references + crypto-shredding; financial records retained under the legal-obligation basis; documented in the DPIA | --- ## 10. Roadmap Rebuilding a Frappe-class platform with a three-tier sandbox, distributed transactions, multi-tenancy, a compliance engine and an AI surface is years of work for a team; a single repository cannot finish it in a year. The roadmap therefore has two readings: the **thin slice** is what gets built and demonstrated first, end to end; later phases are sequenced so each phase ships something usable on its own. The compliance table is a catalogue of **profiles enabled over time**, not a single Phase 5 deliverable. **Thin slice (first demo, in progress):** one DocType-driven app (CMS `Article`), tenancy by `ScopedValue` and row-level security, zero-DDL JSONB storage with generated REST, one Tier 0 hook, running on Docker Compose and k3d. Next increments, in order: a minimal `GL Entry` ledger with a submittable invoice; a Seata spike proving AT on PostgreSQL + TCC on Redis on virtual threads; one Tier 2 Wasm script; one canary rollout across two pods; a thin MCP server with `doc.search / get / create`. Each increment comes with a public benchmark. 1. **Phase 1 (in progress):** Maven skeleton, `astra-spi`, DocType model and YAML schema **including compliance attributes**, manifest, maturity levels, deprecation, `AGENTS.md`/`llms.txt`, `SECURITY.md`, OSCAL catalogue skeleton. 2. **Phase 2:** kernel, three-tier plugin host **with instruction counting for Tier 2**, **tenancy core** (Tenant DocType, resolution, `ScopedValue` context, RLS, `shared-rls` profile, bulkheads), DocType engine core (JSONB, expression-index projections with quotas, naming, links, child tables, permissions, submittable, versions), **tamper-evident audit ledger from the first write**, generated REST, **thin MCP** (`doc.search / get / create`), **Seata spike** (AT + TCC + virtual threads, go/no-go for Phase 4), testkit, SLSA Level 3 build pipeline, first benchmarks. 3. **Phase 3:** control plane and compatibility: registry, signatures, SBOM, four-eyes approval, canary, shims, conformance suite including overlays, data patches, DORA. 4. **Phase 4:** Seata extension (or the fallback chosen by the Phase 2 spike: transactional outbox plus idempotent TCC without a central coordinator), ledger storage, materialised projections with backfill jobs, cache bus, upcasters, date-versioned APIs. 5. **Phase 5: Compliance & Tenancy Pack.** Identity (OIDC/SAML, MFA, SCIM per tenant), access reviews, SoD, `database-per-tenant` and `cluster-per-tenant` profiles, tenant migration, organisations and sharing policies, quotas and metering, `KeyProvider` with BYOK and field-level encryption, `ResidencyPolicy` and region profiles (eu, us, vn, sg), privacy engine (RoPA, DSAR, consent, retention, crypto-shredding, breach workflow), `AuditSink` to SIEM and compliance platforms, evidence export CLI, SOC 2 / ISO 27001 / GDPR / PDPL mappings published. 6. **Phase 6:** Desk to WCAG 2.2 AA, overlays, workflow, reports, print, notifications. 7. **Phase 7:** AI surface and AI governance: MCP, LLM gateway with PII redaction and region pinning, agent runtime with budgets and approval gates, prompt registry, AI inventory. 8. **Phase 8:** CIS-hardened Helm, Operator, Seata Raft, OpenTelemetry, Prometheus, backup/restore evidence jobs, failover testing, third-party penetration test. 9. **Phase 9:** Astra CMS. 10. **Phase 10:** Astra ERP: core, accounts, stock, then selling, buying, manufacturing, hr, projects, crm, assets; SOX ITGC evidence; Vietnam localisation (e-invoice, VAT, PDPL dossiers) as the reference Tier 1 app; signed marketplace. --- ## 11. Closing line > *"Astra is a Frappe-style, metadata-driven application platform, rewritten from scratch in Java 25. Everything is a plugin, including the CMS and the ERP. DocTypes are data, customisations are overlays that survive upgrades, postings are immutable ledger entries, and compliance is a property of the metadata. Versioned apps run side by side, pinned per tenant, hot-swapped across the cluster with a canary that rolls itself back. Every write across PostgreSQL and Redis is one real transaction. Every DocType is also a tool for AI agents, under the same permissions and audit. Ship every day, never break yesterday, and be ready for an auditor in any country."* --- ## Appendix: expected questions **"Is this a port of ERPNext to Java?"** - No. Frappe is MIT, ERPNext is GPL-3.0, Astra is MIT. No code, schema, fixtures or assets are used. Only the concepts are borrowed: DocType, app, overlay, submittable, and the shape of the ERP modules. Every implementation is original. **"Why not just use ERPNext?"** - ERPNext is very good for SMEs. Astra targets cluster scale, high-density multi-tenancy, zero-DDL customisation, multi-tier isolation for third-party and AI code, transactions across Postgres and Redis, and an AI surface. If you do not need those things, ERPNext is the better choice. **"Can JSONB handle ERP?"** - Flexible fields live in JSONB, filter/report fields in indexed projected columns, postings in append-only relational ledgers. No report query scans JSONB. The 1M rows per tenant benchmark comes in Phase 2. **"Does upgrading an app break tenant customisations?"** - Overlays are versioned data, applied at metadata-load time; the conformance suite runs every tenant's overlays before rollout; if something breaks, it does not ship. **"Is it really zero downtime?"** - Tier 1/2, overlays, DocTypes, prompts: 0, with rollback in seconds. Tier 0 and the kernel: rolling under a PDB. Infrastructure failover: sub-second for writes, to be straight about it. **"Is it safe to run AI-generated code in production?"** - Wasm sandbox by default with budgets; provenance recorded; contract tests; canary + auto-rollback. More layers than most human-written code gets. **"Is Seata production-ready?"** - It entered the Apache Incubator in 10/2023, release 2.6 (2/2026), is graduating, and is widely used in Chinese e-commerce. The immature part is the Quarkus bridge, and that is the work Astra does. **"Is MCP stable?"** - It was donated to the Agentic AI Foundation under the Linux Foundation in 12/2025, co-founded by Anthropic, Block and OpenAI, backed by Google, Microsoft and AWS. An open, multi-vendor standard. **"Is Astra SOC 2 or ISO 27001 certified?"** - No open-source codebase is certified; certification belongs to the operator, over an observation period. Astra provides the controls, generates the evidence, and keeps the data model compliant by construction, so the operator reaches certification faster and cheaper. The shared-responsibility matrix is public in the README. **"Does Vietnamese customer data have to be stored in Vietnam?"** - The tenant pins region `vn`; `ResidencyPolicy` enforces it on storage, cache, backups, search, and LLM calls too. Cross-border transfer dossiers under Law 91/2025 and Decree 356/2025 are generated from metadata. **"How do you erase personal data when postings must be immutable?"** - Pseudonymise the subject reference and destroy the encryption key (crypto-shredding); financial records are retained under the legal-obligation basis; documented in the DPIA. This is what GDPR allows and accounting requires. **"Does an AI agent posting entries need human approval?"** - By default, yes. Human approval gates are configurable per tenant for high-impact actions; every agent action is logged with the model and prompt version; PII is redacted before external models are called. **"Does multi-tenancy slow things down or risk leaks?"** - The tenant boundary is applied by the kernel through `ScopedValue` and RLS; no API accepts a tenant from the caller. Bulkheads are per tenant, so a noisy tenant only slows itself down. Customers who need stronger separation choose a dedicated database or cluster without changing code. **"Where is the code? Where are the performance numbers?"** - There is a running thin slice in the repo: `make k3d-up && make demo` brings up a two-pod k3d cluster and proves tenant isolation end to end. The rest is blueprint; benchmarks come in Phase 2. Transparency matters more than impressing. **"One lesson from the demo itself?"** - The first version bypassed row-level security without a single error, because the application connected as the PostgreSQL superuser. Now the kernel refuses to start if its role can bypass RLS. That is why "fail-closed" has to be the default, not an option. --- # Architecture One Quarkus server, one DocType engine, PostgreSQL with row-level security; apps, Wasm, Seata, Keycloak and a CDC worker around it. 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/architecture ![Astra architecture: every write goes through one engine](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/architecture/astra-architecture.en.png) Clients (the Desk web UI, the mobile app and AI agents over MCP) call one Quarkus server. A request passes the REST API and the tenant-scoping kernel into the DocType engine, which writes to PostgreSQL under row-level security. ## What is in the picture - **Kernel and DocType engine:** one JSONB table holds every DocType; overlays add per-tenant fields without migrations. - **Apps:** plain Java and YAML on `astra-spi`, hooked into the write path (CMS, Express, HRM, STMS). - **Wasm host:** per-tenant business formulas run in a sandbox with time limits. - **Transactions:** Seata (AT and TCC) with Redis coordinates writes that span services. - **Identity:** Keycloak with OIDC, SAML SSO and SCIM provisioning. - **CDC worker:** a Rust worker reads the write-ahead log and keeps read replicas up to date. The interactive version is [astra-architecture.en.html](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/architecture/astra-architecture.en.html). The articles walk through each part: [DocType](/astra-cms/en/doctype), [multi-tenancy](/astra-cms/en/tenants), [Seata](/astra-cms/en/seata), [Keycloak](/astra-cms/en/keycloak), [Wasm](/astra-cms/en/wasm) and [CDC](/astra-cms/en/cdc). --- # IAM: integrating the Flutter app How the mobile app signs users in to Astra and calls the API. The local stack (make k3d-up) serves the API and the Keycloak login page from one origin, http://localhost:8080 by default. 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/iam-mobile How the mobile app signs users in to Astra and calls the API. The local stack (`make k3d-up`) serves the API and the Keycloak login page from one origin, `http://localhost:8080` by default. ## What the app talks to | Item | Value (local stack) | | --- | --- | | Bootstrap config (public) | `GET {BASE}/api/iam/config` | | Issuer | `{BASE}/auth/realms/astra` | | Discovery | `{BASE}/auth/realms/astra/.well-known/openid-configuration` | | Client id | `astra-mobile` (public client, no secret) | | Flow | Authorization Code + PKCE (S256) in the system browser. Password grant is disabled for this client. | | Redirect URI | `io.astra.mobile:/oauthredirect` (also the post-logout redirect) | | Scopes | `openid profile email`, plus `offline_access` for "stay signed in" | | Languages | `vi` (default) and `en`. Pass `ui_locales` to pick one. | | API auth | `Authorization: Bearer ` | | Tenant | `X-Astra-Tenant: `. This header is optional when the user belongs to exactly one tenant. | `{BASE}` is the value of `ASTRA_PUBLIC_URL` that the cluster was started with. Keycloak writes it into every token as the issuer, and the server rejects tokens from any other issuer. The app must therefore reach the stack through exactly that origin. ## Reaching the laptop from a device | Device | Setup | | --- | --- | | iOS simulator | Works as is with `http://localhost:8080`. | | Android emulator or USB phone | Run `adb reverse tcp:8080 tcp:8080`, then use `http://localhost:8080`. | | Phone on Wi-Fi | Run `ASTRA_PUBLIC_URL=http://:8080 make k3d-up`, then use that URL in the app. | The local stack is plain HTTP. Allow it in debug builds only: * **Android:** pass `allowInsecureConnections: true` to `flutter_appauth` requests, and allow cleartext traffic to the dev host with a debug-only `network_security_config.xml`. * **iOS:** add `NSAppTransportSecurity` → `NSAllowsLocalNetworking = YES` to the debug `Info.plist`. If token calls to a LAN IP are still blocked, add a debug-only exception for that host. ## Flutter setup ```yaml # pubspec.yaml dependencies: flutter_appauth: ^12.1.0 flutter_secure_storage: ^11.2.0 http: ^1.6.0 ``` Android, `android/app/build.gradle`: ```groovy android { defaultConfig { manifestPlaceholders += [appAuthRedirectScheme: 'io.astra.mobile'] } } ``` iOS, `ios/Runner/Info.plist`: ```xml CFBundleURLTypes CFBundleURLSchemes io.astra.mobile ``` ## Sign in, register, refresh, sign out ```dart import 'dart:convert'; import 'package:flutter_appauth/flutter_appauth.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:http/http.dart' as http; class AstraAuth { AstraAuth(this.base); // e.g. 'http://localhost:8080' final String base; final _appAuth = const FlutterAppAuth(); final _store = const FlutterSecureStorage(); static const clientId = 'astra-mobile'; static const redirect = 'io.astra.mobile:/oauthredirect'; static const scopes = ['openid', 'profile', 'email', 'offline_access']; String get discovery => '$base/auth/realms/astra/.well-known/openid-configuration'; /// Opens the Astra login page. Set [register] to open the sign-up form instead. Future signIn({String locale = 'vi', bool register = false}) async { final r = await _appAuth.authorizeAndExchangeCode(AuthorizationTokenRequest( clientId, redirect, discoveryUrl: discovery, scopes: scopes, additionalParameters: { 'ui_locales': locale, if (register) 'prompt': 'create', }, allowInsecureConnections: true, // local HTTP stack only )); await _save(r.accessToken, r.refreshToken, r.idToken); } /// Exchanges the refresh token. Call on 401, and after joining a tenant or a role change. Future refresh() async { final rt = await _store.read(key: 'refresh_token'); if (rt == null) return false; try { final r = await _appAuth.token(TokenRequest(clientId, redirect, discoveryUrl: discovery, refreshToken: rt, scopes: scopes, allowInsecureConnections: true)); await _save(r.accessToken, r.refreshToken ?? rt, r.idToken); return true; } catch (_) { await _store.deleteAll(); // refresh token expired or revoked: sign in again return false; } } Future signOut() async { final idToken = await _store.read(key: 'id_token'); await _appAuth.endSession(EndSessionRequest( idTokenHint: idToken, postLogoutRedirectUrl: redirect, discoveryUrl: discovery, allowInsecureConnections: true, )); await _store.deleteAll(); } Future accessToken() => _store.read(key: 'access_token'); Future _save(String? at, String? rt, String? idt) async { await _store.write(key: 'access_token', value: at); await _store.write(key: 'refresh_token', value: rt); await _store.write(key: 'id_token', value: idt); } } /// Calls the Astra API with the bearer token and tenant header; retries once after a refresh on 401. class AstraApi { AstraApi(this.auth, {this.tenant}); final AstraAuth auth; String? tenant; Future send(String method, String path, {Object? body}) async { Future once() async { final req = http.Request(method, Uri.parse('${auth.base}$path')) ..headers['Authorization'] = 'Bearer ${await auth.accessToken()}' ..headers['Content-Type'] = 'application/json'; if (tenant != null) req.headers['X-Astra-Tenant'] = tenant!; if (body != null) req.body = jsonEncode(body); return http.Response.fromStream(await req.send()); } var res = await once(); if (res.statusCode == 401 && await auth.refresh()) res = await once(); return res; } } ``` ## After sign-in 1. Call `GET /api/iam/me`. It returns the user's profile, `platformAdmin`, and a `tenants` list. Each entry holds the user's `roles` in that tenant and the effective `permissions` per DocType, for example `{"Article": {"read": true, "create": true, "write": "own"}}`. Use these to show or hide actions. The server enforces the same rules either way. 2. If `tenants` is empty, the user just self-registered. Join the app's tenant with `POST /api/iam/tenants/{tenant}/join`, or let the user pick one from `GET /api/iam/tenants/public`. The user joins as `reader`. The response contains `"refreshToken": true`. Call `refresh()` before using the tenant, because the current access token was issued before the join. 3. If the user belongs to several tenants, send `X-Astra-Tenant` on every document call. Role changes made by a tenant admin reach the user's token at the next refresh, within 5 minutes. ## API reference | Method and path | Who | Purpose | | --- | --- | --- | | `GET /api/iam/config` | anyone | Issuer, discovery URL, client id, redirect URI, scopes, locales | | `GET /api/iam/me` | signed in | Profile, memberships, effective permissions | | `GET /api/iam/tenants` | signed in | Tenants the caller belongs to (all of them for a platform admin) | | `GET /api/iam/tenants/public` | signed in | Tenants open to self-join | | `POST /api/iam/tenants/{t}/join` | signed in | Join a public tenant as `reader`. Repeating it is harmless. | | `GET /api/iam/tenants/{t}/members` | tenant admin, platform admin | List members and their roles | | `POST /api/iam/tenants/{t}/members` | tenant admin, platform admin | `{username, email, firstName, lastName, role, temporaryPassword}`. Creates the user if new. The temporary password must be changed at first login, on the themed page. | | `PUT /api/iam/tenants/{t}/members/{userId}` | tenant admin, platform admin | `{role}`. Each user has one role per tenant. | | `DELETE /api/iam/tenants/{t}/members/{userId}` | tenant admin, platform admin | Remove the user from the tenant. The last admin cannot be removed or demoted (409). | | `POST /api/iam/tenants` | platform admin | `{id, name, publicSignup}`. Creates the tenant and its role groups. | | `PUT /api/iam/tenants/{t}` | platform admin | `{name, publicSignup}` | | `/api/doc/{DocType}` | tenant member | Generated document API, checked against the DocType's `permissions` | Tenant roles are `admin`, `editor`, `author` and `reader`. For `Article`, admins and editors can read, create and write everything. Authors can read and create, and can write only their own documents. Readers can only read. A platform admin manages tenants and members but gets no document access unless also made a member. Errors are JSON `{"error": "..."}`: | Status | Meaning | | --- | --- | | 401 | Token missing, expired, or from the wrong issuer | | 403 | Not a member of the tenant, or the role does not allow the action | | 400 | Tenant could not be resolved, or the request was invalid | | 409 | Conflict, such as a duplicate username, an existing tenant, or the last admin | ## Seeded local accounts All seeded accounts use the password `Astra@123`. They exist only in the local realm export. | Username | Access | | --- | --- | | `platform` | platform admin | | `lan` | acme admin | | `minh` | acme editor | | `hoa` | acme author | | `an` | acme reader | | `john` | globex admin | `acme` allows public sign-up and `globex` does not. The Keycloak admin console is at `{BASE}/auth/admin` (user `admin`; the password is in the `astra-keycloak` secret). ## Not in this release Forgot-password by email (no SMTP yet), MFA, social login, and filtering drafts out of readers' lists. ## Troubleshooting * **"Invalid parameter: redirect_uri":** the app's redirect must be exactly `io.astra.mobile:/oauthredirect`. * **401 on every API call right after sign-in:** the app reached the stack through a different origin than `ASTRA_PUBLIC_URL`, so the token issuer does not match. Compare `iss` in the token with `/api/iam/config`. * **403 "not a member of tenant ..." after joining:** refresh the token first. * **Run the checks:** `scripts/demo-iam.sh http://localhost:8080` exercises all of the above with real tokens. --- # Demo videos All demo videos with English and Vietnamese versions and a link to the article behind each one. 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/videos Each video is a short, scripted demo of code that actually runs in the repository. Watch in order, or jump to the one that answers your question. - **Teaser.** [▶ English](https://media.zerohostcms.com/astra-cms/astra-video-0-teaser-en.mp4) · [▶ Tiếng Việt](https://media.zerohostcms.com/astra-cms/astra-video-0-teaser-vi.mp4) - **Video 1: What is Astra & running it locally** (2:32). Kernel + DocType engine + plugin host, then the whole k3d cluster with `make k3d-up`, data seeded through the REST API, and a Desk login. [▶ English](https://media.zerohostcms.com/astra-cms/astra-video-1-quickstart-en.mp4) · [▶ Tiếng Việt](https://media.zerohostcms.com/astra-cms/astra-video-1-quickstart-vi.mp4) · [Read the article](/astra-cms/en/quickstart) - **Video 2: DocType: from one YAML file to API, UI and indexes** (2:38). One YAML file generates the form, REST API, validation and an online expression index; every DocType shares one JSONB table, no ALTER TABLE. [▶ English](https://media.zerohostcms.com/astra-cms/astra-video-2-doctype-en.mp4) · [▶ Tiếng Việt](https://media.zerohostcms.com/astra-cms/astra-video-2-doctype-vi.mp4) · [Read the article](/astra-cms/en/doctype) - **Video 3: Business rules: hooks & validation** (2:53). A 73,000 ₫ shipping fee computed by a hook, all validation errors returned at once, Links checked inside the tenant; the Desk and the API hit the same rules. [▶ English](https://media.zerohostcms.com/astra-cms/astra-video-3-hooks-en.mp4) · [▶ Tiếng Việt](https://media.zerohostcms.com/astra-cms/astra-video-3-hooks-vi.mp4) · [Read the article](/astra-cms/en/hooks) - **Video 4: Document lifecycle: Submit, Cancel, Amend** (3:10). Submit creates the waybill and the journal entry in one transaction; submitted documents are immutable; cancel posts a reversing entry; amend creates a new revision. [▶ English](https://media.zerohostcms.com/astra-cms/astra-video-4-lifecycle-en.mp4) · [▶ Tiếng Việt](https://media.zerohostcms.com/astra-cms/astra-video-4-lifecycle-vi.mp4) · [Read the article](/astra-cms/en/lifecycle) - **Video 5: Operations: the parcel state machine** (3:00). Filtering on a projected field, the scan timeline, no skipped states, an error then a success on the same form, and no cancelling a delivered order. [▶ English](https://media.zerohostcms.com/astra-cms/astra-video-5-ops-en.mp4) · [▶ Tiếng Việt](https://media.zerohostcms.com/astra-cms/astra-video-5-ops-vi.mp4) · [Read the article](/astra-cms/en/ops) - **Video 6: Customer care sees operations** (2:30). Tickets jump to High with a +24h SLA based on keywords; the CARE replica card reads a copy kept by the CDC worker. [▶ English](https://media.zerohostcms.com/astra-cms/astra-video-6-care-en.mp4) · [▶ Tiếng Việt](https://media.zerohostcms.com/astra-cms/astra-video-6-care-vi.mp4) · [Read the article](/astra-cms/en/care) - **Video 7: Multi-tenancy** (2:28). Two companies on one server: same URL, different data; Links never cross tenants, 403/400 when the tenant is unclear, RLS returns 0 rows. [▶ English](https://media.zerohostcms.com/astra-cms/astra-video-7-tenants-en.mp4) · [▶ Tiếng Việt](https://media.zerohostcms.com/astra-cms/astra-video-7-tenants-vi.mp4) · [Read the article](/astra-cms/en/tenants) - **Video 8: Fail-closed security** (2:02). Connect as a superuser and the kernel refuses to start, before Flyway runs any migration; the application role is NOSUPERUSER NOBYPASSRLS. [▶ English](https://media.zerohostcms.com/astra-cms/astra-video-8-failclosed-en.mp4) · [▶ Tiếng Việt](https://media.zerohostcms.com/astra-cms/astra-video-8-failclosed-vi.mp4) · [Read the article](/astra-cms/en/failclosed) - **Video 9: For developers: writing an Astra app** (2:22). An app is plain Java + YAML on astra-spi; dev-mode live reload adds a new column in 0.73 s; 44 integration tests against real PostgreSQL, Seata and Redis. [▶ English](https://media.zerohostcms.com/astra-cms/astra-video-9-developer-en.mp4) · [▶ Tiếng Việt](https://media.zerohostcms.com/astra-cms/astra-video-9-developer-vi.mp4) · [Read the article](/astra-cms/en/developer) - **Video 10: Running on Kubernetes** (2:06). Two kernel pods write in parallel with no duplicate and no gap in the numbering; a rolling restart under load, and what is still missing (no drain yet). [▶ English](https://media.zerohostcms.com/astra-cms/astra-video-10-k8s-en.mp4) · [▶ Tiếng Việt](https://media.zerohostcms.com/astra-cms/astra-video-10-k8s-vi.mp4) · [Read the article](/astra-cms/en/k8s) - **Video 11: Keycloak: one identity, roles per tenant, permissions per DocType** (2:18). PKCE login with the Astra theme, roles as Keycloak groups, permissions declared on the DocType; the maker of a payment voucher cannot approve it (403). [▶ English](https://media.zerohostcms.com/astra-cms/astra-video-11-keycloak-en.mp4) · [▶ Tiếng Việt](https://media.zerohostcms.com/astra-cms/astra-video-11-keycloak-vi.mp4) · [Read the article](/astra-cms/en/keycloak) - **Video 12: Append-only ledger** (2:32). Double-entry postings ride the document's transaction, cancel reverses, a trigger blocks UPDATE even for the table owner, monthly partitions. [▶ English](https://media.zerohostcms.com/astra-cms/astra-video-12-ledger-en.mp4) · [▶ Tiếng Việt](https://media.zerohostcms.com/astra-cms/astra-video-12-ledger-vi.mp4) · [Read the article](/astra-cms/en/ledger) - **Video 13: Tenant overlays** (2:24). Hanoi adds VAT fields and an island zone with one YAML file, live immediately; the Express app does not change a line. [▶ English](https://media.zerohostcms.com/astra-cms/astra-video-13-overlay-en.mp4) · [▶ Tiếng Việt](https://media.zerohostcms.com/astra-cms/astra-video-13-overlay-vi.mp4) · [Read the article](/astra-cms/en/overlay) - **Video 14: Tier 2 Wasm: each tenant's own shipping formula** (2:20). A Tết promotion written in Rust (421 bytes) switches the fee from 73,000 to 51,000; an infinite loop is stopped at 50 ms. [▶ English](https://media.zerohostcms.com/astra-cms/astra-video-14-wasm-en.mp4) · [▶ Tiếng Việt](https://media.zerohostcms.com/astra-cms/astra-video-14-wasm-vi.mp4) · [Read the article](/astra-cms/en/wasm) - **Video 15: MCP: AI agents use Astra with the user's permissions** (2:24). Real Claude calls tools over MCP: PII is masked, the customer care agent cannot create orders, and the accountant's COD question is answered from the ledger. [▶ English](https://media.zerohostcms.com/astra-cms/astra-video-15-mcp-en.mp4) · [▶ Tiếng Việt](https://media.zerohostcms.com/astra-cms/astra-video-15-mcp-vi.mp4) · [Read the article](/astra-cms/en/mcp) - **Video 16: Rust CDC + Sync-Aware LSN** (2:36). A Rust worker reads the WAL, writes return X-Tx-LSN, and a read from a replica that has not caught up gets 202 Sync Pending instead of stale data. [▶ English](https://media.zerohostcms.com/astra-cms/astra-video-16-cdc-en.mp4) · [▶ Tiếng Việt](https://media.zerohostcms.com/astra-cms/astra-video-16-cdc-vi.mp4) · [Read the article](/astra-cms/en/cdc) - **Video 17: Seata: one submit, three resources** (2:26). The AT branch charges the BMS credit limit, the TCC branch holds a slot in Redis, the kernel commits last; one branch refusing rolls everything back. [▶ English](https://media.zerohostcms.com/astra-cms/astra-video-17-seata-en.mp4) · [▶ Tiếng Việt](https://media.zerohostcms.com/astra-cms/astra-video-17-seata-vi.mp4) · [Read the article](/astra-cms/en/seata) - **Video 18: the rest of use-case-01** (1:36). What videos 1 to 17 and 19 to 21 already run for real (including HRM, STMS, SAML and SCIM) and what is still in development: multi-schema, Tier 1, streaming CDC, autonomous agents, audit hash-chain, field encryption. [▶ English](https://media.zerohostcms.com/astra-cms/astra-video-18-roadmap-en.mp4) · [▶ Tiếng Việt](https://media.zerohostcms.com/astra-cms/astra-video-18-roadmap-vi.mp4) · [Read the article](/astra-cms/en/roadmap) - **Video 19: HRM: attendance and payroll** (2:40). Following Frappe HRMS concepts: attendance sets working days, Payroll Entry computes salary slips in parallel, a courier bonus in Wasm, one accrual journal entry for the period, cancel reverses; the AI agent only sees masked names. [▶ 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) · [Read the article](/astra-cms/en/hrm) - **Video 20: STMS: vehicle dispatch with Timefold + AI copilot** (2:58). Timefold plans routes by capacity and time window and re-plans on a breakdown, an urgent order or a traffic jam; the copilot takes Vietnamese commands and calls real tools. The copilot's LLM is simulated from the demo script, labelled on every frame. [▶ English](https://media.zerohostcms.com/astra-cms/astra-video-20-stms-en.mp4) · [▶ Tiếng Việt](https://media.zerohostcms.com/astra-cms/astra-video-20-stms-vi.mp4) · [Read the article](/astra-cms/en/stms) - **Video 21: SAML SSO and SCIM** (2:32). Login through a signed SAML IdP, IdP groups become tenant roles; SCIM 2.0 creates, regroups, deactivates and deletes users per tenant, and another tenant's token is blocked. [▶ English](https://media.zerohostcms.com/astra-cms/astra-video-21-sso-en.mp4) · [▶ Tiếng Việt](https://media.zerohostcms.com/astra-cms/astra-video-21-sso-vi.mp4) · [Read the article](/astra-cms/en/sso) --- # Video scripts Vietnamese original: demo-video-series.md. 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/video-scripts Vietnamese original: [demo-video-series.md](/astra-cms/en/video-scripts). This document is used to record a series of marketing videos that introduce Astra. The storyline comes from [`use-case-01.md`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/use-case-01.md): a courier company that takes orders (**OMS**), runs parcel operations (**OPS**), handles customer care (**CARE**) and manages receivables and the ledger (**BMS**). Everything shown in the videos is **code that actually runs** in the repository, as a *thin slice*: enough to prove the mechanism, not yet the full scope of the use case. The platform mechanism videos (9, 11 to 17) and the new business videos (19 to 21) have a **"To be accurate"** section that states the current limits; each video links to the code to show developers in [`demo-code-snippets.md`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/demo-code-snippets.md) (code excerpted from the repo, with flow diagrams). Background music: "Corporate Tower" by Mare Production ([freesound.org/s/612264](https://freesound.org/s/612264/)), CC BY 4.0, edited. Vietnamese voice-over: the male voice "Xuân Vĩnh" from [VieNeu-TTS](https://github.com/pnnbao97/VieNeu-TTS) v3 Turbo (Apache-2.0), synthesized by `scripts/video-voiceover.py` from the `narration.json` file in each video folder. The English versions use the Kokoro-82M voice `am_michael` (Apache-2.0) via `hyperframes tts`, synthesized by `scripts/video-voiceover.py --lang en` from `narration.en.json`. Fonts: Be Vietnam Pro (SIL OFL 1.1) for text, JetBrains Mono for code. --- ## 1. Preparing the recording environment Requirements: JDK 25, Docker Desktop (the whole stack runs with 8 GB of RAM allocated to Docker), `kubectl`, `k3d`, `jq`, **Bash 5** (`brew install bash`; the scripts re-run themselves under Bash 5 if the default shell is macOS Bash 3.2), Rust (only needed when changing the Wasm modules or the CDC worker). ```bash make k3d-up # k3d cluster: 2 kernel pods, PostgreSQL (wal_level=logical), Redis, Keycloak, Seata, Rust CDC worker make demo-seed # 2 tenants: saigon-express, hanoi-express; data via the REST API, Keycloak login open http://localhost:8080/desk/ # Desk (Keycloak login, PKCE) make demo-reset && make demo-seed # clean up before each recording session (documents, ledger, overlays, BMS, Redis) make demo-express SCENES="10 11" PAUSE=0 # run terminal scenes; drop PAUSE=0 to press Enter through each step ``` `make k3d-up` rebuilds the images, syncs the Keycloak realm (`scripts/keycloak-sync-realm.sh`), prepares the database (`scripts/db-setup.sh`: BMS schema, CDC role/publication) and deploys everything. Video 8 (the kernel refuses a superuser) and Video 9 (live reload) also need the Docker Compose stack. k3d and dev mode both default to port 8080, so run dev mode on another port and point every script at it: ```bash make infra-up && make build # PostgreSQL :5432, Redis, Keycloak :8180 (Video 8 only needs this far) # infra-up runs scripts/keycloak-compose-dev.sh: dev Keycloak accepts HTTP from the host + realm sync make dev DEV_ARGS=-Dquarkus.http.port=8081 # Video 9 BASE=http://localhost:8081 make demo-seed # seed into dev mode; reset with: scripts/demo-reset.sh compose ``` ### Demo users (shared password: `Astra@123`, realm `deploy/keycloak/realm/astra-realm.json`) | User | Tenant | Role | Used for | | --- | --- | --- | --- | | `mai` | saigon-express | admin | tenant administration, overlays | | `tuan` | saigon-express | sales | creating / submitting orders | | `quang` | saigon-express | ops | scanning parcels | | `ha` | saigon-express | care | customer care tickets, reading the replica | | `khoa`, `thu` | saigon-express | accountant | ledger, COD remittance (maker / checker) | | `nam` | hanoi-express | admin | second tenant | | `demo` | both | admin | seeding data, two-tenant comparison screen | | `hanh` | saigon-express | hr | HR and payroll (Video 19) | | `linh` | both | dispatcher | vehicle dispatch (Video 20) | ### Recording tools | Tool | Used for | Command / URL | | --- | --- | --- | | **Astra Desk** | UI generated from metadata: list, form, lifecycle, ledger, CDC replica, overlays | `/desk/` | | **Two-tenant comparison** | Two tenants side by side (log in as `demo` first) | `/desk/compare.html` | | **API inspector** | The real requests/responses Desk makes, including `X-Tx-LSN` | "API inspector" button | | **Scene script** | 15 terminal scenes, one per video | `scripts/demo-express.sh` | | **Claude Code + MCP** | A real AI agent calling Astra | `scripts/mcp-claude.sh tuan saigon-express` | Tip: 1440×900 window; terminal at 16 to 18 pt; record right after `demo-reset` + `demo-seed` so document numbers match the script (`ORD--00007`…). The date in the order number is the date the seed ran. --- ## 2. Feature map → where it is proven | Feature (actually running) | Video | Scene | Code | | --- | --- | --- | --- | | An app is a Tier 0 plugin that uses only `astra-spi` | 1, 9 | 1 | `astra-apps/express`, `astra-apps/cms` | | DocType YAML → generated REST + Desk; JSONB, no ALTER TABLE | 2, 9 | 2 | `*.doctype.yaml`, `DocResource`, Desk | | `projected: true` → online expression index | 2, 5 | 2, 5 | `ProjectionIndexer` | | Metadata-driven validation, business hooks | 3 | 3 | `DocumentValidator`, `ShippingFeeHook` | | Lifecycle Draft → Submitted → Cancelled → Amend | 4 | 4 | `DocumentStore.submit/cancel/amend` | | State machine + linked documents in 1 transaction | 5 | 5 | `WaybillScanHook`, `OrderWaybillHook` | | CARE sees OPS, triage, SLA | 6 | 6 | `TicketTriageHook` | | Multi-tenant RLS, `ScopedValue` | 7 | 7 | `TenantContext`, `TenantResolverFilter` | | Fail-closed when connecting as a superuser | 8 | 8 | `RowLevelSecurityGuard` | | **Keycloak OIDC, per-tenant roles, DocType permissions, SoD** | 11 | 9 | `astra-iam`, `DocPermissions`, realm | | **Append-only ledger, monthly partitions, reversing entries** | 12 | 10 | `V3__ledger.sql`, `LedgerStore`, `GeneralLedger` SPI | | **Per-tenant overlays, applied without a restart** | 13 | 11 | `Overlays`, `DocTypeOverlays`, `tenants/*/overlays` | | **Tier 2 Wasm (Chicory 1.7.5), tenant Rust scripts** | 14 | 12 | `astra-plugin-host`, `astra-apps/express/wasm` | | **MCP server, PII masking, app-contributed tools** | 15 | 13 | `astra-ai`, `McpTool` SPI | | **Rust CDC (pgoutput) + Sync-Aware LSN** | 16 | 14 | `astra-cdc-rust`, `SyncAwareLsnFilter` | | **Seata 2.6 AT + TCC: global transaction** | 17 | 15 | `astra-tx-seata`, `OrderCreditHook`, `PickupCapacity` | | Kubernetes: 2 pods, series safe across pods | 10 | (none) | `deploy/k8s`, `SeriesService` | | **HRM: attendance, Payroll Entry, salary slips, Wasm KPI bonus, accrual journal entry** | 19 | `demo-hrm` 1 to 8 | `astra-apps/hrm`, `PayrollEntryHook`, `SalaryCalculator` | | **STMS: Timefold VRP, replanning, MCP tools, copilot (simulated LLM)** | 20 | `demo-stms` 1 to 8 | `astra-apps/stms`, `RoutePlanner`, `Replanner`, `DispatchCopilot` | | **SAML SSO through Keycloak, per-tenant SCIM 2.0** | 21 | `demo-sso` 1 to 8 | `deploy/keycloak/realm`, `ScimResource`, `ScimDirectory` | --- ## 3. Video list Suggested length: teaser 60 to 90 seconds, feature videos 3 to 6 minutes, developer videos 6 to 8 minutes. ### Video 0: Teaser "One platform, every business process" (90 seconds) **Developer code:** [S0](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/demo-code-snippets.md#s0-big-picture) Cut quickly from footage of the later videos: 1. The `customer-order.doctype.yaml` file → the "Customer Order" Desk appears. 2. Submit an order → the waybill is generated, the journal entry Debit 131 / Credit 511 appears under the form. 3. Two tenants side by side: Hanoi has an extra VAT field (overlay), Saigon does not. 4. An infinite-loop Wasm script: "stopped after 50 ms". 5. Claude asks "where is parcel WB-…?", the phone number shows as `*******567`. 6. The "sync pending" badge → "in sync" after a few tens of ms (Rust CDC). 7. Seata: an order over the credit limit, everything rolls back, the undo log is clean. 8. Closing: "Astra: Java 25, Quarkus, PostgreSQL, Rust. Open source." ### Video 1: What Astra is & running it in 5 minutes **Developer code:** [S0](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/demo-code-snippets.md#s0-big-picture) | Step | Action | Narration | | --- | --- | --- | | 1 | Architecture diagram in `README.md` | Kernel, DocType Engine, Plugin Host; today we look at the parts that actually run. | | 2 | `make k3d-up` (fast-forward), `kubectl -n astra get pods` | 2 kernel pods, PostgreSQL, Redis, Keycloak, Seata, Rust CDC worker. | | 3 | `make demo-seed` | All data is loaded through the REST API, logging in with Keycloak. | | 4 | `make demo-express SCENES=1` | `/api/app`: 2 apps; `/api/doctype`: 8 DocTypes. | | 5 | Desk → log in as `mai` → Installed apps | The Express app contributes OMS, OPS, CARE, BMS. | ### Video 2: DocType, from one YAML file to API, UI and index **Developer code:** [S1](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/demo-code-snippets.md#s1-doctype-is-the-source-of-truth), [S12](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/demo-code-snippets.md#s12-desk-generated-from-metadata) | Step | Action | Narration | | --- | --- | --- | | 1 | `SCENES=2`: the `CustomerOrder` YAML | Fields, Select, Link, read-only, `pii`, `submittable`, `transaction: global`. | | 2 | Desk → DocType metadata → CustomerOrder | The field table is read from `GET /api/doctype` (the same definition as `GET /api/doctype/CustomerOrder`, with the tenant overlay applied). | | 3 | Projections section | `projected: true` → `CREATE INDEX CONCURRENTLY`, no write lock. | | 4 | Terminal: `\d astra_doc` | Every DocType shares one JSONB table. | ### Video 3: Business rules, hooks & validation **Developer code:** [S2](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/demo-code-snippets.md#s2-an-app-and-a-hook) | Step | Action | Narration | | --- | --- | --- | | 1 | Desk (`tuan`) → New Customer Order: C001, Domestic, Express, 2.2 kg, COD 500,000 → Create | Number from a date-based series; **fee 73,000 ₫** computed by `ShippingFeeHook`; "Priced by: Standard tariff". | | 2 | Explaining the fee | 30,000 + 3 × 5,000 (per 0.5 kg above the first kg) = 45,000; ×1.5 (Express) = 67,500; COD fee max(5,000; 1%) = 5,000 → 72,500; rounded up to the thousand → 73,000. | | 3 | Change the phone to `123`, weight to `45` → Save | The hook returns 2 errors at once (422). | | 4 | Fix them, customer `C999` → Save | The kernel checks the Link. | | 5 | `SCENES=3` + API inspector | Read-only fields, unknown fields and invalid Select values are all rejected. | ### Video 4: Document lifecycle, Submit, Cancel, Amend **Developer code:** [S3](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/demo-code-snippets.md#s3-lifecycle-in-one-transaction) | Step | Action | Narration | | --- | --- | --- | | 1 | Desk (`mai`) → New Customer Order for C001 → Create | Status bar 0 → 1 → 2 → Amend. (The draft order `ORD-…-00005` for C002 is reserved for Video 17: it exceeds the credit limit.) | | 2 | **Submit** | The waybill is created and the freight journal entry shows in the "Ledger entries" card, in the same transaction. | | 3 | The form is locked | A submitted document is immutable. | | 4 | **Cancel** | The waybill is cancelled with it; the ledger records a reversing entry (old rows are not edited). | | 5 | **Amend**, then Amend again on the cancelled copy | Amended copy `-1`; the second attempt is rejected. | ### Video 5: Operations, the parcel state machine **Developer code:** [S3](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/demo-code-snippets.md#s3-lifecycle-in-one-transaction) | Step | Action | Narration | | --- | --- | --- | | 1 | Desk (`quang`) → Waybill, filter `Ops status = At Hub` | Filter on a projected field (⚡). | | 2 | `WB-…-000001` | Full scan timeline through Delivered. | | 3 | Waybill `Created` → choose `Delivered` | No skipping states. | | 4 | `Picked Up`; then `At Hub` with the hub empty; then fill in the hub | An error, then success; the timeline gets a new entry. | | 5 | Desk (`mai`) → cancel a delivered order | Rejected; no data changes. | ### Video 6: Customer care sees operations **Developer code:** [S3](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/demo-code-snippets.md#s3-lifecycle-in-one-transaction) (the `TicketTriageHook` hook follows the same pattern as the hooks in S2/S3) | Step | Action | Narration | | --- | --- | --- | | 1 | Desk (`ha`) → New Support Ticket "Hàng bị vỡ, khách yêu cầu đền bù" (goods broken, customer asks for compensation), choose the waybill | Priority goes up to High automatically, SLA +24h, waybill status. | | 2 | Bottom of the form: the "CARE replica (CDC)" card | This card reads the copy kept by the CDC worker, not the operations table directly (details in Video 16). The ticket's `waybill_status` field is read from the Waybill by the hook at save time. | | 3 | `SCENES=6` | The API version. | ### Video 7: Multi-tenancy **Developer code:** [S4](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/demo-code-snippets.md#s4-tenants-and-row-level-security) | Step | Action | Narration | | --- | --- | --- | | 1 | Log in as `demo`, open `compare.html`, Screen `#/list/Customer` | Same URL, same DocType, two tenants. | | 2 | Screen `#/form/Customer/C001` | Same ID, different data. | | 3 | `SCENES=7` | Links do not cross tenants; `nam` on saigon → 403; missing tenant → 400; SQL: no tenant bound → 0 rows. | ### Video 8: Fail-closed security **Developer code:** [S4](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/demo-code-snippets.md#s4-tenants-and-row-level-security) `make infra-up && make build && make demo-express SCENES=8`: a second kernel connects as a superuser and refuses to start. Open `deploy/docker/init/01-app-role.sql`: the application role is `NOSUPERUSER NOBYPASSRLS`. ### Video 9: For developers, writing an Astra app **Developer code:** [S2](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/demo-code-snippets.md#s2-an-app-and-a-hook), [S12](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/demo-code-snippets.md#s12-desk-generated-from-metadata) | Step | Action | Narration | | --- | --- | --- | | 1 | `make infra-up && make dev` | Dev mode with live reload (the Compose Keycloak is at `localhost:8180`). | | 2 | The `astra-apps/express` tree | App = `ExpressApp` + YAML + hooks + MCP tools + TCC participant; depends only on `astra-spi`. | | 3 | `OrderWaybillHook`, `OrderPostingHook` | `DocumentService` and `GeneralLedger` join the source document's transaction. | | 4 | **Live**: add `- { name: zalo_id, type: Data, in-list: true }` to `customer.doctype.yaml` | No migration, no restart: the new column appears right away. | | 5 | `ExpressFlowTest`, `LedgerTest`, `SeataTest` | Integration tests against a real PostgreSQL; `SeataTest` also runs a real Seata Server and Redis. | **To be accurate:** after step 4, run `make demo-reset && make demo-seed`, because removing a field from a DocType makes older documents that still have that field impossible to edit; safe schema evolution is still on the roadmap. ### Video 10: Running on Kubernetes | Step | Action | Narration | | --- | --- | --- | | 1 | `kubectl -n astra get pods` | 2 kernel replicas + infrastructure (Keycloak, Seata, CDC, PostgreSQL, Redis). | | 2 | Desk → Customer Order | Order numbers stay sequential even with 2 pods writing: the counter uses a row lock. | | 3 | `kubectl -n astra rollout restart deployment/astra-server` while Desk is open | Rolling restart, `maxUnavailable: 0`: Desk stays usable. **To be accurate:** there is no preStop/drain yet, so requests that land exactly when an old pod stops can fail (measured run: 2/47). | ### Video 11: Keycloak, one identity, per-tenant roles, per-DocType permissions (scene 9) **Developer code:** [S5](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/demo-code-snippets.md#s5-keycloak-permissions-and-sod) | Step | Action | Narration | | --- | --- | --- | | 1 | Desk → Sign in → Astra-themed login page (vi/en) → `tuan` | Authorization Code + PKCE in the browser; no password passes through Astra. | | 2 | Top right: "Tuấn Lê · sales"; `tuan` creates an order and reopens it: the Submit button is there, the Cancel button is not (other people's orders are read-only) | Desk reads the effective permissions from `/api/iam/me` and hides buttons that are not allowed. | | 3 | `SCENES=9` | `tuan` (sales) cannot scan or cancel orders; `quang` (ops) can scan; `ha` (care) opens tickets; `khoa` (accountant) can cancel. | | 4 | Keycloak group `/tenants/saigon-express/ops` | Roles are Keycloak groups; the kernel reads them from the token and does not trust headers. | | 5 | Desk (`khoa`) → CODRemittance `CODR-…-0002` (350,000, created by `khoa` during the seed) → Submit; then log in as `thu` → Submit | **Segregation of duties**: the maker cannot approve their own document (403); `thu` can. Record right after `demo-seed`: scene 10 pays out another 100,000 to C001, after which 350,000 would exceed the amount owed. | **To be accurate:** row-level permission conditions are still on the roadmap. SAML SSO and SCIM now run, see Video 21. ### Video 12: Append-only ledger (scene 10) **Developer code:** [S6](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/demo-code-snippets.md#s6-append-only-ledger) | Step | Action | Narration | | --- | --- | --- | | 1 | Desk (`khoa`) → General ledger | Trial balance on the Vietnamese chart of accounts (TT200: 112, 131, 1388, 3388, 511). | | 2 | `tuan` submits an order → the form shows the "Ledger entries" card | Debit 131 / Credit 511 in the same transaction as the order. | | 3 | Cancel the order | Two new **reversing** rows (`reversal`); the old rows stay unchanged. | | 4 | `quang` scans Delivered | Debit 1388 / Credit 3388: COD cash collected by the courier, owed to the shop. | | 5 | Terminal: `UPDATE astra_ledger …` | `ERROR: astra_ledger is append-only`, blocked in the database, even for the table owner. | | 6 | Partitions section | Monthly partitions, created ahead of time by the kernel. | | 7 | CODRemittance above what the shop is owed → 422; maker/checker | No paying out more than is owed; maker ≠ checker. | **To be accurate:** a trigger blocks UPDATE/DELETE; the role that owns the table can still DROP/ALTER, so production should run the kernel with a role that does not own the tables. An audit hash chain is still on the roadmap. ### Video 13: Per-tenant overlays (scene 11) **Developer code:** [S7](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/demo-code-snippets.md#s7-tenant-overlays) | Step | Action | Narration | | --- | --- | --- | | 1 | `tenants/hanoi-express/overlays/customer-order.overlay.yaml` | Hanoi adds a VAT field, an "Island" zone and a required note; the Express app does not change a single line. | | 2 | `compare.html`, Screen `#/new/CustomerOrder` | The Hanoi side has fields labelled "✚ tenant overlay". | | 3 | Desk (`mai`) → DocType metadata → CustomerOrder → Tenant overlay → add `gift_wrap` → Save overlay | Applied immediately, no restart; other tenants are not affected. When done, click Remove overlay so scene 11 still shows saigon-express without an overlay. | | 4 | An overlay that changes a field type → 422; `tuan` edits the overlay → 403 | An overlay cannot break the app; only the tenant admin can edit it. | **To be accurate:** overlays add/modify fields, they do not add hooks or permissions yet; they cannot remove an app's fields. ### Video 14: Tier 2 Wasm, a tenant's own freight formula (scene 12) **Developer code:** [S8](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/demo-code-snippets.md#s8-wasm-tier-2) | Step | Action | Narration | | --- | --- | --- | | 1 | `astra-apps/express/wasm/tet-promo/src/lib.rs` | A Tết promotion written in Rust, compiled to 421 bytes of WebAssembly. | | 2 | Desk (`mai`) → Pricing Rule → open the rule → `self_test` | The kernel test-runs the module in the sandbox on save. | | 3 | Turn on `active` → create a B2B order (C001, Domestic, Express, 2.2 kg, COD 500,000) | Fee 73,000 → **51,000 ₫**, "Priced by: PRL-001 … (Wasm)". Turn `active` off again afterwards, otherwise B2B orders in the other videos will no longer be 73,000. | | 4 | New Pricing Rule → choose the file `astra-apps/express/wasm/modules/runaway.wasm` (infinite loop) → Create | "module rejected (timeout): script exceeded its 50 ms deadline and was stopped"; the kernel keeps running normally. | | 5 | Talk about the limits | 1 MiB maximum memory, no host functions, a new instance per call. | **To be accurate:** Chicory has no instruction counting (fuel) yet, so the CPU limit is a time deadline. Tier 1 (JVM hot-swap) is still on the roadmap. ### Video 15: MCP, an AI agent uses Astra with the user's permissions (scene 13) **Developer code:** [S9](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/demo-code-snippets.md#s9-mcp-for-ai-agents) | Step | Action | Narration | | --- | --- | --- | | 1 | `SCENES=13` | MCP 2025-11-25 handshake, 7 tools (5 from the kernel, 2 contributed by the Express app). | | 2 | `search_documents` | `pii: true` fields are masked before they reach the model: phone numbers keep only the last 3 digits (`*******567`), names and addresses keep only the first letter of each word (`L*** V*** D***`); `shipping_fee` is unchanged. | | 3 | `scripts/mcp-claude.sh ha saigon-express` → `claude` → "Bưu kiện WB-2610-000002 đang ở đâu, có khiếu nại không?" (where is parcel WB-2610-000002, are there complaints?) | Claude calls `track_waybill` and answers with the route history and the ticket. | | 4 | Ask "tạo đơn hàng mới cho C001" (create a new order for C001) | Rejected: `ha` (care) is not allowed to create orders, so the agent cannot exceed the user's permissions. | | 5 | `scripts/mcp-claude.sh khoa` → "Khách nào đang chờ trả COD?" (which customers are waiting for COD payouts?) | `cod_outstanding` reads from the ledger. | **To be accurate:** the client uses a static bearer token (the dev client's 1-hour token); OAuth discovery for MCP, autonomous AI agents and vector search are still on the roadmap. PII masking applies to the MCP channel, not to Desk. ### Video 16: Rust CDC + Sync-Aware LSN (scene 14) **Developer code:** [S10](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/demo-code-snippets.md#s10-rust-cdc-and-sync-aware-lsn) | Step | Action | Narration | | --- | --- | --- | | 1 | Desk → Replication (CDC) | The Rust worker decodes the WAL (`pgoutput`), lag of a few ms, bytes remaining. | | 2 | `SCENES=14`: pause the worker, `quang` scans a parcel | The response carries `X-Tx-LSN`. | | 3 | `ha` reads the replica with `X-Required-LSN` | **202 Sync Pending**, no stale data returned. | | 4 | Restart the worker | 200 after a few tens of ms; steady state: the replica catches up a few tens to over a hundred ms after the write (varies between runs). | | 5 | Desk: open the Waybill just scanned → the "CARE replica (CDC)" card | "sync pending" → "in sync" automatically (read-your-writes) if the worker catches up within the 10 s that Desk keeps polling; if the worker is off longer, reopen the form. | | 6 | `pg_replication_slots` | Logical slot `astra_cdc` (`active = f` is normal: the worker reads the slot with SQL peek/advance and does not hold a streaming connection); the worker writes the replica **under RLS**, without BYPASSRLS. | **To be accurate:** the worker reads the slot with `pg_logical_slot_peek_binary_changes` every 50 ms (at least once, idempotent upsert) and does not use the streaming protocol yet; nothing is pushed to Apache Doris yet. ### Video 17: Seata, one submit across three resources (scene 15) **Developer code:** [S11](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/demo-code-snippets.md#s11-seata-at-and-tcc) | Step | Action | Narration | | --- | --- | --- | | 1 | `customer-order.doctype.yaml`: `transaction: global` | The kernel opens a global transaction around submit/cancel. | | 2 | `SCENES=15`: submit an order for C001 | The **AT** branch deducts the credit limit in the `bms` schema (its own role); the **TCC** branch reserves a pickup slot in Redis; the kernel transaction commits last. | | 3 | An order for C002 over the credit limit | 422; the order stays a draft, no waybill, no journal entry, no pickup slot taken (BMS rejects it before the TCC branch is reached). | | 4 | A fully booked day: TCC rejects **after** AT has committed | Seata restores the credit limit from the undo log; `undo_log` is clean; Redis reserved goes back to 0. | | 5 | Log `Global transaction … rolled back` | A real Seata Server 2.6.0 (JDK 25) coordinates. | **To be accurate:** Seata Server uses the file store (enough for the demo; production uses DB/Raft); TCC handles idempotency/empty rollback/suspension per branch itself and does not use Seata's fence table yet; the kernel commits its own transaction last (last resource) and is not yet a Seata branch. ### Video 19: HRM, HR and payroll **Developer code:** `astra-apps/hrm` (`PayrollEntryHook`, `SalaryCalculator`, `astra-apps/hrm/wasm/courier-kpi`); test `HrmPayrollTest`. Goal: an HR and payroll app written on the public SPI, following the Frappe HRMS concepts (Employee, Salary Component, Salary Structure, Salary Structure Assignment, Attendance, Additional Salary, Payroll Entry, Salary Slip). Attendance determines working days, the courier bonus is a tenant Wasm module, and each Payroll Entry writes exactly **one** accrual journal entry to the ledger. Only the concepts are taken; no code, schema or UI is copied from ERPNext/HRMS. Setup: `make demo-seed` loads September 2026 (26 working days) for saigon-express: NV001 has full attendance, delivered 1,067 parcels; NV002 absent on 15/9, half day on 22/9, delivered 1,007 parcels; NV003 started on 14/9, has a 500,000 bonus; NV004 on unpaid leave on 29/9. The script logs in as `hanh` (hr). If the realm just changed, run `scripts/keycloak-sync-realm.sh` to get `hanh`. | Step | Action | Narration | | --- | --- | --- | | 1 | `make demo-hrm SCENES=1` | App `io.astra.hrm`, 8 DocTypes in module `hrm`, depends only on `astra-spi`. 8 Salary Components, including social insurance (BHXH) 8 %, health insurance (BHYT) 1.5 %, unemployment insurance (BHTN) 1 % (accounts 3383, 3384, 3386); Salary Structure COURIER and 4 assignments. | | 2 | `SCENES=2`: the Rust code in `astra-apps/hrm/wasm/courier-kpi` | Tiered bonus: 1,500 ₫/parcel up to parcel 600, 2,000 ₫ up to parcel 1,000, 2,500 ₫ from parcel 1,001, plus a 500,000 attendance bonus for full attendance. On save, the kernel test-runs it: `kpi(1000 pieces, 26/26 days) = 2200000 VND`. An infinite-loop module (LOOP) → 422 "module rejected (timeout)". | | 3 | `SCENES=3` | NV002: Absent 15/9, Half Day 22/9; NV004: On Leave / Leave Without Pay 29/9. `quang` (ops) can create and submit today's attendance, gets 403 on Salary Slip; `tuan` (sales) gets 403 on Payroll Entry. | | 4 | `SCENES=4` | Save a draft Payroll Entry `PAY-2610-001` (series by creation date): 4 draft salary slips, computed in parallel with one virtual thread per slip, in the same transaction as the entry. | | 5 | `SCENES=5` | NV002's slip line by line: `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. NV003: 15 working days, BONUS 500,000. Editing `net_pay` → 422 read-only. | | 6 | `SCENES=6`: submit the entry | Salary slips and the ledger in one transaction. Entry: 4 employees, gross 35,837,501, deductions 3,517,500, net 32,320,001. One 5-line accrual journal entry: 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 has `accrual_voucher` set to the entry name. Cancelling NV001's slip on its own → 422 "cancel the payroll entry instead"; running September payroll again → 422 "NV001 already has submitted salary slip …". | | 7 | `SCENES=7` | The MCP tool `payroll_summary` returns totals and slip lines with masked employee names (`N*** V*** A***`); `tuan` calls it → `isError: true`, not permitted. | | 8 | `SCENES=8`: cancel the entry | The slips move to docstatus 2; the entry's journal entry gets 5 more lines with `is_reversal: true`; the payroll account balances return to 0. No row is deleted. | **To be accurate:** - 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 leave balances; the working week is fixed Monday to Saturday; days without an attendance record count as worked. Parcel counts are entered by ops with attendance, not yet pulled automatically from Waybill or STMS through CDC as the use case describes. - Only 4 calculation types (Fixed, % base, % gross, Wasm), no formula editor yet. No salary payout journal entry (Debit 334 / Credit 112), no bank file export. The accrual journal entry records totals per account, not per employee on 334; all cost goes to 642, with no cost-center allocation. - The batch runs inside the request that saves the entry (up to 1,000 employees per run); there is no month-end background job or leader election yet. Salary slips and the ledger share one PostgreSQL transaction, without Seata. - Employees cannot view their own salary slips yet: the `if-owner` permission applies to writes only, not to reads. - There is no dedicated payroll Desk yet: the generic Desk shows slip lines as "N item(s)". - The figures in the table above are the expected results computed from the hook's logic; use the on-screen numbers from the actual recording. ### Video 20: STMS, vehicle dispatch with Timefold + AI copilot **Developer code:** `astra-apps/stms` (`solver/RoutePlanner`, `Replanner`, `copilot/DispatchCopilot`, `copilot/MockLlmClient`); tests `RoutePlannerTest`, `StmsDispatchTest`, `StmsMcpTest`. Goal: the vehicle routing problem (VRP with capacity and time windows) solved by Timefold Solver inside the kernel; when an incident happens, the plan is re-solved from the current routes; the dispatcher gives commands in Vietnamese through the copilot, and the copilot calls those same MCP tools. **The solver, replanning and the tools are real; the copilot's language model is a simulated script for the demo** (`llm: "mock"`). Every frame showing the copilot must show the label "LLM giả lập (kịch bản demo)" (simulated LLM, demo script). Setup: `make demo-seed` creates, for each tenant, a fictional hub (saigon-express: HCM-TB Kho Tân Bình), 3 vehicles and 20 delivery/pickup stops for the seed date (`STMS_DATE`, defaults to today in Vietnam time; use the same value for seed and demo). The script logs in as `linh` (dispatcher). Run the scenes in order: scenes 3 to 8 use the plan created by scene 2. | Step | Action | Narration | | --- | --- | --- | | 1 | `make demo-stms SCENES=1` | 5 DocTypes: Hub, Vehicle, DeliveryStop, DispatchPlan, DispatchEvent. Hub HCM-TB, 3 vehicles (300/250/250 kg), 20 stops with time windows and Normal/VIP/Urgent priority. | | 2 | `SCENES=2` | Save a DispatchPlan with `solve: true`: Timefold solves within a 5-second budget. Hard constraints: capacity, time windows; soft: distance, serving priority stops early, load balancing. Result Solved, `0hard/…soft` (feasible), about 99 km, 2 vehicles, about 1 second; prints the route table. | | 3 | `SCENES=3`: copilot "Xe 51C-12345 hỏng ở Thủ Đức, chuyển hết đơn sang xe khác" (vehicle 51C-12345 broke down in Thủ Đức, move all its orders to other vehicles) | The response has `llm: "mock"` and `llm_note`; tool calls `stms_vehicle_unavailable` then `stms_explain_plan`. The explanation starts with `[LLM giả lập – kịch bản demo]`, states how many stops changed vehicle and the score before → after, still 0hard. The solver re-solves from the current routes (warm start). | | 4 | `SCENES=4`: "Ưu tiên khách VIP trước 10h, cân bằng tải giữa các xe" (prioritize VIP customers before 10:00, balance load across vehicles) | The copilot calls `stms_set_weights` (priority 500, balance 10). VIP/urgent stops are served before 10:00, at the cost of a longer distance. The soft score changes scale when the weights change, so only compare plans with the same weights. | | 5 | `SCENES=5`: "Thêm đơn gấp 30kg ở Bến Thành trước 11h" (add an urgent 30 kg order at Bến Thành before 11:00) | `stms_add_urgent_stop` with coordinates taken from the simulation's fixed list of place names; the order is inserted into one vehicle, VIP/urgent stops are still on time. | | 6 | `SCENES=6` | Without the copilot: `stms_traffic_delay` (Thủ Đức, 4 km radius, 2.5 times slower) and `stms_change_time_window` (Linh kiện Tân Tiến → 08:00 to 09:00). Each call prints the score before/after, km, `solve_ms` and the moved stops; still 0hard. | | 7 | Desk (`linh`) → the dispatch copilot page, type one of the commands from scenes 3 to 5 | The page sends the command to `POST /api/stms/copilot`, the same copilot as the `stms_copilot` tool; it shows the simulated LLM label, the tool calls that ran and the explanation. Open the DispatchPlan: `route_summary` (text route table) and `last_change` (moved stops). | | 8 | `SCENES=7` | `quang` (ops) calls `stms_vehicle_unavailable` → "not permitted: read DispatchEvent"; `ha` (care) calls `stms_explain_plan` → not permitted; the same plan name in hanoi-express → "not found". | | 9 | `SCENES=8` | DispatchEvent log: source (Copilot or MCP), score before → after, moved stops, `solve_ms`. Submit → docstatus 1, Dispatched; PUT `{solve: true}` → 422 "submitted documents are immutable"; `stms_set_weights` → "is dispatched and frozen; amend it to replan". | **To be accurate:** - The copilot does not call any language model: `MockLlmClient` matches commands against fixed Vietnamese patterns and a small list of place names; commands outside the script produce no tool call. The explanation is written from the solver's real results. The plug-in point for a real LLM provider is the `LlmClient` interface; there is no implementation yet. - No map, real road network or live traffic: distance is straight-line distance × 1.3, at a fixed speed of 25 km/h, and a "traffic jam" is a slow zone declared by the dispatcher. No geocoding: stops come with coordinates. - Replanning means re-solving from the current routes, without keeping the solver running all day; time does not advance, so delivered stops and vehicles on the road are not locked. Adding an urgent order and re-solving are two transactions: if the re-solve fails, the new order is still saved. - Capacity is one combined total per vehicle (deliveries and pickups added together); there are no pickup-before-delivery constraints, multiple trips, driver shifts and breaks, return-to-hub deadlines, vehicle types or vehicle costs. One hub per plan, no transfers between hubs. - No AI load forecasting (Tier 1), driver app, GPS or field ETA updates yet. No route map yet: Desk shows a text route table and JSON. - The demo has about 20 stops and solves in 1 to 2 seconds; larger problems have not been measured. On-screen numbers come from the actual recording; numbers in the phase report were measured on test data and will differ slightly from the seed data. - DispatchEvents and urgent orders created by tools have the owner `stms-mcp` / `stms-copilot`; the real caller is recorded in the MCP server log. ### Video 21: SAML SSO and SCIM **Developer code:** `deploy/keycloak/realm/acme-corp-idp-realm.json`, `deploy/keycloak/realm/astra-realm.json` (identity provider `acme-corp`), `astra-iam` (`ScimResource`, `ScimDirectory`, `ScimPatch`); tests `SamlBrokerTest`, `ScimTest`. Goal: a company's employees log in to Astra with their company's IdP over SAML 2.0, and IdP groups become tenant roles; the company's identity management system creates, assigns roles to, disables and deletes accounts over SCIM 2.0, only within its own tenant. Setup: Keycloak must have the realm `acme-corp-idp`, the identity provider `acme-corp` and the SCIM clients. `make infra-up` (runs `scripts/keycloak-compose-dev.sh`) or `make k3d-up` syncs the realm; if Keycloak was already running, re-run `scripts/keycloak-sync-realm.sh`, then restart astra-server to get `/scim/v2`. The video's tenant is `acme`. The users `linh` and `bao` here belong to the IdP realm `acme-corp-idp`; this is not the dispatcher `linh` from Video 20. | Step | Action | Narration | | --- | --- | --- | | 1 | `make demo-sso SCENES=1` | The SP-initiated flow replayed with curl: Desk → Keycloak (`kc_idp_hint=acme-corp`) → AuthnRequest to the IdP → `linh` logs in at the IdP → signed SAMLResponse back to the broker → OIDC code. Assertion: NameID `linh`, Audience `urn:astra:sp`, `memberOf astra-editors`, RSA-SHA256 signature on both assertion and response. Token: `preferred_username: acme-corp.linh`, `groups: ["/tenants/acme/editor"]`; `/api/iam/me` → acme `[editor]`; create an Article → 201. | | 2 | `SCENES=2` | `bao`'s SAMLResponse has `astra-readers` changed to `astra-admins` in transit → Keycloak returns 400 "Chữ ký không hợp lệ…" (invalid signature), no code is issued. | | 3 | `SCENES=3` | Discovery: `ServiceProviderConfig` (`patch: true`, filter maximum 200, `bulk: false`, `etag: false`), ResourceTypes User and Group. Client-credentials token for `scim-acme`: `scim_tenant: acme`, role `scim`; the tenant is taken only from this claim. | | 4 | `SCENES=4` | `POST /scim/v2/Users` → 201, `application/scim+json`; filter `userName eq "…"` → 1 result; creating a duplicate → 409 `uniqueness`. The new user has no role yet: `/api/doc/Article` → 403 "not a member of tenant acme". | | 5 | `SCENES=5` | Groups are the tenant roles: admin, editor, author, reader. PATCH adds a member to editor → after a new login the token has `/tenants/acme/editor`, create an Article → 201. | | 6 | `SCENES=6` | PATCH `{"op":"Replace","path":"active","value":"False"}` (the form Entra sends) → the account is disabled: password grant → `invalid_grant` "Account disabled", refresh token → "Session not active". An access token already issued stays usable until it expires; the scene prints the expiry time. | | 7 | `SCENES=7` | globex's token: an acme user → 404; filter → 0 results; header `X-Astra-Tenant: acme` → 403; adding an acme user to a globex role → 400 `invalidValue`; filter `co` → 400 `invalidFilter`. Errors follow the RFC 7644 format. | | 8 | `SCENES=8` | `DELETE` → 204, GET again → 404. The demo's SCIM user is deleted when the script ends; the linked accounts `acme-corp.linh`, `acme-corp.bao` are kept, like real linked accounts. | **To be accurate:** - The IdP is a Keycloak realm (`acme-corp-idp`) in the same container, not Okta, Entra or ADFS; the SAML protocol between the two sides is real. The demo replays the browser steps with curl and does not follow the redirect back to Desk. - Assertion and response are signed and verified; the assertion is **not encrypted**, the AuthnRequest is **not signed**. Only SP-initiated; IdP-initiated is not configured; **Single Logout is not wired up and not tested**. - On first login Keycloak creates the account `acme-corp.`; if the email matches a local account, Keycloak asks to link them, and the demo does not show this step. The group → role mapping is fixed in the realm configuration and only for tenant `acme`; each other company needs its own IdP and mapper. - SCIM has no Bulk, ETag/`meta.version`, sort, `/Me`, `meta.lastModified` or `attributes` parameter yet; `excludedAttributes` only applies to Groups `members`. Filters support only `userName eq` and `displayName eq`. `externalId` is ignored; only one email is kept; `userName` cannot be changed. - Groups are fixed because they are the tenant roles: creating or deleting a group returns 501, only membership can change. A tenant's SCIM client only sees and edits users it provisioned. Deleting through SCIM also deletes the Keycloak account. - Role changes take effect from the next login or refresh. After `active=false`, an access token already issued stays valid until it expires, because Astra verifies the JWT offline (5 minutes for regular clients, 1 hour for the demo client `astra-dev-cli`). - Listing users fetches members from Keycloak and paginates in memory, with one extra group read call per user: enough for the demo, not meant for hundreds of thousands of users. The SCIM client secrets in the realm JSON are for local environments only. ### Video 18: Roadmap, the rest of use-case-01 *(In development)* Every frame is labelled **"Roadmap / In development"**. | Done (Videos 1 to 17, 19 to 21) | Still on the roadmap | | --- | --- | | JSONB + projection index, ledger, separate schema for BMS | Multi-schema for every module, materialised hot columns | | Seata AT + TCC | AT for the kernel itself, TCC fence table, Seata DB/Raft store | | Keycloak, DocType permissions, SoD | Row-level permission conditions | | Tier 0, Tier 2 Wasm | Tier 1 Isolated JVM (hot-swap), Wasm instruction counting | | Rust CDC + LSN | Streaming protocol, Apache Doris, NOC dashboards | | MCP + PII masking | Autonomous agents, pgvector RAG, OAuth for MCP | | Video 19: HRM (HR, payroll), accrual journal entries through `GeneralLedger` | Personal income tax, employer insurance share, salary payout, month-end background payroll job, employee self-service salary slips | | Video 20: STMS (Timefold), replanning, dispatch copilot | A real LLM for the copilot, real maps and traffic, load forecasting (Tier 1), driver app | | Video 21: SAML SSO, per-tenant SCIM 2.0 | Single Logout, SCIM Bulk/ETag, full SCIM filters | | (not yet) | Audit hash chain, field encryption | --- ## 4. Pre-publication checklist - [ ] Each video only claims what is in the table in section 2; read the "To be accurate" sections before recording the voice-over. - [ ] Video 18 and every roadmap frame carry the "In development" label. - [ ] Video 20: every frame with the copilot clearly shows "LLM giả lập (kịch bản demo)" (simulated LLM, demo script); never say or imply there is a real language model. - [ ] Performance figures (CDC lag, sandbox time) come from the actual recording, not rounded to look better than reality. - [ ] Customer data is fictional seed data (`scripts/demo-seed.sh`). - [ ] Demo passwords appear only on the local stack; do not record screens that show tokens. - [ ] Videos with CC BY music: the video description includes a credit line (Video 0: see `docs/video/teaser/CREDITS.txt`). --- # What is Astra & running it locally Astra is a metadata-driven business kernel; five commands give you a real Kubernetes cluster on a laptop, two seeded courier companies and a Desk generated from the DocTypes the apps contribute. Read this if: you want… 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/quickstart ▶ [English](https://media.zerohostcms.com/astra-cms/astra-video-1-quickstart-en.mp4) · [Tiếng Việt](https://media.zerohostcms.com/astra-cms/astra-video-1-quickstart-vi.mp4) · 2:32 · Script: [demo-video-series.en.md](/astra-cms/en/video-scripts) > **In one sentence:** Astra is a metadata-driven business kernel; five commands give you a real Kubernetes cluster on a laptop, two seeded courier companies and a Desk generated from the DocTypes the apps contribute. > **Read this if:** you want to run the whole stack locally and know what each pod is for (developer angle) / you want to know what is actually running today, what it costs to operate, and where the line between built and designed sits (tech-lead angle). ## The problem Astra Express is a courier company with two branches run as separate businesses: `saigon-express` and `hanoi-express`. It takes orders (OMS), moves parcels (OPS), answers customer calls (CARE) and collects cash on delivery (BMS). Each of those is usually a separate product with its own database, login and integration code. Every new branch or rule turns into a project: a new schema, a new service, a new deployment. The usual answer to "show me it works" is a slide deck or a single-container toy that hides the hard parts: tenancy, identity, transactions across stores, and more than one application server. Teams then find out in production that their naming counter collides across pods or that tenant isolation was a `WHERE` clause someone forgot. Frappe/ERPNext solved the first half of this with one idea: the application is metadata. A DocType describes a business record, and the forms, API and permissions come from it. Astra keeps that idea, rebuilt in Java 25 on Quarkus, and adds the constraints a cluster brings ([explain.en.md](/astra-cms/en/why-astra)). Video 1 exists to show that the starting point is a real cluster, not a toy: two kernel pods from the first command, so tenancy and naming series have to be cluster-safe from day one. ## What the video shows | Scene | What you see | What it proves | | --- | --- | --- | | Architecture | The README diagram: kernel, DocType Engine, Plugin Host | Apps plug in through metadata; the narration separates what runs from what is design | | Prerequisites | JDK 25, Docker Desktop, `kubectl`, `k3d`, `jq`, Bash 5; Rust only for Wasm | The toolchain is ordinary; no proprietary installer | | `make k3d-up` | Real output, sped up; the cluster already existed, so it rebuilt three images and redeployed every pod in 1 minute 29 seconds | One command builds and deploys the whole stack | | `kubectl -n astra get pods` | Seven pods, all `Running` | Two kernel pods behind one Service, plus PostgreSQL, Redis, Keycloak, Seata and the Rust CDC worker | | `make demo-seed` | Two courier companies loaded | All data goes through the REST API with Keycloak logins; hooks, lifecycle, ledger and overlays run during the load; no SQL | | Scene 1 of `demo-express` | `/api/app` and `/api/doctype` | At recording time: one server, two apps, eight DocTypes across OMS, OPS, CARE, BMS and CMS | | Desk | Login as `mai`, Installed apps, left menu | The menu is generated from the DocTypes the app contributes | ![Figure 2: Five commands from laptop to Desk](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-01-figure-2.png) ![Figure 2: Five commands from laptop to Desk.](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-01-figure-2.png) ## How it works ### One server, apps compiled in An app is a CDI bean that implements the `AstraApp` SPI and lists its DocType YAML files. It depends only on `astra-spi`, never on kernel internals. *`astra-spi/src/main/java/io/astra/spi/app/AstraApp.java`* ```java public interface AstraApp { /** Stable, reverse-DNS style identifier, e.g. {@code io.astra.cms}. */ String id(); /** Semantic version of this app build. */ String version(); /** * Classpath resource paths of DocType definitions (YAML) this app contributes, * e.g. {@code META-INF/astra/doctypes/article.doctype.yaml}. Explicit listing keeps * loading deterministic inside jars and native images. */ List doctypeResources(); } ``` *`astra-apps/express/src/main/java/io/astra/apps/express/ExpressApp.java`* ```java @ApplicationScoped public class ExpressApp implements AstraApp { @Override public String id() { return "io.astra.express"; } @Override public String version() { return "0.1.0"; } @Override public List doctypeResources() { return List.of( "META-INF/astra/doctypes/customer.doctype.yaml", "META-INF/astra/doctypes/customer-order.doctype.yaml", // … "META-INF/astra/doctypes/pricing-rule.doctype.yaml"); } } ``` The server module depends on each app module, so the apps are compiled into one image. That is what the API reports as tier `0 (shared, build-time)`: *`astra-doctype/src/main/java/io/astra/doctype/api/AppResource.java`* ```java @GET public List> list() { return apps.apps().stream().map(app -> { Map m = new LinkedHashMap<>(); m.put("id", app.id()); m.put("version", app.version()); m.put("tier", "0 (shared, build-time)"); m.put("doctypes", registry.namesByApp(app.id())); return m; }).toList(); } ``` Why explicit resource lists instead of classpath scanning: the javadoc says it keeps loading deterministic inside jars and native images. You always know which DocTypes an app brings by reading one method. ![Figure 3: An app is metadata plus code, compiled in](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-01-figure-3.png) ![Figure 3: An app is metadata plus code, compiled in.](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-01-figure-3.png) ### What `make k3d-up` actually does The Makefile target calls [`scripts/k3d-up.sh`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/scripts/k3d-up.sh). It creates or reuses the k3d cluster `astra`, builds the Maven modules, builds three images (server, Keycloak with the Astra theme, Rust CDC worker), imports them into k3d, applies `deploy/k8s/base`, prepares the database with `scripts/db-setup.sh`, syncs the Keycloak realm, and restarts the deployments so a re-imported image with the same tag is picked up. ```mermaid flowchart LR dev["make k3d-up"] --> mvn["./mvnw package"] mvn --> img["docker build
astra/server · astra/keycloak · astra/cdc"] img --> imp["k3d image import"] imp --> k8s["kubectl apply -k deploy/k8s/base"] k8s --> db["scripts/db-setup.sh k3d
BMS schema, CDC role"] db --> kc["keycloak-sync-realm.sh"] kc --> srv["rollout astra-server (2 pods)"] srv --> pub["db-setup.sh k3d again
CDC publication"] pub --> cdc["rollout astra-cdc"] ``` The CDC publication is created after the server has started because the server's Flyway migrations create the tables the publication covers; the script comments say so directly (`# now the CDC publication, on the migrated schema`). ### Seven pods, and why two of them are the kernel *`deploy/k8s/base/astra-server.yaml`* ```yaml replicas: 2 # two pods from the start: tenancy and naming series must be cluster-safe ``` Every other workload in `deploy/k8s/base` (PostgreSQL, Redis, Keycloak, Seata, CDC) runs one replica, which gives the seven pods in the video. Starting with two kernel pods is a deliberate test condition: a counter kept in memory, or a cache that assumes one writer, breaks immediately. The naming series is stored in PostgreSQL per tenant (`astra_series`), which is why order numbers stay unique across pods (Video 10 proves it under load). | Pod | Role in the demo | | --- | --- | | `astra-server` ×2 | Quarkus kernel: REST, Desk, MCP, hooks, DocType engine | | `postgres-0` | `astra_doc`, ledger, BMS schema, replicas; `wal_level=logical` for CDC | | `redis` | TCC pickup capacity (Video 17) | | `keycloak` | OIDC login, per-tenant roles as groups (Video 11) | | `seata-server` | Global transaction coordinator (Video 17) | | `astra-cdc` | Rust worker decoding `pgoutput` into replica tables (Video 16) | ![Figure 1: The seven pods and who talks to whom](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-01-figure-1.png) ![Figure 1: The seven pods and who talks to whom.](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-01-figure-1.png) ### Seeding through the front door [`scripts/demo-seed.sh`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/scripts/demo-seed.sh) signs in through Keycloak as the `demo` user and creates everything with HTTP calls. Nothing is inserted with SQL: *`scripts/demo-seed.sh`* ```bash customer() { call "$1" POST /api/doc/Customer "$2" >/dev/null; echo " customer $(jq -r .code <<<"$2")"; } order() { call "$1" POST /api/doc/CustomerOrder "$2" | jq -r .name; } submit() { call "$1" POST "/api/doc/CustomerOrder/$2/submit" | jq -r .data.waybill; } scan() { call "$1" PUT "/api/doc/Waybill/$2" "$3" >/dev/null; } ``` Why this matters: seed data that bypasses the API also bypasses validation, hooks, permissions and the ledger. Seeding through the API means the demo data was produced by the same rules a user hits, so a fee or a journal entry you see in Desk was computed, not typed in. ### Desk knows nothing in advance Desk is a static page served by the kernel (`astra-server/src/main/resources/META-INF/resources/desk/`). After login it asks `/api/doctype` for the tenant's effective DocTypes and builds the left menu by module. Video 2 covers the form renderer; the point for Video 1 is that installing an app is enough for its DocTypes to show up. ```mermaid sequenceDiagram autonumber actor M as mai (admin, saigon-express) participant D as Desk (browser) participant KC as Keycloak participant K as astra-server M->>D: open /desk/ D->>KC: login (PKCE) KC-->>D: token with /tenants/saigon-express/admin D->>K: GET /api/app, GET /api/doctype (Bearer, X-Astra-Tenant) K-->>D: apps + DocType definitions (overlay applied) D->>D: sidebar grouped by module: OMS, OPS, CARE, BMS ``` ## Real-world example *Illustrative scenario: the company, people and values are made up; the mechanism and the rules it applies are the real ones shown above.* A regional third-party logistics company in Vietnam runs two legal entities, one in Hanoi and one in Ho Chi Minh City, and its CTO wants a proof of concept before committing a team. The question is narrow: can one platform serve both entities with separate data, real logins and the company's own order rules? Inputs: - A laptop with Docker Desktop given 8 GB of RAM, JDK 25, `kubectl`, `k3d`, `jq`, Bash 5. - The repository, unchanged. What Astra does, in order: 1. `make k3d-up` builds and deploys seven pods. 2. `make demo-seed` creates the two tenants' data through the API. In `saigon-express` it creates, among others, customer `C001` "Shop Hoa Mai" (segment B2B, credit limit 2,000,000 ₫) and posts orders that are priced by the shipping-fee hook and, when submitted, create waybills and ledger entries. 3. Signing in as `mai` (admin) shows only `saigon-express`; `nam` (admin) sees only `hanoi-express`. Outputs the CTO can check in about half an hour: - `kubectl -n astra get pods`: two `astra-server` pods plus the five infrastructure pods. - `GET /api/app`: the apps compiled into the image and their DocTypes. - Desk at `http://localhost:8080/desk/`: orders, operations, customer care and accounting menus, generated from metadata. What the proof of concept does not answer, and should not be stretched to answer: throughput, upgrade behaviour, or production hardening. Those need their own tests. ## For technical leaders - **What you are evaluating.** A thin slice that runs end to end: DocType engine, hooks, lifecycle, ledger, overlays, Wasm sandbox, MCP, CDC, Seata, Keycloak, two kernel pods. The README's [Project Status](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/README.md#project-status) is the authority on what runs; the rest of the README is target architecture. - **Operational footprint.** Seven pods for one demo environment, with Keycloak, Seata and a CDC worker each being a system to patch and monitor. If you do not need distributed transactions or a read replica, that is cost without benefit; the kernel uses Seata only when a DocType declares `transaction: global` and a coordinator is available. - **Build-time apps.** Every app today is Tier 0: compiled into the image. Adding or changing an app means a new image and a rollout. Runtime loading (Tier 1 isolated JVM) and versioned side-by-side apps are roadmap. - **What to measure in a pilot.** Time from `git clone` to a working Desk on a clean machine; time for a developer to add one DocType and one hook (Videos 2, 3, 9); whether your security team accepts row-level security as the tenant boundary (Videos 7, 8). - **When not to use it.** If you need a finished ERP today, ERPNext is a mature product and Astra is not. If one tenant and one database are enough, a conventional Spring or Quarkus service is simpler to run. - **Compared with Frappe/ERPNext.** Same core idea (DocType as metadata, apps, submittable documents). Different storage (one JSONB table, no DDL per field), Java 25 virtual threads instead of Python workers, and row-level security instead of one database per site. Astra is clean-room: no code, schema or assets from either project. ## Try it yourself Prerequisites: JDK 25, Docker Desktop with about 8 GB of RAM allocated, `kubectl`, `k3d`, `jq` and Bash 5 (`brew install bash` on macOS; the scripts re-run themselves under Bash 5 if the default shell is 3.2). Rust is only needed to change the Wasm modules or the CDC worker locally. ```bash make k3d-up # cluster, images, deploy, wait kubectl -n astra get pods # expect 7 pods Running: astra-server x2, postgres-0, redis, keycloak, seata-server, astra-cdc make demo-seed # seed saigon-express and hanoi-express through the REST API make demo-express SCENES=1 PAUSE=0 # /q/health/ready, /api/app, /api/doctype open http://localhost:8080/desk/ # log in as mai / Astra@123 ``` Expected output of the scene (abridged): ```text ━━━ Scene 1 · One server, many apps ━━━ $ curl -X GET http://localhost:8080/q/health/ready … HTTP 200 "UP" $ curl -X GET http://localhost:8080/api/app … HTTP 200 [ { "id": "io.astra.express", "version": "0.1.0", "tier": "0 (shared, build-time)", "doctypes": [ "Customer", "CustomerOrder", … ] }, … ] ``` To run it again from a clean state, `make demo-reset && make demo-seed`. `scripts/demo-reset.sh` targets the k3d database when its PostgreSQL pod exists and Docker Compose otherwise; it uses the local bootstrap superuser, so never point it at real data. `make k3d-down` deletes the cluster. ## To be accurate - **Counts in the video reflect the recording.** The video shows two apps and eight DocTypes (Express and CMS). The current server image also bundles the HRM and STMS apps, so `/api/app` now returns four apps and `/api/doctype` returns more DocTypes. - **The 1 minute 29 seconds** was a rebuild on a machine where the cluster already existed. A first run also pulls base images and creates the cluster, and takes longer. - **Tier 0 only for compiled apps.** Tier 2 Wasm runs (Video 14). Tier 1 isolated JVM loading, versioned apps side by side, canary rollout and automatic rollback are roadmap, marked ○ or ◐ in the README. - **Desk is a demo UI** generated from metadata: list, form, lifecycle, ledger, replica and overlay views. Dashboards, reports and the full Desk are not built. - **k3d is a local cluster.** The manifests in `deploy/k8s/base` are not a production deployment: no managed database, no backups, no high availability for PostgreSQL or Keycloak. ## Figures - [Figure 1 · The seven pods and who talks to whom](#seven-pods-and-why-two-of-them-are-the-kernel) - [Figure 2 · Five commands from laptop to Desk](#what-the-video-shows) - [Figure 3 · An app is metadata plus code, compiled in](#one-server-apps-compiled-in) ## Related - Next: [Video 2 · DocType: from one YAML file to API, UI and indexes](/astra-cms/en/doctype) - Then: [Video 3 · Business rules: hooks & validation](/astra-cms/en/hooks) - Script and recording setup: [demo-video-series.en.md](/astra-cms/en/video-scripts) - Why the design looks like this: [explain.en.md](/astra-cms/en/why-astra) - What runs today: [README Project Status](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/README.md#project-status) - Source: [`scripts/k3d-up.sh`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/scripts/k3d-up.sh), [`scripts/demo-seed.sh`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/scripts/demo-seed.sh), [`scripts/demo-express.sh`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/scripts/demo-express.sh), [`deploy/k8s/base`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/deploy/k8s/base), [`AstraApp.java`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/astra-spi/src/main/java/io/astra/spi/app/AstraApp.java), [`AppResource.java`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/astra-doctype/src/main/java/io/astra/doctype/api/AppResource.java) --- # DocType: from one YAML file to API, UI and indexes A DocType is one YAML file; from it the kernel serves a validated REST API, Desk builds the form and list, projected fields get online expression indexes, and every DocType is stored in one shared JSONB table with no… 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/doctype ▶ [English](https://media.zerohostcms.com/astra-cms/astra-video-2-doctype-en.mp4) · [Tiếng Việt](https://media.zerohostcms.com/astra-cms/astra-video-2-doctype-vi.mp4) · 2:38 · Script: [demo-video-series.en.md](/astra-cms/en/video-scripts) > **In one sentence:** A DocType is one YAML file; from it the kernel serves a validated REST API, Desk builds the form and list, `projected` fields get online expression indexes, and every DocType is stored in one shared JSONB table with no `ALTER TABLE`. > **Read this if:** you want to know exactly which YAML keys turn into which runtime behaviour (developer angle) / you need to judge the zero-DDL storage choice: what it buys, what it costs, and where it stops scaling (tech-lead angle). ## The problem Astra Express needs a Customer Order record: who is sending, who receives, the zone, the service level, weight, cash on delivery, and a fee nobody may type by hand. In a conventional stack that one record means a migration, an entity class, a DTO, a controller, validation annotations, a form component, a list page and a permission check. Six or seven artefacts have to agree about the same twelve fields, and when the business adds a thirteenth, all of them change again, plus an `ALTER TABLE` on a table that is being written to all day. Frappe/ERPNext removed most of that duplication: the DocType is the single definition and the rest is generated. It still creates one table per DocType and runs DDL when a Custom Field is added ([explain.en.md](/astra-cms/en/why-astra)). On a busy multi-tenant database, DDL means locks, deployment windows, and one tenant's customisation touching every other tenant's table. Astra keeps the DocType idea and changes the storage: business fields live in JSONB in one table, and the only DDL a new indexed field ever causes is an online `CREATE INDEX CONCURRENTLY`. ## What the video shows | Scene | What you see | What it proves | | --- | --- | --- | | YAML | `customer-order.doctype.yaml`: module, fields, Select, Link, read-only, `pii`, `submittable`, `transaction: global` | The whole definition of a business record fits in one file in the app | | Derive | Bad Select values get 422; Links must point to records in the tenant; clients cannot write read-only fields; projected fields get an index | Behaviour comes from metadata, not per-DocType code | | Desk metadata page | Field table for Customer Order | Built from the definition the API returns, with the tenant overlay already applied | | Renderer | `desk.js` code | Each field type is a control; permissions decide whether Submit appears | | Projections | Two valid indexes for Customer Order | Built at kernel startup with `CREATE INDEX CONCURRENTLY`, no write lock, no `ALTER TABLE` | | `\d astra_doc` | One table, a GIN index, ten projection indexes at recording time, forced RLS, `astra_cdc` publication | Every DocType shares one JSONB table | ## How it works ### The definition *`astra-apps/express/src/main/resources/META-INF/astra/doctypes/customer-order.doctype.yaml`* ```yaml name: CustomerOrder module: oms kind: transaction submittable: true # Draft (0) -> Submitted (1) -> Cancelled (2) -> Amend transaction: global # submit/cancel span BMS credit (Seata AT) and pickup capacity (Seata TCC) naming: { series: "ORD-.YYYYMMDD.-.#####" } fields: - { name: customer, type: Link, options: Customer, required: true, in-list: true, projected: true } - { name: receiver_name, type: Data, required: true, in-list: true, pii: true } - { name: receiver_phone, type: Data, required: true, pii: true } # … - { name: zone, type: Select, options: [Same city, Domestic, Remote], default: Domestic, in-list: true, projected: true } - { name: service, type: Select, options: [Standard, Express], default: Standard, in-list: true } - { name: weight_kg, type: Float, required: true, label: Weight (kg) } - { name: cod_amount, type: Currency, default: 0, label: COD amount } - { name: shipping_fee, type: Currency, read-only: true, in-list: true } # computed by ShippingFeeHook # … permissions: - { role: admin, read: true, create: true, write: true, submit: true, cancel: true } - { role: sales, read: true, create: true, write: true, if-owner: true, submit: true } # … ``` The loader parses each field into an immutable `FieldDefinition` and rejects anything that is not a snake_case identifier. That check is not cosmetic: field names are later spliced into SQL expressions (`data->>'zone'`), so the name rule is what keeps those expressions safe. *`astra-doctype/src/main/java/io/astra/doctype/registry/DocTypeLoader.java`* ```java static FieldDefinition field(JsonNode f, String where) { String fieldName = f.path("name").asText(""); if (!fieldName.matches("[a-z][a-z0-9_]{0,50}")) { throw new IllegalStateException(where + ": field name '" + fieldName + "' must be snake_case"); } return new FieldDefinition( fieldName, FieldType.valueOf(f.path("type").asText("Data").toUpperCase(Locale.ROOT)), f.path("label").asText(null), f.path("required").asBoolean(false), f.path("read-only").asBoolean(false), f.path("in-list").asBoolean(false), f.path("projected").asBoolean(false), options(f), f.hasNonNull("default") ? YAML.convertValue(f.get("default"), Object.class) : null, f.path("pii").asBoolean(false)); } ``` ### What each key turns into | YAML | Runtime behaviour | Owner | | --- | --- | --- | | `name`, `fields` | `GET/POST /api/doc/CustomerOrder`, `GET/PUT /api/doc/CustomerOrder/{name}`; Desk form and list | `DocResource`, `desk.js` | | field not declared | 422 `unknown field '…' for DocType CustomerOrder` | `DocumentValidator` | | `required`, `default` | 422 when missing; default filled in before hooks run | `DocumentValidator` | | `type: Select` + `options` | 422 `field 'zone' must be one of [Same city, Domestic, Remote]` | `DocumentValidator` | | `read-only: true` | 422 `field 'shipping_fee' is read-only` when a client sends it; hooks may set it | `DocumentValidator.rejectReadOnly` | | `type: Link` + `options` | 422 `field 'customer': Customer '…' not found` unless the target exists in this tenant | `DocumentStore.validateLinks` | | `projected: true` | Expression index `astra_doc_p_`; list filters use the same expression | `ProjectionIndexer` | | `naming.series` | `ORD--00001`, counted per tenant in `astra_series` | `SeriesService` | | `submittable` | `/submit`, `/cancel`, `/amend` endpoints (Video 4) | `DocumentStore` | | `transaction: global` | Submit and cancel run inside a Seata global transaction when a coordinator is available (Video 17) | `DocumentStore.global` | | `pii: true` | Masked when data leaves through MCP to an AI model (Video 15) | `PiiMasking` | | `permissions` | Per-role read/create/write/submit/cancel; `if-owner` limits writes to the owner (Video 11) | `DocPermissions` | ![Figure 1: One YAML file, many outputs](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-02-figure-1.png) ![Figure 1: One YAML file, many outputs.](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-02-figure-1.png) ```mermaid flowchart LR Y["customer-order.doctype.yaml"] --> L["DocTypeLoader
(snake_case check)"] L --> R["DocTypeRegistry"] O["tenant overlay
(Video 13)"] --> E["DocTypeOverlays.effective()"] R --> E E --> API["/api/doc/CustomerOrder
validation, Links, read-only"] E --> META["/api/doctype
→ Desk form, list, menu"] E --> MCP["MCP tools
pii masked"] R --> IDX["ProjectionIndexer
CREATE INDEX CONCURRENTLY"] IDX --> PG[("astra_doc
one JSONB table")] API --> PG ``` Note the two arrows into `effective()`: the API, Desk and MCP never read the app's YAML directly. They read the effective definition for the tenant in scope, which is the app's DocType with that tenant's overlay applied. That is why a Hanoi-only field appears in Hanoi's forms and API without the Express app changing (Video 13). ### Desk is a renderer, not an application Desk asks the API for definitions and maps field types to controls. The form code switches on the field type and never on a DocType name: *`astra-server/src/main/resources/META-INF/resources/desk/desk.js`* ```js function control(f, value, locked, linkOptions) { // … switch (f.type) { case 'SELECT': input = h('select', { disabled: locked }, f.required ? null : h('option', { value: '' }, ''), f.options.map((o) => h('option', { selected: String(v) === o }, o))); break; // … case 'INT': case 'FLOAT': case 'CURRENCY': input = h('input', { type: 'number', step: f.type === 'INT' ? '1' : 'any', disabled: locked, value: v }); break; // … case 'LINK': { const listId = 'opts-' + f.name; input = h('input', { list: listId, disabled: locked, value: v, placeholder: f.options[0] + ' name', spellcheck: 'false' }); // … } } // … } ``` Buttons follow the signed-in user's effective permissions, not hard-coded roles: *`astra-server/src/main/resources/META-INF/resources/desk/desk.js`* ```js if (doc.docstatus === 0 && perm.submit) actions.append(h('button', { class: 'btn', type: 'button', onclick: () => lifecycle(def, doc, 'submit', linkOptions) }, 'Submit')); if (doc.docstatus === 1 && perm.cancel) actions.append(h('button', { class: 'btn danger', type: 'button', onclick: () => lifecycle(def, doc, 'cancel', linkOptions) }, 'Cancel')); ``` Why build Desk this way: it is the cheapest proof that the metadata is complete. If Desk can render and operate a DocType it has never heard of, then so can an MCP client or a mobile app reading the same `/api/doctype`. ### One table for every DocType *`astra-doctype/src/main/resources/db/migration/V1__astra_doc.sql`* ```sql CREATE TABLE astra_doc ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id text NOT NULL, doctype text NOT NULL, name text NOT NULL, docstatus smallint NOT NULL DEFAULT 0, owner text NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), modified_at timestamptz NOT NULL DEFAULT now(), version integer NOT NULL DEFAULT 1, data jsonb NOT NULL DEFAULT '{}'::jsonb, CONSTRAINT astra_doc_tenant_doctype_name_key UNIQUE (tenant_id, doctype, name) ); CREATE INDEX astra_doc_list_idx ON astra_doc (tenant_id, doctype, modified_at DESC); CREATE INDEX astra_doc_data_gin ON astra_doc USING gin (data jsonb_path_ops); ALTER TABLE astra_doc ENABLE ROW LEVEL SECURITY; ALTER TABLE astra_doc FORCE ROW LEVEL SECURITY; -- applies to the table owner too ``` The columns are the ones the kernel itself needs on every record: identity, tenant, type, lifecycle state (`docstatus`), owner, timestamps and an optimistic-lock `version`. Everything the business defines goes into `data`. Adding a DocType or a field is therefore a metadata change, never a schema change. (A later migration adds `amended_from` for Video 4's amend chain.) ![Figure 2: The shared JSONB table](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-02-figure-2.png) ![Figure 2: The shared JSONB table.](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-02-figure-2.png) ### Projections: indexes without `ALTER TABLE` *`astra-doctype/src/main/java/io/astra/doctype/store/ProjectionIndexer.java`* ```java static String expression(FieldDefinition field) { return "(data->>'" + field.name() + "')"; } // … void onStart(@Observes StartupEvent ev) { Map fields = projectedFields(); try (Connection conn = dataSource.getConnection(); Statement st = conn.createStatement()) { conn.setAutoCommit(true); // CREATE INDEX CONCURRENTLY cannot run inside a transaction block for (FieldDefinition f : fields.values()) { try { st.execute("CREATE INDEX CONCURRENTLY IF NOT EXISTS " + indexName(f.name()) + " ON astra_doc (tenant_id, doctype, " + expression(f) + ")"); // … } catch (SQLException e) { // Another pod may be building the same index right now; the next start retries. // … } } } // … } ``` Three design choices are packed in here: 1. **`CONCURRENTLY`** builds the index without blocking writes. It cannot run in a transaction, hence `setAutoCommit(true)`. 2. **Indexes are keyed by field name, not by DocType.** `astra_doc_p_zone` serves every DocType that has a projected `zone`, and the leading `(tenant_id, doctype, …)` columns keep lookups narrow. This keeps the index count proportional to distinct projected field names, not to DocTypes times fields. 3. **The list filter uses the same expression.** A planner can only use an expression index when the query repeats the expression exactly, so `DocumentStore.list` builds its `WHERE` with `ProjectionIndexer.expression(field)`: *`astra-doctype/src/main/java/io/astra/doctype/store/DocumentStore.java`* ```java FieldDefinition field = def.field(f.getKey()) .orElseThrow(() -> new DocValidationException("unknown filter '" + f.getKey() + "' for DocType " + doctype)); sql.append(" AND ").append(ProjectionIndexer.expression(field)).append(" = ?"); ``` The projections endpoint reads the result back from the PostgreSQL catalog, not from the YAML, so what Desk shows is what the database actually has: ```sql SELECT i.indisvalid, pg_get_indexdef(i.indexrelid) FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid WHERE c.relname = ? ``` ![Figure 3: Online projection index at startup](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-02-figure-3.png) ![Figure 3: Online projection index at startup.](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-02-figure-3.png) ## Real-world example A cold-chain distributor (hypothetical, mechanisms real) ships vaccines and seafood from Ho Chi Minh City and needs to separate frozen, chilled and ambient orders. Dispatchers filter by that class all day. Input: one field added to the app's order DocType and a new image rolled out. ```yaml - { name: temperature_class, type: Select, options: [Frozen, Chilled, Ambient], default: Ambient, in-list: true, projected: true } ``` What Astra does: 1. On startup, each kernel pod runs `CREATE INDEX CONCURRENTLY IF NOT EXISTS astra_doc_p_temperature_class ON astra_doc (tenant_id, doctype, (data->>'temperature_class'))`. Writes continue during the build. If the second pod collides with the first, it logs a warning and the index is retried on its next start. 2. Existing orders have no `temperature_class` in `data`. When a draft is next saved, the validator fills the default `Ambient`. No backfill migration runs; submitted and untouched rows simply lack the key, so a `?temperature_class=Ambient` filter does not return them. 3. The API rejects `{"temperature_class":"Warm"}` with 422 and `field 'temperature_class' must be one of [Frozen, Chilled, Ambient]`. 4. Desk shows a drop-down on the form and a column in the list, because `in-list` is true. 5. `GET /api/doc/CustomerOrder?temperature_class=Frozen` filters with the indexed expression. Output: a new, validated, indexed field with zero `ALTER TABLE` and no change to Desk code. If only one tenant needed the field, a tenant overlay would add it at run time without a new image (Video 13). ## For technical leaders - **What zero-DDL buys.** Adding DocTypes and fields never locks a table, and tenant customisations cannot collide in the schema. Rollouts no longer need a migration window for form changes. - **What it costs.** Every DocType shares one table and one set of indexes. Type safety moves from the database to the kernel validator; a buggy writer that bypasses the kernel can store anything in `data`. The kernel is the only intended writer, and row-level security still applies to every statement. - **Index semantics are text equality.** The expression is `data->>'field'`, so the index serves equality filters on text. Range queries on numbers or dates, sorting by a business field, or aggregation are not served by these indexes today. - **Hot fields.** PostgreSQL rewrites the whole row on `UPDATE`. A DocType whose few fields change many times a day (stock, balances, counters) bloats a JSONB row. The design answer is materialised projection columns and ledgers ([explain.en.md](/astra-cms/en/why-astra)); materialised projections are not built yet. Measure update rate per DocType before putting high-churn data in `astra_doc`. - **Index lifecycle.** The indexer only creates. Removing `projected: true` does not drop the index; that is a manual `DROP INDEX CONCURRENTLY` today. - **What to measure.** Index count on `astra_doc` and its write amplification; `indisvalid` for every projection after each rollout (`GET /api/doctype/{name}/projections`); p95 list latency per filter. - **When not to use it.** Heavy analytical queries, wide numeric reporting, or data that needs database constraints between business fields. Those belong in a ledger, a module schema (as BMS does for credit, Video 17) or an analytics store. - **Compared with Frappe.** Frappe: one table per DocType, real columns, DDL per Custom Field, strong per-column typing. Astra: one JSONB table, online expression indexes, typing enforced in the kernel. ## Try it yourself Prerequisites: the stack from [Video 1](/astra-cms/en/quickstart) running (`make k3d-up`, `make demo-seed`). ```bash make demo-express SCENES=2 PAUSE=0 ``` The scene prints the YAML, then the projection status, then `\d astra_doc` from the k3d PostgreSQL pod. Expected projection output: ```text $ curl -X GET http://localhost:8080/api/doctype/CustomerOrder/projections … HTTP 200 [ { "field": "customer", "valid": true, "definition": "CREATE INDEX astra_doc_p_customer ON public.astra_doc USING btree (tenant_id, doctype, ((data ->> 'customer'::text)))" }, { "field": "zone", "valid": true, "definition": "CREATE INDEX astra_doc_p_zone ON public.astra_doc USING btree (tenant_id, doctype, ((data ->> 'zone'::text)))" } ] ``` `\d astra_doc` ends with `Policies (forced row security enabled)` and `Publications: "astra_cdc"`. In Desk (`http://localhost:8080/desk/`, user `mai`): open **DocType metadata**, choose `CustomerOrder`, and expand "Raw JSON from GET /api/doctype/CustomerOrder" to see the exact definition the page rendered. ## To be accurate - **`CONCURRENTLY` is not visible in the catalog.** `pg_index` stores only the index definition, so the definition column never shows the word `CONCURRENTLY`; the narration says this on screen. - **The index count is install-specific.** The video showed ten projection indexes. The current image also bundles HRM and STMS, whose projected fields add more `astra_doc_p_*` indexes. - **Projection indexes are built at kernel startup.** A field added by an app needs a new image; there is no projection builder triggered by a metadata change at run time. - **`pii` masks only the MCP path.** Desk and REST show PII to users whose role can read the DocType. - **Not built:** materialised projections, typed expression indexes, projection quotas and dropping unused indexes. These appear in the README as target architecture. Child tables are declared in the SPI's `FieldType` enum (`TABLE`), but neither the kernel validator nor Desk handles them yet. - **Any declared field can be filtered**, projected or not. Only projected fields have an index behind the filter; filtering on another field scans the tenant's rows of that DocType. - **Desk has one DocType reference.** The form, list and menu code never name a DocType, but `desk.js` maps `CustomerOrder` and `Waybill` to their CDC replica tables for the replica card (Video 16). ## Figures - [Figure 1 · One YAML file, many outputs](#what-each-key-turns-into) - [Figure 2 · The shared JSONB table](#one-table-for-every-doctype) - [Figure 3 · Online projection index at startup](#projections-indexes-without-alter-table) ## Related - Previous: [Video 1 · What is Astra & running it locally](/astra-cms/en/quickstart) - Next: [Video 3 · Business rules: hooks & validation](/astra-cms/en/hooks) - Script: [demo-video-series.en.md](/astra-cms/en/video-scripts); design rationale for storage: [explain.en.md](/astra-cms/en/why-astra); developer excerpts: [demo-code-snippets.md](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/demo-code-snippets.md) (S1, S12) - Source: [`customer-order.doctype.yaml`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/astra-apps/express/src/main/resources/META-INF/astra/doctypes/customer-order.doctype.yaml), [`DocTypeLoader.java`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/astra-doctype/src/main/java/io/astra/doctype/registry/DocTypeLoader.java), [`DocumentValidator.java`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/astra-doctype/src/main/java/io/astra/doctype/store/DocumentValidator.java), [`ProjectionIndexer.java`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/astra-doctype/src/main/java/io/astra/doctype/store/ProjectionIndexer.java), [`V1__astra_doc.sql`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/astra-doctype/src/main/resources/db/migration/V1__astra_doc.sql), [`desk.js`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/astra-server/src/main/resources/META-INF/resources/desk/desk.js) --- # Business rules: hooks & validation A business rule is a plain Java CDI bean that the kernel calls before every write of its DocType, inside the write's transaction, so the 73,000 ₫ shipping fee and the validation errors are identical whether the order… 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/hooks ▶ [English](https://media.zerohostcms.com/astra-cms/astra-video-3-hooks-en.mp4) · [Tiếng Việt](https://media.zerohostcms.com/astra-cms/astra-video-3-hooks-vi.mp4) · 2:53 · Script: [demo-video-series.en.md](/astra-cms/en/video-scripts) > **In one sentence:** A business rule is a plain Java CDI bean that the kernel calls before every write of its DocType, inside the write's transaction, so the 73,000 ₫ shipping fee and the validation errors are identical whether the order comes from Desk, REST or an AI agent over MCP. > **Read this if:** you need to write a hook and want to know exactly when it runs and what it may do (developer angle) / you want to know where business rules live, how they are enforced across channels, and what that costs (tech-lead angle). ## The problem Astra Express charges for delivery by zone, weight and service, adds a fee for cash on delivery, and refuses parcels over 30 kg or phone numbers that are not Vietnamese mobile format. The rule is simple; keeping it in one place is not. A typical system ends up with the fee formula in the web form (for the preview), again in the API controller, again in the mobile app, and sometimes in a nightly job. They drift. The first time someone notices is when a customer is quoted one price on the website and charged another. Validation has a second, quieter problem: errors that arrive one at a time. The clerk fixes the phone number, saves, and only then learns the weight is wrong. A typical Frappe controller behaves this way too, because `frappe.throw` stops at the first problem unless the developer collects messages by hand. Astra's answer is two layers that every channel passes through: - the **DocType** enforces the structural rules it declares: required fields, Select options, unknown fields, read-only fields, and Links that must exist in the tenant; - **hooks** enforce the business rules the YAML cannot express, and may compute fields such as the fee. Both run in the kernel, below Desk, REST and MCP, so no client can skip them. ![Figure 1: Three channels, one rule](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-03-figure-1.png) ![Figure 1: Three channels, one rule.](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-03-figure-1.png) ## What the video shows | Scene | What you see | What it proves | | --- | --- | --- | | Create | `tuan` (sales) creates an order for `C001`: Domestic, Express, 2.2 kg, COD 500,000 ₫ | The number comes from a daily series; `ShippingFeeHook` sets the fee in the same transaction as the write | | Fee | 30,000 + 3 × 5,000 = 45,000; × 1.5 = 67,500; + 5,000 COD = 72,500; rounded up to 73,000 ₫ | The rule is visible arithmetic, not a black box; "Priced by: Standard tariff" | | Errors | Phone `123`, weight `45` → Save | Both errors in a single 422, before anything is written | | Link | Valid values, customer `C999` | Not a hook: the kernel checks the Link target exists; the draft is unchanged | | Channels | Same data through REST | The same two messages, in the same order | | Code | `ExpressApp` and `ShippingFeeHook` | A hook is a CDI bean; no registration, no configuration | | API rules | `shipping_fee` sent by the client, unknown field `discount`, Select value `Mars` | All rejected from the YAML alone, data unchanged | ## How it works ### The hook *`astra-spi/src/main/java/io/astra/spi/hook/DocLifecycleHook.java`* ```java public interface DocLifecycleHook { /** The DocType this hook applies to, e.g. {@code "Article"}. */ String doctype(); /** Runs before a document is inserted or updated, after kernel validation of declared fields. */ default void beforeSave(Document doc) { } /** Runs after the document has been persisted in the same transaction. */ default void afterSave(Document doc) { } // … onSubmit and onCancel (Video 4) } ``` The Express app's rule implements it. The structure is the important part: collect every error, throw once, then compute. *`astra-apps/express/src/main/java/io/astra/apps/express/ShippingFeeHook.java`* ```java @ApplicationScoped public class ShippingFeeHook implements DocLifecycleHook { static final double MAX_WEIGHT_KG = 30; static final long MAX_COD = 50_000_000; /** Zone → {price for the first kg, price per additional 0.5 kg}, in VND. */ private static final Map TARIFF = Map.of( "Same city", new long[] {18_000, 2_500}, "Domestic", new long[] {30_000, 5_000}, "Remote", new long[] {45_000, 7_000}); // … @Override public String doctype() { return "CustomerOrder"; } @Override public void beforeSave(Document doc) { List errors = new ArrayList<>(); String phone = doc.getString("receiver_phone"); if (phone != null && !phone.matches("0\\d{9}")) errors.add("receiver_phone must be 10 digits starting with 0"); double weight = Numbers.toDouble(doc.get("weight_kg")); if (weight <= 0 || weight > MAX_WEIGHT_KG) errors.add("weight_kg must be between 0 and " + (int) MAX_WEIGHT_KG); long cod = Math.round(Numbers.toDouble(doc.get("cod_amount"))); if (cod < 0 || cod > MAX_COD) errors.add("cod_amount must be between 0 and " + MAX_COD); if (!errors.isEmpty()) throw new DocValidationException(errors); String zone = doc.getString("zone"); // … a tenant's active Wasm PricingRule takes precedence (Video 14) doc.set("shipping_fee", fee(zone, doc.getString("service"), weight, cod)); doc.set("priced_by", "Standard tariff"); } // … static long fee(String zone, String service, double weightKg, long cod) { long[] t = TARIFF.getOrDefault(zone, TARIFF.get("Domestic")); long extraSteps = (long) Math.ceil(Math.max(0, weightKg - 1) / 0.5); double fee = t[0] + extraSteps * t[1]; if ("Express".equals(service)) fee *= 1.5; if (cod > 0) fee += Math.max(5_000, cod * 0.01); return (long) Math.ceil(fee / 1_000) * 1_000; } } ``` The fee for the demo order, step by step against `fee(...)`: | Step | Value | | --- | --- | | Domestic, first kg | 30,000 | | 2.2 kg − 1 = 1.2 kg → `ceil(1.2 / 0.5)` = 3 steps × 5,000 | + 15,000 = 45,000 | | Express × 1.5 | 67,500 | | COD: `max(5,000; 1% × 500,000)` | + 5,000 = 72,500 | | Round up to the thousand | **73,000 ₫** | ![Figure 2: The fee, step by step](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-03-figure-2.png) ![Figure 2: The fee, step by step.](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-03-figure-2.png) ### Discovery: no registration The app class lists only DocTypes. Hooks are found because they are CDI beans implementing the SPI. At startup the kernel groups them by `doctype()` once, so dispatch on the write path is a list iteration: *`astra-kernel/src/main/java/io/astra/kernel/extension/HookRegistry.java`* ```java @Inject public HookRegistry(Instance hooks) { this.chains = hooks.stream().collect(Collectors.groupingBy(DocLifecycleHook::doctype)); // … logs "DocType %s: %d lifecycle hook(s) [%s]" } public void beforeSave(Document doc) { for (DocLifecycleHook hook : chains.getOrDefault(doc.doctype(), List.of())) { hook.beforeSave(doc); } } ``` Why CDI and not a hooks file or annotations scanned by name: the hook is ordinary Java, injectable (`ShippingFeeHook` injects `DocumentService` and `ScriptRuntime`), and testable like any other bean. The binding itself is still a string (`doctype()` returns `"CustomerOrder"`) and the registry does not check it against the loaded DocTypes, so a misspelt name means the hook never runs. The startup log line per chain is where to catch that. ### The write path, in order Every create and update goes through `DocumentStore`, whatever the channel. `DocResource` (REST, which Desk also calls) and `McpServer` (AI agents) both call `store.insert` / `store.update`. *`astra-doctype/src/main/java/io/astra/doctype/store/DocumentStore.java`* ```java public Document insert(String doctype, Map data, String owner) { DocTypeDefinition def = overlays.effective(doctype); DocumentValidator.rejectReadOnly(def, data); return inTenantTx(conn -> persistNew(conn, def, new Document(doctype, data).owner(owner))); } // … private Document persistNew(Connection conn, DocTypeDefinition def, Document doc) throws SQLException { DocumentValidator.applyDefaultsAndValidate(def, doc); hooks.beforeSave(doc); validateLinks(conn, def, doc); if (doc.name() == null) doc.name(assignName(conn, def, doc)); try (PreparedStatement ps = conn.prepareStatement( "INSERT INTO astra_doc (id, tenant_id, doctype, name, owner, data, amended_from) " + "VALUES (?, ?, ?, ?, ?, ?::jsonb, ?) RETURNING *")) { // … hooks.afterSave(saved); // … } } ``` ```mermaid sequenceDiagram autonumber actor U as tuan (Desk / curl / MCP) participant API as DocResource / McpServer participant S as DocumentStore participant H as ShippingFeeHook participant PG as PostgreSQL (RLS) U->>API: POST /api/doc/CustomerOrder API->>API: DocPermissions: sales may create API->>S: insert(CustomerOrder, data) S->>S: rejectReadOnly (shipping_fee sent? → 422) S->>PG: BEGIN; set_config('astra.tenant', …) S->>S: applyDefaultsAndValidate (unknown, required, Select) S->>H: beforeSave(doc) H-->>S: errors → 422, or shipping_fee = 73000 S->>PG: validateLinks: Customer C001 exists? (RLS: this tenant only) S->>PG: astra_series +1 → ORD--0000n S->>PG: INSERT astra_doc … RETURNING * S->>PG: COMMIT API-->>U: 201 + document ``` ![Figure 3: The write path and where each rejection happens](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-03-figure-3.png) ![Figure 3: The write path and where each rejection happens.](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-03-figure-3.png) Design choices worth noticing: - **The hook runs after the kernel's own validation.** Defaults are already filled and Select values already checked, so a hook never sees `zone: "Mars"`. Hook code stays about business, not shape. - **Errors are reported per stage, all at once within a stage.** `DocValidationException` carries a list, and the exception mapper returns it whole: *`astra-doctype/src/main/java/io/astra/doctype/api/ApiExceptionMappers.java`* ```java @ServerExceptionMapper public Response validation(DocValidationException e) { return Response.status(422).entity(Map.of("error", "validation", "errors", e.errors())).build(); } ``` - **Link checks run inside the tenant's transaction.** The query is `SELECT 1 FROM astra_doc WHERE doctype = ? AND name = ?` with no tenant filter in the SQL; row-level security adds it. A customer that exists only in `hanoi-express` is "not found" in `saigon-express` (Video 7). - **The number is taken last.** The series counter increments only after validation, hook and Links pass, and in the same transaction as the insert. A rejected order never consumes a number, so the daily series has no gaps from validation failures. - **Read-only means "owned by hooks".** The client cannot send `shipping_fee`, but the hook sets it. The fee therefore always comes from the rule, never from a client that computed it differently. - **Same transaction for reads inside the hook.** `ShippingFeeHook` looks up the customer's segment and the tenant's active pricing rule through `DocumentService`. `TenantTransactions` carries the open connection in a `ScopedValue`, so those reads join the write's transaction and see the same tenant. ## Real-world example A last-mile courier in Vietnam (hypothetical, numbers from the real standard tariff in `ShippingFeeHook`, no active `PricingRule` for the tenant) receives five orders from a marketplace integration in the morning batch: | Order | Input | What Astra does | Output | | --- | --- | --- | --- | | A | Same city, Standard, 0.8 kg, no COD | 18,000; `ceil(max(0, −0.2) / 0.5)` = 0 extra steps; no COD fee | 201, fee **18,000 ₫** | | B | Domestic, Standard, 3 kg, COD 1,200,000 | 30,000 + 4 × 5,000 = 50,000; COD `max(5,000; 12,000)` = 12,000 | 201, fee **62,000 ₫** | | C | Remote, Express, 5 kg, COD 2,000,000 | 45,000 + 8 × 7,000 = 101,000; × 1.5 = 151,500; + 20,000 COD = 171,500; round up | 201, fee **172,000 ₫** | | D | Domestic, phone `84901234567`, 31 kg | Hook collects two errors, throws once | 422, `["receiver_phone must be 10 digits starting with 0", "weight_kg must be between 0 and 30"]`, nothing written, no number consumed | | E | Valid parcel, `"shipping_fee": 0` sent by the integration | Rejected before the transaction opens | 422, `["field 'shipping_fee' is read-only"]` | Order E is the one that matters to finance: an integration partner cannot set its own price, however the client was written. If the courier's sales team later gives a B2B segment a promotional formula, it is deployed as a tenant Wasm rule and the same hook picks it up, with "Priced by" recording which rule applied (Video 14). ## For technical leaders - **Where rules live.** Structural rules in DocType YAML; business rules in Java hooks in the app; tenant-specific formulas in Wasm (Video 14) or overlays (Video 13). That split is the main design decision to review with your team. - **Enforcement point.** Hooks run in `DocumentStore`, below every channel. Anything that writes through the kernel gets the rule. Anything that writes to PostgreSQL directly does not; keep the database role locked down (Video 8). - **Cost on the write path.** Each hook runs synchronously inside the database transaction. A slow hook holds a connection and the row locks the transaction has taken. `ShippingFeeHook` adds reads (customer segment, active pricing rules) to every save of an order. Measure p95 save latency per DocType and hook count per chain (logged at startup). - **Ordering.** The SPI has no priority attribute; hooks of one DocType run in the order CDI provides them. If two hooks on the same DocType depend on each other's output, merge them or make the dependency explicit in one hook. - **Error contract.** 422 with `{"error":"validation","errors":[…]}` is stable and the same on every channel, which makes client error handling and AI agent retries straightforward. Errors arrive per stage: kernel structural errors first, then hook errors, then Link errors. A client that fixes one stage may see the next. - **When not to use a hook.** Long-running work, calls to external services that can hang, or anything that must survive a rollback (sending an SMS). Those belong after commit in a job or event, which is not part of the thin slice yet. - **Compared with Frappe.** Same idea as `validate` / `before_save` controller methods and `doc_events`. Differences: a typed Java interface instead of Python methods resolved by name, discovery through CDI, and an error list returned in one response. ## Try it yourself Prerequisites: the stack from [Video 1](/astra-cms/en/quickstart) running and seeded. ```bash make demo-express SCENES=3 PAUSE=0 ``` The scene creates a real draft order in `saigon-express`, so run `make demo-reset && make demo-seed` afterwards if you want order numbers to match the video. What it does, in order, with the expected result: | Call | Expected | | --- | --- | | `POST /api/doc/CustomerOrder` (C001, Domestic, Express, 2.2 kg, COD 500,000) | `HTTP 201`, `name` `ORD--0000n`, `shipping_fee` `73000` | | Same with phone `123`, weight `45` | `HTTP 422`, both hook errors | | Customer `C999` | `HTTP 422`, `field 'customer': Customer 'C999' not found` | | Client sends `shipping_fee` | `HTTP 422`, `field 'shipping_fee' is read-only` | | `PUT` the draft with `zone: Mars`, `discount: 10` | `HTTP 422`, `unknown field 'discount' for DocType CustomerOrder` and `field 'zone' must be one of [Same city, Domestic, Remote]` | In Desk, sign in as `tuan` / `Astra@123`, open the Customer Order list, click **New**, and use the **API inspector** button to see the same requests and 422 bodies. The integration test [`ExpressFlowTest`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/astra-server/src/test/java/io/astra/server/ExpressFlowTest.java) asserts the 73,000 fee, the dated name and the read-only, phone and Link rejections against a real PostgreSQL; `make test` runs the whole suite. ## To be accurate - **"One round of fixes" holds within a stage.** The hook returns all of its errors at once, and the validator returns all structural errors at once, but a request that fails the structural stage never reaches the hook. In the video both errors come from the hook, so one round is enough. - **The Link check is the kernel, not the hook.** The video says so; the hook does not look at `customer` except to find the segment for pricing. - **Hook discovery is build-time.** Hooks are CDI beans in Tier 0 apps compiled into the image. Changing a hook means a new image. Runtime-loaded Java hooks (Tier 1) are not built; tenant-specific logic at run time goes through Wasm (Video 14). - **No hook ordering or after-commit hooks.** `afterSave` runs inside the same transaction, before commit. - **Fee rounding uses floating point** (`double`) before rounding up to the thousand. For the tariff's values this is exact enough; a production billing rule would use `BigDecimal` or integer minor units. ## Figures - [Figure 1 · Three channels, one rule](#the-problem) - [Figure 2 · The fee, step by step](#the-hook) - [Figure 3 · The write path and where each rejection happens](#the-write-path-in-order) ## Related - Previous: [Video 2 · DocType: from one YAML file to API, UI and indexes](/astra-cms/en/doctype) - Next: [Video 4 · Document lifecycle: Submit, Cancel, Amend](/astra-cms/en/lifecycle) - First article: [Video 1 · What is Astra & running it locally](/astra-cms/en/quickstart) - Script: [demo-video-series.en.md](/astra-cms/en/video-scripts); developer excerpts: [demo-code-snippets.md](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/demo-code-snippets.md) (S2) - Source: [`ShippingFeeHook.java`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/astra-apps/express/src/main/java/io/astra/apps/express/ShippingFeeHook.java), [`DocLifecycleHook.java`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/astra-spi/src/main/java/io/astra/spi/hook/DocLifecycleHook.java), [`HookRegistry.java`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/astra-kernel/src/main/java/io/astra/kernel/extension/HookRegistry.java), [`DocumentStore.java`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/astra-doctype/src/main/java/io/astra/doctype/store/DocumentStore.java), [`DocumentValidator.java`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/astra-doctype/src/main/java/io/astra/doctype/store/DocumentValidator.java), [`TenantTransactions.java`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/astra-doctype/src/main/java/io/astra/doctype/store/TenantTransactions.java), [`ExpressFlowTest.java`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/astra-server/src/test/java/io/astra/server/ExpressFlowTest.java) --- # Document lifecycle: Submit, Cancel, Amend a submitted document in Astra is final; you correct it by cancelling (which writes reversals, never edits) and amending (which creates one new linked draft), and every step runs in a single database transaction together… 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/lifecycle ▶ [English](https://media.zerohostcms.com/astra-cms/astra-video-4-lifecycle-en.mp4) · [Tiếng Việt](https://media.zerohostcms.com/astra-cms/astra-video-4-lifecycle-vi.mp4) · 3:10 · Script: [demo-video-series.en.md](/astra-cms/en/video-scripts) > **In one sentence:** a submitted document in Astra is final; you correct it by cancelling (which writes reversals, never edits) and amending (which creates one new linked draft), and every step runs in a single database transaction together with the documents and ledger entries it causes. > > **Read this if:** you write hooks and need to know exactly what runs inside the submit/cancel transaction (developer angle) / you are deciding how an ERP-style platform should guarantee audit trails and consistency between orders, waybills and accounting (tech-lead angle). ## The problem Astra Express takes a customer order, hands it to operations as a waybill, and bills the shipping fee to the customer's receivable account. Three records, owned by three teams (OMS, OPS, BMS), describe one business fact. In most hand-built systems these records drift apart: - The order is "confirmed" but the waybill insert failed, so the parcel is never picked up. - Someone fixes a typo in a confirmed order with an `UPDATE`, and the invoice that was already sent no longer matches the database. - Accounting "deletes" a wrong journal line, and the auditor cannot reconstruct what the books said last month. Frappe/ERPNext solved the business side of this years ago with `docstatus` (0 Draft, 1 Submitted, 2 Cancelled) and the Amend button, and ERPNext posts reversing GL entries on cancel. Astra keeps that model, because accountants and operators already understand it, and adds two things a platform kernel can enforce: the lifecycle step and everything its hooks write share one transaction, and the ledger table refuses `UPDATE` and `DELETE` at the database level. ![Figure 1: Three states, one direction](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-04-figure-1.png) *The three document states and legal transitions. Amend creates a new draft; submitted documents can only be cancelled, never edited.* ## What the video shows The video follows one new order for customer C001 in tenant `saigon-express`, signed in as `mai` (admin). | Scene | What you see | What it proves | | --- | --- | --- | | States | Draft → Submitted → Cancelled → Amend, one direction | Only three `docstatus` values; Amend creates a new document instead of moving backwards. | | Create | Draft `ORD-20261021-00011`, fee 58,000 ₫ set by the pricing hook on save, waybill empty | Side effects wait for Submit; a draft is still freely editable. | | Submit | Status becomes Submitted, waybill `WB-2610-000008` appears, "Ledger entries" shows Debit 131 / Credit 511, 58,000 | The waybill and the journal entry are written in the same transaction as the status change. | | One transaction | Code card from `DocumentStore.submit` | Hooks join the open connection; a failure anywhere rolls everything back. | | Locked | Form fields locked; a `PUT` through the API returns 422 `submitted documents are immutable; cancel and amend instead` | Immutability is enforced in the kernel, not only hidden in the UI. | | Cancel | Waybill becomes Cancelled; ledger gains two reversing lines (Debit 511 / Credit 131); the two original lines are unchanged | Cancel never edits or deletes; it appends a mirror entry. | | Amend | `ORD-20261021-00011-1`, Draft, "amends ORD-20261021-00011", waybill empty | A new revision, linked to the old one; the cancelled version stays in history. | | Re-amend | Amend on the cancelled original again → 422 `ORD-20261021-00011 was already amended as ORD-20261021-00011-1` | Each cancelled document gets exactly one amendment; further changes go on `-1`. | | API | `make demo-express SCENES=4`: create (201), submit (200), rejected edit (422), cancel (200), waybill `Cancelled`, amend (201) | Desk uses the same REST endpoints; there is no private UI path. | Document numbers in the Desk scenes (`ORD-…-00011`, `WB-2610-000008`) differ from those in the API log (`ORD-20261021-00008`, `WB-2610-000006`) because they were recorded in separate runs. ## How it works ### The three transitions live in the kernel All lifecycle rules sit in one class, `DocumentStore`, so every app gets the same behaviour. A DocType opts in with `submittable: true`. *`astra-apps/express/src/main/resources/META-INF/astra/doctypes/customer-order.doctype.yaml`* ```yaml name: CustomerOrder module: oms kind: transaction submittable: true # Draft (0) -> Submitted (1) -> Cancelled (2) -> Amend transaction: global # submit/cancel span BMS credit (Seata AT) and pickup capacity (Seata TCC) naming: { series: "ORD-.YYYYMMDD.-.#####" } # … - { name: waybill, type: Link, options: Waybill, read-only: true, in-list: true } # created on submit ``` Submit and cancel are short and symmetrical. Note the order of operations: the hooks run *before* the kernel writes the new `docstatus`, on the same connection, and the final `write` uses an optimistic version check. *`astra-doctype/src/main/java/io/astra/doctype/store/DocumentStore.java`* ```java public Optional submit(String doctype, String name) { DocTypeDefinition def = requireSubmittable(doctype); return global(def, "submit " + doctype + " " + name, () -> inTenantTx(conn -> { Optional existing = get(doctype, name); if (existing.isEmpty()) return Optional.empty(); Document doc = existing.get(); requireDraft(doc); requireCaller(doc, Action.SUBMIT); hooks.onSubmit(doc); DocumentValidator.applyDefaultsAndValidate(def, doc); validateLinks(conn, def, doc); return Optional.of(write(conn, doc, 1)); })); } public Optional cancel(String doctype, String name) { DocTypeDefinition def = requireSubmittable(doctype); return global(def, "cancel " + doctype + " " + name, () -> inTenantTx(conn -> { // … if (doc.docstatus() != 1) throw new DocValidationException("only submitted documents can be cancelled"); requireCaller(doc, Action.CANCEL); hooks.onCancel(doc); return Optional.of(write(conn, doc, 2)); })); } ``` Immutability is one guard, shared by `update` and `submit`: *`astra-doctype/src/main/java/io/astra/doctype/store/DocumentStore.java`* ```java private static void requireDraft(Document doc) { if (doc.docstatus() == 1) throw new DocValidationException("submitted documents are immutable; cancel and amend instead"); if (doc.docstatus() == 2) throw new DocValidationException("cancelled documents are immutable; amend instead"); } ``` Why in the kernel: if immutability were a UI convention, any script, integration or AI agent calling the API could still edit a submitted order. Putting the check in the only write path means Desk, REST, MCP tools and hooks all hit the same rule. ### "One transaction" is a ScopedValue, not a convention The claim "all in one transaction" rests on `TenantTransactions`. The first call opens a connection, binds the tenant for row-level security and stores the connection in a Java `ScopedValue`. Any nested call (a hook inserting a waybill, the ledger posting an entry) finds the bound connection and joins it. *`astra-doctype/src/main/java/io/astra/doctype/store/TenantTransactions.java`* ```java public T inTenantTx(SqlWork work) { if (TX.isBound()) { try { return work.run(TX.get()); } // … } String tenant = TenantContext.require(); try (Connection conn = dataSource.getConnection()) { conn.setAutoCommit(false); try { // … SELECT set_config('astra.tenant', ?, true) T result = ScopedValue.where(TX, conn).call(() -> work.run(conn)); conn.commit(); return result; } catch (Exception e) { conn.rollback(); // … ``` Why a `ScopedValue` rather than a thread-local or passing `Connection` through the SPI: hooks only see `DocumentService` and `GeneralLedger` (from `astra-spi`), so app code never handles connections, cannot leak one, and cannot accidentally open a second transaction. `ScopedValue` is bounded by the call, works with virtual threads, and disappears when the outer operation returns. ### The hooks that ride along `CustomerOrder` has several hooks; three matter for this video. The kernel's `HookRegistry` groups every installed `DocLifecycleHook` by DocType and calls each in turn. *`astra-apps/express/src/main/java/io/astra/apps/express/OrderWaybillHook.java`* ```java @Override public void onSubmit(Document order) { Document waybill = docs.insert("Waybill", Map.of( "order", order.name(), "receiver_name", order.getString("receiver_name")), order.owner()); order.set("waybill", waybill.name()); } @Override public void onCancel(Document order) { String name = order.getString("waybill"); if (name == null) return; Document waybill = docs.get("Waybill", name).orElseThrow(() -> new DocValidationException("waybill " + name + " not found")); String status = waybill.getString("ops_status"); if (!CANCELLABLE.contains(status)) { throw new DocValidationException("cannot cancel " + order.name() + ": waybill " + name + " is already '" + status + "'"); } docs.update("Waybill", name, Map.of("ops_status", "Cancelled")); } ``` *`astra-apps/express/src/main/java/io/astra/apps/express/OrderPostingHook.java`* ```java /** Submitting an order bills its shipping fee (Dr 131 / Cr 511); cancelling it posts the reversal. */ // … @Override public void onSubmit(Document order) { BigDecimal fee = BigDecimal.valueOf(Numbers.toDouble(order.get("shipping_fee"))); String customer = order.getString("customer"); ledger.post("CustomerOrder", order.name(), LocalDate.now(), List.of( Entry.debit(Accounts.RECEIVABLE, customer, fee), Entry.credit(Accounts.REVENUE, customer, fee)), "Shipping fee " + order.name()); } @Override public void onCancel(Document order) { ledger.reverse("CustomerOrder", order.name()); } ``` The third, `OrderCreditHook`, reserves the fee against the customer's credit limit and a pickup slot when a Seata coordinator is available (`transaction: global`); without one it does nothing. That path is the subject of [Video 17](/astra-cms/en/seata). The hooks do not depend on each other's order. If `OrderWaybillHook` vetoes a cancel after `OrderPostingHook` has already written the reversal, the exception rolls back the reversal too, because both writes went through the same connection. ```mermaid sequenceDiagram autonumber participant Desk as Desk / REST client participant DS as DocumentStore.submit participant TX as TenantTransactions (ScopedValue) participant WH as OrderWaybillHook participant PH as OrderPostingHook participant PG as PostgreSQL (one connection) Desk->>DS: POST /api/doc/CustomerOrder/{name}/submit DS->>TX: inTenantTx (opens, binds astra.tenant) DS->>DS: requireDraft, requireCaller(SUBMIT) DS->>WH: onSubmit(order) WH->>TX: docs.insert("Waybill") joins bound tx TX->>PG: INSERT astra_doc (Waybill, scan_log = [Created]) DS->>PH: onSubmit(order) PH->>TX: ledger.post joins bound tx TX->>PG: INSERT astra_ledger 131 Dr / 511 Cr DS->>PG: UPDATE astra_doc SET docstatus = 1 … AND version = ? TX->>PG: COMMIT (or ROLLBACK on any exception) DS-->>Desk: 200, data.waybill = WB-… ``` ![Figure 2: Submit in one transaction](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-04-figure-2.png) *Submit joins the DocumentStore status update, waybill creation, and accounting ledger posting into a single atomic database transaction.* ### Cancel appends, the database enforces it `LedgerStore.reverse` reads the voucher's original lines and inserts their mirror image with `is_reversal = true`. It refuses to reverse the same voucher twice. *`astra-doctype/src/main/java/io/astra/doctype/ledger/LedgerStore.java`* ```java if (reversed) throw new DocValidationException("voucher " + voucherNo + " is already reversed"); if (original.isEmpty()) return null; LocalDate today = LocalDate.now(); ensurePartition(conn, today); List mirror = original.stream().map(e -> new Entry(e.account(), e.party(), e.credit(), e.debit())).toList(); insert(conn, voucherType, voucherNo, today, mirror, true, "Reversal of " + voucherNo); ``` Even if application code tried to edit a posted line, PostgreSQL would refuse: *`astra-doctype/src/main/resources/db/migration/V3__ledger.sql`* ```sql -- Corrections are reversal entries, never UPDATE or DELETE; a trigger enforces this for every role, -- including the table owner. Tenant isolation uses the same row-level security setting as astra_doc. -- … CREATE TRIGGER astra_ledger_no_update_delete BEFORE UPDATE OR DELETE ON astra_ledger FOR EACH ROW EXECUTE FUNCTION astra_ledger_append_only(); ``` The reversal is dated the day of the cancel, not the original posting date. That matches how a closed month is normally corrected: the old period stays as reported, the correction appears in the current one. ![Figure 3: Cancel appends, never edits](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-04-figure-3.png) *Cancelling an order posts a mirror reversal entry to astra_ledger. A database trigger blocks UPDATE and DELETE, preserving audit history.* ### Amend: exactly one successor *`astra-doctype/src/main/java/io/astra/doctype/store/DocumentStore.java`* ```java if (cancelled.docstatus() != 2) throw new DocValidationException("only cancelled documents can be amended"); try (PreparedStatement ps = conn.prepareStatement( "SELECT name FROM astra_doc WHERE doctype = ? AND amended_from = ?")) { // … if (rs.next()) throw new DocValidationException(name + " was already amended as " + rs.getString(1)); } // Read-only fields belong to the kernel and hooks; the new revision recomputes them. Map copy = new LinkedHashMap<>(cancelled.data()); def.fields().stream().filter(FieldDefinition::readOnly).forEach(f -> copy.remove(f.name())); Document draft = new Document(doctype, copy).owner(owner) .name(amendedName(cancelled)).amendedFrom(name); return Optional.of(persistNew(conn, def, draft)); ``` Three design choices are visible here: - **One successor per cancelled version.** Two parallel amendments of the same order would give two live "corrections" of one fact. Refusing the second keeps the chain linear: `ORD-…-00011` → `-1` → `-2`. `amendedName` increments the suffix when you cancel and amend an amendment. - **Read-only fields are dropped.** `waybill`, `shipping_fee` and `priced_by` are kernel/hook output, so the new draft recomputes them through `beforeSave` rather than inheriting stale values. That is why the amended order shows an empty waybill. - **The link is a column.** `amended_from` is a real column on `astra_doc` (`V2__amendments.sql`), so the chain is queryable without parsing JSON. ## Real-world example A Saigon shop books an Express parcel to Đà Nẵng: C001, Domestic, Express, 2.2 kg, COD 500,000 ₫. These are the inputs of `make demo-express SCENES=4`, and `ExpressFlowTest` asserts the resulting fee is 73,000 ₫. 1. **Submit (09:00).** Sales submits. In one commit: the order becomes `docstatus = 1`, waybill `WB-…` is inserted at `Created`, and the ledger gets Debit 131 (C001) 73,000 / Credit 511 73,000. 2. **The customer phones (09:20):** wrong receiver address. Editing is refused with 422. The parcel has not been picked up, so the waybill is still `Created`, which is in `CANCELLABLE`. 3. **Cancel.** In one commit: the waybill moves `Created → Cancelled` (and gets that milestone in its scan log), the ledger appends Debit 511 / Credit 131 73,000 marked as reversal, the order becomes `docstatus = 2`. 4. **Amend.** A new draft `ORD-…-1` with the same customer data, empty waybill, fee recomputed on save. Sales fixes the address and submits: a new waybill and a new 73,000 entry. Outputs an auditor sees for C001 on account 131: +73,000, −73,000, +73,000 = 73,000 outstanding, with three lines that each point to a voucher number. The cancelled order, its cancelled waybill and the amendment are all still readable. If the call had come at 15:00, after the parcel reached a hub, step 3 fails with `cannot cancel …: waybill … is already 'At Hub'`, and nothing changes: no reversal, no status change. That veto is the topic of [Video 5](/astra-cms/en/ops). The same pattern fits a bank's maker/checker flow on payment orders or a month-end payroll: once approved, a record is never edited; it is reversed and reissued, and the trail is the data itself. ## For technical leaders - **Consistency model.** Everything a lifecycle step causes inside PostgreSQL commits or rolls back together. This is the strongest guarantee you can buy cheaply, and it only holds for writes that go through `DocumentService` and `GeneralLedger`. A hook that calls an external HTTP API is outside the transaction; for that, Astra offers Seata AT/TCC per DocType (`transaction: global`, see [Video 17](/astra-cms/en/seata)). - **Audit by construction.** Immutability is enforced twice: in the kernel (`requireDraft`) for documents and by a database trigger for ledger rows. Auditors can rely on the database, not on application discipline. - **Cost.** Every cancel+amend roughly doubles rows for that fact (old doc, new doc, original and reversal lines). For order and accounting volumes this is negligible; monthly ledger partitions keep it manageable. - **Concurrency.** `write` uses `version = ?`; two people submitting the same draft get one success and one `concurrent modification` error, not a double posting. - **Hook discipline matters.** Long-running work inside `onSubmit` holds the transaction (and row locks) open. Measure p95 submit latency and transaction duration per DocType; keep slow integrations out of hooks. - **What to measure.** Cancel rate and amend rate per DocType (a high rate signals a UX or data-quality problem upstream), share of cancels vetoed by hooks, submit latency. - **When not to use it.** Records that are naturally mutable (a customer's phone number, a support ticket's status) should not be submittable; making them so adds ceremony with no audit value. `Customer`, `Waybill` and `SupportTicket` in the demo are deliberately not submittable. - **Compared with Frappe/ERPNext.** Same mental model (`docstatus`, `amended_from`, `-1` suffix), so ERPNext users transfer directly. Differences: hooks are typed Java in the same transaction rather than Python controllers, the ledger is append-only by trigger, and tenancy is enforced by PostgreSQL row-level security. ## Try it yourself Prerequisites: JDK 25, Docker Desktop with about 8 GB RAM, `kubectl`, `k3d`, `jq`, Bash 5 (`brew install bash` on macOS). ```bash make k3d-up # cluster, kernel, PostgreSQL, Redis, Keycloak, Seata, CDC worker make demo-reset && make demo-seed # two tenants, demo users (password Astra@123) make demo-express SCENES=4 PAUSE=0 # the API half of this video open http://localhost:8080/desk/ # sign in as mai, New Customer Order for C001 → Create → Submit → Cancel → Amend ``` Expected terminal output (numbers depend on the seed date and how many orders exist): ```text HTTP 201 X-Tx-LSN: 0/… { "name": "ORD--000NN", "docstatus": 0 } HTTP 200 X-Tx-LSN: 0/… { "name": "ORD-…", "docstatus": 1, "waybill": "WB--0000NN" } HTTP 422 { "errors": ["submitted documents are immutable; cancel and amend instead"], "error": "validation" } HTTP 200 X-Tx-LSN: 0/… { "name": "ORD-…", "docstatus": 2 } HTTP 200 { "name": "WB-…", "ops_status": "Cancelled" } HTTP 201 X-Tx-LSN: 0/… { "name": "ORD-…-1", "docstatus": 0, "amendedFrom": "ORD-…", "waybill": null } ``` The scene does not call amend a second time; to see the rejection, send the same amend request again (`POST /api/doc/CustomerOrder//amend`) and expect HTTP 422 `… was already amended as …-1`. The same behaviour is asserted without a cluster in `astra-server/src/test/java/io/astra/server/ExpressFlowTest.java` (`cancelCascadesAndAmendCreatesRevision`, `submitCreatesWaybillAndCancelIsVetoedOnceInNetwork`); `make test` runs the whole suite with Quarkus Dev Services starting PostgreSQL in Docker. ## To be accurate - **Thin slice.** `CustomerOrder` and `CODRemittance` are the submittable DocTypes in the Express app. The invoices, payment entries and journal entries described in [use-case-01.md](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/use-case-01.md) for BMS are target scope, not built. - **"One transaction" means one PostgreSQL transaction** for documents and ledger. When Seata is connected, the credit reservation (separate BMS schema) and the Redis pickup slot join a Seata global transaction around it; that is shown in [Video 17](/astra-cms/en/seata), not here. - **Hook order is not configurable.** `HookRegistry` calls every hook for the DocType in turn, in the order the CDI container returns them; correctness here relies on all writes sharing the transaction, not on order. - **The 58,000 ₫ fee** in the Desk scenes comes from the order entered in Desk; the API run uses a different order (2.2 kg, Express, COD 500,000) whose fee is 73,000 ₫. - **Reversals are dated the cancel day.** There is no option today to back-date a reversal into the original period. ## Figures - [Figure 1 · Three states, one direction](#the-problem) - [Figure 2 · Submit in one transaction](#the-hooks-that-ride-along) - [Figure 3 · Cancel appends, never edits](#cancel-appends-the-database-enforces-it) ## Related - Previous: [Video 3 · Business rules, hooks & validation](/astra-cms/en/hooks). Next: [Video 5 · Operations, the parcel state machine](/astra-cms/en/ops). - Deeper: [Video 12 · Append-only ledger](/astra-cms/en/ledger), [Video 17 · Seata global transactions](/astra-cms/en/seata), [Video 11 · Keycloak permissions](/astra-cms/en/keycloak) (who may submit and cancel). - Code excerpts: [demo-code-snippets.md, S3](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/demo-code-snippets.md#s3-lifecycle-in-one-transaction). - Source: `astra-doctype/src/main/java/io/astra/doctype/store/DocumentStore.java`, `astra-doctype/src/main/java/io/astra/doctype/store/TenantTransactions.java`, `astra-doctype/src/main/java/io/astra/doctype/ledger/LedgerStore.java`, `astra-apps/express/src/main/java/io/astra/apps/express/OrderWaybillHook.java`, `astra-apps/express/src/main/java/io/astra/apps/express/OrderPostingHook.java`, `astra-spi/src/main/java/io/astra/spi/hook/DocLifecycleHook.java`, `scripts/demo-express.sh` (`scene4`). --- # Operations: the parcel state machine a parcel's status in Astra can only move along a transition table declared in one Java map, every accepted move appends a milestone to a scan log that no user can write, and an order cannot be cancelled once its parcel… 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/ops ▶ [English](https://media.zerohostcms.com/astra-cms/astra-video-5-ops-en.mp4) · [Tiếng Việt](https://media.zerohostcms.com/astra-cms/astra-video-5-ops-vi.mp4) · 3:00 · Script: [demo-video-series.en.md](/astra-cms/en/video-scripts) > **In one sentence:** a parcel's status in Astra can only move along a transition table declared in one Java map, every accepted move appends a milestone to a scan log that no user can write, and an order cannot be cancelled once its parcel is in the network, because the veto and the cascade share one transaction. > > **Read this if:** you need to implement a status workflow with validation and history as a plain hook (developer angle) / you are weighing a workflow engine against a table-driven state machine for operational data such as parcels, tickets or work orders (tech-lead angle). ## The problem A courier's operations team scans each parcel many times a day: picked up, arrived at a hub, transferred to another hub, out for delivery, delivered or failed. Three things go wrong in typical systems: - **Skipped or impossible states.** A scanner app or an integration sets `Delivered` on a parcel that was never picked up. Reports then show deliveries with no pickup, and COD reconciliation breaks. - **Lost history.** The status column is overwritten on each scan, so "where was this parcel on Tuesday" needs a separate audit table that someone forgot to write to. - **Orders and parcels disagree.** Sales cancels an order while the parcel is already on a truck. The order says cancelled, the driver still delivers, and nobody bills it. The usual answers are a BPMN workflow engine (heavy for a status field), database enum checks (cannot express "from A only to B or C"), or scattered `if` statements in controllers (each client re-implements them). Frappe/ERPNext offers a Workflow DocType with states and transitions configured in the UI, mainly aimed at approval chains with roles. Astra Express takes the smallest workable option: the transition table is data in one hook, and the kernel guarantees every write path goes through that hook. ## What the video shows Tenant `saigon-express`. Operations user `quang` (role `ops`), then admin `mai`. | Scene | What you see | What it proves | | --- | --- | --- | | Filter | Waybill list filtered by `Ops status = At Hub` (⚡ projected field); one waybill at HCM-Tân Bình | Status filters use an expression index, not a JSONB scan. | | Timeline | `WB-2610-000001`: six milestones from Created to Delivered, each with time and hub | The scan log is the parcel's history; only `WaybillScanHook` appends to it. | | Diagram | The state machine drawn from the `NEXT` table; Delivered, Cancelled and Returned are final | The rule is a data table checked before every save. | | Skip | A new waybill at `Created`, choose `Delivered` → HTTP 422; the scan log still has one milestone | No skipping states; a rejected write changes nothing. | | Hub | `Picked Up` succeeds; `At Hub` with an empty hub fails; with hub DN-Hải Châu it succeeds and the timeline gains a milestone; the CARE replica also reaches three milestones | Per-state required data; the replica follows (Video 16 explains how). | | Cancel | `mai` cancels a delivered order → rejected; after reload: order still Submitted (version 2), waybill still Delivered (version 6) | The order-side veto and the waybill cascade share one transaction. | | API | `make demo-express SCENES=5`: valid transitions 200; `At Hub → Delivered` 422; cancelling the order while At Hub 422 and the order stays `docstatus 1` | Desk and REST go through the same hook. | | Code | The `NEXT` table, `beforeSave`, and `OrderWaybillHook.onCancel` | One table, one veto. | ![Figure 2: A skipped state is rejected, nothing is written](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-05-figure-2.png) *A skipped state transition is rejected by beforeSave; the document and scan log remain untouched.* ## How it works ### The waybill is an ordinary DocType *`astra-apps/express/src/main/resources/META-INF/astra/doctypes/waybill.doctype.yaml`* ```yaml name: Waybill module: ops kind: transaction naming: { series: "WB-.YYMM.-.######" } fields: - { name: order, type: Link, options: CustomerOrder, required: true, in-list: true } - { name: receiver_name, type: Data, in-list: true, pii: true } - { name: ops_status, type: Select, options: [Created, Picked Up, At Hub, Out for Delivery, Delivered, Failed Delivery, Returned, Cancelled], default: Created, in-list: true, projected: true, label: Ops status } - { name: current_hub, type: Data, in-list: true, projected: true } - { name: scan_log, type: JSON, read-only: true } # appended by WaybillScanHook on every status change permissions: - { role: admin, read: true, write: true } - { role: ops, read: true, write: true } # scanning is an OPS action only - { role: sales, read: true } - { role: care, read: true } ``` Four declarations do most of the work: - `Select` with eight options: the kernel rejects any value outside the list before hooks run. - `projected: true` on `ops_status` and `current_hub`: `ProjectionIndexer` builds `CREATE INDEX CONCURRENTLY … ON astra_doc (tenant_id, doctype, (data->>'ops_status'))` at startup, and list filters use the same expression, so the planner can pick the index. - `read-only: true` on `scan_log`: the kernel rejects any client payload containing it (`DocumentValidator.rejectReadOnly`), so only server-side hooks can write the history. - `permissions`: only `ops` and `admin` can scan; `care` and `sales` can read. `Waybill` is not `submittable`. A parcel's status is supposed to change; what must not change is its history, and that is handled by the read-only log rather than by document immutability. ### The transition table *`astra-apps/express/src/main/java/io/astra/apps/express/WaybillScanHook.java`* ```java static final Map> NEXT = Map.of( "Created", Set.of("Picked Up", "Cancelled"), "Picked Up", Set.of("At Hub", "Cancelled"), "At Hub", Set.of("At Hub", "Out for Delivery", "Returned"), "Out for Delivery", Set.of("Delivered", "Failed Delivery"), "Failed Delivery", Set.of("Out for Delivery", "Returned"), "Delivered", Set.of(), "Returned", Set.of(), "Cancelled", Set.of()); ``` ```mermaid stateDiagram-v2 [*] --> Created Created --> PickedUp: Picked Up Created --> Cancelled PickedUp --> AtHub: At Hub (hub required) PickedUp --> Cancelled AtHub --> AtHub: hub transfer AtHub --> OutForDelivery: Out for Delivery AtHub --> Returned OutForDelivery --> Delivered OutForDelivery --> FailedDelivery: Failed Delivery FailedDelivery --> OutForDelivery: retry FailedDelivery --> Returned Delivered --> [*] Returned --> [*] Cancelled --> [*] ``` ![Figure 1: The parcel state machine](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-05-figure-1.png) *The parcel state machine declared in WaybillScanHook.NEXT. The main happy path goes from Created to Delivered.* `At Hub → At Hub` is allowed on purpose: a parcel moving from HCM-Tân Bình to DN-Hải Châu stays "At Hub" but changes hub, and that transfer is a milestone worth recording. `Failed Delivery → Out for Delivery` models a second delivery attempt. ### The check and the log in one method *`astra-apps/express/src/main/java/io/astra/apps/express/WaybillScanHook.java`* ```java @Override @SuppressWarnings("unchecked") public void beforeSave(Document doc) { List> log = doc.get("scan_log") instanceof List l ? new ArrayList<>((List>) l) : new ArrayList<>(); Map last = log.isEmpty() ? null : log.getLast(); String status = doc.getString("ops_status"); String hub = doc.getString("current_hub"); if (last != null) { String from = (String) last.get("status"); boolean same = from.equals(status) && Objects.equals(last.get("hub"), hub); if (same) return; if (!NEXT.getOrDefault(from, Set.of()).contains(status)) { throw new DocValidationException("ops_status cannot go from '" + from + "' to '" + status + "'"); } } else if (!"Created".equals(status)) { throw new DocValidationException("a new waybill starts as 'Created'"); } if ("At Hub".equals(status) && (hub == null || hub.isBlank())) { throw new DocValidationException("current_hub is required when ops_status is 'At Hub'"); } if ("Delivered".equals(status)) postCollectedCod(doc); Map scan = new LinkedHashMap<>(); scan.put("status", status); scan.put("hub", hub); scan.put("at", Instant.now().toString()); log.add(scan); doc.set("scan_log", log); } ``` Design notes: - **The "from" state is the last log entry, not the stored column.** The log is the source of truth for history, so the check compares against what was actually recorded. - **Idempotent saves.** If status and hub are unchanged (for example, editing another field), the hook returns without adding a milestone. Re-sending the same scan does not duplicate history. - **`beforeSave` runs on every insert and update.** `DocumentStore.update` calls `hooks.beforeSave(doc)` before the `UPDATE`, and `persistNew` calls it before the `INSERT`, which is why the new waybill created at submit already has its `Created` milestone. A thrown `DocValidationException` becomes HTTP 422 and the transaction rolls back, so the stored document is untouched. - **Delivery has an accounting side effect.** On `Delivered`, `postCollectedCod` posts the collected COD: Debit 1388 (COD held by couriers) / Credit 3388 (owed to the shop), in the same transaction as the status change. If the posting fails, the parcel is not marked delivered. ### The order-side veto Cancelling an order (Video 4) cascades to its waybill, but only while the parcel has not entered the network. *`astra-apps/express/src/main/java/io/astra/apps/express/OrderWaybillHook.java`* ```java private static final Set CANCELLABLE = Set.of("Created", "Picked Up"); // … @Override public void onCancel(Document order) { String name = order.getString("waybill"); if (name == null) return; Document waybill = docs.get("Waybill", name).orElseThrow(() -> new DocValidationException("waybill " + name + " not found")); String status = waybill.getString("ops_status"); if (!CANCELLABLE.contains(status)) { throw new DocValidationException("cannot cancel " + order.name() + ": waybill " + name + " is already '" + status + "'"); } docs.update("Waybill", name, Map.of("ops_status", "Cancelled")); } ``` Note the composition: the cascade `docs.update("Waybill", …, "Cancelled")` itself goes through `WaybillScanHook.beforeSave`, so it is validated against `NEXT` and appended to the scan log like any scan. `CANCELLABLE` and the `Cancelled` entries in `NEXT` agree (`Created`, `Picked Up`), so the two hooks cannot contradict each other in practice; if they ever did, the stricter one wins and the whole cancel rolls back. ```mermaid sequenceDiagram autonumber participant U as mai (Desk) participant DS as DocumentStore.cancel participant OW as OrderWaybillHook.onCancel participant WS as WaybillScanHook.beforeSave participant PG as PostgreSQL (one tx) U->>DS: POST /api/doc/CustomerOrder/{name}/cancel DS->>OW: onCancel(order) OW->>PG: read Waybill ops_status alt status in CANCELLABLE (Created, Picked Up) OW->>WS: docs.update(Waybill, ops_status = Cancelled) WS->>WS: check NEXT, append milestone WS->>PG: UPDATE Waybill DS->>PG: UPDATE order docstatus = 2, COMMIT DS-->>U: 200 else At Hub, Out for Delivery, Delivered, … OW-->>DS: DocValidationException DS->>PG: ROLLBACK (nothing written) DS-->>U: 422 cannot cancel …: waybill … is already 'At Hub' end ``` ![Figure 3: Order cancel veto across two documents](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-05-figure-3.png) *Cancelling an order is vetoed if the waybill is already in the network; both documents share one transaction and roll back together.* ### Why a hook and not a workflow engine - The rule fits in nine lines of data; a BPMN engine would add a runtime, a deployment artifact and a second persistence model for the same fact. - The check runs inside the document's own transaction, so it composes with other hooks (COD posting, order cancel) without distributed coordination. - Every write channel (Desk, REST, other hooks through `DocumentService`) reaches the same `beforeSave`, so there is no client that can bypass the table. ## Real-world example A Vietnamese 3PL runs a Saigon → Đà Nẵng lane. The parcel below follows the same scans that `scripts/demo-seed.sh` applies to the first seeded waybill (order for C001, COD 850,000 ₫); the clock times and the premature `Delivered` tap are illustrative additions. | Time | Scan (input) | Astra's response | Scan log length | | --- | --- | --- | --- | | 08:00 | Order submitted | Waybill inserted at `Created` | 1 | | 09:10 | `Picked Up` | 200 | 2 | | 11:30 | `At Hub`, hub `HCM-Tân Bình` | 200 | 3 | | 18:00 | `At Hub`, hub `DN-Hải Châu` (line-haul arrived) | 200, hub transfer recorded | 4 | | 07:30 next day | `Delivered` (driver taps too early) | 422 `ops_status cannot go from 'At Hub' to 'Delivered'` | 4 | | 07:31 | `Out for Delivery` | 200 | 5 | | 10:05 | `Delivered` | 200, ledger: Dr 1388 / Cr 3388 850,000 | 6 | Meanwhile, at 14:00 on day one the shop asks sales to cancel. The order-side veto returns `cannot cancel …: waybill … is already 'At Hub'`, and both documents stay as they were. Customer care can now see the full history in the scan log (and in the CDC replica, Video 6) instead of asking the hub by phone, and finance sees exactly 850,000 ₫ held by couriers for that parcel until it is remitted. ## For technical leaders - **Where the rule lives.** In code (`WaybillScanHook.NEXT`), reviewed and versioned like code. Changing the workflow means a deploy of the app module. That is a feature for core operational rules and a limitation if business users expect to edit transitions themselves; per-tenant variation is the job of overlays ([Video 13](/astra-cms/en/overlay)) or Wasm scripts ([Video 14](/astra-cms/en/wasm)), not of this table. - **Write amplification.** `scan_log` lives inside the JSONB document, so each scan rewrites the whole document and the log grows with every milestone. For tens of scans per parcel this is fine; for very high scan rates measure row size, TOAST growth and update latency. [use-case-01.md](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/use-case-01.md) describes moving hot fields to physical columns or narrow side tables as the target design; it is not built. - **Concurrency.** Two scanners updating the same waybill at once: the optimistic `version` check makes one fail with `concurrent modification`, instead of interleaving two log appends. - **What to measure.** Rate of 422s per transition (a spike means a scanner app or integration is out of step), share of order cancels vetoed, time spent per state (SLA), p95 scan latency. - **When not to use this pattern.** Approval chains with roles, escalations and timers per step are better served by a workflow engine or Frappe-style Workflow configuration. Use the table pattern for physical or technical lifecycles with a small, stable set of states. - **Risk.** Every rule in one hook means one place to get wrong; the transitions are covered by `ExpressFlowTest.submitCreatesWaybillAndCancelIsVetoedOnceInNetwork`. ## Try it yourself Prerequisites: JDK 25, Docker Desktop (about 8 GB RAM), `kubectl`, `k3d`, `jq`, Bash 5. ```bash make k3d-up make demo-reset && make demo-seed make demo-express SCENES=5 PAUSE=0 open http://localhost:8080/desk/ # sign in as quang (ops), Waybill list, filter Ops status = At Hub ``` Expected output, abridged from a recorded run (`docs/video/v05-ops/assets/scene-05.log`): ```text ▸ Scan: picked up, then at hub HTTP 200 { "ops_status": "Picked Up" } HTTP 200 [ {"status":"Created",…}, {"status":"Picked Up",…}, {"hub":"DN-Hải Châu","status":"At Hub",…} ] ▸ Skipping a step is refused by WaybillScanHook HTTP 422 { "errors": ["ops_status cannot go from 'At Hub' to 'Delivered'"], "error": "validation" } ▸ Once the parcel is in the network, cancelling the order is vetoed — and nothing changes (one transaction) HTTP 422 { "errors": ["cannot cancel ORD-…: waybill WB-… is already 'At Hub'"], "error": "validation" } HTTP 200 { "name": "ORD-…", "docstatus": 1 } ▸ Filter on a projected field: every parcel currently At Hub HTTP 200 [ {"name":"WB-…","hub":"DN-Hải Châu"}, {"name":"WB-…","hub":"HCM-Tân Bình"} ] ``` Demo users share the password `Astra@123`. Sign in as `mai` to try cancelling the seeded order whose waybill is Delivered. ## To be accurate - **Thin slice.** One hook covers the parcel lifecycle. Barcode scanners, a mobile courier app, a separate `ScanEventLedger`, per-module schemas (`schema_ops`) and hot-field columns from [use-case-01.md](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/use-case-01.md) are target architecture and are not built. - **Scans are document updates.** A scan is `PUT /api/doc/Waybill/{name}` with `ops_status` and, for `At Hub`, `current_hub`. There is no dedicated scan endpoint. - **The table is not tenant-configurable.** Both tenants share the same `NEXT` map. - **Returned and Failed Delivery** exist in the table but are not exercised in the video. - **COD posting on Delivered** is shown in [Video 12](/astra-cms/en/ledger); this video only shows the status side. - **The replica milestone count** in the Hub scene comes from the Rust CDC worker (`scans` column computed as the length of `scan_log`, see `astra-cdc-rust/cdc-routes.yaml`); how it stays current is Video 16. ## Figures - [Figure 1 · The parcel state machine](#the-transition-table) - [Figure 2 · A skipped state is rejected, nothing is written](#what-the-video-shows) - [Figure 3 · Order cancel veto across two documents](#the-order-side-veto) ## Related - Previous: [Video 4 · Document lifecycle](/astra-cms/en/lifecycle). Next: [Video 6 · Customer care sees operations](/astra-cms/en/care). - Deeper: [Video 2 · DocType and projected fields](/astra-cms/en/doctype), [Video 12 · Append-only ledger](/astra-cms/en/ledger) (COD posting), [Video 16 · Rust CDC](/astra-cms/en/cdc) (the replica), [Video 11 · Keycloak](/astra-cms/en/keycloak) (who may scan). - Code excerpts: [demo-code-snippets.md, S3](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/demo-code-snippets.md#s3-lifecycle-in-one-transaction). - Source: `astra-apps/express/src/main/java/io/astra/apps/express/WaybillScanHook.java`, `astra-apps/express/src/main/java/io/astra/apps/express/OrderWaybillHook.java`, `astra-apps/express/src/main/resources/META-INF/astra/doctypes/waybill.doctype.yaml`, `astra-doctype/src/main/java/io/astra/doctype/store/ProjectionIndexer.java`, `astra-doctype/src/main/java/io/astra/doctype/store/DocumentStore.java`, `scripts/demo-express.sh` (`scene5`). --- # Customer care sees operations when a care agent opens a support ticket against a waybill, a hook raises the priority for loss or damage complaints, sets an SLA deadline and copies the parcel's current status onto the ticket, while a card under the… 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/care ▶ [English](https://media.zerohostcms.com/astra-cms/astra-video-6-care-en.mp4) · [Tiếng Việt](https://media.zerohostcms.com/astra-cms/astra-video-6-care-vi.mp4) · 2:30 · Script: [demo-video-series.en.md](/astra-cms/en/video-scripts) > **In one sentence:** when a care agent opens a support ticket against a waybill, a hook raises the priority for loss or damage complaints, sets an SLA deadline and copies the parcel's current status onto the ticket, while a card under the form shows the parcel as kept by a CDC replica rather than by the operations table. > > **Read this if:** you want to see how a small `beforeSave` hook links two modules without a service call (developer angle) / you are deciding how support teams should see operational data without loading the operational database (tech-lead angle). ## The problem A customer of Astra Express calls the hotline: the ceramics arrived broken, they want compensation. In many companies the care agent then: - opens the CRM to log a ticket, with priority chosen by gut feeling; - switches to the operations system (or phones the hub) to find out where the parcel is; - works out the response deadline from a policy document. Each step is slow and inconsistent: urgent complaints sit at Normal priority, and SLA reports are built later from whatever agents typed. The other common fix, letting the CRM query the operations database directly, puts reporting-style load on the tables that scanners write to all day. Frappe/ERPNext's Issue DocType has priorities and Service Level Agreements configured in the UI, and links to other documents. Astra's demo shows the same business outcome with two pieces: a rule hook for the ticket fields, and a CDC replica for the read-heavy operational view. ## What the video shows Tenant `saigon-express`, signed in as `ha` (role `care`). | Scene | What you see | What it proves | | --- | --- | --- | | New ticket | New Support Ticket "Hàng bị vỡ, khách yêu cầu đền bù" (goods broken, customer asks for compensation), linked to delivered waybill `WB-2610-000001`; priority Normal; the three hook fields empty | The agent enters only what they know. | | Created | `TCK-00004`: priority High, triage note `auto-escalated: mentions 'vỡ'`, SLA due creation time + 24 h, waybill status `Delivered` | One hook sets priority, SLA and the operational snapshot at save time. | | Hook code | `TicketTriageHook`: seven keywords; escalates only from Low or Normal | The rule is plain code; the narration states AI triage over MCP is still roadmap. | | Kernel | `beforeSave` is called right before the `INSERT` | The hook writes into the document being created, in the same transaction. | | Replica card | "CARE replica (CDC) `replica_waybill`": in sync, worker lag 47 ms | The card reads a replica kept by the Rust CDC worker, not the `astra_doc` table. | | API | `make demo-express SCENES=6`: a damage complaint is raised to High with an SLA and a waybill status snapshot; open High tickets are listed | Same hook behind REST and Desk. | One distinction the video is careful about: the ticket's `waybill_status` field is read from the Waybill document by the hook when the ticket is saved. Only the card under the form reads the replica. ## How it works ### The ticket DocType *`astra-apps/express/src/main/resources/META-INF/astra/doctypes/support-ticket.doctype.yaml`* ```yaml name: SupportTicket module: care kind: transaction naming: { series: "TCK-.#####" } fields: - { name: subject, type: Data, required: true, in-list: true } - { name: waybill, type: Link, options: Waybill, in-list: true } - { name: customer, type: Link, options: Customer } - { name: channel, type: Select, options: [Hotline, Zalo, Email, Web], default: Hotline } - { name: description, type: Text } - { name: priority, type: Select, options: [Low, Normal, High, Urgent], default: Normal, in-list: true, projected: true } - { name: status, type: Select, options: [Open, In Progress, Resolved], default: Open, in-list: true, projected: true } - { name: waybill_status, type: Data, read-only: true, in-list: true } # snapshot of the waybill when the ticket is saved - { name: sla_due, type: Datetime, read-only: true, in-list: true } - { name: triage_note, type: Data, read-only: true } permissions: - { role: admin, read: true, create: true, write: true } - { role: care, read: true, create: true, write: true } - { role: sales, read: true, create: true } - { role: ops, read: true } ``` Why these choices: - `waybill_status`, `sla_due` and `triage_note` are `read-only`: a client cannot send them, so an agent cannot set their own deadline or fake the parcel status. Only server-side hooks fill them. - `priority` and `status` are `projected`, so the queue "open, High" is an indexed query (`GET /api/doc/SupportTicket?priority=High&status=Open` in scene 6). - `waybill` is a `Link`: the kernel rejects a ticket that names a waybill that does not exist in this tenant. - `ops` can read tickets but not write them; `care` can read waybills but not scan them (see [Video 5](/astra-cms/en/ops)). Each module keeps write ownership of its own documents. - `SupportTicket` is not `submittable`: a ticket's status is meant to change, so the Draft/Submit lifecycle of [Video 4](/astra-cms/en/lifecycle) would add ceremony with no audit value. ### The triage hook *`astra-apps/express/src/main/java/io/astra/apps/express/TicketTriageHook.java`* ```java /** * Customer care rules: loss or damage complaints are escalated, every ticket gets an SLA deadline from * its priority, and the linked waybill's current status is copied in so the agent sees OPS state * without leaving CARE. Rule-based today; AI triage over MCP is on the roadmap. */ @ApplicationScoped public class TicketTriageHook implements DocLifecycleHook { private static final List ESCALATE = List.of("mất", "thất lạc", "hỏng", "vỡ", "lost", "damaged", "broken"); private static final Set LOW = Set.of("Low", "Normal"); private static final Map SLA = Map.of( "Urgent", Duration.ofHours(4), "High", Duration.ofHours(24), "Normal", Duration.ofHours(72), "Low", Duration.ofDays(5)); @Inject DocumentService docs; @Override public String doctype() { return "SupportTicket"; } @Override public void beforeSave(Document t) { String text = (Objects.toString(t.get("subject"), "") + " " + Objects.toString(t.get("description"), "")).toLowerCase(Locale.ROOT); if (LOW.contains(t.getString("priority"))) { ESCALATE.stream().filter(text::contains).findFirst().ifPresent(word -> { t.set("priority", "High"); t.set("triage_note", "auto-escalated: mentions '" + word + "'"); }); } if (t.get("sla_due") == null) { t.set("sla_due", Instant.now().plus(SLA.getOrDefault(t.getString("priority"), SLA.get("Normal"))).toString()); } String waybill = t.getString("waybill"); if (waybill != null && !waybill.isBlank()) { docs.get("Waybill", waybill).ifPresent(w -> t.set("waybill_status", w.getString("ops_status"))); } } } ``` Design notes, read directly from the code: - **Escalation only goes up, and only from Low or Normal.** An agent who already chose Urgent is never downgraded to High by the rule. - **Keywords cover Vietnamese and English** ("mất", "thất lạc", "hỏng", "vỡ", "lost", "damaged", "broken"), checked in the subject and the description, case-insensitively. The first match is recorded in `triage_note`, so the escalation is explainable to the agent and to an auditor. - **The SLA is set once.** `sla_due` is only filled when empty, so editing the ticket later does not move the deadline. Note the consequence: if an agent raises a ticket to Urgent after creation, the deadline stays at the value computed at creation. - **The waybill status is refreshed on each save.** `beforeSave` runs on insert and on every update, so `waybill_status` is a snapshot as of the ticket's last save, not a live value. The live view is the card below. - **Cross-module read through the SPI.** The hook reads the waybill through `DocumentService`, the same API any app uses. No REST call between modules, no shared DAO, and the read happens inside the ticket's transaction under the same tenant. ![Figure 1: One save, three filled fields](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-06-figure-1.png) *One save fills three fields: TicketTriageHook escalates priority to High, computes the SLA deadline, and takes a snapshot of the linked waybill status.* ### Where the kernel calls it *`astra-doctype/src/main/java/io/astra/doctype/store/DocumentStore.java`* ```java private Document persistNew(Connection conn, DocTypeDefinition def, Document doc) throws SQLException { DocumentValidator.applyDefaultsAndValidate(def, doc); hooks.beforeSave(doc); validateLinks(conn, def, doc); if (doc.name() == null) doc.name(assignName(conn, def, doc)); try (PreparedStatement ps = conn.prepareStatement( "INSERT INTO astra_doc (id, tenant_id, doctype, name, owner, data, amended_from) " + "VALUES (?, ?, ?, ?, ?, ?::jsonb, ?) RETURNING *")) { // … ``` Defaults are applied first (so `priority` is already `Normal` when the hook looks at it), then the hook mutates the document, then links are validated and the row is inserted. Hook output is therefore part of the first version of the row, and part of the first WAL record the CDC worker sees. ### The replica card The card is generic Desk code. For a ticket it looks up the linked waybill in `replica_waybill`: *`astra-server/src/main/resources/META-INF/resources/desk/desk.js`* ```js const REPLICA_OF = { Waybill: ['replica_waybill', (d) => d.name], CustomerOrder: ['replica_order', (d) => d.name], SupportTicket: ['replica_waybill', (d) => d.data.waybill] }; // … const required = state.lastLsn; const res = await api('GET', '/api/replica/' + spec[0] + '?name=' + enc(key), undefined, required ? { 'X-Required-LSN': required } : {}); if (res.status === 202) { // … 'sync pending' badge, retry after 100 ms ``` The replica table is written by the Rust CDC worker from the PostgreSQL WAL, with its columns declared in a routes file: *`astra-cdc-rust/cdc-routes.yaml`* ```yaml routes: - doctype: Waybill target: replica_waybill columns: - { name: order_name, from: order } - { name: ops_status, from: ops_status } - { name: current_hub, from: current_hub } - { name: receiver_name, from: receiver_name } - { name: scans, from: scan_log, transform: length, type: integer } ``` `ReplicaResource` serves `GET /api/replica/{table}` to anyone who may read the source DocType (here `Waybill`, which `care` may read), under the same row-level security as everything else. When the request carries `X-Required-LSN`, `SyncAwareLsnFilter` answers `202 Sync Pending` until the worker's watermark has reached that LSN; that read-your-writes mechanism is the subject of [Video 16](/astra-cms/en/cdc). ```mermaid flowchart LR A["ha creates SupportTicket
(Desk or REST)"] --> B["DocumentStore.persistNew"] B --> C["TicketTriageHook.beforeSave
priority · sla_due · triage_note"] C -->|"docs.get(Waybill)"| D[("astra_doc
Waybill row")] C --> E["INSERT astra_doc
SupportTicket"] D -. "WAL (pgoutput)" .-> F["astra-cdc-rust worker"] F --> G[("replica_waybill")] H["Desk replica card"] -->|"GET /api/replica/replica_waybill
X-Required-LSN"| G ``` ![Figure 2: Two ways care sees a parcel](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-06-figure-2.png) *Two ways care sees a parcel: a point-in-time snapshot on the ticket via DocumentService, and a near-real-time CDC replica for the read-heavy care card.* Why a replica for the care view: care screens are read-heavy, open all day and tolerant of milliseconds of lag; operational tables take the scan writes. Splitting the reads is the pattern described for CARE in [use-case-01.md](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/use-case-01.md). The hook still does one point read on the Waybill at save time, which is cheap and gives the ticket a status that is consistent with the moment it was saved. ## Real-world example A Vietnamese e-commerce fulfilment provider runs a hotline and a Zalo channel. Three tickets arrive on a Monday morning; all values below follow from the hook's rules. | Input | Priority in → out | `triage_note` | SLA due | `waybill_status` | | --- | --- | --- | --- | --- | | 09:00, Hotline, "Hàng bị vỡ, khách yêu cầu đền bù", waybill Delivered | Normal → High | `auto-escalated: mentions 'vỡ'` | Tue 09:00 (+24 h) | Delivered | | 09:05, Zalo, "Hỏi giờ giao hàng", priority Low, waybill Out for Delivery | Low → Low | empty | Sat 09:05 (+5 days) | Out for Delivery | | 09:10, Email, "Parcel lost, no update for 3 days", priority Urgent, waybill At Hub | Urgent → Urgent | empty (already above Normal) | Mon 13:10 (+4 h) | At Hub | ![Figure 3: The triage queue](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-06-figure-3.png) *Incoming complaints pass through the triage rules; damage claims escalate to High, while routine inquiries follow their standard SLAs.* What the team lead gets without anyone typing it: a High queue (`GET /api/doc/SupportTicket?priority=High&status=Open`) sorted with the damage claim on top, a deadline on every ticket that SLA reports can trust, and for each ticket the parcel status at the time it was logged, which matters when a customer later disputes "it was never delivered". The agent sees the parcel's current status, hub and scan count in the replica card on the same screen. For a bank's complaints desk the same pattern holds: keyword or category rules raise priority, the regulator's response window becomes `sla_due`, and the linked transaction's status is captured at logging time. ## For technical leaders - **Rule-based, deliberately.** Seven keywords are transparent, testable (`ExpressFlowTest.ticketsAreTriagedAndSeeTheWaybill`) and free to run. They will miss paraphrases ("cracked", "không còn nguyên vẹn") and can over-escalate ("không bị vỡ"). Measure the false-negative rate by sampling Normal tickets before investing in AI triage. - **AI triage is roadmap.** The hook's own Javadoc says so. When it comes, the cheap path is to keep this hook as the deterministic floor and let a model only raise priority, never lower it. - **SLA policy in code.** Four durations in a map are easy to review but require a deploy to change. Business hours, holidays and per-customer contracts are not modelled. If SLA terms vary by tenant or customer, that is a candidate for overlays ([Video 13](/astra-cms/en/overlay)) or a Wasm rule ([Video 14](/astra-cms/en/wasm)). - **Two kinds of freshness.** `waybill_status` is a point-in-time snapshot (consistent with the ticket write); the replica card is near-real-time (eventually consistent, with read-your-writes on request). Make sure agents and reports know which one they are looking at. - **Operational cost.** The replica requires the CDC worker, a logical replication slot and monitoring of lag. Measure worker lag (the card shows 47 ms in the recording), replication slot retained WAL, and the share of 202 Sync Pending responses. - **When not to use a replica.** For a small team with low read volume, reading the Waybill directly is simpler and the hook already does it. The replica pays off when care read traffic is large enough to compete with operational writes, or when care data must live in a separate store. - **Compared with Frappe/ERPNext.** ERPNext's Service Level Agreement DocType offers configurable priorities and working hours in the UI; Astra's demo is code-first and thinner on SLA policy, but runs the rule in the same transaction as the ticket write and separates the care read path from operations. ## Try it yourself Prerequisites: JDK 25, Docker Desktop (about 8 GB RAM), `kubectl`, `k3d`, `jq`, Bash 5. The replica card needs the k3d stack, which includes the Rust CDC worker. ```bash make k3d-up make demo-reset && make demo-seed make demo-express SCENES=6 PAUSE=0 open http://localhost:8080/desk/ # sign in as ha (password Astra@123), New Support Ticket, link a waybill, Create ``` Expected output, abridged from a recorded run (`docs/video/v06-care/assets/scene-06.log`): ```text ━━━ Scene 6 · Customer care sees operations ━━━ HTTP 200 [ "WB-2610-000002" ] ▸ A complaint mentioning damage is escalated, gets an SLA deadline and a snapshot of the waybill status HTTP 201 X-Tx-LSN: 0/… { "name": "TCK-00003", "priority": "High", "triage_note": "auto-escalated: mentions 'vỡ'", "sla_due": "", "waybill_status": "Out for Delivery" } ▸ Open high-priority tickets HTTP 200 [ {"name":"TCK-00003",…}, {"name":"TCK-00001","subject":"Khách báo hàng gốm bị vỡ"} ] ``` `TCK-00001` is seeded by `scripts/demo-seed.sh`; it is escalated by the same hook because its subject contains "vỡ". Ticket numbers depend on how many tickets exist when you run the scene. ## To be accurate - **Rule-based triage only.** No model is involved. AI triage over MCP, ticket auto-assignment and Tier 2 Wasm triage flows described for CARE in [use-case-01.md](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/use-case-01.md) are not built. - **`waybill_status` is not read from the replica.** The hook reads the Waybill document directly at save time. Only the "CARE replica (CDC)" card reads `replica_waybill`. - **No separate CARE schema.** The replica tables live alongside `astra_doc` (`V5__cdc_replicas.sql`), protected by row-level security; the per-module `schema_care` of the target architecture is not built. - **SLA is wall-clock.** No business hours, holidays, pause on "waiting for customer", or recalculation when priority changes after creation. - **Claims and compensation** (a `ClaimCase` that posts to the ledger on approval) are target scope, not built. - **The 47 ms worker lag** is the value shown in one recording; it varies with load. ## Figures - [Figure 1 · One save, three filled fields](#the-triage-hook) - [Figure 2 · Two ways care sees a parcel](#the-replica-card) - [Figure 3 · The triage queue](#real-world-example) ## Related - Previous: [Video 5 · Operations, the parcel state machine](/astra-cms/en/ops). Next: [Video 7 · Multi-tenancy](/astra-cms/en/tenants). - Deeper: [Video 16 · Rust CDC + Sync-Aware LSN](/astra-cms/en/cdc) (how the replica card stays current), [Video 3 · Hooks](/astra-cms/en/hooks) (the same hook pattern), [Video 15 · MCP](/astra-cms/en/mcp) (AI agents over the same permissions), [Video 11 · Keycloak](/astra-cms/en/keycloak) (the `care` role). - Code excerpts: [demo-code-snippets.md, S3](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/demo-code-snippets.md#s3-lifecycle-in-one-transaction) and [S10](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/demo-code-snippets.md#s10-rust-cdc-and-sync-aware-lsn). - Source: `astra-apps/express/src/main/java/io/astra/apps/express/TicketTriageHook.java`, `astra-apps/express/src/main/resources/META-INF/astra/doctypes/support-ticket.doctype.yaml`, `astra-doctype/src/main/java/io/astra/doctype/store/DocumentStore.java`, `astra-doctype/src/main/java/io/astra/doctype/api/ReplicaResource.java`, `astra-doctype/src/main/java/io/astra/doctype/sync/SyncAwareLsnFilter.java`, `astra-cdc-rust/cdc-routes.yaml`, `scripts/demo-express.sh` (`scene6`). --- # Multi-tenancy One Astra server hosts many companies; the kernel decides which tenant a request belongs to, carries that decision in a Java ScopedValue into a transaction-local PostgreSQL setting, and row-level security returns zero… 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/tenants ▶ [English](https://media.zerohostcms.com/astra-cms/astra-video-7-tenants-en.mp4) · [Tiếng Việt](https://media.zerohostcms.com/astra-cms/astra-video-7-tenants-vi.mp4) · 2:28 · Script: [demo-video-series.en.md](/astra-cms/en/video-scripts) > **In one sentence:** One Astra server hosts many companies; the kernel decides which tenant a request belongs to, carries that decision in a Java `ScopedValue` into a transaction-local PostgreSQL setting, and row-level security returns zero rows whenever that setting is missing. > > **Read this if:** you write Astra apps and want to know why no API takes a `tenant` parameter (developer angle) / you are choosing between shared-schema, database-per-tenant and instance-per-tenant SaaS and want to know where the isolation boundary actually sits (tech-lead angle). ## The problem Astra Express, the demo's courier company, runs as two tenants on one platform: `saigon-express` and `hanoi-express`. Both have a customer called `C001`. In Saigon it is "Shop Hoa Mai"; in Hanoi it is "Nhà sách Tràng Tiền". Both create orders, waybills and COD remittances through the same URLs and the same DocTypes. Some people work for both: the `demo` user is an admin in each. The pain in a shared-schema SaaS is well known. Isolation usually lives in application code: every query needs a `WHERE tenant_id = ?`, every repository method takes a tenant argument, and every new developer, background job, report or AI tool is one more place where that filter can be forgotten. The failure mode is silent: a missing filter does not throw; it returns another company's customers. The usual alternatives trade that risk for operating cost: - **Database or instance per tenant.** Frappe/ERPNext does this: a bench hosts many *sites*, each with its own database. Isolation is strong, but every site is migrated, backed up and monitored separately, and cross-site work (a dispatcher serving two branches) means two logins. - **Shared tables with an ORM filter.** Cheap to run, but the boundary is only as strong as the most careless query, including raw SQL in reports and hooks. Astra's thin slice takes the shared-table route but moves the boundary out of application code into two places the application cannot skip: the kernel's request filter and PostgreSQL row-level security (RLS). ## What the video shows | Scene | What you see | What it proves | | --- | --- | --- | | 1 · Title | "One server, many companies." | Framing. | | 2 · List | `demo` opens `/desk/compare.html`, Screen `#/list/Customer`. Two panes, same URL, same DocType. Saigon shows 3 customers, Hanoi 2. | Each pane calls the same API with a different `X-Astra-Tenant` header; the data differs. | | 3 · Form | `#/form/Customer/C001` in both panes. | The same document name is two separate records. Uniqueness is `(tenant_id, doctype, name)`. | | 4 · Resolve | The resolution order: header, subdomain, the user's only tenant. Unresolved: 400. Non-member: 403. | No API takes the tenant as a parameter; the kernel resolves it once per request. | | 5 · Links | `GET Customer/C003` as Hanoi returns 404; a Hanoi order pointing at `C003` fails with `field 'customer': Customer 'C003' not found`. | Link validation sees only the current tenant's rows. | | 6 · Members | `nam` (Hanoi admin) calling Saigon gets `403 not a member of tenant saigon-express`; `demo` without a header gets `400`. | Fail-closed: if the tenant is ambiguous, the kernel refuses rather than guesses. | | 7 · RLS | In `psql` as the application role: `visible_rows = 0` without a tenant; after `set_config('astra.tenant','saigon-express',true)`, counts per DocType (Customer 3, CustomerOrder 7, Waybill 5, …). | The database itself enforces the boundary, even for raw SQL. | | 8 · Code | `TenantContext`, `TenantTransactions`, `RowLevelSecurityGuard`. | The tenant follows the call flow, not the argument list. | | 9 · Recap | Four layers: one URL, Links, Keycloak per tenant, zero rows without a tenant. | | The terminal half of the video is `make demo-express SCENES=7`. The captured run (`docs/video/v07-tenants/assets/scene-07.log`) shows exactly these responses: ```text $ curl -X GET http://localhost:8080/api/doc/Customer -H "Authorization: Bearer " -H "X-Astra-Tenant: saigon-express" HTTP 403 { "error": "not a member of tenant saigon-express" } $ curl -X GET http://localhost:8080/api/doc/Customer -H "Authorization: Bearer " HTTP 400 { "error": "tenant could not be resolved; send X-Astra-Tenant or use a tenant subdomain" } ``` ![Figure 1: One URL, two companies](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-07-figure-1.png) *One URL, two companies: same hostname, but Keycloak groups and X-Astra-Tenant keep data strictly partitioned.* ## How it works The path from HTTP request to SQL row has four steps. Each one is a place where the tenant is either established or enforced, never passed in by the caller. ```mermaid sequenceDiagram participant C as Client (Desk / curl / MCP) participant F as TenantResolverFilter participant R as DocResource participant T as TenantTransactions participant P as PostgreSQL (RLS) C->>F: Bearer JWT (groups /tenants//) + optional X-Astra-Tenant F->>F: header → subdomain → only membership alt unresolved F-->>C: 400 else not a member F-->>C: 403 end F->>R: tenantRequest.tenantId(t), roles R->>R: TenantContext.call(t, work) (ScopedValue) R->>T: inTenantTx(work) T->>P: BEGIN; set_config('astra.tenant', t, true) T->>P: SELECT/INSERT … (no tenant filter in SQL) P-->>T: only rows where tenant_id = current_setting('astra.tenant') T->>P: COMMIT (setting disappears) ``` ### Step 1: resolve the tenant, once, from trusted inputs *`astra-kernel/src/main/java/io/astra/kernel/tenant/TenantResolverFilter.java`* ```java Memberships memberships = Memberships.of(identity); String tenant = fromHeader(ctx).or(() -> fromHost(ctx)).or(() -> onlyMembership(memberships)).orElse(null); if (tenant == null && "single".equals(mode)) { tenant = defaultTenant; } if (tenant == null || !tenant.matches("[a-z0-9][a-z0-9-]{0,62}")) { ctx.abortWith(error(Response.Status.BAD_REQUEST, "tenant could not be resolved; send " + TENANT_HEADER + " or use a tenant subdomain")); return; } Set roles = memberships.roles(tenant); if (roles.isEmpty()) { ctx.abortWith(error(Response.Status.FORBIDDEN, "not a member of tenant " + tenant)); return; } ``` Why this shape: - **The header is a hint, not an authority.** A client may *name* a tenant, but membership is checked against the verified bearer token. Memberships come from Keycloak group paths of the form `/tenants//` (`astra-kernel/src/main/java/io/astra/kernel/security/Memberships.java`), so roles are always per tenant: `nam` is an admin, but only in `hanoi-express`. - **A single-tenant user never has to say anything.** `onlyMembership` picks the one tenant a user belongs to, so a user like `nam`, who belongs only to `hanoi-express`, does not need a header. - **Ambiguity is an error.** A user in two tenants who sends nothing gets 400 instead of "the first tenant", because a guess is exactly how data ends up in the wrong company. - **Subdomain resolution exists but is off by default.** `fromHost` only runs when `astra.tenancy.base-domain` is set; in `astra-server/src/main/resources/application.yml` it is commented out. ![Figure 2: Who decides the tenant](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-07-figure-2.png) *Resolving the tenant: the OIDC token establishes group membership, and the request header selects which tenant is active.* ### Step 2: carry the tenant in a `ScopedValue`, not in arguments *`astra-spi/src/main/java/io/astra/spi/tenant/TenantContext.java`* ```java /** * The current tenant, carried in a Java 25 {@link ScopedValue}. The kernel binds it once per request; * every kernel service reads it from here. No kernel API accepts a tenant as a caller-supplied * parameter, which is what makes cross-tenant access impossible from app code. */ public final class TenantContext { private static final ScopedValue TENANT = ScopedValue.newInstance(); // … /** The bound tenant; throws when no tenant is in scope, which is always a programming error. */ public static String require() { if (!TENANT.isBound()) { throw new IllegalStateException("No tenant bound in the current scope"); } return TENANT.get(); } ``` Resources bind it around each kernel call: *`astra-doctype/src/main/java/io/astra/doctype/api/DocResource.java`* ```java /** Binds the request's tenant into the ScopedValue scope for the duration of the kernel call. */ private T scoped(ScopedValue.CallableOp work) { return TenantContext.call(tenant.tenantId(), work); } ``` Why a `ScopedValue` rather than a `ThreadLocal` or a parameter: - A **parameter** can be supplied by app code, so an app hook could ask for another tenant's data. The `DocumentService` SPI that hooks use has no tenant argument at all. - A **`ThreadLocal`** can be set and forgotten, and leaks between pooled threads. A `ScopedValue` is immutable inside its scope and unbound outside it, which fits virtual threads: there is nothing to clean up. - `TenantContext` lives in `astra-spi`, so apps can *read* the tenant (for example to log it) but the only way to *bind* one is the call that wraps their code. ### Step 3: turn the scope into a transaction-local database setting *`astra-doctype/src/main/java/io/astra/doctype/store/TenantTransactions.java`* ```java String tenant = TenantContext.require(); try (Connection conn = dataSource.getConnection()) { conn.setAutoCommit(false); try { try (PreparedStatement ps = conn.prepareStatement("SELECT set_config('astra.tenant', ?, true)")) { ps.setString(1, tenant); ps.execute(); } T result = ScopedValue.where(TX, conn).call(() -> work.run(conn)); conn.commit(); return result; } catch (Exception e) { conn.rollback(); // … ``` The third argument of `set_config` is `true`: the setting is **local to the transaction**. When the connection goes back to the pool, the tenant goes with the commit or rollback. The next request that borrows the connection starts with no tenant, which (step 4) means it sees nothing until it binds its own. The open connection is also carried in a `ScopedValue`, so a hook that writes a Waybill while an order is being submitted joins the same transaction and the same tenant. ### Step 4: let PostgreSQL enforce it *`astra-doctype/src/main/resources/db/migration/V1__astra_doc.sql`* ```sql ALTER TABLE astra_doc ENABLE ROW LEVEL SECURITY; ALTER TABLE astra_doc FORCE ROW LEVEL SECURITY; -- applies to the table owner too CREATE POLICY astra_doc_tenant_isolation ON astra_doc USING (tenant_id = current_setting('astra.tenant', true)) WITH CHECK (tenant_id = current_setting('astra.tenant', true)); ``` - `current_setting('astra.tenant', true)` returns NULL when nothing is bound; `tenant_id = NULL` is never true, so the result is **zero rows**, not an error and not all rows. That is scene 7's `visible_rows = 0`. - `WITH CHECK` stops a write from landing in another tenant even if the inserted `tenant_id` were wrong. - `FORCE` matters because the application role owns the tables Flyway creates; without `FORCE`, PostgreSQL exempts the owner from its own policies. - The same pattern is applied in later migrations: `astra_series` (V1), the ledger (V3), overlays (V4) and the CDC replica tables (V5) all use `FORCE ROW LEVEL SECURITY`. This is why Link validation needs no tenant filter. The query that rejects Hanoi's order for `C003` does not mention a tenant at all: *`astra-doctype/src/main/java/io/astra/doctype/store/DocumentStore.java`* ```java try (PreparedStatement ps = conn.prepareStatement( "SELECT 1 FROM astra_doc WHERE doctype = ? AND name = ?")) { ps.setString(1, f.options().getFirst()); ps.setString(2, target); try (ResultSet rs = ps.executeQuery()) { if (!rs.next()) errors.add("field '" + f.name() + "': " + f.options().getFirst() + " '" + target + "' not found"); } } ``` `C003` exists, but only under `saigon-express`. Inside Hanoi's transaction, RLS hides it, so it is "not found". The SQL author did not have to remember anything. ![Figure 3: Zero rows without a tenant](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-07-figure-3.png) *Row-level security enforces isolation at the database level: a query without a tenant context returns zero rows.* ### The safety net All of the above assumes the database role is actually subject to RLS. PostgreSQL superusers and roles with `BYPASSRLS` ignore every policy. `RowLevelSecurityGuard` checks this at startup and stops the kernel; that is the subject of [Video 8](/astra-cms/en/failclosed). ## Real-world example *Illustrative scenario: the company, people and values are made up; the mechanism and the rules it applies are the real ones shown above.* **A 3PL platform in Vietnam hosting several courier brands.** A logistics software vendor runs one Astra cluster for three regional courier companies. Each is a tenant. A freelance dispatcher, Linh, is contracted by two of them. Inputs: - Keycloak groups for Linh: `/tenants/hanoi-express/dispatcher` and `/tenants/saigon-express/dispatcher`. - Both tenants have a customer `C001` and an order series `ORD-.YYYYMMDD.-.#####`. - A careless report written by the vendor's analyst: `SELECT count(*) FROM astra_doc WHERE doctype = 'CustomerOrder'`. What Astra does: 1. Linh opens Desk without choosing a company and calls `GET /api/doc/CustomerOrder`. She is in two tenants and sent no header, so the kernel answers `400 tenant could not be resolved`. The client has to make her pick. 2. She picks Hanoi (`X-Astra-Tenant: hanoi-express`). The filter checks her token, finds the dispatcher role there, and binds `hanoi-express` for the call. 3. She creates an order for `C001`. Link validation finds Hanoi's `C001`. The order number comes from Hanoi's own counter row in `astra_series`, so Saigon's numbering is unaffected. 4. The analyst's report, run through the kernel, counts only the bound tenant's orders. Run directly as the application role with no tenant bound, it returns `0`. Outputs: one order in Hanoi, no change visible in Saigon, a report that cannot over-count, and no code anywhere that had to remember `WHERE tenant_id = ?`. Contrast with a database-per-site model: Linh would hold two logins and the vendor would run three databases, three migration runs and three backup schedules for the same schema. ## For technical leaders - **Where the boundary is.** Two independent layers: the kernel filter (who may act in which tenant) and PostgreSQL RLS (which rows a transaction can touch). A bug in app code cannot cross tenants because app code never chooses the tenant. A bug in the kernel filter is still caught by RLS for anything that runs inside `inTenantTx`. - **What it costs.** One extra `set_config` statement per transaction, and every query evaluated with the RLS predicate. The unique key and the list index in `V1__astra_doc.sql` both start with `tenant_id`, which keeps that predicate cheap for name lookups and lists. No benchmark of RLS overhead is published in the repo; measure it on your workload. - **Operational benefit.** One schema, one migration run, one backup, one connection pool for all tenants. Onboarding a tenant in the demo is creating Keycloak groups and data, not provisioning a database. - **What to measure.** 400 and 403 rates per client (a spike means a client lost its tenant selection), queries that return zero rows unexpectedly (a missing tenant scope), and per-tenant row counts on the largest tables (noisy neighbours). - **Risks.** - Code that bypasses `TenantTransactions` (a raw `DataSource` call) gets zero rows rather than leaked rows. That is the safe direction, but it can look like a bug. - Noisy neighbours share CPU, I/O and the connection pool. Per-tenant bulkheads and quotas are design targets, not built (see "To be accurate"). - RLS protects rows, not everything: caches, logs, files and search indexes need their own tenant keys. - **When not to use shared-schema RLS.** Customers with contractual data-residency, dedicated-key or "own database" requirements. The design names `database-per-tenant` and `cluster-per-tenant` profiles for them; neither is built. - **Compared with Frappe.** Frappe isolates by site and database; Astra isolates by row inside one database and treats multi-company users as first-class. Frappe's model is simpler to reason about per customer; Astra's is cheaper to operate at many tenants. ## Try it yourself Prerequisites: JDK 25, Docker (8 GB for the full stack), `kubectl`, `k3d`, `jq`, Bash 5. ```bash make k3d-up # 2 kernel pods, PostgreSQL, Redis, Keycloak, Seata, CDC make demo-reset && make demo-seed # tenants saigon-express and hanoi-express open http://localhost:8080/desk/compare.html # log in as demo / Astra@123 first make demo-express SCENES=7 PAUSE=0 # the terminal half of the video ``` In `compare.html`, set Screen to `#/list/Customer` and then `#/form/Customer/C001`. Expected: Saigon lists 3 customers and Hanoi 2; `C001` is "Shop Hoa Mai" in Saigon and "Nhà sách Tràng Tiền" in Hanoi. Expected from scene 7, in order: two `HTTP 200` reads of `C001` with different names; `HTTP 404` for `C003` in Hanoi; `HTTP 422` with `field 'customer': Customer 'C003' not found`; `HTTP 403` for `nam` on Saigon; `HTTP 400` for `demo` without a header; then in SQL `visible_rows = 0` followed by per-DocType counts for Saigon. The SQL part runs only when the script can reach the database (`DB=k3d` or `DB=compose`, detected automatically in `scripts/demo-express.sh`). The integration test `astra-server/src/test/java/io/astra/server/TenantIsolationTest.java` covers the same rules without the demo stack: `make test`. ## To be accurate - Built: header, subdomain (only when `astra.tenancy.base-domain` is set) and single-membership resolution; `single` mode with a default tenant; RLS on the document, series, ledger, overlay and CDC replica tables; the startup guard. - The design in [explain.en.md](/astra-cms/en/why-astra) describes more than the thin slice: a `Tenant` DocType with a lifecycle, OIDC-claim and path-prefix resolution, isolation profiles (`database-per-tenant`, `cluster-per-tenant`), per-tenant bulkheads, quotas, metering, organisations and sharing, and `astra tenant migrate`. None of these is built. Tenants in the demo are Keycloak groups plus data; tenant administration is `astra-iam`'s `/api/iam/tenants` resource. - The built resolution order is header → subdomain → only membership → default (single mode). The design document lists a different order (domain, token claim, header, path); the code is the authority for what runs today. - RLS covers PostgreSQL tables only. Redis counters are tenant-scoped because `RedisCounters` (`astra-tx-seata`) prefixes every key with `astra::`, not because of a database policy. - In the original recording plan, Hanoi's order for `C003` failed because Hanoi's overlay requires a `note`, not because of the Link. `scripts/demo-express.sh` now sends a `note`, so the error shown is the Link error. ## Figures - [Figure 1 · One URL, two companies](#what-the-video-shows) - [Figure 2 · Who decides the tenant](#step-1-resolve-the-tenant-once-from-trusted-inputs) - [Figure 3 · Zero rows without a tenant](#step-4-let-postgresql-enforce-it) ## Related - Previous: [Video 6 · Customer care sees operations](/astra-cms/en/care) · Next: [Video 8 · Fail-closed security](/astra-cms/en/failclosed) - [Video 11 · Keycloak](/astra-cms/en/keycloak) (where per-tenant roles come from) · [Video 13 · Per-tenant overlays](/astra-cms/en/overlay) (per-tenant metadata on the same tables) - Code walkthrough: [demo-code-snippets.md, S4](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/demo-code-snippets.md#s4-tenants-and-row-level-security) - Design: [explain.en.md, multi-tenancy](/astra-cms/en/why-astra) - Source: `astra-kernel/src/main/java/io/astra/kernel/tenant/TenantResolverFilter.java`, `astra-spi/src/main/java/io/astra/spi/tenant/TenantContext.java`, `astra-doctype/src/main/java/io/astra/doctype/store/TenantTransactions.java`, `astra-doctype/src/main/resources/db/migration/V1__astra_doc.sql`, `astra-kernel/src/main/java/io/astra/kernel/security/Memberships.java` --- # Fail-closed security If the kernel is pointed at a PostgreSQL role that would ignore row-level security (a superuser or a BYPASSRLS role), it refuses to start, before any migration runs, instead of quietly serving every tenant's data to… 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/failclosed ▶ [English](https://media.zerohostcms.com/astra-cms/astra-video-8-failclosed-en.mp4) · [Tiếng Việt](https://media.zerohostcms.com/astra-cms/astra-video-8-failclosed-vi.mp4) · 2:02 · Script: [demo-video-series.en.md](/astra-cms/en/video-scripts) > **In one sentence:** If the kernel is pointed at a PostgreSQL role that would ignore row-level security (a superuser or a `BYPASSRLS` role), it refuses to start, before any migration runs, instead of quietly serving every tenant's data to every tenant. > > **Read this if:** you deploy or configure Astra and want to know which database role it must use and what happens if you get it wrong (developer angle) / you need to explain to an auditor how a configuration mistake is prevented from becoming a cross-tenant data leak (tech-lead angle). ## The problem [Video 7](/astra-cms/en/tenants) showed that Astra Express's two tenants, `saigon-express` and `hanoi-express`, share one `astra_doc` table and are kept apart by PostgreSQL row-level security (RLS). RLS has one well-documented exception: **superusers and roles with the `BYPASSRLS` attribute ignore every policy.** That turns tenant isolation into a configuration question. In the Docker Compose stack the bootstrap role `astra` is a superuser (it has to be, to create other roles) and the application role `astra_app` is not. The two connection strings differ by four characters. If someone sets `ASTRA_DB_USER=astra`, perhaps to get past a permission error during an incident, the kernel would work perfectly: every page loads, every test that checks "can I read my data" passes. The only symptom is that Saigon's Customer list now also contains Hanoi's customers. Nothing logs an error, because from PostgreSQL's point of view nothing is wrong. Most systems treat this as a deployment checklist item. Frappe/ERPNext sidesteps it with a database per site, so there is no shared table to leak from; the equivalent mistake there is pointing a site at the wrong database. Astra's shared-table design needs a stronger answer than a checklist: the kernel checks the role itself and treats an unsafe role as a fatal error. ![Figure 1: Two connection strings, two outcomes](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-08-figure-1.png) *Two connection strings, two outcomes: a superuser connection fails closed before any schema runs; the application role succeeds under RLS.* ## What the video shows | Scene | What you see | What it proves | | --- | --- | --- | | 1 · Title | "Wrong config, and the kernel won't run." | Framing. | | 2 · Risk | Role attributes in the Compose database: `astra` has `rolsuper = t`, `rolbypassrls = t`; `astra_app` has `f`, `f`. | One wrong connection string is enough to disable isolation silently. | | 3 · Attempt | A second kernel starts with `ASTRA_DB_USER=astra`. It prints `Database role 'astra' is a SUPERUSER: row-level security would be ignored and tenants would see each other's data.` and exits with code 1. | The guard runs before Flyway: no migration runs and no DocType is loaded. | | 4 · Guard | `RowLevelSecurityGuard` code. | It reads `rolsuper` and `rolbypassrls` for `current_user` and throws. It is called twice: before Flyway migrates and at startup. | | 5 · Role | `deploy/docker/init/01-app-role.sql`. | The kernel's role is `NOSUPERUSER NOBYPASSRLS NOCREATEDB NOCREATEROLE`. `FORCE ROW LEVEL SECURITY` covers the table owner too. | | 6 · Live | The running k3d cluster: kernel log line `Tenant isolation guard: role 'astra_app' is subject to row-level security`; connected roles `astra_app` (4 connections), `astra_bms` (2), `astra_cdc` (1), all with `rolsuper = f` and `rolbypassrls = f`. | The production-shaped deployment uses only plain roles. | | 7 · To be accurate | `astra.tenancy.allow-rls-bypass`, default `false`. | When switched on, the kernel starts with a warning. For dev machines only. | The full log of the refused start is in `docs/video/v08-failclosed/assets/unsafe-kernel.log`. The important part of the stack trace is where the exception comes from: ```text Caused by: java.lang.IllegalStateException: Database role 'astra' is a SUPERUSER: row-level security would be ignored and tenants would see each other's data. Connect as a non-superuser application role (see deploy/docker/init/01-app-role.sql). at io.astra.doctype.store.RowLevelSecurityGuard.check(RowLevelSecurityGuard.java:58) at io.astra.doctype.store.RowLevelSecurityGuard_ClientProxy.check(Unknown Source) at io.astra.doctype.store.RowLevelSecurityFlywayCustomizer.customize(RowLevelSecurityFlywayCustomizer.java:20) at io.quarkus.flyway.runtime.FlywayCreator.createFlyway(FlywayCreator.java:155) ``` The guard fires while Quarkus is still *creating* the Flyway instance, which is before Flyway can migrate. ## How it works ```mermaid flowchart TD A[Kernel process starts] --> B["Quarkus creates Flyway
(migrate-at-start: true)"] B --> C["RowLevelSecurityFlywayCustomizer.customize()"] C --> D{"RowLevelSecurityGuard.check()
rolsuper or rolbypassrls
for current_user?"} D -- "no" --> E["Flyway migrates as astra_app
(astra_app owns the tables)"] E --> F["StartupEvent: check() again"] F --> G["Log: role 'astra_app' is subject to
row-level security; serve traffic"] D -- "yes, allow-rls-bypass=false (default)" --> X["IllegalStateException
exit code 1, nothing migrated"] D -- "yes, allow-rls-bypass=true" --> W["WARN … NEVER use this in production
continue"] ``` ### The check *`astra-doctype/src/main/java/io/astra/doctype/store/RowLevelSecurityGuard.java`* ```java @ConfigProperty(name = "astra.tenancy.allow-rls-bypass", defaultValue = "false") boolean allowBypass; void onStart(@Observes StartupEvent ev) { check(dataSource); } // … void check(DataSource dataSource) { try (Connection conn = dataSource.getConnection(); Statement st = conn.createStatement(); ResultSet rs = st.executeQuery( "SELECT current_user, rolsuper, rolbypassrls FROM pg_roles WHERE rolname = current_user")) { if (!rs.next()) throw new IllegalStateException("cannot read role attributes for current_user"); String role = rs.getString(1); boolean superuser = rs.getBoolean(2); boolean bypass = rs.getBoolean(3); if (superuser || bypass) { String msg = "Database role '" + role + "' is " + (superuser ? "a SUPERUSER" : "BYPASSRLS") + ": row-level security would be ignored and tenants would see each other's data. " + "Connect as a non-superuser application role (see deploy/docker/init/01-app-role.sql)."; if (allowBypass) { LOG.warn(msg + " Continuing because astra.tenancy.allow-rls-bypass=true. NEVER use this in production."); } else { throw new IllegalStateException(msg); } } else { LOG.infof("Tenant isolation guard: role '%s' is subject to row-level security", role); } } catch (SQLException e) { throw new IllegalStateException("cannot verify row-level security role attributes", e); } } ``` Design choices worth noting: - **It asks the database, not the configuration.** The guard does not compare `ASTRA_DB_USER` against a deny-list. It asks PostgreSQL what the connected role *is*. A role renamed to something innocent, or a plain role later granted `BYPASSRLS`, is still caught. - **Every failure path is closed.** If the role row cannot be read, or the query itself fails, the guard throws too. "Could not verify" is treated the same as "unsafe". - **The escape hatch is explicit and loud.** `astra.tenancy.allow-rls-bypass` defaults to `false`. Turning it on does not hide the problem; it changes the exception into a warning that says "NEVER use this in production" on every start. - **The success path logs too.** The `INFO` line on a healthy start is evidence an operator or auditor can grep for, which is what scene 6 shows from the k3d cluster. ### Why it runs before Flyway *`astra-doctype/src/main/java/io/astra/doctype/store/RowLevelSecurityFlywayCustomizer.java`* ```java /** * Runs the tenant isolation guard before Flyway migrates the default datasource at start. Quarkus migrates * before {@code StartupEvent} observers run, so without this a kernel started as a superuser would apply the * migrations (and own the tables it creates) before refusing to start. */ @Singleton public class RowLevelSecurityFlywayCustomizer implements FlywayConfigurationCustomizer { @Inject RowLevelSecurityGuard guard; @Override public void customize(FluentConfiguration configuration) { guard.check(configuration.getDataSource()); } } ``` Ordering matters because of ownership. The application role owns the tables Flyway creates, and the migrations depend on that (see `FORCE` below). If a superuser ran the first migration, the superuser would own `astra_doc`, and the kernel would refuse to start only *after* leaving the schema in the wrong hands. An earlier version of the demo did exactly that: the video brief notes that the superuser had already created the V3 to V5 tables before the guard stopped it. Hooking into Flyway's configuration step closes that window. The second call at `StartupEvent` covers deployments that migrate separately and run the kernel with migrations off. ![Figure 2: The guard stands before Flyway](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-08-figure-2.png) *The startup guard runs before Flyway: it inspects role attributes from pg_roles and halts immediately if bypass privileges are detected.* ### The role the kernel is supposed to use *`deploy/docker/init/01-app-role.sql`* ```sql -- Runs once at database initialisation (docker-entrypoint-initdb.d), as the bootstrap superuser. -- The kernel never connects as a superuser: superusers and BYPASSRLS roles ignore row-level -- security, which would silently disable tenant isolation. The application role below owns the -- schema objects Flyway creates; FORCE ROW LEVEL SECURITY keeps the owner subject to policies. CREATE ROLE astra_app LOGIN PASSWORD 'astra_app' NOSUPERUSER NOBYPASSRLS NOCREATEDB NOCREATEROLE NOINHERIT; GRANT CONNECT ON DATABASE astra TO astra_app; GRANT USAGE, CREATE ON SCHEMA public TO astra_app; ``` The same statement is in the k3d manifest (`deploy/k8s/base/postgres.yaml`, ConfigMap `postgres-init`), and the kernel's default username is `astra_app` (`astra-server/src/main/resources/application.yml`). The other roles in the cluster are plain as well: `astra_bms` (Seata AT branches) is `NOSUPERUSER NOBYPASSRLS`, and `astra_cdc` (`deploy/db/20-cdc.sql`) has `REPLICATION` to read the WAL but `NOSUPERUSER NOBYPASSRLS`. ### Two layers, not one *`astra-doctype/src/main/resources/db/migration/V1__astra_doc.sql`* ```sql ALTER TABLE astra_doc ENABLE ROW LEVEL SECURITY; ALTER TABLE astra_doc FORCE ROW LEVEL SECURITY; -- applies to the table owner too ``` By default PostgreSQL does not apply RLS to a table's owner. Because `astra_app` owns the tables, `ENABLE` alone would leave the kernel exempt from its own policies. `FORCE` removes that exemption. The guard and `FORCE` cover different holes: | Hole | Closed by | | --- | --- | | The connected role is a superuser or has `BYPASSRLS` | `RowLevelSecurityGuard` (refuses to start) | | The connected role owns the table | `FORCE ROW LEVEL SECURITY` in every tenant-scoped migration | | No tenant is bound in the transaction | The policy compares against `current_setting('astra.tenant', true)`, which is NULL, so zero rows ([Video 7](/astra-cms/en/tenants)) | ![Figure 3: Two layers, three holes](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-08-figure-3.png) *Two layers of defense: kernel-level tenant context and PostgreSQL row-level security. Superusers bypass both; the locked-down role is stopped by both.* ## Real-world example *Illustrative scenario: the company, people and values are made up; the mechanism and the rules it applies are the real ones shown above.* **An accounting SaaS during a 2 a.m. incident.** A provider runs Astra for 40 small companies' ledgers on one Kubernetes cluster. After a database restore, the kernel fails with a permission error on one table. The on-call engineer, under pressure, edits the `astra-db` Secret so that `username` is the bootstrap superuser, and restarts the deployment. Inputs: - Secret `astra-db`: `username` changed from `astra_app` to the superuser. - Deployment `astra-server`: 2 replicas, `RollingUpdate` with `maxUnavailable: 0` and `maxSurge: 1`, `readinessProbe` on `/q/health/ready` (`deploy/k8s/base/astra-server.yaml`). What Astra does: 1. The first new pod starts, Quarkus prepares Flyway, the customizer calls the guard, and the guard reads `rolsuper = t` for the connected role. 2. The guard throws `Database role '…' is a SUPERUSER: …`; the process exits with code 1 before any migration and before any HTTP request is served. 3. The pod never reports ready. Because `maxUnavailable` is 0, Kubernetes keeps the two old pods, which are still connected as `astra_app`, serving traffic. The rollout stalls instead of completing. Outputs: no tenant ever sees another tenant's ledger, users keep working on the old pods, and the pod log states the cause and the fix in one line. The engineer fixes the actual permission problem (for example, a grant lost in the restore) instead. The stalled rollout in step 3 is standard Kubernetes behaviour given the manifest's settings; the video demonstrates step 2 with a local process, not this exact rollout. Without the guard, the same incident ends with a working system and a reportable data breach, discovered whenever a customer happens to notice someone else's invoices. ## For technical leaders - **What it buys.** It converts the most likely way to disable tenant isolation, a wrong connection string, from a silent breach into a failed deployment. For an auditor, the control is code (`RowLevelSecurityGuard`), plus a log line on every healthy start, plus the role DDL. - **What it costs.** One query against `pg_roles` per start, twice. Operationally, the cost is that teams must keep a separate bootstrap role for administration and never reuse it for the application. - **What it does not cover.** - A role that is not a superuser but owns the tables could in principle run `ALTER TABLE … NO FORCE ROW LEVEL SECURITY` or drop a policy. Nothing in the kernel does that, and the guard does not detect it. Treat migrations as reviewed code. - The guard checks the default datasource only. The BMS datasource used for Seata AT (`astra-tx-seata/src/main/resources/db/bms`) has no RLS policies; its rows carry the tenant in the key. - Direct database access by people or other tools is outside the kernel's control. Give humans the same plain roles and audit superuser sessions. - **What to measure.** Alert if the `Tenant isolation guard: role '…' is subject to row-level security` line is missing from a kernel start, and alert on any `allow-rls-bypass=true` warning outside dev. Periodically list connected roles with `rolsuper` or `rolbypassrls`; scene 6 shows the expected result for the cluster. - **When the switch is acceptable.** `astra.tenancy.allow-rls-bypass=true` exists for a developer's laptop where the only database user is a superuser. It should never appear in a shared environment's configuration; consider failing CI on it. - **Compared with alternatives.** Database-per-tenant designs (Frappe sites) are not exposed to this specific risk but pay for it in operational overhead. Application-level `WHERE tenant_id` filters have no equivalent fail-closed check at all: a missing filter fails open. ## Try it yourself Prerequisites: JDK 25, Docker, `jq`, Bash 5, and an Astra kernel reachable at `BASE` (default `http://localhost:8080`), because `scripts/lib/demo-common.sh` checks `/api/iam/config` before any scene runs. The k3d stack (`make k3d-up`) satisfies this; the recording used it. ```bash make infra-up && make build # Compose PostgreSQL (superuser astra, app role astra_app) + the kernel jar make demo-express SCENES=8 PAUSE=0 # starts a throwaway kernel on :8099 as the superuser ``` Expected output (`docs/video/v08-failclosed/assets/scene-08.log`): ```text ━━━ Scene 8 · The kernel refuses to run unsafely ━━━ ▸ Start a second kernel connected as the PostgreSQL superuser (RLS would be ignored)… $ ASTRA_DB_USER=astra java -jar astra-server/target/quarkus-app/quarkus-run.jar Database role 'astra' is a SUPERUSER: row-level security would be ignored and tenants would see each other's data. ▸ …it refuses to start. Tenant isolation cannot be switched off by a misconfigured connection. ``` If the Compose stack is not running or the jar is missing, the scene prints `Needs the Docker Compose stack (make infra-up) and a build (make build)` instead. To see the healthy path on the k3d cluster, look for the guard's `INFO` line in a kernel pod's log: ```bash kubectl -n astra logs deployment/astra-server | grep "Tenant isolation guard" ``` Expected: `Tenant isolation guard: role 'astra_app' is subject to row-level security`. ## To be accurate - The switch `astra.tenancy.allow-rls-bypass` exists and defaults to `false`. When it is `true`, an unsafe role only produces a warning and the kernel starts. Dev machines only. - Do not read this as "the application role cannot disable RLS". As the table owner, `astra_app` could in theory change RLS settings on its own tables. `FORCE ROW LEVEL SECURITY` and the guard are two separate layers; neither prevents a deliberate DDL change by the owner. - The guard checks role attributes at startup. A role altered to `BYPASSRLS` while the kernel is running is not detected until the next start. - Only the default datasource is checked. The BMS schema is not under RLS. - No automated test in the repository exercises the guard; the evidence is the scene 8 run and the captured logs in `docs/video/v08-failclosed/assets/`. ## Figures - [Figure 1 · Two connection strings, two outcomes](#the-problem) - [Figure 2 · The guard stands before Flyway](#why-it-runs-before-flyway) - [Figure 3 · Two layers, three holes](#two-layers-not-one) ## Related - Previous: [Video 7 · Multi-tenancy](/astra-cms/en/tenants) · Next: [Video 9 · For developers](/astra-cms/en/developer) - [Video 10 · Running on Kubernetes](/astra-cms/en/k8s) (the deployment the guard protects) · [Video 16 · Rust CDC](/astra-cms/en/cdc) (the `astra_cdc` replication role) - Code walkthrough: [demo-code-snippets.md, S4](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/demo-code-snippets.md#s4-tenants-and-row-level-security) - Source: `astra-doctype/src/main/java/io/astra/doctype/store/RowLevelSecurityGuard.java`, `astra-doctype/src/main/java/io/astra/doctype/store/RowLevelSecurityFlywayCustomizer.java`, `deploy/docker/init/01-app-role.sql`, `deploy/k8s/base/postgres.yaml`, `astra-doctype/src/main/resources/db/migration/V1__astra_doc.sql` --- # Running on Kubernetes Astra runs as two stateless kernel replicas from day one; document numbers stay gapless and unique across pods because the counter is one PostgreSQL upsert under a row lock inside the document's own transaction, and a… 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/k8s ▶ [English](https://media.zerohostcms.com/astra-cms/astra-video-10-k8s-en.mp4) · [Tiếng Việt](https://media.zerohostcms.com/astra-cms/astra-video-10-k8s-vi.mp4) · 2:06 · Script: [demo-video-series.en.md](/astra-cms/en/video-scripts) > **In one sentence:** Astra runs as two stateless kernel replicas from day one; document numbers stay gapless and unique across pods because the counter is one PostgreSQL upsert under a row lock inside the document's own transaction, and a rolling restart keeps the service nearly, but not fully, uninterrupted. > > **Read this if:** you want to know why `ORD-20261021-00007` can never be issued twice even when two pods write at once, and what to change before relying on rolling restarts (developer angle) / you are sizing the operational model: what is stateless, what is not, what a deploy costs users today (tech-lead angle). ## The problem Astra Express creates orders from Desk, from the API and from an AI agent. Each order gets a human-readable number such as `ORD-20261021-00007`: prefix, date, then a five-digit counter per tenant per day. Staff read these numbers over the phone, print them on waybills and reconcile COD against them. Two properties matter: **no duplicates** (two parcels with the same order number is an operational incident) and **no gaps** (a missing number makes an auditor ask what was deleted). Single-server systems get both for free: one process, one counter. The moment you run two replicas, the usual approaches each break one property: - **An in-memory counter per process** produces duplicates. - **A database `SEQUENCE`** is unique and fast, but not transactional: a rolled-back insert still consumes a value, so gaps appear. It is also global, not per tenant and per day. - **A Redis `INCR`** is unique across pods but lives outside the document's transaction, so a failed save leaves a gap, and Redis becomes part of the numbering's correctness. - **Frappe/ERPNext** keeps naming-series counters in a database table updated inside the request's transaction, which is the same family of solution Astra uses. The difference is that Astra's kernel is designed to run as several replicas from the start, so the mechanism has to be proven under concurrent writers. The second half of the problem is deployment. A rolling restart with users online should not log anyone out or lose a click. The video measures how close the current manifest gets. ## What the video shows | Scene | What you see | What it proves | | --- | --- | --- | | 1 · Title | "Two pods, one gapless number sequence." | Framing. | | 2 · Deploy | `kubectl` shows `astra-server` `2/2` ready; the manifest has `replicas: 2`, `RollingUpdate` with `maxUnavailable: 0`, readiness and liveness probes. Other pods: `astra-cdc`, `keycloak`, `postgres-0`, `redis`, `seata-server`. | Two replicas from the start, because tenancy and numbering must be cluster-safe. | | 3 · Parallel | 12 orders created at once, 6 sent directly to each pod. All return `201`. Numbers run `ORD-20261021-00007` to `ORD-20261021-00018` with no duplicates and no skips; which pod got which number is interleaved. | The counter is safe under concurrent writers on different pods. | | 4 · Desk | The Customer Order list in Desk shows the same continuous sequence. | The pattern counts per tenant and per day. | | 5 · Code | `SeriesService`: one upsert, one row lock. | The second pod waits for the first pod's lock; a rollback also rolls back the number. No separate sequence, no Redis. | | 6 · Rollout | `kubectl -n astra rollout restart deployment/astra-server` while a client reads orders every 0.25 s through the ingress. 47 requests: 45 returned `200`, 2 got no response as an old pod stopped. | Nearly uninterrupted. | | 7 · To be accurate | No preStop hook, no drain time. | Not zero downtime: one Desk action may need a retry. | The captured pod-to-number mapping (`docs/video/v10-k8s/assets/parallel-sorted.txt`): ```text astra-server-c6966f6f6-p56xb 201 ORD-20261021-00007 astra-server-c6966f6f6-th87l 201 ORD-20261021-00008 astra-server-c6966f6f6-th87l 201 ORD-20261021-00009 astra-server-c6966f6f6-p56xb 201 ORD-20261021-00010 astra-server-c6966f6f6-p56xb 201 ORD-20261021-00011 astra-server-c6966f6f6-th87l 201 ORD-20261021-00012 astra-server-c6966f6f6-p56xb 201 ORD-20261021-00013 astra-server-c6966f6f6-p56xb 201 ORD-20261021-00014 astra-server-c6966f6f6-th87l 201 ORD-20261021-00015 astra-server-c6966f6f6-p56xb 201 ORD-20261021-00016 astra-server-c6966f6f6-th87l 201 ORD-20261021-00017 astra-server-c6966f6f6-th87l 201 ORD-20261021-00018 ``` The sequence starts at 7 because `make demo-seed` had already created six orders that day. ## How it works ### The deployment *`deploy/k8s/base/astra-server.yaml`* ```yaml spec: replicas: 2 # two pods from the start: tenancy and naming series must be cluster-safe selector: matchLabels: app: astra-server strategy: type: RollingUpdate rollingUpdate: maxUnavailable: 0 maxSurge: 1 # … readinessProbe: httpGet: { path: /q/health/ready, port: 8080 } periodSeconds: 5 livenessProbe: httpGet: { path: /q/health/live, port: 8080 } periodSeconds: 10 resources: requests: { cpu: 250m, memory: 512Mi } limits: { memory: 1536Mi } # ZGC heap is 75 % of this; the Timefold solver needs headroom ``` The same file also defines a `Service`, an `Ingress` (path `/auth` to Keycloak, `/` to the kernel) and a `PodDisruptionBudget` with `minAvailable: 1`. Why two replicas from the start rather than "scale later": anything that only works on one pod (an in-memory counter, a local cache treated as truth, a sticky session) would hide until the first scale-out, usually at the worst moment. Running two pods in the demo forces every feature to be cluster-safe now. The kernel holds no session state: identity comes from the bearer token on each request, the tenant from the request (see [Video 7](/astra-cms/en/tenants)), and documents from PostgreSQL. `maxUnavailable: 0` with `maxSurge: 1` means Kubernetes starts a new pod, waits for it to pass readiness, and only then stops an old one. Capacity never drops below two ready pods by design. The gap that remains is covered under "To be accurate". ### The counter *`astra-doctype/src/main/java/io/astra/doctype/store/SeriesService.java`* ```java /** * Naming series such as {@code ART-.#####} or {@code ORD-.YYYYMMDD.-.#####}: literal parts and date * tokens ({@code YYYY}, {@code YY}, {@code MM}, {@code DD}) separated by dots, then a zero-padded * counter whose width is the number of {@code #}. Counters live per tenant in {@code astra_series}, * keyed by the pattern with its date tokens resolved, so dated series restart every period. The row * lock taken by the upsert makes the sequence gap-free within the caller's transaction and safe across pods. */ // … try (PreparedStatement ps = conn.prepareStatement( "INSERT INTO astra_series (tenant_id, series_key, current) VALUES (?, ?, 1) " + "ON CONFLICT (tenant_id, series_key) DO UPDATE SET current = astra_series.current + 1 " + "RETURNING current")) { ps.setString(1, TenantContext.require()); ps.setString(2, key); try (ResultSet rs = ps.executeQuery()) { rs.next(); return prefix + String.format("%0" + width + "d", rs.getLong(1)); } } ``` The pattern comes from the DocType: *`astra-apps/express/src/main/resources/META-INF/astra/doctypes/customer-order.doctype.yaml`* ```yaml naming: { series: "ORD-.YYYYMMDD.-.#####" } ``` And the counter table, created in the first migration with the same tenant policy as documents: *`astra-doctype/src/main/resources/db/migration/V1__astra_doc.sql`* ```sql -- Cluster-safe naming series, gap-free inside the enclosing transaction. CREATE TABLE astra_series ( tenant_id text NOT NULL, series_key text NOT NULL, current bigint NOT NULL DEFAULT 0, PRIMARY KEY (tenant_id, series_key) ); ``` The key detail is *which connection* `next` runs on. `DocumentStore` calls it with the document's own connection, inside the same transaction that inserts the document: *`astra-doctype/src/main/java/io/astra/doctype/store/DocumentStore.java`* ```java private Document persistNew(Connection conn, DocTypeDefinition def, Document doc) throws SQLException { DocumentValidator.applyDefaultsAndValidate(def, doc); hooks.beforeSave(doc); validateLinks(conn, def, doc); if (doc.name() == null) doc.name(assignName(conn, def, doc)); try (PreparedStatement ps = conn.prepareStatement( "INSERT INTO astra_doc (id, tenant_id, doctype, name, owner, data, amended_from) " + "VALUES (?, ?, ?, ?, ?, ?::jsonb, ?) RETURNING *")) { // … ``` ```mermaid sequenceDiagram participant A as Pod p56xb participant B as Pod th87l participant P as PostgreSQL astra_series A->>P: BEGIN; set_config('astra.tenant', 'saigon-express', true) B->>P: BEGIN; set_config('astra.tenant', 'saigon-express', true) A->>P: INSERT … ON CONFLICT DO UPDATE current+1 RETURNING current P-->>A: 7 (row ORD-20261021-##### locked by A) B->>P: INSERT … ON CONFLICT DO UPDATE current+1 Note over B,P: B waits on A's row lock A->>P: INSERT astra_doc ORD-20261021-00007; COMMIT P-->>B: 8 (lock released, B sees 7) B->>P: INSERT astra_doc ORD-20261021-00008; COMMIT ``` ![Figure 1: Two pods, one counter row](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-10-figure-1.png) *Two kernel pods incrementing a per-tenant counter row in PostgreSQL: row-level locks prevent collisions without in-memory caches.* Why this design: - **Uniqueness** comes from the row lock. `ON CONFLICT … DO UPDATE` locks the counter row; a second transaction incrementing the same key waits until the first commits or rolls back, then sees the new value. - **No gaps** comes from sharing the transaction. If validation, a hook or the insert fails after the number is assigned, the whole transaction rolls back, including the increment, so the next order gets the same number. A database `SEQUENCE` cannot do that. ![Figure 2: Rollback returns the number](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-10-figure-2.png) *Rollback preserves numbering continuity: counter increments commit only when the enclosing business transaction commits.* - **Per tenant, per period** comes from the key. The primary key is `(tenant_id, series_key)` and the key has the date resolved (`ORD-.20261021.-.#####`), so each tenant gets its own row each day and numbering restarts daily without a reset job. The table is also under RLS, so a tenant cannot even read another tenant's counters. - **No extra infrastructure.** Correctness depends on PostgreSQL alone. Redis being slow or down does not affect numbering. The cost is serialization: all creations for one tenant's series on one day queue on one row, and the lock is held from `assignName` until the transaction commits, which includes the insert and any `afterSave` hooks. For a tenant creating a few orders per second this is invisible; see "For technical leaders" for when it is not. ### The rolling restart The captured probe (`docs/video/v10-k8s/assets/rollout-summary.txt`) counts response codes for 47 reads sent every 0.25 s through the ingress during `rollout restart`: ```text 2 000 45 200 ``` `000` means curl received no HTTP response at all. Per the video brief, one of the two hit the probe's 5 s timeout, right as an old pod stopped. The cause is in what the manifest does *not* have: no `preStop` hook and no drain delay. When Kubernetes marks a pod for termination, the pod receives `SIGTERM` at roughly the same time as the ingress controller learns to stop routing to it. Requests that the ingress sends in that window reach a process that is already shutting down. The demo scripts themselves show the client-side mitigation. `scripts/lib/demo-common.sh` retries a request up to 15 times when the ingress answers `502` or `503`, because "the ingress found no ready pod (a rollout is draining), so nothing ran yet". That covers 502/503, not a dropped connection, which is what the probe saw. ![Figure 3: Rolling restart, the gap that remains](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-10-figure-3.png) *Rolling restart with two pods: traffic continues uninterrupted, though inflight requests without preStop drains may briefly drop.* ## Real-world example **A 3PL's 11.11 peak with month-numbered COD remittances.** A courier on Astra runs two kernel pods. During a shopping-festival peak, its Saigon tenant's sales staff and an integration with a marketplace both create orders, and accountants batch COD remittances, which use the series `CODR-.YYMM.-.####` (`astra-apps/express/src/main/resources/META-INF/astra/doctypes/cod-remittance.doctype.yaml`). Inputs: - 08:00:00.100: the marketplace integration posts an order, routed to pod A. - 08:00:00.105: a salesperson saves an order in Desk, routed to pod B. - 08:00:00.110: a third order from pod A passes validation, but an `afterSave` hook throws after the insert. (Validation and `beforeSave` run before `assignName`, so failures there never touch the counter.) - 23:30: the ops lead runs a rolling restart to pick up a configuration change. What Astra does: 1. Pod A's transaction takes the day's counter row and gets `ORD--01234`. Pod B's upsert waits for that row lock, then gets `01235` after A commits. 2. The third order gets `01236` inside its transaction; its `afterSave` hook throws; the transaction rolls back, and the counter goes back to `01235`. The next successful order is `01236`. 3. At 23:30 a new pod starts and becomes ready before an old pod is stopped (`maxSurge: 1`, `maxUnavailable: 0`). A request that lands on the stopping pod in its last moment may fail; the user clicks Save again. Outputs: a continuous order sequence for the day per tenant, no duplicate waybill labels, a COD remittance series that restarts each month without a cron job, and at most a retry for a user caught by the restart. The 01234-style numbers are illustrative; the measured run is the 12-order sequence above. ## For technical leaders - **What is stateless.** The kernel pods: identity per request from Keycloak tokens, tenant per request, data in PostgreSQL. You can add replicas without session affinity. - **What is not.** PostgreSQL (`postgres-0`, a StatefulSet in the demo), Keycloak's database, Redis (TCC reservations) and the Seata server. `deploy/k8s/base/postgres.yaml` says it plainly: "Demo-grade PostgreSQL. Production uses an operator (CloudNativePG, Patroni) or a managed service." - **Numbering throughput ceiling.** One counter row per tenant, series and period. As a rule of thumb, if a creating transaction holds the lock for *t* ms, that series tops out near 1000/*t* creations per second, regardless of pod count. Keep heavy work out of `afterSave` hooks on high-volume DocTypes, or choose a coarser key only if the business accepts it. No load test of this ceiling is published in the repo. - **Gapless has a scope.** Numbers are gapless for committed creations. Cancelled documents keep their number; an amendment gets the cancelled name with a `-1` suffix rather than a new number. There is no document delete endpoint in `DocResource`, so committed numbers do not disappear. - **Time zone.** The date part comes from `LocalDate.now()` in the JVM's default time zone, and `astra-server/src/main/docker/Dockerfile.jvm` does not set one. If day boundaries must follow Vietnam time, set the pod's time zone explicitly. - **Deploy cost today.** Measured: 2 of 47 reads failed during one rolling restart. Before promising zero-downtime deploys, add a `preStop` delay, a termination grace period that covers in-flight requests, and client retries for idempotent calls. - **What to measure.** Lock wait time on `astra_series` (PostgreSQL `pg_stat_activity` wait events), error rate at the ingress during each rollout, readiness time of a new pod, and memory against the 1536 Mi limit. - **When not to use this counter.** Very high-volume, non-fiscal identifiers (event IDs, tracking scans) do not need gapless numbering; UUID naming (the default when no series is set) avoids the lock entirely. ## Try it yourself Prerequisites: JDK 25, Docker with 8 GB of RAM, `kubectl`, `k3d`, `jq`, Bash 5. ```bash make k3d-up # builds images, deploys deploy/k8s/base, waits for rollouts make demo-reset && make demo-seed # clean demo data, including the astra_series counters kubectl -n astra get pods # 2 astra-server pods + astra-cdc, keycloak, postgres-0, redis, seata-server kubectl -n astra get deployment astra-server -o wide ``` Expected `get deployment` output (from `docs/video/v10-k8s/assets/deploy.txt`; age will differ): ```text NAME READY UP-TO-DATE AVAILABLE AGE CONTAINERS IMAGES SELECTOR astra-server 2/2 2 2 7h45m astra-server astra/server:dev app=astra-server ``` Open `http://localhost:8080/desk/`, sign in as `tuan` (password `Astra@123`), and create a few Customer Orders: numbers continue from the seeded ones. To reproduce the parallel run, send concurrent `POST /api/doc/CustomerOrder` requests to each pod separately (for example through one `kubectl port-forward` per pod), using the order body defined as `ORDER` in `scripts/demo-express.sh`. The capture script used for the video is not checked into the repository. Rolling restart with Desk open: ```bash kubectl -n astra rollout restart deployment/astra-server kubectl -n astra rollout status deployment/astra-server ``` Expected: `deployment "astra-server" successfully rolled out`, two new pod names in `kubectl -n astra get pods`, and Desk still usable, with the possibility that one action during the switch needs a retry. Clean up with `make k3d-down`. ## To be accurate - Nearly uninterrupted, not zero downtime. There is no `preStop` hook or drain time in `deploy/k8s/base/astra-server.yaml`, so requests that reach a pod as it stops can fail. Measured run: 2 of 47 requests got no response. - The demo PostgreSQL is a single StatefulSet pod, not a highly available setup. - The design document ([explain.en.md](/astra-cms/en/why-astra)) describes naming series as "Redis counters, gap-free mode for fiscal documents" and kernel rolling upgrades that drain transactions. What is built is the PostgreSQL row-lock counter shown here, used for every series, and a plain rolling update without drain. - No automated test in the repository exercises `SeriesService` under concurrent writers; the evidence is the 12-order capture in `docs/video/v10-k8s/assets/`. - The orders `ORD-20261021-00007` to `00018` were created in the shared demo data; reset and seed before recording later videos. ## Figures - [Figure 1 · Two pods, one counter row](#the-counter) - [Figure 2 · Rollback returns the number](#the-counter) - [Figure 3 · Rolling restart, the gap that remains](#the-rolling-restart) ## Related - Previous: [Video 9 · For developers](/astra-cms/en/developer) · Next: [Video 11 · Keycloak](/astra-cms/en/keycloak) - [Video 4 · Document lifecycle](/astra-cms/en/lifecycle) (cancel and amend keep the original number) · [Video 7 · Multi-tenancy](/astra-cms/en/tenants) (why counters are per tenant) · [Video 8 · Fail-closed security](/astra-cms/en/failclosed) (the roles these pods connect with) - Setup: [demo-video-series.en.md, preparing the recording environment](/astra-cms/en/video-scripts) - Source: `deploy/k8s/base/astra-server.yaml`, `deploy/k8s/base/postgres.yaml`, `scripts/k3d-up.sh`, `astra-doctype/src/main/java/io/astra/doctype/store/SeriesService.java`, `astra-doctype/src/main/java/io/astra/doctype/store/DocumentStore.java` --- # For developers: writing an Astra app an Astra app is plain Java and YAML compiled against one small SPI module (astra-spi); the kernel turns the YAML into storage, REST, Desk and validation, and runs your hooks inside the document's own transaction. Read… 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/developer ▶ [English](https://media.zerohostcms.com/astra-cms/astra-video-9-developer-en.mp4) · [Tiếng Việt](https://media.zerohostcms.com/astra-cms/astra-video-9-developer-vi.mp4) · 2:22 · Script: [demo-video-series.en.md](/astra-cms/en/video-scripts) > **In one sentence:** an Astra app is plain Java and YAML compiled against one small SPI module (`astra-spi`); the kernel turns the YAML into storage, REST, Desk and validation, and runs your hooks inside the document's own transaction. > > **Read this if:** you are going to write business logic on Astra and want to know what you write, what you do not write, and how you test it / you are deciding whether a team can build modules on Astra without touching the kernel, and what the development loop costs. ## The problem Astra Express is a courier company. Its developers need to ship business features every week: a new field the call centre wants on the customer record, a rule that creates a waybill when an order is submitted, a journal entry for the shipping fee. In most stacks each of those touches four layers: a database migration, an entity class, a controller and a form. The waybill and the journal entry also raise a correctness question: if the order is saved but the waybill insert fails, the two documents disagree, and someone reconciles them by hand at month end. Three pains follow from that: - **Schema churn.** Every new field is an `ALTER TABLE` plus a deploy. On a large table that is a lock and a maintenance window. - **Cross-document consistency.** Side effects (waybill, ledger) are usually written in separate calls or in an event handler after commit, so a failure in the middle leaves half a business operation. - **Platform coupling.** If app code can reach anything in the platform, every platform upgrade risks breaking every app. Frappe/ERPNext solved the first problem with metadata (a DocType generates the form and the API) and the third with installable apps and hooks. It still uses one table per DocType and runs DDL when a field is added ([explain.en.md](/astra-cms/en/why-astra)). Astra keeps the DocType idea, stores every DocType in one JSONB table, and gives apps exactly one dependency: `astra-spi`. ## What the video shows The video is a 2:22 guided walkthrough recorded against the Docker Compose stack in dev mode. 1. **Two commands start a kernel with live reload.** `make infra-up`, then `make dev`. k3d already holds port 8080, so dev mode runs on 8081 (`DEV_ARGS=-Dquarkus.http.port=8081`). 2. **The app tree.** `astra-apps/express` contains the `ExpressApp` class, hooks, MCP tools, seven DocType YAML files and two Wasm modules. The app sees only `astra-spi`; the kernel provides the rest. 3. **Hooks join the transaction.** On submit, `OrderWaybillHook` creates the waybill and `OrderPostingHook` posts the journal entry. Both run inside the order's transaction, so the documents never drift apart. 4. **Live edit.** One YAML line, `- { name: zalo_id, type: Data, in-list: true }`, is added to `customer.doctype.yaml`. Dev mode restarts (the recorded log shows `started in 0.731s`) and Desk shows a Zalo id column. No migration, no `ALTER TABLE`: the field lives in JSONB. 5. **Same definition everywhere.** `GET /api/doctype/Customer` already lists `zalo_id` (recorded response: HTTP 200, fields `code, customer_name, segment, phone, address, credit_limit, zalo_id`). 6. **Integration tests against real infrastructure.** The recorded `./mvnw test` run: 44 tests, 0 failures, build success, with containers for PostgreSQL, Seata Server 2.6 and Redis. 7. **To be accurate.** Adding fields is easy; removing them breaks editing of older documents. Safe schema evolution is on the roadmap. What it proves: an app needs no framework of its own, a field change needs no migration, and cross-document side effects are atomic. ## How it works ### An app is a list of DocTypes plus CDI beans The app's entry point declares its id, version and DocType resources. Nothing else is registered by hand. *`astra-apps/express/src/main/java/io/astra/apps/express/ExpressApp.java`* ```java @ApplicationScoped public class ExpressApp implements AstraApp { @Override public String id() { return "io.astra.express"; } @Override public String version() { return "0.1.0"; } @Override public List doctypeResources() { return List.of( "META-INF/astra/doctypes/customer.doctype.yaml", "META-INF/astra/doctypes/customer-order.doctype.yaml", // … "META-INF/astra/doctypes/pricing-rule.doctype.yaml"); } } ``` The SPI explains why the list is explicit rather than scanned: *`astra-spi/src/main/java/io/astra/spi/app/AstraApp.java`* ```java /** * Classpath resource paths of DocType definitions (YAML) this app contributes, * e.g. {@code META-INF/astra/doctypes/article.doctype.yaml}. Explicit listing keeps * loading deterministic inside jars and native images. */ List doctypeResources(); ``` The dependency boundary is enforced by the build, not by convention. The Express module's only compile dependency besides the CDI API is `astra-spi`: *`astra-apps/express/pom.xml`* ```xml io.astra astra-spi jakarta.enterprise jakarta.enterprise.cdi-api provided ``` Why: if the app cannot import kernel classes, the kernel can change its internals (storage, caching, transactions) without breaking apps. The SPI packages in this repository are `app`, `doc`, `doctype`, `hook`, `ledger`, `script`, `tenant`, `tx`, `kv` and `ai` under `astra-spi/src/main/java/io/astra/spi/`. ![Figure 1: What an Astra app contains and what it may depend on](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-09-figure-1.png) *An Astra app consists of DocType YAML definitions and plain CDI beans implementing lifecycle hooks, depending strictly on astra-spi.* ### A hook is one interface with four defaults *`astra-spi/src/main/java/io/astra/spi/hook/DocLifecycleHook.java`* ```java public interface DocLifecycleHook { /** The DocType this hook applies to, e.g. {@code "Article"}. */ String doctype(); /** Runs before a document is inserted or updated, after kernel validation of declared fields. */ default void beforeSave(Document doc) { } /** Runs after the document has been persisted in the same transaction. */ default void afterSave(Document doc) { } /** * Runs when a draft of a submittable DocType is submitted, inside the transaction that writes * {@code docstatus = 1}. The hook may mutate the document and write other documents through * {@link io.astra.spi.doc.DocumentService}; throwing rolls all of it back. */ default void onSubmit(Document doc) { } /** Runs when a submitted document is cancelled, inside the transaction that writes {@code docstatus = 2}. */ default void onCancel(Document doc) { } } ``` The kernel collects every hook bean once and precomputes a chain per DocType (`astra-kernel/src/main/java/io/astra/kernel/extension/HookRegistry.java`), so dispatch on the hot path is a list iteration, not a lookup by name. ### Side effects join the source document's transaction Two hooks on `CustomerOrder` do the cross-module work shown in the video. *`astra-apps/express/src/main/java/io/astra/apps/express/OrderWaybillHook.java`* ```java @Inject DocumentService docs; @Override public void onSubmit(Document order) { Document waybill = docs.insert("Waybill", Map.of( "order", order.name(), "receiver_name", order.getString("receiver_name")), order.owner()); order.set("waybill", waybill.name()); } @Override public void onCancel(Document order) { // … if (!CANCELLABLE.contains(status)) { throw new DocValidationException("cannot cancel " + order.name() + ": waybill " + name + " is already '" + status + "'"); } docs.update("Waybill", name, Map.of("ops_status", "Cancelled")); } ``` *`astra-apps/express/src/main/java/io/astra/apps/express/OrderPostingHook.java`* ```java @Inject GeneralLedger ledger; @Override public void onSubmit(Document order) { BigDecimal fee = BigDecimal.valueOf(Numbers.toDouble(order.get("shipping_fee"))); String customer = order.getString("customer"); ledger.post("CustomerOrder", order.name(), LocalDate.now(), List.of( Entry.debit(Accounts.RECEIVABLE, customer, fee), Entry.credit(Accounts.REVENUE, customer, fee)), "Shipping fee " + order.name()); } @Override public void onCancel(Document order) { ledger.reverse("CustomerOrder", order.name()); } ``` Neither hook opens a transaction. The kernel's submit does, and everything inside it shares the connection: *`astra-doctype/src/main/java/io/astra/doctype/store/DocumentStore.java`* ```java public Optional submit(String doctype, String name) { DocTypeDefinition def = requireSubmittable(doctype); return global(def, "submit " + doctype + " " + name, () -> inTenantTx(conn -> { // … requireDraft(doc); requireCaller(doc, Action.SUBMIT); hooks.onSubmit(doc); DocumentValidator.applyDefaultsAndValidate(def, doc); validateLinks(conn, def, doc); return Optional.of(write(conn, doc, 1)); })); } ``` `inTenantTx` reuses the bound connection when one is already open, which is how `docs.insert(...)` inside a hook joins the caller's transaction instead of starting its own: *`astra-doctype/src/main/java/io/astra/doctype/store/TenantTransactions.java`* ```java public T inTenantTx(SqlWork work) { if (TX.isBound()) { try { return work.run(TX.get()); // … } String tenant = TenantContext.require(); try (Connection conn = dataSource.getConnection()) { conn.setAutoCommit(false); // … SELECT set_config('astra.tenant', ?, true) ``` ```mermaid sequenceDiagram autonumber actor U as tuan (sales) participant K as DocumentStore.submit participant W as OrderWaybillHook participant L as OrderPostingHook participant PG as PostgreSQL (one tx, RLS) U->>K: POST /api/doc/CustomerOrder/ORD-…/submit K->>PG: BEGIN · set_config('astra.tenant', …) Note over K,L: HookRegistry runs every CustomerOrder hook in turn K->>W: onSubmit(order) W->>PG: INSERT Waybill (same connection) K->>L: onSubmit(order) L->>PG: INSERT astra_ledger Dr 131 / Cr 511 K->>PG: UPDATE order docstatus = 1 K->>PG: COMMIT (or ROLLBACK if any step throws) K-->>U: 200 ``` Why this design: atomicity is the cheapest consistency model there is. There is no outbox, no retry and no reconciliation job between order, waybill and ledger, because they are rows in one PostgreSQL transaction. When a side effect crosses databases (BMS credit in another schema, a pickup slot in Redis), `transaction: global` on the DocType wraps the same call in a Seata global transaction; that is Video 17. ![Figure 2: One submit, one transaction](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-09-figure-2.png) *Hooks join the ambient tenant transaction: waybill creation and general ledger postings commit or rollback atomically with the document.* ### A field is one YAML line, stored in JSONB Every DocType shares the `astra_doc` table, and business fields live in its `data jsonb` column, so adding a field is a metadata change, not a schema change. The Customer DocType before the live edit: *`astra-apps/express/src/main/resources/META-INF/astra/doctypes/customer.doctype.yaml`* ```yaml fields: - { name: code, type: Data, required: true, in-list: true, label: Customer code } - { name: customer_name, type: Data, required: true, in-list: true, label: Customer name } - { name: segment, type: Select, options: [Retail, B2B], default: Retail, in-list: true, projected: true } - { name: phone, type: Data, pii: true } - { name: address, type: Text, pii: true } - { name: credit_limit, type: Currency, label: Credit limit (BMS) } # mirrored to bms.customer_credit ``` The video appends `- { name: zalo_id, type: Data, in-list: true }`. Dev mode only restarts on a YAML edit because the DocType files are listed as watched resources: *`astra-server/src/main/resources/application.yml`* ```yaml "%dev": quarkus: # … live-reload: # DocType YAML is read once at startup; without this, dev mode sees the edit but does not restart watched-resources: - META-INF/astra/doctypes/account.doctype.yaml # … - META-INF/astra/doctypes/customer.doctype.yaml ``` Desk has no code that names a DocType; it renders whatever `GET /api/doctype` returns (`astra-server/src/main/resources/META-INF/resources/desk/desk.js`, see [S12](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/demo-code-snippets.md#s12-desk-generated-from-metadata)). That is why the column appears without a frontend change. ![Figure 3: Adding a field without a migration](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-09-figure-3.png) *Adding a field requires only one line of YAML; the value is stored in JSONB with zero ALTER TABLE migrations.* ### Why removing a field is harder than adding one The validator rejects any key in a document that the DocType does not declare: *`astra-doctype/src/main/java/io/astra/doctype/store/DocumentValidator.java`* ```java if (def.field(key).isEmpty()) errors.add("unknown field '" + key + "' for DocType " + def.name()); ``` That strictness is what catches typos and stale clients. Its cost: if you delete `zalo_id` from the YAML, every existing document that still carries `zalo_id` in its JSONB fails validation on its next save. This is the limit the video states at the end. ## Real-world example *Illustrative scenario: the company, people and values are made up; the mechanism and the rules it applies are the real ones shown above.* A Vietnamese 3PL serving e-commerce shops runs order intake and accounting on one system. Month-end close keeps finding orders that were billed but never handed to the warehouse, because the old system created the waybill in a background job after the order commit. On Astra the same flow is the two hooks above. Take one order from the demo's B2B customer C001: Domestic, Express, 2.2 kg, COD 500,000 ₫, standard tariff. | Step | Input | What Astra does | Output | | --- | --- | --- | --- | | Save draft | the order body | `ShippingFeeHook.beforeSave` validates phone and weight, prices the parcel | `shipping_fee = 73,000`, `priced_by = Standard tariff` | | Submit | `POST …/submit` | one transaction: waybill insert, ledger post, `docstatus = 1` | waybill `WB-…`, entries Dr 131 73,000 / Cr 511 73,000 | | Submit fails midway | e.g. the waybill insert throws | the whole transaction rolls back | order stays Draft, no waybill, no ledger rows | | Cancel early | waybill still `Created` or `Picked Up` | waybill set to `Cancelled`, ledger posts mirror entries | Dr 511 / Cr 131 reversal; old rows unchanged | | Cancel late | waybill already `At Hub` | `onCancel` throws `DocValidationException` | 422 `cannot cancel ORD-…: waybill WB-… is already 'At Hub'`; nothing changes | The fee arithmetic (from Video 3): 30,000 + 3 × 5,000 = 45,000; × 1.5 Express = 67,500; + COD fee max(5,000; 1 %) = 72,500; rounded up to the thousand = 73,000. When the call centre later asks for a Zalo id on each customer (Zalo is the messaging app most Vietnamese customers use), it is one YAML line and one rolling deploy of the app image; no migration window. ## For technical leaders - **What a developer writes.** A DocType YAML file per entity, CDI beans implementing SPI interfaces (hooks, MCP tools, TCC participants), and integration tests. No controllers, no entities, no migrations, no form code. - **Boundary enforcement.** Apps compile against `astra-spi` only. This is the main lever for keeping platform upgrades cheap; review any request to widen it. - **Consistency model.** Same-database side effects are atomic by construction. Cross-store effects need `transaction: global` (Seata), which adds a coordinator and its operational cost. Do not reach for it when one PostgreSQL transaction suffices. - **Hook order.** `HookRegistry` groups hooks per DocType from CDI discovery and declares no ordering, so hooks on the same DocType should not depend on each other's side effects. - **Dev loop.** Edit YAML or Java, dev mode restarts (0.73 s in the recording). The full test suite needs Docker because it starts real PostgreSQL, Seata and Redis containers; budget CI minutes accordingly. - **Field removal is a known gap.** Adding is free; removing or renaming breaks edits of old documents until safe schema evolution ships. Treat removals as a data migration you own. - **Deploying an app change.** An Express app change is a Tier 0 change: it is compiled into the server image and rolled out like any image (Video 10). Per-tenant changes without a deploy use overlays (Video 13) and Wasm (Video 14). - **When not to use this.** If your team needs to customise kernel behaviour itself (storage, transactions) rather than business rules, the SPI will feel narrow. If your data model is mostly relational analytics rather than documents with a lifecycle, a conventional schema may fit better. - **What to measure.** Dev-mode restart time, test-suite wall time, number of hooks per DocType (the chain grows on every save), and 422 rates after a DocType change. ## Try it yourself Prerequisites: JDK 25, Docker Desktop, `jq`, Bash 5. See the [recording environment](/astra-cms/en/video-scripts). ```bash make infra-up && make build # PostgreSQL :5432, Redis, Keycloak :8180 make dev DEV_ARGS=-Dquarkus.http.port=8081 # dev mode with live reload on 8081 BASE=http://localhost:8081 make demo-seed # demo tenants and data, through the REST API open http://localhost:8081/desk/ # log in as mai / Astra@123 ``` Then add this line under `fields:` in `astra-apps/express/src/main/resources/META-INF/astra/doctypes/customer.doctype.yaml`: ```yaml - { name: zalo_id, type: Data, in-list: true } ``` Expected: the dev-mode log prints a restart line like `started in 0.731s. Listening on: http://localhost:8081`, and the Customer list in Desk shows a `zalo_id` column after a refresh. Undo the edit and reset the demo data afterwards; the reset command for the Compose stack is `scripts/demo-reset.sh compose`, then seed again. Run the test suite (Docker required; Dev Services starts PostgreSQL): ```bash make test ``` The recorded run reported `Tests run: 44, Failures: 0, Errors: 0, Skipped: 0` and `BUILD SUCCESS`. The repository has gained tests since then (HRM, STMS, SCIM), so the count you see will be higher. The tests the video names are `astra-server/src/test/java/io/astra/server/ExpressFlowTest.java`, `LedgerTest.java` and `SeataTest.java`. ## To be accurate - Removing a field from a DocType makes older documents that still have that field impossible to edit (the validator rejects unknown fields). Safe schema evolution is on the roadmap. After the live edit, run `make demo-reset && make demo-seed` (or the Compose reset above). - Live reload watches only the DocType files listed in `application.yml`; a new DocType file must be added there to trigger a dev-mode restart. - The test count in the video (44) is the count at recording time. - Tier 1 (isolated JVM apps, hot-swapped at runtime) is described in [explain.en.md](/astra-cms/en/why-astra) but is not built. Every app in this repository is Tier 0, compiled into the server image. ## Figures - [Figure 1 · What an Astra app contains and what it may depend on](#an-app-is-a-list-of-doctypes-plus-cdi-beans) - [Figure 2 · One submit, one transaction](#side-effects-join-the-source-documents-transaction) - [Figure 3 · Adding a field without a migration](#a-field-is-one-yaml-line-stored-in-jsonb) ## Related - Previous: [Video 8 · Fail-closed security](/astra-cms/en/failclosed) · Next: [Video 10 · Running on Kubernetes](/astra-cms/en/k8s) - [Video 2 · DocType](/astra-cms/en/doctype), [Video 3 · Hooks](/astra-cms/en/hooks), [Video 4 · Lifecycle](/astra-cms/en/lifecycle), [Video 12 · Ledger](/astra-cms/en/ledger), [Video 17 · Seata](/astra-cms/en/seata) - Code excerpts: [S2 An app and a hook](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/demo-code-snippets.md#s2-an-app-and-a-hook), [S12 Desk generated from metadata](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/demo-code-snippets.md#s12-desk-generated-from-metadata) - Source: `astra-apps/express/`, `astra-spi/src/main/java/io/astra/spi/`, `astra-doctype/src/main/java/io/astra/doctype/store/DocumentStore.java`, `astra-kernel/src/main/java/io/astra/kernel/extension/HookRegistry.java`, `astra-server/src/test/java/io/astra/server/ExpressFlowTest.java` --- # Tenant overlays a tenant overlay is a small YAML file, stored per tenant under row-level security, that adds fields to an app's DocType or adjusts a safe subset of field properties, and takes effect at once for that tenant only… 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/overlay ▶ [English](https://media.zerohostcms.com/astra-cms/astra-video-13-overlay-en.mp4) · [Tiếng Việt](https://media.zerohostcms.com/astra-cms/astra-video-13-overlay-vi.mp4) · 2:24 · Script: [demo-video-series.en.md](/astra-cms/en/video-scripts) > **In one sentence:** a tenant overlay is a small YAML file, stored per tenant under row-level security, that adds fields to an app's DocType or adjusts a safe subset of field properties, and takes effect at once for that tenant only, without forking or redeploying the app. > > **Read this if:** you need to know exactly what an overlay can change, how the kernel merges it, and why some changes are refused / you run one product for many companies and want per-customer configuration without per-customer code branches. ## The problem Astra Express runs two tenants on the same Express app: `saigon-express` and `hanoi-express`. Hanoi has requirements Saigon does not. Its B2B shippers ask for a VAT invoice on each order, it delivers to islands (Cát Bà, Cô Tô), and its dispatchers want a delivery note on every order. Those requirements come straight from the comment at the top of the real overlay file, `tenants/hanoi-express/overlays/customer-order.overlay.yaml`. The usual options are all expensive: - **Fork the app** for Hanoi. Every upstream fix must now be merged twice, and upgrades stall. - **Add every tenant's fields to the shared app**, guarded by feature flags. The shared DocType grows fields most tenants never use, and Saigon users see or can submit fields that mean nothing to them. - **A generic "extra attributes" key-value table.** Flexible, but the fields are invisible to validation, forms and APIs unless every layer special-cases them. Frappe/ERPNext answered this with Custom Fields and Property Setters: per-site customisations that survive upgrades ([explain.en.md](/astra-cms/en/why-astra)). Astra borrows that idea and changes two things. The overlay is a per-tenant row in a shared database rather than per-site schema changes, and because business fields live in JSONB, adding a field runs no DDL. ## What the video shows The video is 2:24. 1. **The overlay file.** Hanoi's YAML adds VAT invoice, tax code and invoice email fields, adds an `Island` zone, and makes the delivery note required. The tenant admin applies it through the overlay API; the file lives in Git. The Express app does not change a single line. 2. **Two tenants, one form.** `compare.html` shows the same New Customer Order screen for both tenants. Hanoi's added and modified fields carry a `✚ tenant overlay` or `✎ tenant overlay` tag; Saigon is unchanged. 3. **Validation is per tenant.** Hanoi accepts an Island order with a VAT invoice. Saigon refuses the same body: unknown fields, invalid zone. Hanoi refuses an order without a note. Form, API and validation read one effective definition. 4. **Live edit in Desk.** `mai`, a Saigon admin, adds a `gift_wrap` field in the overlay editor and saves. It takes effect at once: no restart, no migration. Other pods pick up the change within two seconds. (The overlay is removed afterwards so later recordings show Saigon without an overlay.) 5. **Guardrails.** Changing a field type returns 422. A sales user (`tuan`) editing the overlay gets 403. 6. **For developers.** Overlays are tenant data in their own table, under the same row-level security as documents. 7. **To be accurate.** Overlays add fields and edit some properties. No hooks or permissions yet, no field removal. ## How it works ### The overlay format *`tenants/hanoi-express/overlays/customer-order.overlay.yaml`* ```yaml # Hanoi Express only: B2B shippers ask for a VAT invoice on each order, deliveries reach islands # (Cát Bà, Cô Tô), and dispatchers want delivery notes on every order. The Express app is untouched; # applied with: PUT /api/overlay/CustomerOrder (tenant admin), see scripts/demo-seed.sh. doctype: CustomerOrder fields: add: - { name: vat_invoice, type: Check, label: "Xuất hóa đơn VAT", in-list: true } - { name: tax_code, type: Data, label: "Mã số thuế" } - { name: invoice_email, type: Data, label: "Email nhận hóa đơn" } modify: - { name: zone, options: [Same city, Domestic, Remote, Island] } - { name: note, label: "Ghi chú giao hàng", required: true } ``` Two verbs, `add` and `modify`. The base fields being modified are declared by the app: *`astra-apps/express/src/main/resources/META-INF/astra/doctypes/customer-order.doctype.yaml`* ```yaml - { name: zone, type: Select, options: [Same city, Domestic, Remote], default: Domestic, in-list: true, projected: true } # … - { name: note, type: Text } ``` ### Merge rules: what an overlay may and may not do All the rules live in one pure function. *`astra-doctype/src/main/java/io/astra/doctype/registry/Overlays.java`* ```java public final class Overlays { private static final Set MODIFIABLE = Set.of("name", "label", "required", "options", "default", "in-list"); /** Returns the effective DocType; throws {@link IllegalArgumentException} with a readable reason for a bad overlay. */ public static DocTypeDefinition apply(DocTypeDefinition base, String yaml) { // … parse YAML, check the doctype name matches List fields = new ArrayList<>(base.fields()); for (JsonNode m : root.path("fields").path("modify")) { String name = m.path("name").asText(""); int i = indexOf(fields, name); if (i < 0) throw new IllegalArgumentException("cannot modify unknown field '" + name + "' of " + base.name()); m.fieldNames().forEachRemaining(k -> { if (!MODIFIABLE.contains(k)) throw new IllegalArgumentException("field property '" + k + "' cannot be changed by an overlay"); }); FieldDefinition f = fields.get(i); fields.set(i, new FieldDefinition(f.name(), f.type(), m.hasNonNull("label") ? m.get("label").asText() : f.label(), m.has("required") ? m.get("required").asBoolean() : f.required(), f.readOnly(), // … in-list, projected, options, default, pii } for (JsonNode a : root.path("fields").path("add")) { // … parse the field definition if (indexOf(fields, f.name()) >= 0) throw new IllegalArgumentException("field '" + f.name() + "' already exists in " + base.name()); if (f.projected() || f.readOnly()) { throw new IllegalArgumentException("overlay field '" + f.name() + "' cannot be projected or read-only"); } fields.add(f); } return new DocTypeDefinition(base.name(), base.module(), base.kind(), base.submittable(), base.naming(), fields, base.permissions(), base.segregationOfDuties(), base.globalTransaction()); } ``` Why each rule exists: | Rule | Reason | | --- | --- | | Only `label`, `required`, `options`, `default`, `in-list` can be modified | These change what users see and must enter. They do not change what the app's Java code reads. | | `type` cannot change | App hooks read fields with typed accessors (`ShippingFeeHook` reads `weight_kg` as a number). Retyping would break the app, and documents already stored with the old type. | | `read-only`, `projected` cannot be set | `read-only` fields are reserved for hooks (for example `shipping_fee`). `projected` creates an expression index on the shared `astra_doc` table, which a tenant should not trigger. | | No removal | Documents written before the overlay must stay valid, and the app may depend on every field it declared. | | `permissions`, `submittable`, naming, `transaction: global` copied from the base | Security and lifecycle stay the app's decision. | The class comment summarises it: an overlay "may not remove or retype fields, so documents written before the overlay stay valid and the app's own code keeps working". ![Figure 1: Base DocType plus overlay equals the tenant's effective DocType](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-13-figure-1.png) *Base DocType plus overlay equals the tenant's effective DocType: custom fields merge seamlessly without touching app code.* ### Storage: tenant data under row-level security *`astra-doctype/src/main/resources/db/migration/V4__overlays.sql`* ```sql CREATE TABLE astra_overlay ( tenant_id text NOT NULL, doctype text NOT NULL, yaml text NOT NULL, version integer NOT NULL DEFAULT 1, updated_by text NOT NULL, updated_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY (tenant_id, doctype) ); ALTER TABLE astra_overlay ENABLE ROW LEVEL SECURITY; ALTER TABLE astra_overlay FORCE ROW LEVEL SECURITY; CREATE POLICY astra_overlay_tenant_isolation ON astra_overlay USING (tenant_id = current_setting('astra.tenant', true)) WITH CHECK (tenant_id = current_setting('astra.tenant', true)); ``` Why a table and not files on disk: overlays are tenant configuration, so they get the same isolation as tenant documents (Video 7). A query in Hanoi's scope cannot see Saigon's overlay even if the code forgot a `WHERE` clause. The primary key allows one overlay per tenant and DocType; every save increments `version` and records `updated_by`. The YAML file under `tenants/` is the Git copy; the seed script applies it through the API (`scripts/demo-seed.sh`). ### The effective DocType, cached for two seconds Every place in the kernel that needs a DocType asks for "the DocType as this tenant sees it": *`astra-doctype/src/main/java/io/astra/doctype/registry/DocTypeOverlays.java`* ```java static final long TTL_MILLIS = 2_000; /** The effective DocType for the tenant in scope, or the app's definition outside a tenant scope. */ public DocTypeDefinition effective(String doctype) { DocTypeDefinition base = registry.require(doctype); Optional tenant = TenantContext.current(); if (tenant.isEmpty()) return base; String key = tenant.get() + "|" + doctype; Cached c = cache.get(key); if (c != null && System.currentTimeMillis() - c.loadedAt() < TTL_MILLIS) return c.def(); DocTypeDefinition def = base; Optional> overlay = get(doctype); if (overlay.isPresent()) { try { def = Overlays.apply(base, (String) overlay.get().get("yaml")); } catch (IllegalArgumentException e) { // Stored overlays are validated on write; one that no longer fits a newer app version is skipped. LOG.warnf("Overlay of %s for tenant %s ignored: %s", doctype, tenant.get(), e.getMessage()); } } cache.put(key, new Cached(def, System.currentTimeMillis())); return def; } ``` `DocumentStore` calls `overlays.effective(doctype)` for list, insert, update and validation (`astra-doctype/src/main/java/io/astra/doctype/store/DocumentStore.java`), and `GET /api/doctype` serves the same result to Desk and API clients. That single call site is why form, list, REST and validation cannot disagree. Why a 2-second TTL: the pod that handles the `PUT` evicts its own cache entry immediately, so the editor sees the change at once. Other pods have no invalidation channel; they re-read within `TTL_MILLIS`. That trades a bounded delay for having no message bus in the kernel. Why "skip with a warning" on read: an overlay was valid when saved, but a later app version might remove or rename a field it modifies. The kernel then serves the app's base definition and logs a warning instead of failing every request for that tenant. ```mermaid sequenceDiagram autonumber actor A as mai (admin, saigon-express) participant R as OverlayResource participant O as DocTypeOverlays participant PG as astra_overlay (RLS) participant P2 as other kernel pod A->>R: PUT /api/overlay/CustomerOrder (application/yaml) R->>R: requireAdmin → 403 for non-admin R->>O: put(doctype, yaml, user) O->>O: Overlays.apply(base, yaml) → 422 if unsafe O->>PG: INSERT … ON CONFLICT DO UPDATE version + 1 O->>O: evict local cache (tenant|doctype) R-->>A: 200 {version, added, modified} Note over P2: cache entry expires within 2 s,
next request re-reads and re-applies ``` ![Figure 3: Saving an overlay across two pods](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-13-figure-3.png) *Saving an overlay across two pods: the 2-second cache TTL guarantees near-instant propagation without cluster cache sync overhead.* ### Who may change an overlay *`astra-doctype/src/main/java/io/astra/doctype/api/OverlayResource.java`* ```java /** * Per-tenant DocType overlays. Every member may read them (they shape the forms they use); only the * tenant's {@code admin} role may change them. Bodies are YAML ({@code application/yaml} or text). */ @Path("/api/overlay") // … @PUT @Path("/{doctype}") @Consumes({"application/yaml", "text/yaml", MediaType.TEXT_PLAIN}) public Map put(@PathParam("doctype") String doctype, String yaml) { requireAdmin("change the overlay of " + doctype); return scoped(() -> overlays.put(doctype, yaml, tenant.userId())); } ``` The role check reads roles from the Keycloak token for the current tenant (Video 11), so `admin` in Hanoi grants nothing in Saigon. ## Real-world example *Illustrative scenario: the company, people and values are made up; the mechanism and the rules it applies are the real ones shown above.* A regional courier in northern Vietnam serves B2B shippers in Hanoi and delivers to Hạ Long Bay islands. Finance needs a tax code on any order that requires a VAT invoice; dispatch needs a note on every order. The courier licenses the same Express app as a southern operator that has none of these rules. The behaviour below is what `astra-server/src/test/java/io/astra/server/OverlayTest.java` and scene 11 of `scripts/demo-express.sh` assert. | Request | Tenant | Result | | --- | --- | --- | | `PUT /api/overlay/CustomerOrder` with the Hanoi YAML, as admin | hanoi | 200, `version: 1` | | `GET /api/doctype/CustomerOrder` | hanoi | fields include `tax_code`; `zone` options include `Island`; `note.required = true` | | same | saigon | no `tax_code`; original options | | order with `zone: Island`, `vat_invoice: true`, `tax_code: 0100109106`, `note: Giao trước 10h` | hanoi | 201 | | same body | saigon | 422: `unknown field 'tax_code' for DocType CustomerOrder`, and the zone is not one of the allowed options | | order without `note` | hanoi | 422: `field 'note' is required` | | overlay modifying `weight_kg` to `type: Data` | hanoi | 422: `field property 'type' cannot be changed by an overlay` | | overlay adding `gift_wrap`, as `tuan` (sales) | saigon | 403 | ![Figure 2: Same request, two tenants, two answers](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-13-figure-2.png) *Same request, two tenants, two answers: Saigon processes standard orders, while Hanoi enforces VAT and island shipping fields.* What the regional courier gets: no fork, no schema change, and its rules are enforced on every channel (Desk, REST, MCP), because all three validate against the effective definition. One thing the overlay alone does not do: price island deliveries differently. The standard tariff in `ShippingFeeHook` has no `Island` entry and falls back to the Domestic rate (`TARIFF.getOrDefault(zone, TARIFF.get("Domestic"))`). A tenant that needs an island price uses a Tier 2 pricing rule, whose ABI already maps `Island` to zone 3; see [Video 14](/astra-cms/en/wasm). ## For technical leaders - **Customisation without divergence.** Each tenant's difference is a few lines of YAML, versioned in a table and in Git, not a branch. Upgrading the app upgrades every tenant. - **Bounded power by design.** Overlays can add fields and change presentation and mandatory-ness. They cannot change types, security, lifecycle or indexing. This keeps the blast radius of a tenant admin's mistake to that tenant's forms and validation. - **Consistency window.** A change is immediate on the pod that saved it and visible on other pods within 2 seconds. For configuration that is acceptable; do not use overlays for anything that needs cluster-wide atomic switch-over. - **Upgrade risk.** If an app release removes or renames a field an overlay modifies, the kernel silently falls back to the base definition for that tenant and logs `Overlay of … ignored`. Monitor that log line after every app deploy; there is no automated pre-rollout conformance check yet. - **Operational cost.** One extra cached lookup per DocType per tenant per 2 seconds per pod. No DDL, no deploys. - **When not to use overlays.** When a tenant needs different behaviour (hooks, workflow, permissions), not just different fields; that is not supported yet. When a field should be indexed for filtering at scale; overlay fields cannot be projected. - **What to measure.** Number of tenants with overlays, fields added per overlay, 422 rate per tenant after an overlay change, and occurrences of the "ignored" warning. ## Try it yourself Prerequisites: the k3d stack from [the recording environment](/astra-cms/en/video-scripts) (`make k3d-up`), then a fresh seed. The seed applies Hanoi's overlay through the API. ```bash make demo-reset && make demo-seed make demo-express SCENES=11 # add PAUSE=0 to run without pressing Enter ``` Expected: scene 11 prints the overlay file, then `GET /api/doctype/CustomerOrder` for both tenants (Hanoi shows `vat_invoice`, `tax_code`, `invoice_email`, `Island` and a required `note`; Saigon does not), a 201 for the Island VAT order in hanoi-express, a 422 for the same body in saigon-express, a 422 for the order without a note, a 422 for the type change and a 403 for `tuan`. In the browser: 1. Log in as `demo` / `Astra@123` at `http://localhost:8080/desk/`, then open `http://localhost:8080/desk/compare.html` and go to `#/new/CustomerOrder`. 2. Log in as `mai`, open DocType metadata → CustomerOrder → Tenant overlay, add `- { name: gift_wrap, type: Check }` under `fields: add:`, and click Save overlay. Click Remove overlay when you are done. Run the overlay tests alone (Docker required) with Maven's standard test filter, for example `./mvnw -pl astra-server -am test -Dtest=OverlayTest -Dsurefire.failIfNoSpecifiedTests=false`. ## To be accurate - Overlays add fields and modify `label`, `required`, `options`, `default` and `in-list`. They do not add hooks or permissions yet, and they cannot remove an app's fields. - Overlay fields cannot be `projected` or `read-only`. - Other pods see a change within 2 seconds, not instantly. - An overlay that no longer fits a newer app version is skipped with a log warning; there is no conformance check before rollout yet. - [overlay-wasm.md](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/overlay-wasm.md) describes a broader target design (`add-permissions`, Wasm `scripts` attached to an overlay, an upgrade conformance check). Those parts are not built. ## Figures - [Figure 1 · Base DocType plus overlay equals the tenant's effective DocType](#merge-rules-what-an-overlay-may-and-may-not-do) - [Figure 2 · Same request, two tenants, two answers](#real-world-example) - [Figure 3 · Saving an overlay across two pods](#the-effective-doctype-cached-for-two-seconds) ## Related - Previous: [Video 12 · Append-only ledger](/astra-cms/en/ledger) · Next: [Video 14 · Tier 2 Wasm](/astra-cms/en/wasm) - [Video 2 · DocType](/astra-cms/en/doctype), [Video 7 · Multi-tenancy](/astra-cms/en/tenants), [Video 9 · Writing an Astra app](/astra-cms/en/developer), [Video 11 · Keycloak](/astra-cms/en/keycloak) - Code excerpts: [S7 Tenant overlays](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/demo-code-snippets.md#s7-tenant-overlays) - Background (target design, partly built): [overlay-wasm.md](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/overlay-wasm.md), [explain.en.md](/astra-cms/en/why-astra) - Source: `astra-doctype/src/main/java/io/astra/doctype/registry/Overlays.java`, `astra-doctype/src/main/java/io/astra/doctype/registry/DocTypeOverlays.java`, `astra-doctype/src/main/java/io/astra/doctype/api/OverlayResource.java`, `astra-doctype/src/main/resources/db/migration/V4__overlays.sql`, `tenants/hanoi-express/overlays/customer-order.overlay.yaml`, `astra-server/src/test/java/io/astra/server/OverlayTest.java` --- # Tier 2 Wasm: each tenant's own shipping formula a tenant uploads its own shipping-fee formula as a WebAssembly module inside an ordinary PricingRule document; the kernel test-runs it in a pure-Java sandbox on save, and once switched on it prices orders with a 1 MiB… 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/wasm ▶ [English](https://media.zerohostcms.com/astra-cms/astra-video-14-wasm-en.mp4) · [Tiếng Việt](https://media.zerohostcms.com/astra-cms/astra-video-14-wasm-vi.mp4) · 2:20 · Script: [demo-video-series.en.md](/astra-cms/en/video-scripts) > **In one sentence:** a tenant uploads its own shipping-fee formula as a WebAssembly module inside an ordinary `PricingRule` document; the kernel test-runs it in a pure-Java sandbox on save, and once switched on it prices orders with a 1 MiB memory cap, no host access and a hard deadline, without a deploy. > > **Read this if:** you want to know the exact ABI, sandbox limits and failure behaviour before writing a tenant script / you need to let customers (or an AI) supply business logic and must know what that code can and cannot do to the platform. ## The problem Shipping prices change constantly. Astra Express's Saigon tenant wants a Tết promotion for its B2B shippers: 20 % off outside the city, free same-city delivery up to 2 kg, Express at +30 % instead of +50 %, and a 0.8 % COD fee (the header comment of `astra-apps/express/wasm/tet-promo/src/lib.rs`). Hanoi will want a different promotion next month. The usual options: - **Hard-code each tenant's formula in the app.** Every promotion is a release of the shared app, and the app fills up with `if tenant == …` branches. - **A rules table or expression language.** Safe, but every new kind of rule needs a new column or operator, and real formulas (stepped weights, rounding, caps) outgrow it quickly. - **Run tenant scripts in the server's own language.** Flexible, but a script can loop forever, allocate the heap or call anything on the classpath. One bad script takes down the pod for every tenant. Frappe/ERPNext has Server Scripts: Python run in a restricted interpreter. Astra's design notes describe the same idea "with a real sandbox instead of a restricted interpreter" ([explain.en.md](/astra-cms/en/why-astra)); Shopify Functions, which runs merchant logic as Wasm, is cited there as a reference point. The approach: compile the formula to WebAssembly and run it in a sandbox that cannot reach anything but its own memory. ## What the video shows The video is 2:20. 1. **The formula is a pure Rust function.** It takes weight, zone, service and COD and returns a fee. It compiles to 421 bytes: no standard library, no I/O, only computation. The tenant uploads the file like any other document. 2. **Saving is a test run.** In a Pricing Rule, saving runs the module in the sandbox. The hook prices a sample order; a module that fails to run is never saved. 3. **Rule off, rule on.** The same B2B order (C001, Domestic, Express, 2.2 kg, COD 500,000): rule off, 73,000 ₫. Rule on, with no deploy or restart: 51,000 ₫. By hand: 45,000, minus 20 %, plus 30 % for Express, plus the COD fee, rounded up to 51,000. 4. **The order says who priced it.** Desk shows 51,000 ₫ "by rule one, Wasm" (`priced_by` is `PRL-001 … (Wasm)`). The hook sets the fee and its source; users cannot edit them. 5. **A hostile script.** An infinite loop, uploaded as a new rule, is rejected after 50 ms and never switched on: `module rejected (timeout): script exceeded its 50 ms deadline and was stopped`. The next order is priced normally. 6. **The limits.** Chicory runs WebAssembly in pure Java. 1 MiB memory cap, no host imports, no I/O or network, a fresh instance per call. Each rule sets a deadline, at most 200 ms. 7. **To be accurate.** Chicory cannot count instructions yet, so CPU is capped by time. Tier 1 is on the roadmap. ![Figure 3: A runaway script is stopped and never goes live](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-14-figure-3.png) *A runaway script is stopped and never goes live: verification test runs terminate infinite loops before saving the rule.* ## How it works ```mermaid flowchart LR R["tet-promo/src/lib.rs"] -->|scripts/build-wasm.sh| W["tet-promo.wasm · 421 bytes"] W -->|"POST /api/doc/PricingRule (module_wasm, base64)"| P["PricingRule document"] P -->|"PricingRuleHook.beforeSave: self-test"| S["WasmSandbox (Chicory 1.7.5)"] S -->|"422 if invalid / trap / timeout"| X["not saved"] S -->|"ok: self_test, module_sha256, module_bytes"| P O["CustomerOrder save"] -->|"ShippingFeeHook: active rule for segment?"| S S -->|"fee(weight_g, zone, express, cod)"| F["shipping_fee + priced_by"] ``` ![Figure 1: From Rust source to a priced order](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-14-figure-1.png) *From Rust source to a priced order: compiled WebAssembly rules evaluate custom shipping formulas within the order write path.* ### The tenant's code: one exported function, no imports *`astra-apps/express/wasm/tet-promo/src/lib.rs`* ```rust #![no_std] const ZONE_BASE: [i64; 4] = [18_000, 30_000, 45_000, 60_000]; // first kg, per zone const ZONE_STEP: [i64; 4] = [2_500, 5_000, 7_000, 9_000]; // each further 500 g #[unsafe(no_mangle)] pub extern "C" fn fee(weight_g: i64, zone: i64, express: i64, cod: i64) -> i64 { let z = zone.clamp(0, 3) as usize; if z == 0 && weight_g <= 2_000 && express == 0 { return cod_fee(cod); } let extra_steps = ((weight_g - 1_000).max(0) + 499) / 500; let mut fee = ZONE_BASE[z] + extra_steps * ZONE_STEP[z]; if z > 0 { fee = fee * 80 / 100; } if express != 0 { fee = fee * 130 / 100; } round_up_thousand(fee + cod_fee(cod)) } fn cod_fee(cod: i64) -> i64 { if cod <= 0 { 0 } else { (cod * 8 / 1_000).max(4_000) } } ``` The ABI is documented in the workspace manifest: *`astra-apps/express/wasm/Cargo.toml`* ```toml # Tenant pricing scripts for the Express demo, compiled to WebAssembly and run by Astra's Tier 2 sandbox. # ABI: export `fee(weight_g: i64, zone: i64, express: i64, cod: i64) -> i64` returning VND; # zone is 0 = Same city, 1 = Domestic, 2 = Remote, 3 = Island. Build with scripts/build-wasm.sh. ``` Why integers only: `i64` in, `i64` out means no shared memory, no serialisation and no host functions are needed to pass arguments. Weight is in grams and money is in whole đồng, so integer arithmetic is exact. `#![no_std]` keeps the module at 421 bytes and guarantees it cannot link to any OS facility. One build detail matters because of the memory cap: *`astra-apps/express/wasm/.cargo/config.toml`* ```toml # Tier 2 caps a script's linear memory (16 pages = 1 MiB by default). Rust reserves a 1 MiB stack # unless told otherwise, which alone would exceed the cap; pricing scripts need only a few KiB. [target.wasm32-unknown-unknown] rustflags = ["-C", "link-arg=-zstack-size=32768"] ``` The hostile counterpart is ten lines: *`astra-apps/express/wasm/runaway/src/lib.rs`* ```rust #[unsafe(no_mangle)] pub extern "C" fn fee(weight_g: i64, _zone: i64, _express: i64, _cod: i64) -> i64 { let mut i = weight_g; loop { i = core::hint::black_box(i.wrapping_add(1)); } } ``` ### The sandbox: fresh instance, capped memory, no imports, a deadline The kernel exposes Tier 2 to apps through one SPI method: *`astra-spi/src/main/java/io/astra/spi/script/ScriptRuntime.java`* ```java public interface ScriptRuntime { /** * Calls an exported function that takes and returns {@code i64} values. * * @throws ScriptException when the module is invalid, traps, exceeds its memory limit or misses its deadline */ long callLong(byte[] module, String function, long timeoutMillis, long... args); } ``` The implementation: *`astra-plugin-host/src/main/java/io/astra/pluginhost/WasmSandbox.java`* ```java @ConfigProperty(name = "astra.scripts.max-memory-pages", defaultValue = "16") int maxPages; @ConfigProperty(name = "astra.scripts.max-timeout-ms", defaultValue = "200") long maxTimeoutMillis; private final ExecutorService threads = Executors.newVirtualThreadPerTaskExecutor(); @Override public long callLong(byte[] module, String function, long timeoutMillis, long... args) { WasmModule wasm = parse(module); long deadline = Math.min(Math.max(timeoutMillis, 1), maxTimeoutMillis); // … Future call = threads.submit(() -> instantiate(wasm).export(function).apply(args)); try { long[] result = call.get(deadline, TimeUnit.MILLISECONDS); // … exactly one i64 or "invalid" return result[0]; } catch (TimeoutException e) { call.cancel(true); // interrupts the interpreter loop, which stops at its next check // … throw new ScriptException("timeout", "script exceeded its " + deadline + " ms deadline and was stopped", e); } catch (ExecutionException e) { // … ScriptException("trap", "script failed: " + cause.getMessage(), cause) } } private Instance instantiate(WasmModule wasm) { // … declared initial pages return Instance.builder(wasm) .withMemoryLimits(new MemoryLimits(declaredInitial, maxPages)) .build(); } /** Parses and vets a module once per content hash. */ private WasmModule parse(byte[] module) { // … return parsed.computeIfAbsent(sha256(module), k -> { // … Parser.parse(module), else "not a WebAssembly module" if (wasm.importSection().importCount() > 0) { throw new ScriptException("invalid", "module imports host functions; Tier 2 scripts must be self-contained", null); } // … initial pages > maxPages → "invalid" return wasm; }); } ``` Why each control: | Control | What it prevents | | --- | --- | | Chicory, pure Java, no JNI | Native code crashing the JVM. The module cannot address anything outside its linear memory. | | Imports refused | Any I/O, network, clock or host callback. The module can compute and nothing else. | | `MemoryLimits(declaredInitial, 16)` | Memory exhaustion. 16 pages × 64 KiB = 1 MiB per instance. | | Fresh `Instance` per call | State leaking between calls or between tenants. | | Own virtual thread, `call.get(deadline)`, `cancel(true)` | A loop holding the request thread. Deadline is the rule's `timeout_ms`, clamped to 1…200 ms. | | Parsed module cached by SHA-256 | Re-parsing on every order; also makes the vetting result content-addressed. | | Three failure reasons: `invalid`, `trap`, `timeout` | Callers can react precisely (`ScriptException.reason()`). | Why a time deadline rather than an instruction budget: Chicory has no instruction counting ("fuel") yet, so wall-clock time is the only CPU control available. That is the limit stated in the video. ![Figure 2: What the sandbox walls off](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-14-figure-2.png) *What the sandbox walls off: linear memory limits, zero host imports, and execution deadlines protect the host JVM.* ### Saving a rule is a test run The rule is an ordinary DocType, so it gets REST, Desk, permissions and tenant isolation for free: *`astra-apps/express/src/main/resources/META-INF/astra/doctypes/pricing-rule.doctype.yaml`* ```yaml fields: - { name: rule_name, type: Data, required: true, in-list: true } - { name: segment, type: Select, options: [All, Retail, B2B], default: All, in-list: true, projected: true } - { name: active, type: Check, default: false, in-list: true, projected: true } - { name: timeout_ms, type: Int, default: 50, label: Deadline (ms) } - { name: module_wasm, type: Attach, required: true, label: WebAssembly module } - { name: module_sha256, type: Data, read-only: true, label: Module SHA-256 } - { name: module_bytes, type: Int, read-only: true, in-list: true, label: Module size (bytes) } - { name: self_test, type: Data, read-only: true, in-list: true } # set by PricingRuleHook on every save permissions: - { role: admin, read: true, create: true, write: true } - { role: accountant, read: true, create: true, write: true } - { role: sales, read: true } ``` *`astra-apps/express/src/main/java/io/astra/apps/express/PricingRuleHook.java`* ```java @Override public void beforeSave(Document rule) { byte[] module = Base64.getDecoder().decode(rule.getString("module_wasm")); rule.set("module_sha256", sha256(module)); rule.set("module_bytes", module.length); long started = System.nanoTime(); long fee; try { fee = PricingRules.fee(scripts, rule, 1.0, "Domestic", false, 0); } catch (ScriptException e) { throw new DocValidationException("module rejected (" + e.reason() + "): " + e.getMessage()); } if (fee < 0 || fee > 10_000_000) throw new DocValidationException("module rejected: fee(1 kg, Domestic) returned " + fee); long micros = (System.nanoTime() - started) / 1_000; rule.set("self_test", "fee(1 kg, Domestic) = " + fee + " VND in " + micros + " µs"); } ``` Why test on save: a broken module is caught when an admin uploads it, not when a customer places an order. The runaway script never reaches `active`. ### Pricing an order, and failing safe *`astra-apps/express/src/main/java/io/astra/apps/express/ShippingFeeHook.java`* ```java Optional rule = activeRule(doc.getString("customer")); if (rule.isPresent()) { try { doc.set("shipping_fee", PricingRules.fee(scripts, rule.get(), weight, zone, express, cod)); doc.set("priced_by", rule.get().name() + " " + rule.get().getString("rule_name") + " (Wasm)"); return; } catch (ScriptException e) { // … log a warning doc.set("priced_by", "Standard tariff (" + rule.get().name() + " failed: " + e.reason() + ")"); doc.set("shipping_fee", fee(zone, doc.getString("service"), weight, cod)); return; } } doc.set("shipping_fee", fee(zone, doc.getString("service"), weight, cod)); doc.set("priced_by", "Standard tariff"); ``` `activeRule` picks the newest active rule whose segment is `All` or matches the customer's segment. Why fall back instead of rejecting the order: a tenant script is the tenant's responsibility; a script failure should cost the tenant its discount on that order, not stop order intake. `priced_by` records which path was taken, so the fallback is visible. The argument mapping lives in one place, shared by the self-test and pricing: *`astra-apps/express/src/main/java/io/astra/apps/express/PricingRules.java`* ```java static long fee(ScriptRuntime scripts, Document rule, double weightKg, String zone, boolean express, long cod) { byte[] module = Base64.getDecoder().decode(rule.getString("module_wasm")); long timeout = Math.round(Numbers.toDouble(rule.get("timeout_ms"))); return scripts.callLong(module, FUNCTION, timeout <= 0 ? 50 : timeout, Math.round(weightKg * 1000), Math.max(0, ZONES.indexOf(zone)), express ? 1 : 0, cod); } ``` ## Real-world example *Illustrative scenario: the company, people and values are made up; the mechanism and the rules it applies are the real ones shown above.* A Saigon courier runs a Tết campaign for its B2B shippers from late January to mid-February. Marketing wants it live on a Monday morning and gone two weeks later, without an engineering release. 1. A developer (or the shipper's own IT) writes the formula in Rust and builds it with `scripts/build-wasm.sh`: 421 bytes. 2. An `admin` or `accountant` in the tenant creates a Pricing Rule, segment `B2B`, `active: false`, and attaches the file. The self-test answers `fee(1 kg, Domestic) = 24000 VND in … µs`. 3. On launch day they set `active: true`. On the last day, `active: false`. No deploy at either end. Fees with the rule on, against the standard tariff. The first three rows are asserted in `astra-server/src/test/java/io/astra/server/WasmTest.java`; the 2.2 kg row is the video's order. | Parcel (B2B customer) | Standard tariff | Tết rule (Wasm) | | --- | --- | --- | | 1 kg, Domestic, Standard, no COD | 30,000 | 24,000 (30,000 − 20 %) | | 1.5 kg, Same city, Standard, no COD | 21,000 | 0 (free same-city ≤ 2 kg) | | 3 kg, Domestic, Express, COD 500,000 | 80,000 | 56,000 (50,000 − 20 % × 1.3 + 4,000) | | 2.2 kg, Domestic, Express, COD 500,000 | 73,000 | 51,000 (45,000 − 20 % = 36,000; × 1.3 = 46,800; + 4,000; round up) | | any parcel for Retail customer C003 | standard | standard (`priced_by: Standard tariff`, the rule is B2B only) | If the module ever fails at run time, for example a later upload that traps, the order is still created at the standard price and `priced_by` reads `Standard tariff (PRL-… failed: trap)`. The ABI also covers zone 3 (`Island`), which only exists for tenants whose overlay adds it (Hanoi, [Video 13](/astra-cms/en/overlay)). Computed from `lib.rs`, not shown in the video: 2.2 kg Island Express with COD 500,000 gives 60,000 + 3 × 9,000 = 87,000; − 20 % = 69,600; × 1.3 = 90,480; + 4,000 = 94,480; rounded up to 95,000. The standard tariff has no Island rate and would charge the Domestic 73,000. Overlay plus Wasm is how one tenant gets an island price without touching the app. ## For technical leaders - **Trust boundary.** Tier 2 is for code you do not trust: tenant scripts, marketplace logic, AI-generated logic. The sandbox has no imports at all, so there is no capability to grant by mistake. - **Blast radius.** Bounded per call to 1 MiB and at most 200 ms of wall time on a virtual thread. A failing script degrades to the standard tariff on that order; the pod and other tenants are unaffected (the runaway test asserts the next call still returns 24,000). - **CPU accounting is approximate.** A deadline is not an instruction budget. Under heavy load a legitimate script can time out; a hostile one can still burn up to its deadline per call. Keep deadlines tight (the default rule deadline is 50 ms) and watch timeout rates. - **ABI design is the real work.** `i64` in, `i64` out is trivially safe and fast, but cannot pass strings or structures. Richer extension points need a memory-passing ABI, which the current sandbox does not implement. - **Governance.** Rules are documents: each one records the exact module it runs (`module_sha256`, `module_bytes`), and only `admin` and `accountant` can create or edit rules. - **When not to use Tier 2.** Logic that needs to read other documents, call services or write data. The sandbox cannot do any of that; such logic belongs in a Tier 0 hook in the app. - **What to measure.** Self-test duration (`self_test` records µs), rate of `priced_by` values containing `failed`, timeout warnings from `WasmSandbox` in the logs, and module sizes against the 1 MiB attachment limit. ## Try it yourself Prerequisites: the k3d stack (`make k3d-up`) from [the recording environment](/astra-cms/en/video-scripts), and a fresh seed. The seed uploads the Tết rule as inactive. Rust is only needed if you change the Rust sources; the `.wasm` files are committed. ```bash make demo-reset && make demo-seed make demo-express SCENES=12 # add PAUSE=0 to run without pressing Enter ``` Expected: the scene prints `lib.rs`, lists `runaway.wasm` (145 bytes) and `tet-promo.wasm` (421 bytes), shows the Pricing Rule with its `self_test`, prices the B2B order at 73,000 with `Standard tariff`, switches the rule on and prices it at 51,000 with `PRL-… (Wasm)`, rejects the runaway upload with `module rejected (timeout): script exceeded its 50 ms deadline and was stopped`, prices the next order normally, and switches the rule off again. To rebuild the modules after editing Rust: ```bash scripts/build-wasm.sh # adds the wasm32-unknown-unknown target, builds, copies to wasm/modules ``` In Desk, as `mai`: Pricing Rule → open the rule → check `self_test` → turn on `active` → create a B2B order for C001 (Domestic, Express, 2.2 kg, COD 500,000). Turn `active` off afterwards, otherwise B2B orders in other demos will no longer be 73,000. ## To be accurate - Chicory has no instruction counting (fuel) yet, so the CPU limit is a wall-clock deadline (rule default 50 ms, kernel maximum 200 ms). - The ABI is `i64` arguments and one `i64` result. There is no Extism or memory-passing ABI in the code yet, even though [explain.en.md](/astra-cms/en/why-astra) lists an Extism-compatible ABI in the target design. - Tier 2 is used by two apps in this repository: Express pricing rules and the HRM KPI bonus (Video 19). Wasm scripts attached to overlays, as sketched in [overlay-wasm.md](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/overlay-wasm.md), are not built. - Tier 1 (isolated JVM apps with hot-swap) is on the roadmap, not built. ## Figures - [Figure 1 · From Rust source to a priced order](#how-it-works) - [Figure 2 · What the sandbox walls off](#the-sandbox-fresh-instance-capped-memory-no-imports-a-deadline) - [Figure 3 · A runaway script is stopped and never goes live](#what-the-video-shows) ## Related - Previous: [Video 13 · Tenant overlays](/astra-cms/en/overlay) · Next: [Video 15 · MCP](/astra-cms/en/mcp) - [Video 3 · Hooks](/astra-cms/en/hooks) (standard tariff), [Video 9 · Writing an Astra app](/astra-cms/en/developer), [Video 19 · HRM](/astra-cms/en/hrm) (Wasm KPI bonus) - Code excerpts: [S8 Wasm Tier 2](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/demo-code-snippets.md#s8-wasm-tier-2) - Background (target design, partly built): [explain.en.md](/astra-cms/en/why-astra), [overlay-wasm.md](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/overlay-wasm.md) - Source: `astra-plugin-host/src/main/java/io/astra/pluginhost/WasmSandbox.java`, `astra-spi/src/main/java/io/astra/spi/script/ScriptRuntime.java`, `astra-apps/express/src/main/java/io/astra/apps/express/PricingRuleHook.java`, `astra-apps/express/src/main/java/io/astra/apps/express/ShippingFeeHook.java`, `astra-apps/express/wasm/`, `scripts/build-wasm.sh`, `astra-server/src/test/java/io/astra/server/WasmTest.java` --- # Keycloak: one identity, roles per tenant, permissions per DocType a person signs in once with Keycloak, their roles come from signed Keycloak group paths /tenants//, and each DocType's YAML decides what those roles may read, create, write, submit and cancel, with… 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/keycloak ▶ [English](https://media.zerohostcms.com/astra-cms/astra-video-11-keycloak-en.mp4) · [Tiếng Việt](https://media.zerohostcms.com/astra-cms/astra-video-11-keycloak-vi.mp4) · 2:18 · Script: [demo-video-series.en.md](/astra-cms/en/video-scripts) > **In one sentence:** a person signs in once with Keycloak, their roles come from signed Keycloak group paths `/tenants//`, and each DocType's YAML decides what those roles may read, create, write, submit and cancel, with maker/checker enforced by the kernel. > **Read this if:** you need to know where an `AccessDeniedException` comes from and how to declare permissions for your own DocType (developer) / you are deciding how identity, tenancy and approval rules should be split between an IdP and the application (tech lead). ## The problem Astra Express runs two tenants, `saigon-express` and `hanoi-express`. In Saigon alone, five kinds of people touch the same order: sales staff create and submit it, operations staff scan its waybill, customer care opens tickets about it, and accountants cancel orders and pay collected COD (cash on delivery) out to shops. One person can work for both tenants with different jobs in each. The usual ways of handling this each leave a gap: - **Roles in the application database.** Every app keeps its own user table and role table. Offboarding means remembering every app, and password handling lives in code that should not see passwords. - **Global roles in the IdP.** "accountant" in the token says nothing about *which* company the person is an accountant for. Multi-tenant apps then trust a tenant header from the client, which is exactly what an attacker controls. - **Permission checks in controllers and UI.** The UI hides a button, the API forgets the check, and a script calls the API directly. - **Approval rules in process documents.** "The person who prepares a payout cannot approve it" is written in a policy PDF, not enforced anywhere. Frappe/ERPNext puts role permissions on the DocType (with an "if owner" option), which is the right place; Astra keeps that idea. The difference is where identity lives: in Astra, users, passwords and role membership live in Keycloak, and the application only reads a signed token. ## What the video shows | Scene | What you see | What it proves | | --- | --- | --- | | Login | Desk opens the Astra-themed Keycloak login page; `tuan` signs in | Authorization Code with PKCE in the browser. The password is typed into Keycloak only; Astra receives an authorization code. | | Token | Tuan belongs to the Keycloak group `/tenants/saigon-express/sales` | Roles are Keycloak groups. The `X-Astra-Tenant` header only *picks* a tenant; the roles come from the signed token. | | `/api/iam/me` | Tuan may read, create and submit orders, not cancel | Permissions are declared per DocType in YAML; Desk reads the effective result from `/api/iam/me`. | | Desk | Tuan's draft order has Save and Submit; a submitted order has no Cancel; someone else's order cannot be saved, but sales can still submit it | Buttons follow permissions. `if-owner` limits *write*, not *submit*, exactly as the YAML says. | | API, one order, four roles | Tuan (sales) cannot edit the waybill or cancel; Quang (ops) edits the waybill but cannot create orders; Ha (care) opens a ticket; Khoa (accountant) cancels | The kernel checks, so the API refuses the same things the UI hides. | | Segregation of duties | Khoa's COD payment voucher of 350,000 VND: his own Submit is refused with 403; Thu, a second accountant, submits it and the ledger posts at once | Maker ≠ checker is a property of the DocType, enforced for Desk and API alike. | The video ends with its "To be accurate" note: row-level conditions are still on the roadmap, and SAML and SCIM now run, in [Video 21](/astra-cms/en/sso). ![Figure 2: Same order, four roles](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-11-figure-2.png) *Same order, four roles: permissions declared on the DocType govern whether sales, ops, care, or admin can read, write, or submit.* ## How it works ### The path of one request ```mermaid sequenceDiagram autonumber participant B as Desk (browser) participant K as Keycloak (realm astra) participant F as TenantResolverFilter participant R as DocResource participant P as DocPermissions B->>K: /auth?client_id=astra-desk&code_challenge=S256(verifier) K-->>B: redirect /desk/?code=… B->>K: POST /token (code + code_verifier) K-->>B: access token, groups: ["/tenants/saigon-express/sales"] B->>F: POST /api/doc/CODRemittance/…/submit
Bearer token + X-Astra-Tenant: saigon-express F->>F: Memberships.of(identity) → roles in saigon-express alt no role in that tenant F-->>B: 403 not a member of tenant … end F->>R: tenantId, userId, roles R->>P: requireSubmit(doctype, roles, userId, existing) P-->>B: 403 when the rule denies or the submitter is the owner ``` ### 1. Sign-in: the browser holds the code verifier, not Astra Desk is a public OIDC client (`astra-desk`). It creates a random verifier, sends only its SHA-256 hash to Keycloak, and later proves possession of the verifier when it exchanges the code. Astra's server never sees the password, and a stolen authorization code is useless without the verifier kept in `sessionStorage`. *`astra-server/src/main/resources/META-INF/resources/desk/desk.js`* ```js async function signIn() { const verifier = b64url(crypto.getRandomValues(new Uint8Array(32))); const challenge = b64url(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))); const st = b64url(crypto.getRandomValues(new Uint8Array(16))); sessionStorage.setItem('astra-desk.pkce', JSON.stringify({ verifier, state: st, back: location.hash, tenant: state.tenant })); const q = new URLSearchParams({ client_id: state.config.deskClientId, redirect_uri: redirectUri(), response_type: 'code', scope: 'openid', code_challenge: challenge, code_challenge_method: 'S256', state: st, ui_locales: 'vi' }); location.assign(oidc('auth') + '?' + q); } ``` Why PKCE and not a server-side session: Desk is a static page served by the kernel, and the same flow serves the mobile client (`astra.iam.mobile.client-id` in `IdentityResource`). One flow, no client secret in a browser or an app bundle. ### 2. The server is a resource server that reads group paths The kernel is configured as an OIDC *service* (it only verifies bearer tokens) and maps both the `groups` claim and realm roles into identity roles. *`astra-server/src/main/resources/application.yml`* ```yaml # Resource server: every /api call carries a Keycloak access token (Authorization Code + PKCE on mobile). # Group paths /tenants// and realm roles both become identity roles. oidc: auth-server-url: ${ASTRA_OIDC_URL:http://localhost:8180/auth/realms/astra} # deploy/docker compose Keycloak client-id: astra-server application-type: service token: audience: astra-server principal-claim: sub roles: role-claim-path: groups,realm_access/roles ``` `Memberships` turns those paths back into a `tenant → roles` map. Roles are never global; "accountant" only means something next to a tenant name. *`astra-kernel/src/main/java/io/astra/kernel/security/Memberships.java`* ```java public record Memberships(Map> byTenant, boolean platformAdmin) { public static final String GROUP_PREFIX = "/tenants/"; // … public static Memberships of(SecurityIdentity identity) { if (identity == null || identity.isAnonymous()) return new Memberships(Map.of(), false); Map> out = new TreeMap<>(); for (String role : identity.getRoles()) { if (!role.startsWith(GROUP_PREFIX)) continue; String[] parts = role.substring(GROUP_PREFIX.length()).split("/"); if (parts.length == 2 && !parts[0].isEmpty() && !parts[1].isEmpty()) { out.computeIfAbsent(parts[0], t -> new TreeSet<>()).add(parts[1]); } } // … } ``` Why groups and not client roles: a Keycloak group path carries two facts at once (tenant and role), it is what SCIM later manipulates as "Groups" ([Video 21](/astra-cms/en/sso)), and an administrator can see a tenant's whole staff by opening one group in the Keycloak console. ![Figure 1: One identity, roles per tenant](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-11-figure-1.png) *One identity, roles per tenant: Keycloak group paths map a single user identity to distinct roles across different tenants.* ### 3. The tenant header selects; it does not grant `TenantResolverFilter` resolves the tenant from the header, then a host subdomain, then the user's only membership. Whatever it picks, the request continues only if the token holds at least one role in that tenant. *`astra-kernel/src/main/java/io/astra/kernel/tenant/TenantResolverFilter.java`* ```java Memberships memberships = Memberships.of(identity); String tenant = fromHeader(ctx).or(() -> fromHost(ctx)).or(() -> onlyMembership(memberships)).orElse(null); // … Set roles = memberships.roles(tenant); if (roles.isEmpty()) { ctx.abortWith(error(Response.Status.FORBIDDEN, "not a member of tenant " + tenant)); return; } tenantRequest.tenantId(tenant); tenantRequest.userId(identity.getPrincipal().getName()); tenantRequest.roles(roles); ``` A client that sends `X-Astra-Tenant: hanoi-express` with a Saigon-only token gets 403 before any resource code runs. Database row-level security ([Video 7](/astra-cms/en/tenants)) is the second wall behind this one. ### 4. Permissions are data on the DocType Each DocType lists rules per role. This is the order's table, which explains every refusal in the video: *`astra-apps/express/src/main/resources/META-INF/astra/doctypes/customer-order.doctype.yaml`* ```yaml permissions: - { role: admin, read: true, create: true, write: true, submit: true, cancel: true } - { role: sales, read: true, create: true, write: true, if-owner: true, submit: true } - { role: ops, read: true } - { role: care, read: true } - { role: accountant, read: true, cancel: true } ``` `if-owner` restricts only `write`. That is why Tuan cannot save a colleague's order but can still submit it, a behaviour the video shows on purpose rather than hides. The evaluator is a union over matching rules; no matching rule means no access: *`astra-doctype/src/main/java/io/astra/doctype/permission/DocPermissions.java`* ```java private static boolean allows(DocTypeDefinition def, Set roles, Action action, boolean owner) { for (PermissionRule rule : def.permissions()) { if (!roles.contains(rule.role())) continue; boolean granted = switch (action) { case READ -> rule.read(); case CREATE -> rule.create(); case WRITE -> rule.write() && (!rule.ifOwner() || owner); case SUBMIT -> rule.submit(); case CANCEL -> rule.cancel(); }; if (granted) return true; } return false; } ``` The generated REST resource calls it before every operation (`DocResource`: `require(…READ)` for list and get, `requireWrite` for update, `requireSubmit` for submit, `require(…CANCEL)` for cancel). Because apps do not write their own controllers, an app cannot forget the check. ### 5. Desk asks, it does not decide `/api/iam/me` returns, per tenant, the effective permissions computed by the same `DocPermissions` class: *`astra-iam/src/main/java/io/astra/iam/IdentityResource.java`* ```java private Map tenantView(String tenant, Set roles) { Map m = new LinkedHashMap<>(); m.put("tenant", tenant); m.put("roles", roles); m.put("permissions", permissions.effective(roles)); return m; } ``` Desk draws buttons from that answer (`desk.js`, `drawForm`: Submit when `perm.submit`, Cancel when `perm.cancel`). Hiding a button is a convenience; the 403 from the kernel is the control. ### 6. Segregation of duties is one flag *`astra-apps/express/src/main/resources/META-INF/astra/doctypes/cod-remittance.doctype.yaml`* ```yaml submittable: true segregation-of-duties: true # maker/checker: whoever prepares a payout cannot approve it # … permissions: - { role: admin, read: true, create: true, write: true, submit: true, cancel: true } - { role: accountant, read: true, create: true, write: true, submit: true, cancel: true } ``` *`astra-doctype/src/main/java/io/astra/doctype/permission/DocPermissions.java`* ```java public void requireSubmit(String doctype, Set roles, String userId, Document existing) { DocTypeDefinition def = registry.require(doctype); if (!allows(def, roles, Action.SUBMIT, false)) { throw new AccessDeniedException("submit " + doctype); } if (def.segregationOfDuties() && userId != null && userId.equals(existing.owner())) { throw new AccessDeniedException("submit " + doctype + "/" + existing.name() + ": segregation of duties, the creator cannot submit their own document"); } } ``` The comparison uses the token's subject (`principal-claim: sub`) against the document's `owner`, so renaming a user in Keycloak does not let them approve their own old drafts. The effective-permission map does not model SoD, so Desk still shows Submit to Khoa; the kernel answers 403, which is what the video shows. ![Figure 3: Maker is not checker](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-11-figure-3.png) *Segregation of duties: if-owner permissions ensure that the creator of a payment order cannot approve or submit their own voucher.* ## Real-world example *Illustrative scenario: the company, people and values are made up; the mechanism and the rules it applies are the real ones shown above.* A regional 3PL runs two franchise tenants on one Astra cluster. Lan is the chief accountant of `hanoi-express` and also helps the Saigon customer-care desk on weekends. **Input (Keycloak):** Lan is a member of `/tenants/hanoi-express/accountant` and `/tenants/saigon-express/care`. **What Astra does:** | Request | Resolved roles | Result | | --- | --- | --- | | `GET /api/iam/me` | both tenants | `hanoi-express: [accountant]`, `saigon-express: [care]`, with per-DocType permissions for each | | `POST /api/doc/CustomerOrder//cancel`, `X-Astra-Tenant: hanoi-express` | `{accountant}` | allowed (`accountant: cancel: true`) | | Same call, `X-Astra-Tenant: saigon-express` | `{care}` | 403, care has only `read` on orders | | `POST /api/doc/SupportTicket`, `X-Astra-Tenant: saigon-express` | `{care}` | 201 | | Lan creates a 12,000,000 VND `CODRemittance` in Hanoi, then submits it | `{accountant}` | 403, segregation of duties | | Her colleague Minh (also `hanoi-express/accountant`) submits it | `{accountant}` | 200; the payout posts to the ledger ([Video 12](/astra-cms/en/ledger)) | **Output:** one login, two different jobs, no per-app user table, and an approval trail where the document's `owner` and the submitter are provably different people. When Lan leaves the weekend shift, an administrator removes one group membership in Keycloak; her next token no longer carries Saigon. ## For technical leaders - **Where the truth lives.** Identity and membership in Keycloak; what a role may do in Git, next to the DocType. Reviews of a permission change are code reviews. - **Fail-closed by default.** No rule means no access; no role in the resolved tenant means 403 before resource code. A new DocType without a `permissions:` block is unreachable, not open. - **Token lifetime is your revocation latency.** Astra verifies JWTs offline. Removing a group takes effect at the next token refresh, not instantly. Size access-token lifetime to your risk; the realm uses 5 minutes for regular clients (see the Video 21 notes). - **Token size.** Every membership is a string in `groups`. A consultant in hundreds of tenants produces a large token; if that is your shape, plan for it. - **What to measure.** 403 rate per DocType and role (a spike after a deploy usually means a YAML regression), SoD refusals per month (evidence for auditors), and time from HR offboarding to group removal. - **When not to use this as is.** If you need "sales may see only orders of their own branch" or amount limits ("submit up to 50 million"), that is row-level or attribute-based permission, which is not built yet. Today it has to be a hook that throws, which is code, not metadata. - **Compared with alternatives.** Keycloak Authorization Services (policy enforcer) can centralise decisions but moves per-DocType rules out of the app's repository; Astra keeps them with the DocType. Frappe has the same DocType-level model but its own user store; Astra delegates users to an IdP. ## Try it yourself Prerequisites: JDK 25, Docker Desktop with 8 GB for the k3d stack, `kubectl`, `k3d`, `jq`, Bash 5. ```bash make k3d-up # cluster with Keycloak, realm synced by scripts/keycloak-sync-realm.sh make demo-reset && make demo-seed # tenants saigon-express and hanoi-express, demo users make demo-express SCENES=9 PAUSE=0 # the scene used in this video open http://localhost:8080/desk/ # sign in as tuan / Astra@123, then khoa, then thu ``` Expected in the terminal (scene 9 of `scripts/demo-express.sh`): `/api/iam/me` for `tuan` with `CustomerOrder` and `Waybill` permissions; `tuan` creates and submits an order; his `PUT /api/doc/Waybill/` and his cancel are refused; `quang` updates the waybill to `Picked Up` and is refused when creating an order; `ha` creates a `SupportTicket`; `khoa` cancels the order. For the SoD scene, record right after `demo-seed`: as `khoa`, open `CODR-…-0002` (350,000, created by `khoa` during the seed) and press Submit (403), then sign in as `thu` and submit it. Tests that pin this behaviour live in `astra-server/src/test/java/io/astra/server/IamAccessTest.java` and `LedgerTest.java`; run them with `make test`. `make demo-iam` runs end-to-end IAM checks with real Keycloak tokens. Demo users share the password `Astra@123` and are defined in `deploy/keycloak/realm/astra-realm.json`. They are for local environments only. ## To be accurate - Row-level permission conditions (for example "only orders of my branch", or limits on amounts) are still on the roadmap. Permissions today are per role and per DocType, plus `if-owner` for writes and the `segregation-of-duties` flag for submit. - SAML SSO and SCIM provisioning now run; they are shown in [Video 21](/astra-cms/en/sso). - Desk's effective-permission view does not model segregation of duties, so the Submit button can appear and the kernel then refuses with 403. - Role changes reach Astra with the next token; an access token already issued keeps its roles until it expires. ## Figures - [Figure 1 · One identity, roles per tenant](#2-the-server-is-a-resource-server-that-reads-group-paths) - [Figure 2 · Same order, four roles](#what-the-video-shows) - [Figure 3 · Maker is not checker](#6-segregation-of-duties-is-one-flag) ## Related - Previous: [Video 10 · Running on Kubernetes](/astra-cms/en/k8s). Next: [Video 12 · Append-only ledger](/astra-cms/en/ledger). - [Video 7 · Multi-tenancy](/astra-cms/en/tenants) (row-level security behind the tenant filter) and [Video 21 · SAML SSO and SCIM](/astra-cms/en/sso) (enterprise IdPs and provisioning into the same groups). - Docs: [demo-code-snippets.md, S5](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/demo-code-snippets.md#s5-keycloak-permissions-and-sod), [iam-mobile-integration.md](/astra-cms/en/iam-mobile). - Source: `astra-kernel/src/main/java/io/astra/kernel/security/Memberships.java`, `astra-kernel/src/main/java/io/astra/kernel/tenant/TenantResolverFilter.java`, `astra-doctype/src/main/java/io/astra/doctype/permission/DocPermissions.java`, `astra-doctype/src/main/java/io/astra/doctype/api/DocResource.java`, `astra-iam/src/main/java/io/astra/iam/IdentityResource.java`, `deploy/keycloak/realm/astra-realm.json`, `scripts/demo-express.sh` (scene 9). --- # SAML SSO and SCIM a company's employees sign in to Astra through their own SAML 2.0 IdP brokered by Keycloak, IdP groups become tenant roles in the OIDC token, and the company's directory creates, assigns roles to, disables and deletes… 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/sso ▶ [English](https://media.zerohostcms.com/astra-cms/astra-video-21-sso-en.mp4) · [Tiếng Việt](https://media.zerohostcms.com/astra-cms/astra-video-21-sso-vi.mp4) · 2:32 · Script: [demo-video-series.en.md](/astra-cms/en/video-scripts) > **In one sentence:** a company's employees sign in to Astra through their own SAML 2.0 IdP brokered by Keycloak, IdP groups become tenant roles in the OIDC token, and the company's directory creates, assigns roles to, disables and deletes those accounts over SCIM 2.0, only inside its own tenant. > **Read this if:** you need to know what `/scim/v2` accepts, how the tenant is bound to a SCIM token, and why the kernel has no SAML code (developer) / you are answering an enterprise customer's security questionnaire about SSO, joiner-mover-leaver and tenant isolation (tech lead). ## The problem When Astra is sold to a company rather than used by one, the company's IT team asks two questions before anyone signs in: 1. **"Can our staff use our IdP?"** They will not let employees keep a second password in a vendor's system. Their IdP (Entra ID, Okta, ADFS…) speaks SAML 2.0, sometimes OIDC. 2. **"Who creates and removes accounts?"** When someone joins, changes team or leaves, their HR-driven directory must push that change. Manual invitations do not pass an audit, and a forgotten leaver is a breach. The usual answers are an SSO plugin inside the application (SAML parsing, certificate rotation and replay protection written by the app team) and a CSV import for users. Frappe offers social login and LDAP out of the box; SAML and SCIM usually come from third-party apps or a proxy. Astra already delegates identity to Keycloak ([Video 11](/astra-cms/en/keycloak)). This video shows the consequence: SAML lives entirely in Keycloak's identity brokering, and Astra adds only a SCIM endpoint that edits the same `/tenants//` groups the kernel already reads. ## What the video shows The demo tenant is `acme`. The IdP is a separate Keycloak realm, `acme-corp-idp`, standing in for the company's IdP. Its users `linh` and `bao` are not the dispatcher `linh` from [Video 20](/astra-cms/en/stms). | Scene | What you see | What it proves | | --- | --- | --- | | Login | One button, "Acme Corp SSO". `linh` signs in at the IdP | The password stays at the IdP; Astra receives an OIDC code. | | Flow | SP-initiated: Desk → Keycloak (`kc_idp_hint=acme-corp`) → AuthnRequest to the IdP → signed SAMLResponse → OIDC code | The realms speak real SAML 2.0; Desk speaks only OIDC with Keycloak. | | Assertion | NameID `linh`, Audience `urn:astra:sp`, `memberOf astra-editors`, RSA-SHA256 signature on assertion and response. Token: `preferred_username: acme-corp.linh`, `groups: ["/tenants/acme/editor"]` | Keycloak maps IdP groups to tenant roles; Astra reads the role from the token. | | Desk | Linh Vo signs in as acme editor and creates an Article (201) | No SSO code in the kernel; DocType permissions apply as for local users. | | Tamper | `bao`'s SAMLResponse is altered in transit (`astra-readers` → `astra-admins`); Keycloak answers 400 for an invalid signature | No code is issued, so nobody signs in. | | Discovery | `/scim/v2/ServiceProviderConfig`: `patch: true`, filter maximum 200, `bulk: false`, `etag: false` | The tenant comes only from the `scim_tenant` claim of the client's token, never from the request. | | Create | `POST /scim/v2/Users` → 201; repeat `userName` → 409 `uniqueness`; the new user gets 403 on `/api/doc/Article` | New users have no role, so no rights yet. | | Role | PATCH adds the user to group `editor`; after a new sign-in the token has `/tenants/acme/editor`; Article → 201 | Groups are roles. Changes apply at the next sign-in or refresh. | | Deactivate | PATCH `{"op":"Replace","path":"active","value":"False"}` → password grant `invalid_grant` "Account disabled", refresh "Session not active" | Sign-in is blocked at once; an access token already issued stays valid until it expires. | | Fail closed | globex's token: acme user → 404, filter → 0 results, `X-Astra-Tenant: acme` → 403, adding an acme user to a globex role → 400 `invalidValue`, filter `co` → 400 `invalidFilter` | Errors follow RFC 7644; another tenant's token sees nothing. | | Delete | `DELETE` → 204, GET → 404 | Deleted users are gone. | ![Figure 2: Tampered assertion](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-21-figure-2.png) *Cryptographic assertion verification: tampered SAML assertions or mismatched tenant tokens are rejected immediately.* ## How it works ### SAML: brokered by Keycloak, invisible to the kernel ```mermaid sequenceDiagram autonumber participant D as Desk (browser) participant K as Keycloak realm astra
(SP, entityId urn:astra:sp) participant I as IdP realm acme-corp-idp participant A as Astra kernel D->>K: OIDC /auth (PKCE) + kc_idp_hint=acme-corp K->>I: SAML AuthnRequest (POST binding) I->>I: linh signs in (password stays here) I-->>K: SAMLResponse, signed (RSA-SHA256)
NameID linh, memberOf astra-editors K->>K: verify signatures, map memberOf → /tenants/acme/editor
user acme-corp.linh K-->>D: OIDC code D->>K: code + code_verifier K-->>D: access token, groups ["/tenants/acme/editor"] D->>A: POST /api/doc/Article (Bearer token) A-->>D: 201 ``` The service-provider side is configuration in the `astra` realm. Signatures are required and verified; the AuthnRequest is not signed and assertions are not encrypted, which the "To be accurate" section repeats. *`deploy/keycloak/realm/astra-realm.json`* (identity provider `acme-corp`, trimmed) ```json "alias": "acme-corp", "displayName": "Acme Corp SSO", "providerId": "saml", "config": { "entityId": "urn:astra:sp", "postBindingAuthnRequest": "true", "postBindingResponse": "true", "wantAuthnRequestsSigned": "false", "validateSignature": "true", "wantAssertionsSigned": "true", "wantAssertionsEncrypted": "false", "signatureAlgorithm": "RSA_SHA256", "syncMode": "FORCE" } ``` Group mapping is one Keycloak mapper per role. The IdP's `memberOf` value is matched exactly (no regex) and turned into the tenant role group: *`deploy/keycloak/realm/astra-realm.json`* (`identityProviderMappers`, one of four role mappers) ```json { "name": "acme-corp editor", "identityProviderAlias": "acme-corp", "identityProviderMapper": "saml-advanced-group-idp-mapper", "config": { "syncMode": "INHERIT", "attributes": "[{\"key\":\"memberOf\",\"value\":\"astra-editors\"}]", "are.attribute.values.regex": "false", "group": "/tenants/acme/editor" } } ``` A username mapper with template `${ALIAS}.${NAMEID}` produces `acme-corp.linh`, so a brokered user can never collide with a local `linh`. Why broker instead of embedding SAML in Astra: XML signature validation is a classic source of vulnerabilities, and Keycloak already handles metadata, certificate rotation, clock skew (`allowedClockSkew: 30`) and account linking. The kernel keeps a single trust path, an OIDC JWT, and the code in [Video 11](/astra-cms/en/keycloak) works unchanged. The tamper scene is therefore a test of Keycloak's verification, which is the point: nothing in Astra had to be written for it. ![Figure 1: SP-initiated SAML through a broker](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-21-figure-1.png) *SP-initiated SAML flow: enterprise identity providers authenticate users through Keycloak without exposing SAML complexity to the kernel.* ### SCIM: the tenant comes from the token, never from the request SCIM clients authenticate with client credentials of a per-tenant Keycloak client, `scim-`. A hardcoded claim mapper on that client puts the tenant into the token: *`deploy/keycloak/realm/astra-realm.json`* (client `scim-acme`, protocol mapper) ```json { "name": "scim tenant", "protocolMapper": "oidc-hardcoded-claim-mapper", "config": { "claim.name": "scim_tenant", "claim.value": "acme", "jsonType.label": "String", "access.token.claim": "true" } } ``` `ScimResource` resolves the tenant only from that claim, requires the realm role `scim`, and refuses a conflicting tenant header: *`astra-iam/src/main/java/io/astra/iam/ScimResource.java`* ```java private String tenant() { if (identity.isAnonymous()) { throw ScimException.unauthorized("a bearer token of the tenant's SCIM client is required"); } if (!identity.hasRole(SCIM_ROLE)) throw ScimException.forbidden("the token lacks the " + SCIM_ROLE + " role"); Object claim = jwt.getClaim(TENANT_CLAIM); String tenant = claim instanceof JsonString s ? s.getString() : claim == null ? null : claim.toString(); if (tenant == null || !tenant.matches(TENANT_ID)) { throw ScimException.forbidden("the token is not bound to a tenant (claim " + TENANT_CLAIM + ")"); } String requested = headers.getHeaderString(TenantResolverFilter.TENANT_HEADER); if (requested != null && !requested.isBlank() && !requested.trim().equals(tenant)) { throw ScimException.forbidden("this SCIM token is bound to tenant " + tenant + " and cannot act on " + requested.trim()); } if (!scim.tenantExists(tenant)) throw ScimException.forbidden("tenant " + tenant + " does not exist"); return tenant; } ``` Why a claim and not a path like `/scim/v2/acme/Users`: a path is chosen by the caller. A claim set by Keycloak for one client cannot be changed by the caller, so a leaked `scim-globex` secret can never reach `acme`. ### SCIM users and groups are Keycloak users and groups `ScimDirectory` stores nothing of its own. Its class comment states the two rules that make tenant isolation work: *`astra-iam/src/main/java/io/astra/iam/ScimDirectory.java`* ```java /** * SCIM users and groups of one tenant, stored in Keycloak through {@link KeycloakDirectory}'s admin client. * *
    *
  • A tenant's SCIM users are the identities its provisioning client created: direct members of the * tenant group {@code /tenants/}. That membership grants no role; it marks ownership, so a * tenant's SCIM client can neither see nor change local members or another tenant's users.
  • *
  • A tenant's SCIM groups are its role groups {@code /tenants//}. Group membership is * the tenant role that reaches the user's token (claim {@code groups}) at the next sign-in.
  • *
*/ ``` That explains the scenes: a new user is a member of `/tenants/acme` only, which `Memberships` ignores (it needs `/`), so the kernel answers 403 "not a member of tenant acme". A user not owned by the caller's tenant answers 404 rather than 403, so its existence is not revealed. Creating or deleting a group returns 501, because roles are defined by Astra's DocTypes, not by the directory. Deactivation also ends the user's Keycloak sessions: *`astra-iam/src/main/java/io/astra/iam/ScimDirectory.java`* ```java if (in.password() != null) setPassword(user, in.password()); if (wasActive && !Boolean.TRUE.equals(rep.isEnabled())) user.logout(); return user(tenant, id); ``` The PATCH parser accepts what real clients send, including Entra's capitalised `"Replace"` and string booleans such as `"False"`: *`astra-iam/src/main/java/io/astra/iam/ScimPatch.java`* ```java static Boolean bool(JsonNode v, String attribute) { if (v == null || v.isNull()) return null; if (v.isBoolean()) return v.booleanValue(); if (v.isTextual() && (v.asText().equalsIgnoreCase("true") || v.asText().equalsIgnoreCase("false"))) { return Boolean.parseBoolean(v.asText().toLowerCase(Locale.ROOT)); } throw ScimException.invalidValue(attribute + " must be a boolean"); } ``` ```mermaid flowchart LR HR["Company directory
(SCIM client scim-acme)"] -->|client credentials| KC["Keycloak
token: scim_tenant=acme, role scim"] HR -->|"POST/PATCH/DELETE /scim/v2"| SR["ScimResource.tenant()"] SR --> SD["ScimDirectory"] SD -->|admin client| G1["/tenants/acme (ownership)"] SD -->|admin client| G2["/tenants/acme/editor (role)"] G2 -->|next sign-in| T["user token groups[]"] T --> K["Astra kernel: Memberships → DocPermissions"] ``` ## Real-world example *Illustrative scenario: the company, people and values are made up; the mechanism and the rules it applies are the real ones shown above.* A Vietnamese media group runs its newsroom CMS as tenant `acme` on Astra. Its IT team manages 300 staff in a corporate directory that speaks SAML 2.0 and SCIM 2.0. **Joiner.** HR hires Chi as an editor on Monday. - The directory calls `POST /scim/v2/Users` with `userName: chi`, then PATCHes group `editor` to add her. - Astra (through Keycloak) creates the account under `/tenants/acme` and adds it to `/tenants/acme/editor`. - Chi signs in with the company's credentials; her token carries `/tenants/acme/editor`; she can create Articles (`editor: read, create, write` in `article.doctype.yaml`). **Mover.** In June she becomes a section reader only. - The directory PATCHes `editor` to remove her and `reader` to add her. - Her current access token still says editor until it expires; the next refresh carries `reader`, and Article creation then returns 403. **Leaver.** On her last day at 17:00, HR disables her. - The directory sends `PATCH active=False`. Keycloak disables the account and ends her sessions. - 17:00:01: password and refresh grants fail. An access token issued at 16:58 is still accepted by Astra until it expires: at most 5 minutes later for a regular client with the realm's 300-second lifespan. - A month later the directory sends `DELETE`; GET returns 404. **Isolation.** A sister company on tenant `globex` uses its own `scim-globex` client. Its directory cannot list, read, or add Chi to a role; every attempt returns 404, 0 results, 403 or 400 as in the video. What the company has to supply for this to work in production is in the "To be accurate" list: its own IdP entry and group mappers in Keycloak, and a tested group naming convention. ![Figure 3: SCIM joiner, mover, leaver inside one tenant](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-21-figure-3.png) *Automated identity lifecycle: SCIM 2.0 synchronizes joiners, role changes, and departures directly into tenant group memberships.* ## For technical leaders - **One trust path.** The kernel trusts one thing, a Keycloak-signed JWT. SAML, local passwords and future brokers all end there, so security review of Astra's code does not grow with each IdP. - **Revocation latency equals access-token lifetime.** Astra verifies JWTs offline. With 300 seconds, a disabled user can keep using a token for up to 5 minutes. If that is unacceptable, shorten the lifespan or add token introspection, at the cost of a Keycloak call per request. - **Per-customer onboarding is configuration, not code.** Each company needs an identity provider entry and one group mapper per role in the realm, plus a `scim-` client. That is work for an operator; automate it before you have dozens of enterprise tenants. - **Scale limits.** Listing users reads group members from Keycloak and paginates in memory, with one extra group read per user. Fine for hundreds or a few thousand users per tenant, not hundreds of thousands. - **Usernames are realm-wide.** `create` checks uniqueness across the whole Keycloak realm, so two tenants cannot provision the same `userName`. Prefer email-style usernames for SCIM. - **What to measure.** SCIM error rate by `scimType` (`uniqueness`, `invalidFilter`, `invalidValue`), time from HR leaver event to `active=false`, broker login failures per IdP (expired certificates show up here first). - **When not to use this.** If a customer requires IdP-initiated login, Single Logout, encrypted assertions or SCIM Bulk today, those are not built. - **Compared with alternatives.** A commercial identity platform gives the same broker plus a managed SCIM connector catalogue; Keycloak keeps everything self-hosted and open source, with more operator work. ## Try it yourself Prerequisites: the k3d or Docker Compose stack, plus `python3` and `openssl` (the scene script checks for both), `jq` and Bash 5. ```bash make k3d-up # or: make infra-up (runs scripts/keycloak-compose-dev.sh) # If Keycloak was already running: scripts/keycloak-sync-realm.sh, then restart astra-server to get /scim/v2 make demo-sso PAUSE=0 # all eight scenes make demo-sso SCENES="1 5" # pick scenes ``` Expected, scene by scene (`scripts/demo-sso.sh`): 1. Decoded assertion for `linh`; token claims `preferred_username: acme-corp.linh`, `groups: ["/tenants/acme/editor"]`; `/api/iam/me` shows acme `[editor]`; `POST /api/doc/Article` → 201. 2. No authorization code for `bao` after his assertion is altered. 3. `ServiceProviderConfig` and `ResourceTypes`; SCIM token claims include `scim_tenant: acme`. 4. Create → 201, filter by `userName` → 1 result, duplicate → 409, Article → 403 for the role-less user. 5. Groups list (admin, editor, author, reader); PATCH editor; new token has `/tenants/acme/editor`; Article → 201. 6. `active=False`; password grant `invalid_grant`; refresh refused; old access token still answers, with its expiry printed. 7. globex token: 404, 0 results, 403, 400 `invalidValue`; `co` filter → 400 `invalidFilter`. 8. DELETE → 204, GET → 404. The script deletes its SCIM user when it ends. The tests `astra-server/src/test/java/io/astra/server/SamlBrokerTest.java` and `ScimTest.java`, plus `astra-iam/src/test/java/io/astra/iam/ScimFilterTest.java` and `ScimPatchTest.java`, pin the behaviour; run `make test`. The IdP passwords and the SCIM client secrets in the realm JSON files are for local environments only. ## To be accurate - The IdP is a Keycloak realm (`acme-corp-idp`) in the same container, not Okta, Entra or ADFS; the SAML protocol between the two sides is real. The demo replays the browser steps with curl and does not follow the redirect back to Desk. - Assertion and response are signed and verified; the assertion is not encrypted and the AuthnRequest is not signed. Only SP-initiated login; IdP-initiated is not configured; Single Logout is not wired up and not tested. - On first login Keycloak creates the account `acme-corp.`; if the email matches a local account, Keycloak asks to link them, which the demo does not show. The group → role mapping is fixed in the realm configuration and only for tenant `acme`; each other company needs its own IdP and mappers. - SCIM has no Bulk, ETag/`meta.version`, sort, `/Me`, `meta.lastModified` or `attributes` parameter yet; `excludedAttributes` only applies to Groups `members`. Filters support only `userName eq` and `displayName eq`. `externalId` is ignored; only one email is kept; `userName` cannot be changed. - Groups are fixed because they are the tenant roles: creating or deleting a group returns 501; only membership changes. A tenant's SCIM client only sees and edits users it provisioned. Deleting through SCIM also deletes the Keycloak account. - Role changes take effect from the next login or refresh. After `active=false`, an access token already issued stays valid until it expires (5 minutes for regular clients, 1 hour for the demo client `astra-dev-cli`). - Listing users paginates in memory with one extra group read per user: enough for the demo, not for hundreds of thousands of users. ## Figures - [Figure 1 · SP-initiated SAML through a broker](#saml-brokered-by-keycloak-invisible-to-the-kernel) - [Figure 2 · Tampered assertion](#what-the-video-shows) - [Figure 3 · SCIM joiner, mover, leaver inside one tenant](#real-world-example) ## Related - Previous: [Video 20 · STMS](/astra-cms/en/stms). Next: [Video 18 · Roadmap](/astra-cms/en/roadmap). - [Video 11 · Keycloak](/astra-cms/en/keycloak) (tenant roles from group paths, DocType permissions) and [Video 7 · Multi-tenancy](/astra-cms/en/tenants). - Docs: [demo-video-series.en.md, Video 21](/astra-cms/en/video-scripts), [iam-mobile-integration.md](/astra-cms/en/iam-mobile). - Source and configuration: `deploy/keycloak/realm/astra-realm.json`, `deploy/keycloak/realm/acme-corp-idp-realm.json`, `astra-iam/src/main/java/io/astra/iam/ScimResource.java`, `astra-iam/src/main/java/io/astra/iam/ScimDirectory.java`, `astra-iam/src/main/java/io/astra/iam/ScimPatch.java`, `astra-iam/src/main/java/io/astra/iam/ScimFilter.java`, `astra-kernel/src/main/java/io/astra/kernel/security/Memberships.java`, `scripts/demo-sso.sh`. --- # Append-only ledger business documents post balanced double-entry lines into one PostgreSQL table, astraledger, in the same transaction as the document; corrections are reversing entries, and a database trigger refuses UPDATE and DELETE… 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/ledger ▶ [English](https://media.zerohostcms.com/astra-cms/astra-video-12-ledger-en.mp4) · [Tiếng Việt](https://media.zerohostcms.com/astra-cms/astra-video-12-ledger-vi.mp4) · 2:32 · Script: [demo-video-series.en.md](/astra-cms/en/video-scripts) > **In one sentence:** business documents post balanced double-entry lines into one PostgreSQL table, `astra_ledger`, in the same transaction as the document; corrections are reversing entries, and a database trigger refuses UPDATE and DELETE even from the table owner. > **Read this if:** you write a hook that must book money and want to know what `GeneralLedger.post` and `reverse` guarantee (developer) / you need to explain to an auditor or CFO why the books cannot be silently edited, and where that guarantee stops (tech lead). ## The problem Astra Express bills a shipping fee when an order is submitted, collects COD (cash on delivery) from the receiver when the parcel is delivered, and later pays that cash out to the shop. Each of those is money, and each has to be visible in the general ledger on the Vietnamese chart of accounts (Circular 200). Three things usually go wrong: - **The ledger drifts from the documents.** The order commits, then a background job posts the journal entry and fails. Now the order says "billed" and the books say nothing. - **History gets edited.** A wrong fee is "fixed" with an UPDATE. The balance is right, but nobody can show what was booked last month, which is exactly what an auditor asks. - **Balances live in a summary table** updated beside the entries. The two disagree after the first bug. ERPNext answers the first two in application code: GL Entries are written on submit, and cancelling posts reversing entries. Astra follows the same accounting model and adds two things: the posting joins the document's database transaction through an SPI, and the database itself refuses to rewrite ledger rows. ## What the video shows | Scene | What you see | What it proves | | --- | --- | --- | | General ledger | `khoa` opens the trial balance: accounts 112, 131, 1388, 3388, 511 | Balances are computed from the ledger rows; there is no summary table. | | Submit | `tuan` submits an order; the form shows a "Ledger entries" card: Debit 131 / Credit 511, 73,000 VND | The journal entry is written in the same transaction as the order. | | Cancel | The order is cancelled; two new lines appear, marked as reversal; the two original lines are unchanged | Cancelling deletes nothing. The balance returns to zero and the history stays intact. | | Delivered | `quang` scans the parcel Delivered: Debit 1388 / Credit 3388, 850,000 VND | COD collected by the courier is owed to the shop, booked in the scan's transaction. | | UPDATE | A terminal `UPDATE astra_ledger …` fails with `astra_ledger is append-only` | A trigger in the database blocks the change, even for the role that owns the table. | | Partitions | The partitions section lists monthly partitions | At startup the kernel creates this month's partition and the next two; outside that range rows go to a default partition. | | Payout | A COD payout of 99,000,000 against 350,000 owed is refused with 422; the creator cannot approve a payout, Thu can | The amount owed is read from the ledger inside the hook; maker ≠ checker ([Video 11](/astra-cms/en/keycloak)). | ## How it works ```mermaid flowchart LR A["POST /api/doc/CustomerOrder/…/submit"] --> B["DocumentStore.submit
(tenant transaction)"] B --> C["OrderPostingHook.onSubmit"] C --> D["GeneralLedger.post
(LedgerStore)"] D --> E{"debit = credit?
accounts exist?"} E -- no --> X["422, whole transaction rolls back"] E -- yes --> F["ensurePartition(month)"] F --> G[("astra_ledger
PARTITION BY RANGE (posting_date)")] G --> H["COMMIT: order + entries together"] U["UPDATE / DELETE"] -. trigger .-> G U -.-> R["ERROR: append-only"] ``` ![Figure 1: One transaction, two writes](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-12-figure-1.png) *One transaction, two writes: submitting a document commits the document status and debit/credit ledger lines atomically.* ### 1. Apps post through an SPI, not SQL An app never touches the ledger table. It injects `GeneralLedger` from `astra-spi` and describes a voucher. *`astra-spi/src/main/java/io/astra/spi/ledger/GeneralLedger.java`* ```java /** * Double-entry postings to the append-only ledger of the current tenant. Called from lifecycle hooks, * a posting joins the triggering document's transaction: the voucher and its entries commit together. * Entries are never changed; {@link #reverse} posts mirror entries instead. */ public interface GeneralLedger { // … /** Posts a balanced voucher (total debit = total credit); zero-amount lines are skipped. */ void post(String voucherType, String voucherNo, LocalDate postingDate, List entries, String remarks); /** Posts the mirror of every entry of a voucher, dated today; refuses a voucher already reversed. */ void reverse(String voucherType, String voucherNo); /** Debit minus credit of an account, optionally for one party. */ BigDecimal balance(String account, String party); } ``` The order's hook is the whole accounting integration for billing: *`astra-apps/express/src/main/java/io/astra/apps/express/OrderPostingHook.java`* ```java @Override public void onSubmit(Document order) { BigDecimal fee = BigDecimal.valueOf(Numbers.toDouble(order.get("shipping_fee"))); String customer = order.getString("customer"); ledger.post("CustomerOrder", order.name(), LocalDate.now(), List.of( Entry.debit(Accounts.RECEIVABLE, customer, fee), Entry.credit(Accounts.REVENUE, customer, fee)), "Shipping fee " + order.name()); } @Override public void onCancel(Document order) { ledger.reverse("CustomerOrder", order.name()); } ``` The account codes are constants in the app, named after Circular 200: *`astra-apps/express/src/main/java/io/astra/apps/express/Accounts.java`* ```java static final String BANK = "112"; // Tiền gửi ngân hàng static final String RECEIVABLE = "131"; // Phải thu của khách hàng: shipping fees static final String COD_HELD = "1388"; // Phải thu khác: COD collected, still with couriers static final String COD_PAYABLE = "3388"; // Phải trả khác: COD owed to the shop static final String REVENUE = "511"; // Doanh thu cung cấp dịch vụ ``` Why an SPI: the same contract serves every app (the HRM app posts its payroll accrual through it, see [Video 19](/astra-cms/en/hrm)), and the kernel can change storage without touching apps. ### 2. The kernel validates, then inserts in the tenant transaction `LedgerStore` implements the SPI. It rounds to two decimals, refuses negatives, refuses an unbalanced voucher, refuses accounts that are not in the tenant's `Account` DocType, and inserts inside `tx.inTenantTx`, the same transaction the document is being saved in. *`astra-doctype/src/main/java/io/astra/doctype/ledger/LedgerStore.java`* ```java @Override public void post(String voucherType, String voucherNo, LocalDate postingDate, List entries, String remarks) { List lines = entries.stream().map(LedgerStore::rounded) .filter(e -> e.debit().signum() != 0 || e.credit().signum() != 0).toList(); if (lines.isEmpty()) return; BigDecimal debit = lines.stream().map(Entry::debit).reduce(BigDecimal.ZERO, BigDecimal::add); BigDecimal credit = lines.stream().map(Entry::credit).reduce(BigDecimal.ZERO, BigDecimal::add); if (debit.compareTo(credit) != 0) { throw new DocValidationException("unbalanced voucher " + voucherNo + ": debit " + debit + " ≠ credit " + credit); } tx.inTenantTx(conn -> { requireAccounts(conn, lines); ensurePartition(conn, postingDate); insert(conn, voucherType, voucherNo, postingDate, lines, false, remarks); return null; }); } ``` Because the posting and the document share one transaction, a refused posting rolls back the submit. There is no state where the order is submitted and its fee is missing from the books. Every row also records `posted_by` (the token's user, or `system` outside a request). ### 3. Reversal mirrors, it never edits *`astra-doctype/src/main/java/io/astra/doctype/ledger/LedgerStore.java`* ```java if (reversed) throw new DocValidationException("voucher " + voucherNo + " is already reversed"); if (original.isEmpty()) return null; LocalDate today = LocalDate.now(); ensurePartition(conn, today); List mirror = original.stream().map(e -> new Entry(e.account(), e.party(), e.credit(), e.debit())).toList(); insert(conn, voucherType, voucherNo, today, mirror, true, "Reversal of " + voucherNo); ``` Debit and credit swap; `is_reversal` is true; the posting date is *today*. Why today: an order billed in October and cancelled in November must leave October's figures as they were reported. The reversal lands in November's partition. ![Figure 2: Cancel adds, never edits](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-12-figure-2.png) *Cancel adds, never edits: cancelling a document posts an exact mirror reversal entry; previous lines remain immutable.* ### 4. The database protects itself *`astra-doctype/src/main/resources/db/migration/V3__ledger.sql`* ```sql CREATE TABLE astra_ledger ( id bigint GENERATED ALWAYS AS IDENTITY, tenant_id text NOT NULL, -- … debit numeric(18, 2) NOT NULL DEFAULT 0, credit numeric(18, 2) NOT NULL DEFAULT 0, voucher_type text NOT NULL, voucher_no text NOT NULL, is_reversal boolean NOT NULL DEFAULT false, remarks text, posted_by text NOT NULL, posted_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY (id, posting_date), CONSTRAINT astra_ledger_one_side CHECK (debit >= 0 AND credit >= 0 AND (debit = 0) <> (credit = 0)) ) PARTITION BY RANGE (posting_date); -- Rows outside the monthly partitions that the kernel creates ahead of time land here. CREATE TABLE astra_ledger_default PARTITION OF astra_ledger DEFAULT; -- … CREATE FUNCTION astra_ledger_append_only() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'astra_ledger is append-only: post a reversal instead of %', TG_OP USING ERRCODE = 'insufficient_privilege'; END $$; CREATE TRIGGER astra_ledger_no_update_delete BEFORE UPDATE OR DELETE ON astra_ledger FOR EACH ROW EXECUTE FUNCTION astra_ledger_append_only(); ``` Design choices worth noticing: - **Fixed relational columns, not JSONB.** Documents live in `astra_doc` as JSONB ([Video 2](/astra-cms/en/doctype)); ledger rows are narrow and numeric so sums are exact `numeric(18,2)` arithmetic and indexes are plain B-trees (`astra_ledger_voucher_idx`, `astra_ledger_account_idx`). - **One side per line**, enforced by a CHECK constraint, so a line cannot be both debit and credit or negative. - **Row-level security** with the same `astra.tenant` setting as documents, also enabled on each monthly partition, so querying a partition directly is as isolated as querying the parent. - **A trigger instead of REVOKE.** Privileges do not bind the table owner; a trigger fires for every role that does not drop it. ### 5. Partitions are created ahead of time *`astra-doctype/src/main/java/io/astra/doctype/ledger/LedgerPartitions.java`* ```java void onStart(@Observes StartupEvent ev) { try (Connection conn = dataSource.getConnection()) { conn.setAutoCommit(true); for (int i = 0; i < 3; i++) { LocalDate month = LocalDate.now().plusMonths(i); try { ledger.ensurePartition(conn, month); } catch (SQLException e) { // Another pod may be creating the same partition; the next posting retries. LOG.warnf("Ledger partition for %s not created yet: %s", month.withDayOfMonth(1), e.getMessage()); } } // … ``` `LedgerStore.ensurePartition` is also called lazily before each posting. Monthly partitions keep closing a period cheap: a past month is a table you can archive or attach to a reporting system without touching the current one. ![Figure 3: Monthly partitions](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-12-figure-3.png) *Monthly partitions: ledger entries route into monthly partitions automatically, keeping table scans fast and maintenance manageable.* ### 6. COD: owed money comes from the ledger Delivery books the cash the courier now holds (`WaybillScanHook`), and a payout may not exceed what the ledger says is owed to that shop: *`astra-apps/express/src/main/java/io/astra/apps/express/RemittanceHook.java`* ```java private BigDecimal outstanding(Document doc) { String customer = doc.getString("customer"); return customer == null ? BigDecimal.ZERO : ledger.balance(Accounts.COD_PAYABLE, customer).negate(); } private void check(Document doc) { BigDecimal amount = BigDecimal.valueOf(Numbers.toDouble(doc.get("amount"))); BigDecimal owed = outstanding(doc); if (amount.signum() <= 0) throw new DocValidationException("amount must be positive"); if (amount.compareTo(owed) > 0) { throw new DocValidationException("amount " + plain(amount) + " exceeds the COD owed to " + doc.getString("customer") + " (" + plain(owed) + ")"); } } ``` `check` runs on save and again on submit, then the payout posts Debit 3388 / Credit 112. There is no "outstanding" column to maintain; the number is a sum over append-only rows. ## Real-world example *Illustrative scenario: the company, people and values are made up; the mechanism and the rules it applies are the real ones shown above.* A small e-commerce courier in Ho Chi Minh City runs one day of business on Astra. **Inputs:** 1. Sales submits order A for shop S01 with a computed fee of 45,000 VND. 2. The weight was wrong. An accountant cancels order A (sales has no `cancel` permission); sales amends it, and the amended order is submitted with a fee of 62,000. 3. The parcel is delivered; the receiver paid COD of 1,200,000. 4. Accountant An prepares a payout of 1,200,000 to S01; accountant Binh approves it. **What Astra posts (`astra_ledger`):** | Voucher | Account | Debit | Credit | `is_reversal` | | --- | --- | ---: | ---: | --- | | Order A | 131 | 45,000 | | false | | Order A | 511 | | 45,000 | false | | Order A | 511 | 45,000 | | true | | Order A | 131 | | 45,000 | true | | Amended order | 131 | 62,000 | | false | | Amended order | 511 | | 62,000 | false | | Waybill | 1388 | 1,200,000 | | false | | Waybill | 3388 | | 1,200,000 | false | | CODRemittance | 3388 | 1,200,000 | | false | | CODRemittance | 112 | | 1,200,000 | false | **Output (trial balance, debit minus credit):** 131 = 62,000; 511 = −62,000; 1388 = 1,200,000; 3388 = 0; 112 = −1,200,000. The sum is zero. The wrong 45,000 fee is still visible, together with who posted it and who reversed it. If An had tried to approve her own payout, or pay 1,300,000, the kernel would have answered 403 or 422 and nothing would have been written. Note that the demo app does not post the courier handing cash to the company, so 1388 stays open here; a real deployment needs that voucher (for example Debit 111 or 112 / Credit 1388). ## For technical leaders - **The guarantee is layered.** Apps cannot write SQL to the ledger (SPI), the kernel refuses unbalanced or unknown-account vouchers, and the database refuses UPDATE and DELETE. The weakest layer is the database role: run the kernel with a role that does not own the tables, so it cannot DROP or ALTER them. - **Atomicity is local.** Posting joins the PostgreSQL transaction of the document. A posting that must also commit with another resource (credit limits in another schema, capacity in Redis) needs Seata, covered in [Video 17](/astra-cms/en/seata). - **Cost.** Every submit and cancel adds rows; nothing is ever removed. Plan storage per month of postings, and partition archiving as the retention mechanism. - **Balances are aggregates.** `balance()` and the trial balance sum rows on demand. Fine for the demo's volume; at millions of rows per month, add period snapshots or a reporting replica ([Video 16](/astra-cms/en/cdc)) rather than a mutable summary. - **What to measure.** Rows in `astra_ledger_default` (should be zero; non-zero means postings outside the pre-created months), unbalanced-voucher refusals (a bug in a hook), and reversal ratio per voucher type. - **When not to use this.** If your accounting system of record is an external ERP and Astra only feeds it, post there instead and keep Astra's documents as the source. - **Compared with alternatives.** An event-sourced store gives similar immutability with more machinery; a mutable GL table with audit logs gives edit history but not prevention. ERPNext uses reversing GL entries enforced in application code; Astra adds the database-level refusal. ## Try it yourself Prerequisites: JDK 25, Docker Desktop with 8 GB for the k3d stack, `kubectl`, `k3d`, `jq`, Bash 5. ```bash make k3d-up make demo-reset && make demo-seed make demo-express SCENES=10 PAUSE=0 open http://localhost:8080/desk/ # sign in as khoa / Astra@123 → General ledger ``` Expected from scene 10 of `scripts/demo-express.sh`: - `GET /api/ledger/trial-balance` lists `account, debit, credit, balance`. - After `tuan` submits an order, `GET /api/ledger/entries?voucher_no=` shows Debit 131 / Credit 511 and the partition each row sits in. - After the cancel, the same call shows two more rows with `is_reversal: true`. - When the script can reach the database (`DB=k3d` or `DB=compose`), the `UPDATE astra_ledger …` prints `ERROR: astra_ledger is append-only: post a reversal instead of UPDATE`. - `GET /api/ledger/partitions` lists the monthly partitions and the default partition with row counts. - `POST /api/doc/CODRemittance` with `amount: 99000000` returns 422; a 100,000 payout by `khoa` is refused on submit by `khoa` and accepted by `thu`. The behaviour is pinned by `astra-server/src/test/java/io/astra/server/LedgerTest.java` (for example `theLedgerIsAppendOnlyEvenForItsOwner`); run `make test`. ## To be accurate - The trigger blocks only UPDATE and DELETE. The role that owns the table can still DROP or ALTER it (and a row trigger does not fire on TRUNCATE). Production should run the kernel with a role that does not own the tables. - An audit hash chain over ledger rows is still on the roadmap. - The ledger lives in the kernel's PostgreSQL transaction; the separate `storage: ledger` DocType kind and per-app schemas described in [use-case-01.md](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/use-case-01.md) are target architecture, not built. - The demo app posts billing, COD collection and COD payout. It does not post the courier's cash handover, VAT, or period closing. ## Figures - [Figure 1 · One transaction, two writes](#how-it-works) - [Figure 2 · Cancel adds, never edits](#3-reversal-mirrors-it-never-edits) - [Figure 3 · Monthly partitions](#5-partitions-are-created-ahead-of-time) ## Related - Previous: [Video 11 · Keycloak](/astra-cms/en/keycloak). Next: [Video 13 · Per-tenant overlays](/astra-cms/en/overlay). - [Video 4 · Document lifecycle](/astra-cms/en/lifecycle) (Submit, Cancel, Amend), [Video 5 · Operations](/astra-cms/en/ops) (the waybill scan that books COD), [Video 17 · Seata](/astra-cms/en/seata), [Video 19 · HRM](/astra-cms/en/hrm). - Docs: [demo-code-snippets.md, S6](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/demo-code-snippets.md#s6-append-only-ledger), [use-case-01.md](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/use-case-01.md), [explain.en.md](/astra-cms/en/why-astra). - Source: `astra-doctype/src/main/resources/db/migration/V3__ledger.sql`, `astra-doctype/src/main/java/io/astra/doctype/ledger/LedgerStore.java`, `astra-doctype/src/main/java/io/astra/doctype/ledger/LedgerPartitions.java`, `astra-doctype/src/main/java/io/astra/doctype/api/LedgerResource.java`, `astra-spi/src/main/java/io/astra/spi/ledger/GeneralLedger.java`, `astra-apps/express/src/main/java/io/astra/apps/express/OrderPostingHook.java`, `astra-apps/express/src/main/java/io/astra/apps/express/RemittanceHook.java`, `astra-apps/express/src/main/java/io/astra/apps/express/WaybillScanHook.java`. --- # MCP: AI agents use Astra with the user's permissions Astra exposes one MCP endpoint, /mcp, where an AI agent signs in with the same Keycloak token as its human user, so it gets that user's tenant, row-level security and DocType permissions, and personal data is masked… 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/mcp ▶ [English](https://media.zerohostcms.com/astra-cms/astra-video-15-mcp-en.mp4) · [Tiếng Việt](https://media.zerohostcms.com/astra-cms/astra-video-15-mcp-vi.mp4) · 2:24 · Script: [demo-video-series.en.md](/astra-cms/en/video-scripts) > **In one sentence:** Astra exposes one MCP endpoint, `/mcp`, where an AI agent signs in with the same Keycloak token as its human user, so it gets that user's tenant, row-level security and DocType permissions, and personal data is masked before it reaches the model. > **Read this if:** you want to add an AI tool to an Astra app with one class and no auth code (developer) / you have to decide whether letting an LLM touch business data is safe, auditable and bounded (tech lead). ## The problem Astra Express runs customer care (CARE) for two courier tenants, Saigon and Hanoi. Care staff field calls such as "where is my parcel, and has anyone complained?" Answering means opening the waybill, its scan history, the order behind it and any support tickets, which takes four screens. An AI assistant could do it in one question, but connecting an LLM to business data usually goes wrong in one of three ways: - **A service account with god mode.** The assistant gets a database user or an API key that can read every tenant and every table. Whatever the care agent is not allowed to see, the model can still see, and a prompt injection can make it act on that. - **A second permission model.** The AI integration re-implements "who can see what" in its own layer. It drifts from the real rules the first time someone adds a role. - **Raw personal data to a third-party model.** Receiver names, phone numbers and addresses are sent to the model provider in full, which is hard to defend under Vietnam's personal data protection law or GDPR's data minimisation principle. Frappe/ERPNext exposes a REST API and lets you script around it, but there is no built-in agent protocol and no masking layer between the data and a model: each integration solves the three problems above on its own. Astra's answer is that **the agent is just another client of the same kernel**. It authenticates as the user, every tool call goes through the same permission checks as the REST API and the Desk, and fields declared `pii: true` in the DocType are masked on the MCP channel. ![Figure 1: The agent carries the user's identity](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-15-figure-1.png) *The AI agent accesses Astra over MCP using the caller's verified bearer token, inheriting tenant and role permissions.* ## What the video shows | Scene | What you see | What it proves | | --- | --- | --- | | Tools | `initialize` with protocol `2025-11-25`, then `tools/list` returns 7 tools | 5 kernel tools (`list_doctypes`, `search_documents`, `get_document`, `create_document`, `ledger_trial_balance`) and 2 contributed by the Express app (`track_waybill`, `cod_outstanding`) | | PII | `search_documents` for customer C001's orders | `receiver_phone` comes back as `*******888`, `receiver_name` as `B*** A*** K***`, the address as `3*** L*** L*** …`; `shipping_fee` (23,000) is unchanged. The signed-in Desk still shows full data | | Track | Real Claude Code, signed in as `ha` (care), asked in Vietnamese where parcel `WB-2610-000002` is and whether there are complaints | Claude picks `track_waybill` by itself and answers with the route (Created → Picked Up → HCM-Tân Bình → HN-Long Biên → Out for Delivery), the order and ticket `TCK-00002` | | Refuse | `ha` asks Claude to create a new order for C001 | Claude calls `create_document`, the kernel answers `not permitted: create CustomerOrder`; Claude explains the problem is permissions, not data | | COD | Accountant `khoa` asks which shops are waiting for COD payouts | `cod_outstanding` reads the ledger: Shop Hoa Mai (C001), 350,000 ₫ collected, not yet paid out | | Code | `TrackWaybillTool.java` | An app adds a tool with one class; the kernel handles auth, tenant, RLS, permission checks and masking | The terminal scene (`SCENES=13`) also shows two things the narration only touches on: `ha` calling `cod_outstanding` is refused with `not permitted: read Account`, and `ha` creating a SupportTicket through MCP succeeds, with the app's triage hook still raising its priority to High (`auto-escalated: mentions 'vỡ'`). ![Figure 4: Refused, not broken](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-15-figure-4.png) *Permission boundaries enforced cleanly: an unauthorized tool invocation returns an informative error without breaking the agent loop.* ## How it works ### One endpoint, same identity The MCP server is a JAX-RS resource in `astra-ai`. It speaks JSON-RPC over the Streamable HTTP transport with plain JSON responses (no server-initiated stream), and it has no authentication code of its own: the bearer token and `X-Astra-Tenant` header are validated by the same filters that protect `/api/doc`, and `TenantRequest` carries the resolved user, tenant and roles. *`astra-ai/src/main/java/io/astra/ai/McpServer.java`* ```java @Path("/mcp") @RunOnVirtualThread public class McpServer { private static final Logger LOG = Logger.getLogger(McpServer.class); static final List PROTOCOL_VERSIONS = List.of("2025-11-25", "2025-06-18", "2025-03-26"); static final Set KERNEL_TOOLS = Set.of("list_doctypes", "search_documents", "get_document", "create_document", "ledger_trial_balance"); // … /** No server-initiated stream: the spec allows 405 for clients that try to open one. */ @GET public Response stream() { return Response.status(405).header("Allow", "POST").build(); } ``` Kernel tools call the same `DocPermissions` check as REST before touching the store. That is why `ha` gets exactly the answer she would get from the Desk: *`astra-ai/src/main/java/io/astra/ai/McpServer.java`* ```java private Object create(Map args) { String doctype = required(args, "doctype"); permissions.require(doctype, tenant.roles(), Action.CREATE); if (!(args.get("data") instanceof Map data)) throw new IllegalArgumentException("'data' must be an object"); Map fields = new LinkedHashMap<>(); data.forEach((k, v) -> fields.put(String.valueOf(k), v)); return doc(store.insert(doctype, fields, tenant.userId())); } ``` The rule that refused her lives in the app's DocType, not in any AI code. `care` may read orders but has no `create`: *`astra-apps/express/src/main/resources/META-INF/astra/doctypes/customer-order.doctype.yaml`* ```yaml permissions: - { role: admin, read: true, create: true, write: true, submit: true, cancel: true } - { role: sales, read: true, create: true, write: true, if-owner: true, submit: true } - { role: ops, read: true } - { role: care, read: true } - { role: accountant, read: true, cancel: true } ``` A refusal is returned as a tool result with `isError: true`, not as a transport error, so the model can read the reason and explain it to the user (which is what Claude did in the video): *`astra-ai/src/main/java/io/astra/ai/McpServer.java`* ```java } catch (AccessDeniedException | DocValidationException | DocTypeNotFoundException | IllegalArgumentException e) { result = toolResult(Map.of("error", String.valueOf(e.getMessage())), true); wrote = false; } ``` ```mermaid sequenceDiagram participant U as ha (care) in Claude Code participant M as Claude (model) participant S as Astra /mcp participant P as DocPermissions participant D as astra_doc (RLS) U->>M: "Bưu kiện WB-2610-000002 đang ở đâu?" M->>S: tools/call track_waybill {waybill} · Bearer · X-Astra-Tenant S->>P: require READ on Waybill, CustomerOrder, SupportTicket P-->>S: ok for role care S->>D: read in tenant saigon-express D-->>S: waybill, order, tickets S->>S: PiiMasking.mask(result) S-->>M: masked result M-->>U: route, order, ticket TCK-00002 U->>M: "tạo đơn hàng mới cho C001" M->>S: tools/call create_document CustomerOrder S->>P: require CREATE on CustomerOrder P-->>S: denied S-->>M: isError: true, "not permitted: create CustomerOrder" ``` ### Masking on the way out `PiiMasking` walks the tool result and recognises documents by shape (a map with `doctype` and `data`). For each field the effective DocType (including the tenant overlay) marks `pii`, it replaces the value: *`astra-ai/src/main/java/io/astra/ai/PiiMasking.java`* ```java static Object maskValue(Object v) { if (v == null) return null; String s = v.toString(); String digits = s.replaceAll("\\D", ""); if (digits.length() >= 8 && digits.length() >= s.replaceAll("\\s", "").length() - 2) { return "*".repeat(digits.length() - 3) + digits.substring(digits.length() - 3); } return Arrays.stream(s.trim().split("\\s+")) .map(w -> w.isEmpty() ? w : w.substring(0, w.offsetByCodePoints(0, 1)) + "***") .collect(Collectors.joining(" ")); } ``` Why this design: - **Masking is driven by metadata.** The DocType author already says which fields are personal (`receiver_name`, `receiver_phone`, `receiver_address` are `pii: true` in `customer-order.doctype.yaml`). No AI integration has to keep its own list. - **Something useful survives.** The last three digits of a phone and the first letter of each word are enough for a care agent to confirm "is this the customer on the line?" without the model ever holding the full value. - **It is applied at the channel, not at storage.** The Desk shows full data to the signed-in user; only data leaving through MCP is masked. That keeps the human workflow intact. ![Figure 2: What masking does to one order](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-15-figure-2.png) *Masking PII on the way out: customer name and phone numbers declared as pii: true are redacted before reaching the model.* ### An app contributes a tool with one class The `McpTool` SPI is in `astra-spi`, so an app depends on nothing in `astra-ai`. The tool declares the DocTypes it reads; the kernel checks read permission on all of them before the tool runs, binds the tenant, logs the call and masks the result. *`astra-apps/express/src/main/java/io/astra/apps/express/TrackWaybillTool.java`* ```java @ApplicationScoped public class TrackWaybillTool implements McpTool { @Inject DocumentService docs; @Override public String name() { return "track_waybill"; } // … @Override public List reads() { return List.of("Waybill", "CustomerOrder", "SupportTicket"); } @Override public Object call(Map arguments) { String name = String.valueOf(arguments.get("waybill")); Document waybill = docs.get("Waybill", name).orElseThrow(() -> new IllegalArgumentException("waybill " + name + " not found")); Map out = new LinkedHashMap<>(); out.put("waybill", doc(waybill)); docs.get("CustomerOrder", waybill.getString("order")).ifPresent(o -> out.put("order", doc(o))); out.put("tickets", docs.find("SupportTicket", Map.of("waybill", name), 20).stream().map(TrackWaybillTool::doc).toList()); return out; } static Map doc(Document d) { return Map.of("doctype", d.doctype(), "name", d.name(), "docstatus", d.docstatus(), "data", d.data()); } } ``` *`astra-ai/src/main/java/io/astra/ai/AppToolInvoker.java`* ```java public McpTool authorize(String name) { McpTool tool = tools.stream().filter(t -> t.name().equals(name)).findFirst() .orElseThrow(() -> new IllegalArgumentException("unknown tool: " + name)); for (String doctype : tool.reads()) permissions.require(doctype, tenant.roles(), Action.READ); return tool; } // … public Outcome call(String channel, String name, Map args) { McpTool tool = authorize(name); LOG.infof("%s tool %s by %s in tenant %s (arguments: %s)", channel, name, tenant.userId(), tenant.tenantId(), args.keySet()); Object result = TenantContext.call(tenant.tenantId(), () -> pii.mask(tool.call(args))); return new Outcome(result, tool.writes()); } ``` `cod_outstanding` declares `reads() = List.of("Customer", "Account")`. The `care` role has no rule on `Account`, so `ha` is refused with `not permitted: read Account` before the tool's code runs; `khoa` (accountant) can read `Account` and gets the ledger balance of account 3388 per shop. Two design notes worth knowing: - **The permission check is declarative and up front.** A tool that reads three DocTypes is refused if the user lacks any one of them, even if this particular call would not have touched it. That is stricter than necessary, but it means a reviewer can audit a tool by reading one line. - **Masking depends on the result shape.** The SPI asks tools to return documents as `{doctype, name, data}` maps. A tool that flattens a PII field into its own map (for example `{"phone": "0901…"}`) would bypass masking. Review app tools for this. ![Figure 3: One app tool, many kernel duties](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-15-figure-3.png) *A single McpTool SPI implementation provides tool execution while the kernel handles tenant isolation, permissions, and auditing.* ### Writes report their WAL position Every MCP call is a POST, so the kernel adds `X-Tx-LSN` only after a successful call of a tool that writes (`create_document`, or an app tool whose `writes()` is true). The scene log shows it on the SupportTicket creation (`X-Tx-LSN: 0/432E3F8`). This is the same read-your-writes mechanism explained in [Video 16](/astra-cms/en/cdc). ## Real-world example **A 3PL's customer hotline in Ho Chi Minh City.** A care agent, signed in as a `care` user, uses an AI assistant during calls. The scenario below applies the mechanism above to illustrative values; it is not output from the demo. **Input.** A caller says her parcel `WB-2611-004211` has not arrived. The agent types: "Where is WB-2611-004211 and is there an open complaint?" **What Astra does.** 1. The assistant calls `track_waybill`. The kernel checks that `care` may read Waybill, CustomerOrder and SupportTicket, binds the agent's tenant, and runs the tool. Row-level security means a waybill number from another tenant simply returns "not found". 2. The order's `receiver_name` "Trần Thị Hồng", `receiver_phone` "0912 345 678" and `receiver_address` "12 Nguyễn Huệ, Quận 1" are masked to `T*** T*** H***`, `*******678` and `1*** N*** H*** Q*** 1***`. 3. The agent asks the caller for the last three digits of her phone; "678" matches the masked value, so the agent has verified the caller without the model ever seeing the full number. **Output.** The assistant answers with the scan route and the open ticket. When the caller then asks for a refund and the agent says "create a refund order", the assistant's `create_document` call is refused for the same reason the agent could not do it in the Desk. The log line `MCP tool create_document by in tenant ` records who tried. ## For technical leaders - **The security boundary is the user, not the AI.** An agent can never do more than the person whose token it carries. That also means it can do *everything* that person can, including writes allowed by their role. Give agent users the narrowest role that serves the workflow. - **Tokens are the weak point today.** The demo client registers a static bearer token that lasts about an hour (`scripts/mcp-claude.sh`). There is no OAuth flow for MCP clients yet, so token handling on the client machine is your responsibility. - **Masking is not anonymisation.** First letters plus last three digits reduce exposure; they do not make data anonymous. Fields not marked `pii: true` (for example the shop name "Shop Hoa Mai" in `cod_outstanding`) go to the model in full. Treat the `pii` flag as part of DocType review. - **Masking covers MCP only.** The Desk and the REST API return full data to authorised users, which is intended. - **What to measure:** tool calls per user and tenant (from the `MCP tool … by … in tenant …` log lines), refusal rate (`isError: true`), which tools are used, and which DocTypes still lack `pii` annotations. - **When not to use it:** if you need the model to see unmasked personal data (for example, drafting a letter to a named customer), this channel will not give it to you, by design. If you need autonomous agents running without a user present, that is not built. - **Compared with alternatives:** a hand-written REST wrapper per AI vendor duplicates auth and permission logic; a read-only database replica for the model loses tenant isolation unless you rebuild RLS around it. MCP is an open protocol, so the same endpoint serves Claude, Cursor or an in-house agent. ## Try it yourself Prerequisites: the k3d stack from [demo-video-series.en.md](/astra-cms/en/video-scripts) (JDK 25, Docker with 8 GB, `kubectl`, `k3d`, `jq`, Bash 5), and Claude Code (`claude`) for the agent part. ```bash make k3d-up make demo-reset && make demo-seed make demo-express SCENES=13 PAUSE=0 ``` Expected output (abbreviated from the recorded run): `protocolVersion: "2025-11-25"`, a list of 7 tools, masked `receiver_phone` values such as `*******888`, `track_waybill` with five scans, then `not permitted: create CustomerOrder` and `not permitted: read Account` for `ha`, and for `khoa` `[{"customer": "C001", "customer_name": "Shop Hoa Mai", "cod_owed_vnd": 350000.00}]`. To talk to it with a real agent: ```bash scripts/mcp-claude.sh ha saigon-express # registers the "astra" MCP server in Claude Code with ha's token claude # ask: "Bưu kiện WB-2610-000002 đang ở đâu, có khiếu nại không?" scripts/mcp-claude.sh khoa saigon-express # then ask: "Khách nào đang chờ trả COD?" ``` The script runs `claude mcp add --transport http astra http://localhost:8080/mcp` with the user's bearer token and `X-Astra-Tenant` header. Run it again when the token expires (about one hour). Waybill numbers contain the seed month, so pick one that exists in your seed. The integration tests are in `astra-server/src/test/java/io/astra/server/McpTest.java` (handshake, masking, permissions, unknown tools) and run with `make test`. ## To be accurate - The client uses a static bearer token (the dev client's one-hour token). OAuth discovery for MCP is on the roadmap. - Autonomous AI agents and vector search (pgvector RAG) are on the roadmap, not built. - PII masking applies to the MCP channel, not to the Desk. - Claude's answers on screen are real but shortened (the last scan time and SLA deadline were cut); the video labels them as shortened. - The MCP server answers with JSON only; a client that opens a GET stream gets 405. ## Figures - [Figure 1 · The agent carries the user's identity](#the-problem) - [Figure 2 · What masking does to one order](#masking-on-the-way-out) - [Figure 3 · One app tool, many kernel duties](#an-app-contributes-a-tool-with-one-class) - [Figure 4 · Refused, not broken](#what-the-video-shows) ## Related - Previous: [Video 14 · Tier 2 Wasm](/astra-cms/en/wasm) · Next: [Video 16 · Rust CDC + Sync-Aware LSN](/astra-cms/en/cdc) - [Video 11 · Keycloak](/astra-cms/en/keycloak) (where roles and DocType permissions come from) · [Video 20 · STMS](/astra-cms/en/stms) (more app MCP tools) - Code walkthrough: [S9 MCP for AI agents](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/demo-code-snippets.md#s9-mcp-for-ai-agents) (Vietnamese) - Background: [explain.en.md](/astra-cms/en/why-astra) (AI surface and MCP in the target architecture) - Source: `astra-ai/src/main/java/io/astra/ai/McpServer.java`, `astra-ai/src/main/java/io/astra/ai/PiiMasking.java`, `astra-ai/src/main/java/io/astra/ai/AppToolInvoker.java`, `astra-spi/src/main/java/io/astra/spi/ai/McpTool.java`, `astra-apps/express/src/main/java/io/astra/apps/express/TrackWaybillTool.java`, `astra-apps/express/src/main/java/io/astra/apps/express/CodOutstandingTool.java`, `scripts/mcp-claude.sh` --- # Rust CDC + Sync-Aware LSN A Rust worker reads PostgreSQL's write-ahead log and keeps replica tables for customer care, every write tells the client its WAL position (X-Tx-LSN), and a replica read that demands that position gets 202 Sync Pending… 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/cdc ▶ [English](https://media.zerohostcms.com/astra-cms/astra-video-16-cdc-en.mp4) · [Tiếng Việt](https://media.zerohostcms.com/astra-cms/astra-video-16-cdc-vi.mp4) · 2:36 · Script: [demo-video-series.en.md](/astra-cms/en/video-scripts) > **In one sentence:** A Rust worker reads PostgreSQL's write-ahead log and keeps replica tables for customer care, every write tells the client its WAL position (`X-Tx-LSN`), and a replica read that demands that position gets `202 Sync Pending` until the replica has caught up, so care never reads stale data. > **Read this if:** you need one module to read another module's data without querying its tables, and still see your own writes immediately (developer) / you are weighing eventual consistency against coupling and want a concrete, measurable contract (tech lead). ## The problem In Astra Express, operations (OPS) scans parcels and customer care (CARE) answers calls about them. The two modules should not share tables: OPS changes its schema on its own schedule, CARE needs a narrow, stable view, and in the target design the analytics side reads the same change stream. The standard answer is change data capture (CDC): stream changes out of the operational database into a copy the other module owns. CDC brings the classic problem of every replica: **staleness**. A courier scans a parcel "Out for Delivery"; two seconds later the customer calls; the care agent opens the parcel and sees "At Hub". Nobody can tell whether the copy is behind or the scan never happened. Teams usually pick one of these: - **Read the source tables directly.** Fresh, but CARE is now coupled to OPS's schema and load. - **Accept eventual consistency and hope.** Most of the time the copy is a few hundred milliseconds behind, and the rare stale read becomes a support ticket nobody can reproduce. - **Add a sleep or a "refresh in a minute" banner.** Slower for everybody, still no guarantee. Frappe/ERPNext keeps every module in the same database and reads source tables directly, so it does not have this problem, and also does not get the decoupling. Astra keeps the decoupling and makes staleness **visible and bounded**: the write returns a position in the log; a read can say "I need at least that position"; the kernel either serves data that includes the write or answers "not yet" without data. ![Figure 1: Write, position, read](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-16-figure-1.png) *Write, position, read: writes return an X-Tx-LSN watermark; subsequent reads from read replicas await synchronization.* ## What the video shows | Scene | What you see | What it proves | | --- | --- | --- | | Replication page | Desk → Replication (CDC): the worker decodes the WAL (`pgoutput`), a lag of 77 ms, bytes remaining | The worker is live and reports its progress through the kernel | | Code | `astra-cdc-rust/cdc-routes.yaml` and the worker loop | Routes are declared in YAML (DocType, target table, columns); the worker decodes pgoutput itself and writes replica rows and the watermark in one transaction; the slot advances only after commit | | Write | Worker paused (`kubectl scale … --replicas=0`), `quang` (OPS) sets `WB-2610-000003` to Out for Delivery | The response carries `X-Tx-LSN: 0/387F920` | | Pending | `ha` (CARE) reads `replica_waybill` with `X-Required-LSN: 0/387F920` | `202` with `"status": "sync-pending"`, replicated position `0/387CFC0`, no stale row | | Resume | Worker back on | The same read returns 200 26 ms after the worker came back, with `ops_status: Out for Delivery`. Steady state: a write followed by an immediate read caught up in 117 ms. Numbers vary per run | | Desk | The Waybill form's "CARE replica (CDC)" card | Shows "sync pending" and retries for 10 s. In the recording the worker was off longer, so the form was reopened once it ran | | Slot | `pg_replication_slots`, replica table flags | One logical slot `astra_cdc`, plugin `pgoutput`, `active = f` (normal, see below); replica tables have row-level security forced, and the worker role has neither SUPERUSER nor BYPASSRLS | ## How it works ```mermaid sequenceDiagram participant OPS as quang (OPS) participant K as Kernel participant PG as PostgreSQL (astra_doc, WAL) participant W as astra-cdc (Rust) participant CARE as ha (CARE) OPS->>K: PUT /api/doc/Waybill/WB-2610-000003 {"ops_status":"Out for Delivery"} K->>PG: UPDATE astra_doc … COMMIT K-->>OPS: 200 · X-Tx-LSN: 0/387F920 CARE->>K: GET /api/replica/replica_waybill?name=… · X-Required-LSN: 0/387F920 K->>PG: max(last_lsn) >= 0/387F920 ? (_cdc_sync_watermark) K-->>CARE: 202 sync-pending (replicated 0/387CFC0) W->>PG: pg_logical_slot_peek_binary_changes(astra_cdc, current LSN) W->>PG: BEGIN · upsert replica_waybill (under RLS) · watermark = current LSN · COMMIT W->>PG: pg_replication_slot_advance CARE->>K: same GET K-->>CARE: 200 · row ops_status = Out for Delivery ``` ### 1. What gets replicated is declared, not coded *`astra-cdc-rust/cdc-routes.yaml`* ```yaml slot: astra_cdc publication: astra_cdc poll_ms: 50 routes: - doctype: Waybill target: replica_waybill columns: - { name: order_name, from: order } - { name: ops_status, from: ops_status } - { name: current_hub, from: current_hub } - { name: receiver_name, from: receiver_name } - { name: scans, from: scan_log, transform: length, type: integer } # … ``` All DocTypes live in one JSONB table, `astra_doc` ([Video 2](/astra-cms/en/doctype)). The publication covers only that table, and a route picks one DocType and projects chosen JSON keys into typed columns. CARE gets `scans` as a count rather than the full scan log, which is the point of a replica: a narrow view the consumer owns. Route names become SQL identifiers, so the worker refuses anything that is not a plain lower-case name or one of four types. ### 2. The worker loop: peek, apply with the watermark, then advance *`astra-cdc-rust/src/main.rs`* ```rust loop { let cur: String = client.query_one("SELECT pg_current_wal_lsn()::text", &[]).await?.get(0); let rows = client .query( "SELECT lsn::text, data FROM pg_logical_slot_peek_binary_changes($1, $2::text::pg_lsn, NULL, \ 'proto_version', '1', 'publication_names', $3)", &[&cfg.slot, &cur, &cfg.publication], ) .await?; let tx = client.transaction().await?; // … decode each row with pgoutput::decode, upsert matching astra_doc rows into their replica … if applied > 0 || last_watermark.elapsed() >= Duration::from_secs(1) { tx.execute( "INSERT INTO _cdc_sync_watermark (slot, last_lsn, events, lag_ms, updated_at) VALUES ($1, $2::text::pg_lsn, $3, $4, now()) \ ON CONFLICT (slot) DO UPDATE SET last_lsn = excluded.last_lsn, events = _cdc_sync_watermark.events + excluded.events, \ lag_ms = coalesce(excluded.lag_ms, _cdc_sync_watermark.lag_ms), updated_at = now()", &[&cfg.slot, &cur, &(applied as i64), &lag_ms.map(|v| v as i32)], ) .await?; last_watermark = Instant::now(); } tx.commit().await?; client.execute("SELECT pg_replication_slot_advance($1, $2::text::pg_lsn)", &[&cfg.slot, &cur]).await?; // … tokio::time::sleep(Duration::from_millis(cfg.poll_ms)).await; } ``` Why it is built this way: - **The watermark commits with the data.** Replica rows and `_cdc_sync_watermark.last_lsn` are written in the same transaction, so "the watermark says X" always means "every change up to X is in the replicas". There is no window where the watermark is ahead of the data. - **The slot advances after the commit.** If the worker crashes between commit and advance, the next poll peeks the same changes again. Upserts keyed by `(tenant_id, name)` make that replay harmless: at-least-once delivery, idempotent apply. - **SQL peek/advance instead of a streaming connection.** The worker uses an ordinary connection and the SQL functions for logical slots. That is simpler to run and reason about, at the cost of a poll interval (50 ms) and the fact that the slot shows `active = f` between polls. Streaming is on the roadmap. - **pgoutput is decoded in the worker.** `src/pgoutput.rs` decodes protocol version 1 (Begin, Commit, Relation, Insert, Update, Delete, Truncate). pgoutput is PostgreSQL's built-in output plugin, so no extension is needed on the database. Deletes are ignored because documents are never deleted, only cancelled. ![Figure 2: Inside one worker poll](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-16-figure-2.png) *Inside the Rust CDC worker loop: pgoutput decoding, watermark updates, and transactional replay into replica tables.* ### 3. Replica rows are written under RLS The worker does not get a privileged role. Each row's tenant is bound before the upsert: *`astra-cdc-rust/src/main.rs`* ```rust // The replica tables are under row-level security; bind this row's tenant for the write. tx.execute("SELECT set_config('astra.tenant', $1, true)", &[&tenant]).await?; ``` *`astra-doctype/src/main/resources/db/migration/V5__cdc_replicas.sql`* ```sql ALTER TABLE replica_waybill ENABLE ROW LEVEL SECURITY; ALTER TABLE replica_waybill FORCE ROW LEVEL SECURITY; CREATE POLICY replica_waybill_tenant ON replica_waybill USING (tenant_id = current_setting('astra.tenant', true)) WITH CHECK (tenant_id = current_setting('astra.tenant', true)); ``` *`deploy/db/20-cdc.sql`* ```sql -- REPLICATION reads the WAL through a slot; no SUPERUSER and no BYPASSRLS: replica rows are -- written under row-level security like any other. CREATE ROLE astra_cdc LOGIN REPLICATION PASSWORD 'astra_cdc' NOSUPERUSER NOBYPASSRLS NOCREATEDB NOCREATEROLE; ``` `WITH CHECK` means a bug in the worker that binds the wrong tenant fails the write instead of leaking a row into another tenant's replica. The recorded `replica-rls.txt` confirms `relforcerowsecurity = t` on both replica tables and `rolsuper = f`, `rolbypassrls = f` for `astra_cdc`. ### 4. The kernel's LSN gate *`astra-doctype/src/main/java/io/astra/doctype/sync/SyncAwareLsnFilter.java`* ```java @Override public void filter(ContainerRequestContext req) { String required = req.getHeaderString(REQUIRED_LSN); if (required == null || required.isBlank() || !path(req).startsWith("api/replica")) return; // … reject a malformed LSN with 400 … if (!watermark.reached(required)) { Map body = new LinkedHashMap<>(); body.put("status", "sync-pending"); body.put("requiredLsn", required); body.put("replicatedLsn", watermark.status().get("replicatedLsn")); body.put("retryAfterMs", 50); req.abortWith(Response.status(202).entity(body).build()); } } @Override public void filter(ContainerRequestContext req, ContainerResponseContext res) { String path = path(req); if (WRITES.contains(req.getMethod()) && res.getStatus() / 100 == 2 && path.startsWith("api/doc/")) { res.getHeaders().putSingle(TX_LSN, watermark.currentLsn()); } } ``` *`astra-doctype/src/main/java/io/astra/doctype/sync/SyncWatermark.java`* ```java /** Whether everything up to {@code required} has been applied to the replicas. */ public boolean reached(String required) { try (Connection conn = dataSource.getConnection(); PreparedStatement ps = conn.prepareStatement( "SELECT coalesce(max(last_lsn) >= ?::pg_lsn, false) FROM _cdc_sync_watermark")) { // … ``` Design notes: - **The LSN is taken after the commit.** The response filter runs after the document transaction has committed and reads `pg_current_wal_lsn()`, which is at or past the commit record. The position may be slightly later than the write strictly needs (other writes can land in between), so a client may wait a few milliseconds longer than necessary; it can never be told "in sync" too early. In the recording, the row's `source_lsn` (`0/387EFF0`) is just below the `X-Tx-LSN` the client received (`0/387F920`). - **202, not a blocked request.** The kernel does not hold a thread waiting for the worker. It answers immediately with the positions and a retry hint, and the client decides how long to wait. Reads without `X-Required-LSN` are served as before, so a dashboard that tolerates lag pays nothing. - **The contract is opt-in per read.** Only the client knows whether it just wrote something it needs to see. The Desk remembers the `X-Tx-LSN` of its latest write and sends it on replica reads: *`astra-server/src/main/resources/META-INF/resources/desk/desk.js`* ```js for (let attempt = 0; attempt < 100; attempt++) { const required = state.lastLsn; const res = await api('GET', '/api/replica/' + spec[0] + '?name=' + enc(key), undefined, required ? { 'X-Required-LSN': required } : {}); if (res.status === 202) { // … show "sync pending" with required and replicated positions … await new Promise((r) => setTimeout(r, 100)); continue; } // … show "in sync" and the replica row … } status.textContent = 'Still pending after 10 s: is the CDC worker running? See Replication (CDC).'; ``` The replica endpoint itself (`ReplicaResource`) requires read permission on the *source* DocType, so a role that cannot read Waybill cannot read `replica_waybill` either. ![Figure 3: The 202 gate](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-16-figure-3.png) *The 202 gate: reads with an X-Required-LSN yield 202 Sync Pending until the CDC replica watermark catches up.* ## Real-world example **A last-mile operator in Hanoi with a separate support team.** Couriers update parcel status from handhelds through the OPS module; the support team works only from replica tables. The values below are illustrative. **Input.** At 14:02:10 courier Minh marks `WB-2611-018803` as "Failed Delivery" (customer not home). The PUT returns `200` with `X-Tx-LSN: 1/0A41C2D8`. His handheld app forwards that position to the support system with the parcel number, as part of its "call the customer" task. **What Astra does.** 1. At 14:02:11 support agent Lan opens the parcel. Her client sends `X-Required-LSN: 1/0A41C2D8`. 2. The worker is mid-poll; the watermark is `1/0A41B900`. The kernel answers `202 sync-pending` with both positions. 3. Lan's screen shows "sync pending" instead of the old "Out for Delivery". On the next poll the worker applies the change, commits the replica row and watermark `1/0A41D010` together, and the retry returns `200` with `ops_status: Failed Delivery`. **Output.** Lan never sees a stale status and never has to ask "can you refresh?". If the worker is down, she sees "still pending" and the Replication page shows the worker as not reporting, which turns a silent data problem into an operational alert. ## For technical leaders - **What you get:** module decoupling through CDC with an explicit read-your-writes contract. The guarantee is "never stale for the writes you name", not "always fresh". - **What it costs:** one Rust process (`astra-cdc`), one logical replication slot, one watermark table and one extra query per replica read that carries `X-Required-LSN`. `wal_level=logical` is required on PostgreSQL. - **Operational risk: the slot retains WAL.** While the worker is stopped, PostgreSQL keeps the WAL the slot has not consumed. A worker that stays down for a long time grows the WAL on disk. Monitor `bytesBehind` from `GET /api/sync/status` and alert on `workerReporting: false`. - **Latency is a measured number, not a promise.** The recorded run showed a 77 ms lag on the Replication page, 26 ms from worker restart to the first in-sync read, and 117 ms from write to in-sync in steady state. The poll interval (50 ms) sets the floor. Measure p50/p99 of write-to-in-sync on your own load. - **One watermark for all tenants.** The worker applies every tenant's changes in order, so a burst from one tenant delays the watermark for all of them. That is fine at demo scale; watch it under multi-tenant load. - **When not to use it:** if a screen must always show the latest data and cannot handle "pending", read the source DocType through the REST API instead. If you need a replica in another database or an analytics store, that is not built yet. - **Compared with alternatives:** Debezium plus Kafka is a mature general CDC pipeline but adds a broker and a JVM connector, and does not give you a per-request freshness contract on its own. Reading the source tables directly is fresh but couples modules. Astra's approach is small and specific: one table, declared routes, and an LSN handshake between writer and reader. ## Try it yourself Prerequisites: the k3d stack from [demo-video-series.en.md](/astra-cms/en/video-scripts) (JDK 25, Docker with 8 GB, `kubectl`, `k3d`, `jq`, Bash 5). `make k3d-up` starts PostgreSQL with `wal_level=logical` and runs `scripts/db-setup.sh`, which creates the CDC role and publication. Rust is only needed if you change the worker. ```bash make k3d-up make demo-reset && make demo-seed make demo-express SCENES=14 PAUSE=0 ``` The scene pauses the worker (`kubectl -n astra scale deployment/astra-cdc --replicas=0`), writes, reads with the LSN, resumes the worker and polls until the read returns 200. Expected output, excerpted from the recorded run (positions and times differ per run): *`docs/video/v16-cdc/assets/scene-14.log`* ```text HTTP 200 X-Tx-LSN: 0/387F920 // … HTTP 202 { "status": "sync-pending", "requiredLsn": "0/387F920", "replicatedLsn": "0/387CFC0", "retryAfterMs": 50 } // … in sync 26 ms after the worker came back // … replica caught up 117 ms after the write: {"ops_status":"Delivered","scans":5,"source_lsn":"0/3880788"} // … slot_name | plugin | active | confirmed_flush_lsn -----------+----------+--------+--------------------- astra_cdc | pgoutput | f | 0/38812D0 ``` To see it in the Desk: open `http://localhost:8080/desk/`, go to Replication (CDC), then save a Waybill and watch the "CARE replica (CDC)" card under the form. The kernel side is covered by `astra-server/src/test/java/io/astra/server/SyncTest.java` (run with `make test`). That test advances `_cdc_sync_watermark` itself rather than running the Rust worker; the worker end to end is exercised by scene 14 on the k3d stack. ## To be accurate - The worker reads the slot with `pg_logical_slot_peek_binary_changes` every 50 ms (at-least-once, idempotent upsert) and does not use the streaming replication protocol yet. - Nothing is pushed to Apache Doris yet; the analytics pipeline in [use-case-01.md](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/use-case-01.md) is target architecture. - The use case describes the worker as built on `supabase/etl` with replicas in per-module schemas and a Redis cache for the watermark. The running worker is a small custom decoder on `tokio-postgres`, the replicas are in the `public` schema, and the watermark is read from its table on each gated request. - The Desk retries for 10 s. If the worker is off longer, the card says it is still pending and you reopen the form. - Latency numbers vary between runs; the script's original "about 50 ms" became 117 ms in this recording, and the video says so. ## Figures - [Figure 1 · Write, position, read](#the-problem) - [Figure 2 · Inside one worker poll](#2-the-worker-loop-peek-apply-with-the-watermark-then-advance) - [Figure 3 · The 202 gate](#4-the-kernels-lsn-gate) ## Related - Previous: [Video 15 · MCP](/astra-cms/en/mcp) · Next: [Video 17 · Seata](/astra-cms/en/seata) - [Video 6 · Customer care sees operations](/astra-cms/en/care) (the CARE replica card first appears) · [Video 7 · Multi-tenancy](/astra-cms/en/tenants) (row-level security) - Code walkthrough: [S10 Rust CDC and Sync-Aware LSN](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/demo-code-snippets.md#s10-rust-cdc-and-sync-aware-lsn) (Vietnamese) - Target design: [use-case-01.md](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/use-case-01.md) (Rust CDC, Sync-Aware LSN, Doris; Vietnamese) - Source: `astra-cdc-rust/src/main.rs`, `astra-cdc-rust/src/pgoutput.rs`, `astra-cdc-rust/cdc-routes.yaml`, `astra-doctype/src/main/java/io/astra/doctype/sync/SyncAwareLsnFilter.java`, `astra-doctype/src/main/java/io/astra/doctype/sync/SyncWatermark.java`, `astra-doctype/src/main/java/io/astra/doctype/api/ReplicaResource.java`, `astra-doctype/src/main/resources/db/migration/V5__cdc_replicas.sql`, `deploy/db/20-cdc.sql` --- # Seata: one submit, three resources One YAML line, transaction: global, makes submitting an order a single Apache Seata global transaction that spans the BMS credit limit (an AT branch in its own schema), a pickup slot in Redis (a TCC branch) and the… 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/seata ▶ [English](https://media.zerohostcms.com/astra-cms/astra-video-17-seata-en.mp4) · [Tiếng Việt](https://media.zerohostcms.com/astra-cms/astra-video-17-seata-vi.mp4) · 2:26 · Script: [demo-video-series.en.md](/astra-cms/en/video-scripts) > **In one sentence:** One YAML line, `transaction: global`, makes submitting an order a single Apache Seata global transaction that spans the BMS credit limit (an AT branch in its own schema), a pickup slot in Redis (a TCC branch) and the kernel's own document transaction, so either all three change or none do. > **Read this if:** you write a hook that must change data another module owns and want rollback without writing compensation code yourself (developer) / you are deciding between distributed transactions, sagas and a single shared database, and want to see the real trade-offs in running code (tech lead). ## The problem When a sales clerk at Astra Express submits a customer order, three things must happen together: 1. **BMS (billing)** charges the shipping fee against the customer's credit limit. BMS owns its data in its own schema (`bms`), under its own database role. The order module cannot write there in its own transaction. 2. **OPS (operations)** reserves a pickup slot for today. Pickup capacity is a fast counter in Redis, not a database row. 3. **The kernel** marks the order Submitted, creates the waybill and posts the journal entry, all in PostgreSQL. If BMS accepts the charge but OPS has no slot left, the customer has been charged credit for an order that will never be picked up. If the order is submitted but the credit charge failed, billing is wrong. Usually teams pick one of: - **One shared database and one transaction.** This is how Frappe/ERPNext works: all modules share one database, so a credit check and a submit are one database transaction. It is simple, but it gives up module ownership of data, and Redis is still outside the transaction. - **Hand-written compensation (saga).** Each step has an "undo" written by hand; every developer has to get the failure ordering, retries and duplicates right. - **Ignore it.** Reconcile with a nightly job and fix mismatches by hand. Astra uses a transaction coordinator. The app says *what* it wants (use credit, reserve a slot); Seata records each branch and drives commit or rollback for all of them. Database writes get automatic undo (AT mode); non-database resources implement try/confirm/cancel (TCC mode). ![Figure 1: One submit, three resources](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-17-figure-1.png) *One submit, three resources: the document lifecycle coordinates PostgreSQL, Redis pickup capacity, and BMS credit limits.* ## What the video shows | Scene | What you see | What it proves | | --- | --- | --- | | YAML | `customer-order.doctype.yaml`: `transaction: global` | The kernel opens a global transaction around submit and cancel | | Success | Submit order `ORD-20261021-00007` for C001 (fee 73,000 ₫) | Used credit goes from 123,000 to 196,000; pickup slots booked go from 5 to 6; waybill `WB-2610-000006` is created. Seata Server records two branches, AT then TCC, and commits both | | Over limit | Submit the draft `ORD-20261021-00005` for C002 | `422 credit limit of C002 exceeded: 54000.00 used of 150000.00, this order needs 105000.00`. The order stays a draft, no waybill, no journal entry; AT refused first, TCC never reserved | | Late refusal | The day is set to fully booked (200 of 200); submit `ORD-20261021-00008` | AT has already taken the credit, then TCC refuses: `no pickup slot left for 2026-10-21 (capacity 200)`. Seata restores C001's credit from the undo log (still 196,000), `undo_rows` is 0, reserved slots stay at 0 | | Server log | The real log of Seata Server 2.6.0 (JDK 25) | Begin, two branch registrations, both rolled back: about 75 ms on its clock | | Code | `OrderCreditHook`, `PickupCapacity` | The app calls an SPI and sees no Seata type. AT is plain JDBC on a proxied datasource; TCC is an app participant that throws when slots run out | ![Figure 2: Rollback after a branch committed](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-17-figure-2.png) *Two-phase commit in action: if a branch rejects, Seata coordinates global rollback across AT undo logs and TCC cancel handlers.* ## How it works ```mermaid sequenceDiagram participant C as tuan (sales) participant K as Kernel (DocumentStore.submit) participant TC as Seata Server 2.6.0 participant B as bms.customer_credit (AT) participant R as Redis pickup counters (TCC) C->>K: POST /api/doc/CustomerOrder/ORD-…/submit K->>TC: begin global transaction K->>K: open kernel tx (schema public, RLS) K->>B: UPDATE customer_credit SET used = used + fee (via DataSourceProxy) B->>TC: register AT branch, undo_log row (before/after image) K->>R: tcc prepare: reserved + 1 R->>TC: register TCC branch K->>K: waybill, docstatus = 1, ledger · commit kernel tx last alt everything succeeded K->>TC: global commit TC->>B: phase 2: delete undo_log TC->>R: confirm: reserved - 1, booked + 1 else any step threw K->>TC: global rollback TC->>B: restore before image from undo_log TC->>R: cancel: reserved - 1 end ``` ### 1. One line in the DocType *`astra-apps/express/src/main/resources/META-INF/astra/doctypes/customer-order.doctype.yaml`* ```yaml name: CustomerOrder module: oms kind: transaction submittable: true # Draft (0) -> Submitted (1) -> Cancelled (2) -> Amend transaction: global # submit/cancel span BMS credit (Seata AT) and pickup capacity (Seata TCC) ``` The kernel reads this flag and wraps submit and cancel. Other DocTypes are untouched: a draft save, or a submit of a DocType without the flag, never contacts the coordinator. *`astra-doctype/src/main/java/io/astra/doctype/store/DocumentStore.java`* ```java public Optional submit(String doctype, String name) { DocTypeDefinition def = requireSubmittable(doctype); return global(def, "submit " + doctype + " " + name, () -> inTenantTx(conn -> { // … load the draft, check the caller may submit … hooks.onSubmit(doc); DocumentValidator.applyDefaultsAndValidate(def, doc); validateLinks(conn, def, doc); return Optional.of(write(conn, doc, 1)); })); } // … private T global(DocTypeDefinition def, String name, Supplier work) { if (def.globalTransaction() && coordinator.isResolvable() && coordinator.get().available()) { return coordinator.get().inGlobal(name, work); } return work.get(); } ``` The global transaction is the outer scope and the kernel's database transaction is inside it, so the kernel's own writes commit last, after every branch has done its phase 1. If the kernel transaction fails, the exception reaches the global scope and every branch is rolled back. ### 2. The coordinator: begin, run, commit or roll back *`astra-tx-seata/src/main/java/io/astra/tx/seata/SeataTransactions.java`* ```java @Override public T inGlobal(String name, Supplier work) { if (RootContext.inGlobalTransaction()) return work.get(); GlobalTransaction gtx = GlobalTransactionContext.getCurrentOrCreate(); // … gtx.begin(timeoutMillis, name) … String xid = gtx.getXid(); try { T result = work.get(); gtx.commit(); LOG.infof("Global transaction %s committed (%s)", xid, name); return result; } catch (TransactionException e) { throw new IllegalStateException("global commit failed: " + e.getMessage(), e); } catch (Throwable e) { // Any failure, Errors included: a branch may already hold global locks that only a rollback releases. try { gtx.rollback(); LOG.infof("Global transaction %s rolled back (%s): %s", xid, name, e.getMessage()); } catch (TransactionException te) { LOG.errorf(te, "Global transaction %s rollback failed; the coordinator retries it", xid); } throw e; } finally { if (xid != null && xid.equals(RootContext.getXID())) RootContext.unbind(); } } ``` Catching `Throwable` rather than `Exception` is deliberate: once an AT branch has run, it holds a global lock on the credit row, and only a rollback releases it. Seata is disabled unless `astra.seata.enabled` is true; without it, `global(...)` runs the work locally and the hook below skips its checks. ![Figure 3: TCC participant states](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-17-figure-3.png) *TCC participant lifecycle: Try reserves resources, while Confirm commits and Cancel releases reserved capacity.* ### 3. The app hook sees an SPI, not Seata *`astra-apps/express/src/main/java/io/astra/apps/express/OrderCreditHook.java`* ```java @Override public void onSubmit(Document order) { GlobalTransactions tx = coordinator(); if (tx == null) return; CreditLimits.use(tx, order.getString("customer"), fee(order)); tx.tcc(PickupCapacity.NAME, LocalDate.now().toString(), Map.of("capacity", DAILY_PICKUP_CAPACITY, "order", order.name())); } ``` `GlobalTransactions` and `TccParticipant` are interfaces in `astra-spi`. The Express app depends only on the SPI, so the coordinator could be replaced without touching app code. **AT branch: plain JDBC.** `CreditLimits.use` gets a connection from `tx.atDataSource("bms")`, which is the BMS datasource wrapped in Seata's `DataSourceProxy`. The check and the charge are one statement: *`astra-apps/express/src/main/java/io/astra/apps/express/CreditLimits.java`* ```java try (PreparedStatement ps = conn.prepareStatement( "UPDATE customer_credit SET used = used + ?, updated_at = now() WHERE account_key = ? AND used + ? <= credit_limit")) { ps.setBigDecimal(1, amount); ps.setString(2, accountKey(customer)); ps.setBigDecimal(3, amount); if (ps.executeUpdate() == 1) return; } // … otherwise read the row and throw "credit limit of … exceeded: … used of …, this order needs …" ``` The proxy records the row's before and after images in `bms.undo_log` (the table is created by `astra-tx-seata/src/main/resources/db/bms/V1__customer_credit.sql`) and commits the BMS change locally. On global commit, Seata deletes the undo rows; on rollback, it writes the before image back. That is why the video can show "credit restored, `undo_rows` 0" without any compensation code in the app. **TCC branch: an app participant.** Redis has no undo log, so the app implements the three phases itself: *`astra-apps/express/src/main/java/io/astra/apps/express/PickupCapacity.java`* ```java @Override public void prepare(String branchId, String day, Map args) { if (counters.get("tcc-done:" + branchId) > 0) { throw new DocValidationException("pickup reservation " + branchId + " was already cancelled"); // late try after rollback } counters.add("tcc-try:" + branchId, 1, KEEP); long capacity = Math.round(Numbers.toDouble(args.get("capacity"))); long reserved = counters.add(reservedKey(day), 1, KEEP); if (reserved + counters.get(bookedKey(day)) > capacity) { // The branch is registered, so Seata calls rollback, which gives this slot back. throw new DocValidationException("no pickup slot left for " + day + " (capacity " + capacity + ")"); } } @Override public void commit(String branchId, String day, Map args) { if (!counters.markOnce("tcc-done:" + branchId, KEEP)) return; counters.add(reservedKey(day), -1, KEEP); counters.add(bookedKey(day), 1, KEEP); } @Override public void rollback(String branchId, String day, Map args) { if (!counters.markOnce("tcc-done:" + branchId, KEEP)) return; if (counters.get("tcc-try:" + branchId) > 0) counters.add(reservedKey(day), -1, KEEP); // nothing to give back after an empty rollback } ``` The three classic TCC hazards are handled per branch, keyed by `branchId`: - **Idempotent phase 2.** Seata may retry commit or rollback. `markOnce("tcc-done:…")` makes the second call a no-op. - **Empty rollback.** A rollback can arrive for a branch whose try never ran. `tcc-try:…` is checked before giving a slot back. - **Suspension.** A try that arrives after its own rollback is refused. The kernel registers a single Seata TCC resource, `astra-tcc`, and dispatches to app participants by name. Phase 2 runs on a Seata thread with no HTTP request, so the dispatcher re-binds the tenant recorded at prepare time: *`astra-tx-seata/src/main/java/io/astra/tx/seata/AstraTccDispatcher.java`* ```java private void phase2(BusinessActionContext c, boolean commit) { String tenant = String.valueOf(c.getActionContext("tenant")); TccParticipant p = find(String.valueOf(c.getActionContext("participant"))); String key = String.valueOf(c.getActionContext("key")); Map args = arguments(String.valueOf(c.getActionContext("arguments"))); String branch = c.getXid() + "/" + c.getBranchId(); TenantContext.run(tenant, () -> { if (commit) p.commit(branch, key, args); else p.rollback(branch, key, args); }); } ``` ![Figure 4: Who sees Seata](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-17-figure-4.png) *Apps interact strictly with transactional SPI interfaces; the Seata client runtime remains completely decoupled from app logic.* ### 4. What the coordinator saw The video's server log (`docs/video/v17-seata/assets/seata-server.log`) shows, for the late-refusal case, the global begin at `00:48:03.690`, the AT branch registration, the TCC branch registration, a `GlobalRollbackRequest` and the last `BranchRollbackResponse` at `00:48:03.765`: about 75 ms on the server's clock. The branch order in the log (AT before TCC) matches `OrderCreditHook`. ## Real-world example **A B2B distributor with credit terms and a loading-dock schedule.** Finance owns customer credit in its own schema; the warehouse manages dock appointments in Redis because they change every few seconds. The values below are illustrative; the mechanism is the one in this video. **Input.** Customer "Minh Phát Trading" has a credit limit of 500,000,000 ₫ with 462,000,000 ₫ used. A sales rep submits a sales order of 31,500,000 ₫ that needs a dock slot on 15 November. The dock has 40 slots per day; 39 are booked. **What Astra does.** 1. The coordinator begins a global transaction. 2. AT branch: `used` goes from 462,000,000 to 493,500,000 (within the limit); the before image is in `undo_log`. 3. TCC branch: `reserved` goes to 1; 1 + 39 ≤ 40, so the slot is held. 4. The kernel writes the order as Submitted and posts its journal entry, then commits; the coordinator commits both branches: undo rows are deleted, `reserved` returns to 0 and `booked` becomes 40. **Output, and the failure case.** A second order for the same customer, 4,000,000 ₫, arrives a minute later. The AT branch succeeds (493,500,000 + 4,000,000 ≤ 500,000,000), but the dock is now full: the TCC try sees 1 + 40 > 40 and throws. The coordinator restores `used` to 493,500,000 from the undo log and cancels the reservation. The order stays a draft, and the rep sees "no slot left" instead of a half-done order that finance has to chase. ## For technical leaders - **What you get:** all-or-nothing across a module's own schema, Redis and the kernel, with automatic undo for SQL (AT) and an explicit, testable contract for everything else (TCC). App code stays free of coordinator types. - **What it costs:** a Seata Server to operate, extra round trips to it per global submit (in the recorded commit case, begin to "Committing global transaction is successfully done" spans about 67 ms on the server's clock), and an `undo_log` table in each AT schema. Only DocTypes marked `transaction: global` pay this. - **Hot rows serialise.** An AT branch holds a global lock on the rows it changed until phase 2. Many concurrent submits for one large customer will queue on that customer's credit row. Measure lock waits if one shop dominates your volume. - **The coordinator is central.** In this demo it runs with the file store in one pod (`deploy/k8s/base/seata.yaml`); losing that pod loses in-flight transaction state. Production needs Seata's database or Raft store. - **Know the last-resource window.** The kernel's own transaction is not a Seata branch; it commits last, then the global commit is sent. If the coordinator cannot be reached in that window, the order is already committed while the branches wait for the coordinator to resolve them. Making the kernel a branch (AT for the kernel itself) is on the roadmap. Track `global commit failed` in logs. - **TCC participants are real code.** Each one must be idempotent and handle empty rollback and suspension. Review and test them like payment code; `PickupCapacity` is the reference pattern. - **When not to use it:** if all data is in one PostgreSQL database and one schema, a local transaction is simpler and faster. If steps span external partners or take minutes (a carrier API, a human approval), use an asynchronous saga or outbox instead; a global lock should not be held that long. - **Compared with alternatives:** XA two-phase commit needs XA support in every resource and holds database locks through both phases; Redis has no XA. A saga avoids locks but pushes compensation and visible intermediate states onto app developers. [explain.en.md](/astra-cms/en/why-astra) records a transactional outbox plus idempotent TCC as the fallback if Seata did not work out. ## Try it yourself Prerequisites: the k3d stack from [demo-video-series.en.md](/astra-cms/en/video-scripts) (JDK 25, Docker with 8 GB, `kubectl`, `k3d`, `jq`, Bash 5). `make k3d-up` deploys Seata Server (`apache/seata-server:2.6.0.jdk25`) and runs `scripts/db-setup.sh`, which prepares the BMS schema and role. ```bash make k3d-up make demo-reset && make demo-seed make demo-express SCENES=15 PAUSE=0 ``` Expected output, excerpted from the recorded run (order numbers carry the seed date): *`docs/video/v17-seata/assets/scene-15.log`* ```text customer | credit_limit | used ----------+--------------+----------- C001 | 2000000.00 | 196000.00 C002 | 150000.00 | 54000.00 (2 rows) pickup slots today: reserved=0 booked=6 // … "credit limit of C002 exceeded: 54000.00 used of 150000.00, this order needs 105000.00" // … "no pickup slot left for 2026-10-21 (capacity 200)" // … undo_rows ----------- 0 (1 row) ``` The scene fills today's pickup slots to force the late refusal and then sets them back, so other scenes can still submit orders. The draft order for C002 (`ORD-…-00005`) is created by the seed for this scene; if you have already submitted or changed it, run `make demo-reset && make demo-seed` again. The integration test `astra-server/src/test/java/io/astra/server/SeataTest.java` runs the same three cases (commit, BMS refusal, refusal after the AT branch committed) against a real Seata Server and Redis; it runs with `make test`. ## To be accurate - Seata Server uses the file store, which is enough for the demo; production should use a database or Raft store. - TCC handles idempotency, empty rollback and suspension per branch itself and does not use Seata's fence table yet. - The kernel commits its own transaction last (as the last resource) and is not yet a Seata branch. - Only the credit limit (AT) and pickup capacity (TCC) are wired in; the target design in [use-case-01.md](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/use-case-01.md) and [explain.en.md](/astra-cms/en/why-astra) describes more (for example a smart bypass of the coordinator and an `@AstraGlobalTransactional` annotation) that is not built. In the running code, the YAML flag `transaction: global` is the switch. ## Figures - [Figure 1 · One submit, three resources](#the-problem) - [Figure 2 · Rollback after a branch committed](#what-the-video-shows) - [Figure 3 · TCC participant states](#2-the-coordinator-begin-run-commit-or-roll-back) - [Figure 4 · Who sees Seata](#3-the-app-hook-sees-an-spi-not-seata) ## Related - Previous: [Video 16 · Rust CDC + Sync-Aware LSN](/astra-cms/en/cdc) · Next: [Video 18 · Roadmap](/astra-cms/en/roadmap) - [Video 4 · Lifecycle](/astra-cms/en/lifecycle) (submit, cancel, amend in one kernel transaction) · [Video 12 · Ledger](/astra-cms/en/ledger) (the journal entry written on submit) - Code walkthrough: [S11 Seata AT and TCC](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/demo-code-snippets.md#s11-seata-at-and-tcc) (Vietnamese) - Background: [explain.en.md](/astra-cms/en/why-astra) (why Seata, risks and the fallback) · [use-case-01.md](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/use-case-01.md) (target design; Vietnamese) - Source: `astra-tx-seata/src/main/java/io/astra/tx/seata/SeataTransactions.java`, `astra-tx-seata/src/main/java/io/astra/tx/seata/AstraTccDispatcher.java`, `astra-spi/src/main/java/io/astra/spi/tx/GlobalTransactions.java`, `astra-spi/src/main/java/io/astra/spi/tx/TccParticipant.java`, `astra-apps/express/src/main/java/io/astra/apps/express/OrderCreditHook.java`, `astra-apps/express/src/main/java/io/astra/apps/express/CreditLimits.java`, `astra-apps/express/src/main/java/io/astra/apps/express/PickupCapacity.java`, `astra-tx-seata/src/main/resources/db/bms/V1__customer_credit.sql`, `deploy/k8s/base/seata.yaml` --- # 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 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 batch = new ArrayList<>(); List 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> 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> computeAll(List inputs) { try (ExecutorService threads = Executors.newVirtualThreadPerTaskExecutor()) { List>> 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 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). --- # STMS: vehicle dispatch with Timefold + AI copilot Timefold Solver plans delivery routes inside the Astra kernel and plans them again from the current routes when a vehicle breaks down, an urgent order arrives or traffic slows; a dispatch copilot turns Vietnamese… 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/stms ▶ [English](https://media.zerohostcms.com/astra-cms/astra-video-20-stms-en.mp4) · [Tiếng Việt](https://media.zerohostcms.com/astra-cms/astra-video-20-stms-vi.mp4) · 2:58 · Script: [demo-video-series.en.md](/astra-cms/en/video-scripts) > **In one sentence:** Timefold Solver plans delivery routes inside the Astra kernel and plans them again from the current routes when a vehicle breaks down, an urgent order arrives or traffic slows; a dispatch copilot turns Vietnamese commands into calls to those same MCP tools. > > **Read this if:** you want to see a constraint solver, live replanning and agent-callable tools built as an ordinary Tier 0 app on the SPI (developer angle) / you are weighing in-platform optimisation and an AI dispatch assistant, and need to know exactly which parts are real today (tech-lead angle). > [!IMPORTANT] > **The copilot's language model is simulated.** No LLM is called anywhere in this demo. `MockLlmClient` matches each > command against fixed Vietnamese patterns and a small built-in list of place names, then returns pre-defined tool > calls; anything outside the script produces no tool call. Every copilot response carries `llm: "mock"` and an > `llm_note`, and every explanation starts with `[LLM giả lập – kịch bản demo]` ("simulated LLM, demo script"). What is > real: the Timefold solver, the replanning, the MCP tools, permissions and the event log. The plug-in point for a real > model is the `LlmClient` interface, which has no real implementation yet. ## The problem Every morning, a dispatcher at Astra Express's Tân Bình hub has three trucks and twenty stops: shops in District 1, a pharmacy in Thủ Đức, VIP customers who must be served before 10:00, and stops that only accept deliveries between 09:00 and 11:00. The trucks carry 300, 250 and 250 kg. A route that ignores capacity or a time window fails in the field; a route that ignores distance burns fuel. Then the day happens. A truck breaks down at 08:30. A customer calls with an urgent 30 kg order. A jam closes in around Thủ Đức. In most small and mid-size logistics companies in Vietnam this is handled by phone and spreadsheet: the dispatcher reshuffles stops by memory, and nobody can later say what changed and why. Common alternatives: - **A separate routing SaaS**, integrated by export/import. It solves well but lives outside the order, permission and audit model; every replan is another round trip of files or APIs. - **A solver embedded in a custom service.** Flexible, but each team rebuilds tenancy, permissions and audit around it. - **Frappe/ERPNext** has a Delivery Trip DocType that records one vehicle's stops for a trip; multi-vehicle routing with capacity and time-window constraints is not part of it, so teams add an external service. Astra's STMS slice puts the solver behind a DocType hook and a set of MCP tools, so routing inherits the platform's tenancy, permissions, lifecycle (a dispatched plan is frozen) and logging. ## What the video shows The recording runs `make demo-stms` after `make demo-seed`, signed in as `linh` (role `dispatcher`). Every frame that shows the copilot carries the label "LLM giả lập (kịch bản demo)". Numbers are from the recorded run. 1. **Master data** (scene 1). Five DocTypes: Hub, Vehicle, DeliveryStop, DispatchPlan, DispatchEvent. Hub HCM-TB (Kho Tân Bình), three vehicles (51C-12345 300 kg, 51C-67890 250 kg, 51D-24680 250 kg), 20 stops with time windows and Normal, VIP or Urgent priority. The video's map is illustrative and draws straight lines, not roads. 2. **Baseline solve** (scene 2). Saving `DP-2610-0001` with `solve: true` and a 5-second budget: `0hard/-106703soft` (zero hard penalty means feasible), 98.8 km, 2 vehicles, 1,782 ms. 3. **Breakdown, through the copilot** (scene 3). Command: "Xe 51C-12345 hỏng ở Thủ Đức, chuyển hết đơn sang xe khác" (vehicle 51C-12345 broke down in Thủ Đức, move all its orders). The simulated LLM returns two tool calls, `stms_vehicle_unavailable` then `stms_explain_plan`. The real solver moves 10 stops (9 to 51C-67890, 1 to 51D-24680), still 0hard, 98.8 → 98.6 km, 1,019 ms; 3/3 VIP stops before 10:00. 4. **New weights, through the copilot** (scene 4). "Ưu tiên khách VIP trước 10h, cân bằng tải giữa các xe" (VIPs before 10:00, balance load). The copilot calls `stms_set_weights` (priority 500, balance 10). 19 stops change vehicle, both vehicles carry 226 kg, distance grows to 111.6 km, 1,930 ms. Soft scores change scale when weights change, so only plans with the same weights are comparable. 5. **Urgent order, copilot over MCP** (scene 5). "Thêm đơn gấp 30kg ở Bến Thành trước 11h" sent to the `stms_copilot` MCP tool. Bến Thành's coordinates come from the simulation's fixed place list. `DS-2610-00021` is inserted into 51D-24680; 4/4 VIP or urgent stops before 10:00; 111.6 → 112.1 km; 1,010 ms. 6. **Live events without the copilot** (scene 6). `stms_traffic_delay` (Thủ Đức, 4 km radius, 2.5× slower): 112.1 → 117.5 km, 1,958 ms. `stms_change_time_window` (DS-2610-00015, Linh kiện Tân Tiến, now 08:00–09:00): 117.5 → 137.0 km, 1,638 ms. Both stay 0hard and both log a DispatchEvent. 7. **Desk** (Desk scenes). The dispatch copilot page sends a command to `POST /api/stms/copilot`, the same copilot as the MCP tool; it shows the simulated LLM label before and after sending, the tool calls that ran, and the stops that changed vehicle. 8. **Permissions and tenancy** (scene 7). `quang` (ops) is refused: "not permitted: read DispatchEvent". `ha` (care) is refused: "not permitted: read DispatchPlan". The same plan name from `hanoi-express`: "not found". 9. **Dispatch** (scene 8). The DispatchEvent log lists each change with source, score before and after, moved stops and `solve_ms`. Submit sets docstatus 1 and status Dispatched. A later `PUT {solve: true}` returns 422 "submitted documents are immutable"; `stms_set_weights` returns "is dispatched and frozen; amend it to replan". ## How it works ### The flow of one copilot command ```mermaid sequenceDiagram participant D as linh (dispatcher) participant R as POST /api/stms/copilot or MCP stms_copilot participant C as DispatchCopilot participant L as MockLlmClient (SIMULATED, no model) participant T as MCP tools (stms_*) participant P as Replanner participant H as DispatchPlanHook participant S as RoutePlanner (Timefold) D->>R: "Xe 51C-12345 hỏng ở Thủ Đức, …" R->>R: permission check on every DocType the tool reads R->>C: run(plan, instruction) C->>L: toolCalls(instruction) L-->>C: regex match → [stms_vehicle_unavailable, stms_explain_plan] C->>T: stms_vehicle_unavailable(plan, 51C-12345) T->>P: replan(plan, patch unavailable_vehicles) P->>H: update DispatchPlan {solve: true} H->>S: solve(problem, warm start = current routes) S-->>H: score, routes, km, solve_ms H-->>P: last_change (moved stops) P->>P: insert DispatchEvent C->>T: stms_explain_plan C-->>D: llm: "mock", llm_note, tool calls, explanation from real results ``` ### The solver: hard and soft constraints Timefold Solver (the continuation of OptaPlanner) searches for a plan that minimises a `HardSoftScore`. Hard constraints must reach zero for a plan to be feasible; soft constraints rank the feasible plans. *`astra-apps/stms/src/main/java/io/astra/apps/stms/solver/RoutingConstraints.java`* ```java // per kilogram over capacity factory.forEach(PlanVehicle.class) .filter(v -> v.totalDemandGrams() > v.getCapacityGrams()) .penalize(HardSoftScore.ONE_HARD, v -> ceilDiv(v.totalDemandGrams() - v.getCapacityGrams(), 1000)) .asConstraint(CAPACITY), // per minute late factory.forEach(PlanVisit.class) .filter(v -> v.lateSeconds() > 0) .penalize(HardSoftScore.ONE_HARD, v -> ceilDiv(v.lateSeconds(), 60)) .asConstraint(TIME_WINDOW), // per road metre factory.forEach(PlanVehicle.class) .filter(v -> !v.getVisits().isEmpty()) .penalize(HardSoftScore.ONE_SOFT, PlanVehicle::totalMeters) .asConstraint(DISTANCE), // per minute between leaving the depot and starting service at a priority stop // … // squared load per vehicle: for a fixed total it is smallest when loads are equal factory.forEach(PlanVehicle.class) .filter(v -> !v.getVisits().isEmpty()) .penalize(HardSoftScore.ONE_SOFT, v -> v.loadKg() * v.loadKg()) .asConstraint(BALANCE) ``` **Why the soft score is not "kilometres".** The soft score adds metres, minutes of priority waiting and squared kilograms, each multiplied by a weight. The defaults are distance 1, priority 20, balance 0 (`RoutingProblem.Weights.DEFAULT`). When the copilot sets priority 500 and balance 10, the score jumps from about −0.1 million to about −1.26 million even though the plan is still feasible: the numbers are on a different scale. That is why the script warns to compare only plans with the same weights. The weights are applied per plan through Timefold's `ConstraintWeightOverrides`, so changing them needs no redeploy: *`astra-apps/stms/src/main/java/io/astra/apps/stms/solver/RoutePlanner.java`* ```java RoutingProblem.Weights w = problem.weights(); ConstraintWeightOverrides weights = ConstraintWeightOverrides.of(Map.of( RoutingConstraints.DISTANCE, HardSoftScore.ofSoft(w.distance()), RoutingConstraints.PRIORITY, HardSoftScore.ofSoft(w.priority()), RoutingConstraints.BALANCE, HardSoftScore.ofSoft(w.balance()))); ``` ![Figure 2: Hard versus soft constraints](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-20-figure-2.png) *Timefold solver constraints: hard constraints prevent capacity violations; soft constraints minimize total route travel time.* ### Distances without a map There is no road network. Distance is the great-circle (haversine) distance multiplied by a road factor of 1.3, and travel time assumes 25 km/h. A traffic event is a circle in which legs become slower by a factor: *`astra-apps/stms/src/main/java/io/astra/apps/stms/solver/RoutePlanner.java`* ```java double metres = Location.haversineMeters(from.latitude(), from.longitude(), to.latitude(), to.longitude()) * roadFactor; from.metersTo[to.index()] = Math.round(metres); double factor = Math.max(slowdown[from.index()], slowdown[to.index()]); from.secondsTo[to.index()] = Math.round(metres / metresPerSecond * factor); ``` Speed, road factor and the default budget are configuration (`astra.stms.speed-kmh`, `astra.stms.road-factor`, `astra.stms.solve-seconds`), with a hard cap of 30 seconds per solve. ### A bounded solve on a virtual thread *`astra-apps/stms/src/main/java/io/astra/apps/stms/solver/RoutePlanner.java`* ```java /** Stops when the budget is spent, or earlier once the score has not improved for a fifth of it. */ private RoutePlan solveOnVirtualThread(RoutePlan plan, int seconds) { Duration limit = Duration.ofSeconds(seconds); Duration unimproved = Duration.ofMillis(Math.max(500, limit.toMillis() / 5)); Solver solver = solverFactory.buildSolver(new SolverConfigOverride() .withTerminationConfig(new TerminationConfig().withSpentLimit(limit).withUnimprovedSpentLimit(unimproved))); // … } ``` **Why the "unimproved" limit.** With a 5-second budget, the solver stops after 1 second without improvement. That is why the recorded solves take 1.0 to 2.0 seconds rather than the full 5. ### Solving is a save, replanning is a save with a warm start The solve action is a field. When a draft DispatchPlan is saved with `solve: true`, the hook solves it in the same save and writes the result, plus a `last_change` record of what moved: *`astra-apps/stms/src/main/java/io/astra/apps/stms/DispatchPlanHook.java`* ```java @Override public void beforeSave(Document doc) { // settings are validated on every save, not only when solving // … if (!Values.flag(doc.get("solve"))) return; doc.set("solve", false); solve(doc); } ``` The problem is built warm-started from the plan's current routes (`problem.warmStart()` in `RoutePlanner.build`), so a replan begins from what the drivers already have and only moves what the event forces or what clearly improves the score. Every live event goes through `Replanner`, which patches the plan, re-solves and logs: *`astra-apps/stms/src/main/java/io/astra/apps/stms/Replanner.java`* ```java public Map replan(String planName, String eventType, Map detail, Map planPatch) { draftPlan(planName); Map patch = new LinkedHashMap<>(planPatch); patch.put("solve", true); Document after = docs.update("DispatchPlan", planName, patch) .orElseThrow(() -> new DocValidationException("dispatch plan " + planName + " not found")); // … Document logged = docs.insert("DispatchEvent", event, OWNER); ``` `draftPlan` is what makes a dispatched plan frozen for tools; the kernel's lifecycle makes it immutable for REST (see [Video 4](/astra-cms/en/lifecycle)). ![Figure 3: A replan starts from the current routes](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-20-figure-3.png) *Warm-start route replanning: unexpected incidents or vehicle breakdowns adjust routes dynamically from current vehicle locations.* ### Permissions through the tools' declared reads Each replanning tool declares the DocTypes it reads. DispatchEvent is readable only by admin and dispatcher, so including it in the list is what keeps ops and care from replanning, without a special permission system: *`astra-apps/stms/src/main/java/io/astra/apps/stms/Replanner.java`* ```java /** * DocTypes a replanning tool declares it reads. DispatchEvent is readable only by roles that may replan * (admin, dispatcher), so the MCP server's read check is what keeps other roles from replanning. */ public static final List REPLAN_READS = List.of("DispatchPlan", "DispatchEvent", "Vehicle", "DeliveryStop", "Hub"); ``` ![Figure 4: Who may replan](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-20-figure-4.png) *Copilot permission boundaries: dispatchers and ops staff invoke route planning based on fine-grained DocType permissions.* ### The copilot, and why its LLM is simulated The copilot depends on an interface, not on a model: *`astra-apps/stms/src/main/java/io/astra/apps/stms/copilot/LlmClient.java`* ```java /** * The language model behind the dispatch copilot: turns a dispatcher's instruction into tool calls. Implementations * are CDI beans; {@code astra.stms.llm} selects one by {@link #id()}. Only {@link MockLlmClient} exists today; a real * provider plugs in here with the same contract. */ public interface LlmClient { // … /** True when no model is called (scripted answers); the copilot then says so in every response. */ boolean simulated(); ``` The only implementation is a set of regular expressions: *`astra-apps/stms/src/main/java/io/astra/apps/stms/copilot/MockLlmClient.java`* ```java /** * NOT a language model. A deterministic stand-in for the demo: each clause of the instruction is matched against a * few Vietnamese patterns (breakdown, urgent order, congestion, time window, priorities) and mapped to the tool calls * a model would be expected to return. Places are looked up in a small built-in gazetteer instead of being geocoded. * Anything outside the script yields no tool call. The copilot labels every answer produced through this client. */ @ApplicationScoped public class MockLlmClient implements LlmClient { // … static final Pattern BREAKDOWN = Pattern.compile( "xe\\s+(\\d{2}[a-z]{1,2}\\d?-?[\\d.]{4,6})\\b.*?(hỏng|hư|chết máy|nổ lốp|bể lốp|tai nạn|không chạy|nghỉ)", FLAGS); // … @Override public String id() { return "mock"; } @Override public boolean simulated() { return true; } ``` **Why the explanation is still trustworthy.** `DispatchCopilot` never shows text from the "model". It runs the tool calls against the real solver, then writes the explanation from the actual results (score before and after, stops moved, solve time). When the client is simulated, it adds the label and the note: *`astra-apps/stms/src/main/java/io/astra/apps/stms/copilot/DispatchCopilot.java`* ```java Map out = new LinkedHashMap<>(); out.put("llm", llm.id()); if (llm.simulated()) out.put("llm_note", MOCK_NOTE); // … static String explain(LlmClient llm, List calls, List> results, Document before, Document after) { StringBuilder sb = new StringBuilder(llm.simulated() ? "[LLM giả lập – kịch bản demo] " : ""); ``` The copilot may call only the seven STMS tools in its `TOOLS` set; it cannot call itself or any kernel tool. This design is what would let a real model be added later without changing the trust boundary: a model would only choose tool calls, and the tools would still enforce permissions and the solver would still produce the numbers. That is the intended design; it has not been tested with a real model. ![Figure 1: What is real and what is simulated](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-20-figure-1.png) *Separating real optimization from simulation: Timefold route optimization runs live, while demo LLM intent extraction is scripted.* ## Real-world example A regional parcel carrier in Ho Chi Minh City runs the morning wave from one hub. The scenario below is the recorded demo run, read as a dispatcher's morning. The clock times in bold are narrative framing; the inputs and outputs are the logged values. **07:00, plan the day.** Inputs: hub HCM-TB, opens 07:00; 3 vehicles; 20 stops, 15.0 to 30.2 kg each, four with a 09:00–11:00 window, three VIP. The dispatcher saves a DispatchPlan with "Solve on save". Output: feasible, 98.8 km, two vehicles used (the third stays at the hub), 226 kg on each, solved in 1.8 s. **08:30, a truck breaks down.** Input: 51C-12345 is out. In the demo the dispatcher types a command and the simulated copilot maps it to `stms_vehicle_unavailable`; a dispatcher could equally call the tool or edit the plan's `unavailable_vehicles` field. Output: its 10 stops move (9 to 51C-67890, 1 to 51D-24680), still feasible, 98.6 km, and a DispatchEvent records who changed what. **09:15, an urgent order.** Input: 30 kg at Bến Thành, due before 11:00. Output: stop `DS-2610-00021` created and placed on 51D-24680, served at 08:00 in the plan; every VIP or urgent stop still before 10:00; 112.1 km. **09:40, a jam and a customer change.** Inputs: Thủ Đức 2.5× slower within 4 km; one shop narrows its window to 08:00–09:00. Outputs: two more feasible replans, 117.5 km then 137.0 km, each logged. **10:00, dispatch.** Submit freezes the plan. Any later change requires an amendment, which keeps the dispatched version as a record. Caveat for a real carrier: in this slice time does not advance. The 09:15 urgent order was served "at 08:00" because the solver does not know that stops before 09:15 are already done or that trucks are on the road. Locking delivered stops is listed under "To be accurate". ## For technical leaders - **Real versus simulated.** Solver, replanning, MCP tools, permissions, tenancy and the event log run for real. The copilot's language model does not exist yet; it is a regex script. Do not evaluate natural-language robustness from this demo. - **Why a solver in the platform.** Routing becomes a document action with tenancy, permissions, lifecycle and audit for free. The cost is CPU in the kernel pods: each solve holds a virtual thread for up to its budget (default 5 s, capped at 30 s). Measure solve CPU per tenant and consider a dedicated pool or pod before scaling. - **Scale is unmeasured.** The demo has about 20 stops and solves in 1 to 2 seconds. Larger problems have not been measured; benchmark your own stop counts before committing. - **Model fidelity.** Straight-line distance × 1.3 at 25 km/h, one combined capacity per vehicle, one hub per plan. Good enough to prove the mechanism; not a substitute for a road-network routing engine. A production system would replace the distance matrix with a routing service. - **The trust boundary for a future LLM.** The model only picks tool calls; tools re-check permissions; numbers come from the solver. This limits what a hallucinating model can do, but a real provider also needs PII handling, cost control and evaluation, none of which is built. - **Audit gap.** DispatchEvents created by tools are owned by `stms-mcp` / `stms-copilot`; the real caller is in the MCP server log, not on the document. - **When not to use it today.** Any live fleet. Use it to judge whether "solver behind a DocType, tools for agents" is the architecture you want. ## Try it yourself Prerequisites: JDK 25, Docker with about 8 GB of RAM, `kubectl`, `k3d`, `jq`, Bash 5. Seed and demo must use the same `STMS_DATE` (default: today in Vietnam time). Scenes 3 to 8 use the plan created by scene 2, so run them in order. ```bash make k3d-up make demo-seed # hub, 3 vehicles and 20 stops per tenant for STMS_DATE make demo-stms # all scenes, Enter between steps make demo-stms SCENES="2 3" PAUSE=0 # baseline solve, then the breakdown through the copilot STMS_DATE=2026-10-22 make demo-stms # pin the date if the seed ran on another day ``` Expected output, by scene: - Scene 2: `HTTP 201`, `"status": "Solved"`, a score of the form `0hard/…soft`, `"feasible": true`, and the route table. Exact km and milliseconds vary between runs. - Scene 3: `"llm": "mock"`, an `llm_note`, tool calls `stms_vehicle_unavailable` and `stms_explain_plan`, and an explanation that begins `[LLM giả lập – kịch bản demo]`. - Scene 7: `not permitted: read DispatchEvent` for `quang` and `not found` from `hanoi-express`. In Desk (`http://localhost:8080/desk/`, user `linh`, password `Astra@123`, local stack only), open the dispatch copilot page and type one of the commands from scenes 3 to 5. A command outside the script produces no tool call and an explanation saying, in Vietnamese, that no scenario matched and nothing changed: the simulated LLM showing its limit. Tests: `astra-apps/stms/src/test/java/io/astra/apps/stms/solver/RoutePlannerTest.java`, `astra-server/src/test/java/io/astra/server/StmsDispatchTest.java`, `astra-server/src/test/java/io/astra/server/StmsMcpTest.java`. ## To be accurate From the script's "To be accurate" section for Video 20: - **The copilot does not call any language model.** `MockLlmClient` matches commands against fixed Vietnamese patterns and a small list of place names; commands outside the script produce no tool call. The explanation is written from the solver's real results. The plug-in point for a real LLM provider is the `LlmClient` interface; there is no implementation yet. - **No map, road network or live traffic.** Distance is straight-line × 1.3 at a fixed 25 km/h; a "traffic jam" is a slow zone declared by the dispatcher. No geocoding: stops come with coordinates. - **Replanning is re-solving from the current routes**, without keeping the solver running all day. Time does not advance, so delivered stops and vehicles on the road are not locked. Adding an urgent order and re-solving are two transactions: if the re-solve fails, the new order is still saved. - **Simplified capacity model:** one combined total per vehicle (deliveries and pickups added together); no pickup-before-delivery constraints, multiple trips, driver shifts and breaks, return-to-hub deadlines, vehicle types or vehicle costs. One hub per plan, no transfers between hubs. - **Not built yet:** AI load forecasting (Tier 1), driver app, GPS, field ETA updates, a route map. Desk shows a text route table and JSON. - **Small problem only:** about 20 stops, 1 to 2 seconds. Larger problems have not been measured. On-screen numbers come from the recording. - **Ownership:** DispatchEvents and urgent orders created by tools have owner `stms-mcp` / `stms-copilot`; the real caller is recorded in the MCP server log. ## Figures - [Figure 1 · What is real and what is simulated](#the-copilot-and-why-its-llm-is-simulated) - [Figure 2 · Hard versus soft constraints](#the-solver-hard-and-soft-constraints) - [Figure 3 · A replan starts from the current routes](#solving-is-a-save-replanning-is-a-save-with-a-warm-start) - [Figure 4 · Who may replan](#permissions-through-the-tools-declared-reads) ## Related - Previous: [Video 19 · HRM: attendance and payroll](/astra-cms/en/hrm). Next: [Video 21 · SAML SSO and SCIM](/astra-cms/en/sso). 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 7 · tenants](/astra-cms/en/tenants), [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 6, `astra-app-stms`, with a status note on what differs from the running slice). - Source: [`RoutingConstraints`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/astra-apps/stms/src/main/java/io/astra/apps/stms/solver/RoutingConstraints.java), [`RoutePlanner`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/astra-apps/stms/src/main/java/io/astra/apps/stms/solver/RoutePlanner.java), [`DispatchPlanHook`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/astra-apps/stms/src/main/java/io/astra/apps/stms/DispatchPlanHook.java), [`Replanner`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/astra-apps/stms/src/main/java/io/astra/apps/stms/Replanner.java), [`ReplanTools`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/astra-apps/stms/src/main/java/io/astra/apps/stms/ReplanTools.java), [`DispatchCopilot`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/astra-apps/stms/src/main/java/io/astra/apps/stms/copilot/DispatchCopilot.java), [`LlmClient`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/astra-apps/stms/src/main/java/io/astra/apps/stms/copilot/LlmClient.java), [`MockLlmClient`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/astra-apps/stms/src/main/java/io/astra/apps/stms/copilot/MockLlmClient.java), [`StmsCopilotResource`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/astra-server/src/main/java/io/astra/server/StmsCopilotResource.java), [`demo-stms.sh`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/scripts/demo-stms.sh). --- # Roadmap: the rest of use-case-01 Videos 1 to 17 and 19 to 21 show thin slices of running code; this video, and this article, list what the courier use case still needs and label every one of those items In development. Read this if: you want to know… 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/roadmap ▶ [English](https://media.zerohostcms.com/astra-cms/astra-video-18-roadmap-en.mp4) · [Tiếng Việt](https://media.zerohostcms.com/astra-cms/astra-video-18-roadmap-vi.mp4) · 1:36 · Script: [demo-video-series.en.md](/astra-cms/en/video-scripts) > **In one sentence:** Videos 1 to 17 and 19 to 21 show thin slices of running code; this video, and this article, list what the courier use case still needs and label every one of those items **In development**. > > **Read this if:** you want to know which extension points already exist in the code so you can see where the missing pieces will attach (developer angle) / you are deciding whether to pilot Astra and need an honest line between what runs and what is only designed (tech-lead angle). > [!WARNING] > **Everything marked "In development" in this article is not built.** It is target design from > [use-case-01.md](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/use-case-01.md) and the [README roadmap](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/README.md#roadmap). No video demonstrates it, and no > code sample below implements it. Code excerpts in this article are taken from components that **run today**; they are > shown because they are the places where the missing pieces are meant to attach. Status legend used below: **Running** = shown in a demo video and present in the repository. **In development** = not built; target design only. ## The problem Platform demos tend to blur two things: what the product does today and what the architecture slide promises. Evaluators then discover the gap during a pilot, which is the most expensive time to find it. Astra's demo series follows one storyline, [use-case-01](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/use-case-01.md): Astra Express, a courier company with seven business modules (OMS orders, OPS parcel operations, BMS billing and ledger, HRM payroll, NOC insights, STMS dispatch, CARE customer care) on one platform. The videos prove the mechanisms with thin slices: enough code to show the mechanism working, not the full module. Each mechanism video ends with a "To be accurate" note on its own limits. Video 18 collects those limits in one place. Frappe and ERPNext, which Astra uses as a design reference (concepts only, no shared code), are mature products with years of modules behind them. Astra is not at that point. The comparison that matters for a reader today is not feature count, but whether the mechanisms Astra does run (zero-DDL DocTypes, per-tenant overlays, a Wasm sandbox, an append-only ledger, CDC with read-your-writes, MCP with permissions) are the right foundation for the rest. ## What the video shows Ninety-six seconds, no demo footage. Every frame carries the label **"Roadmap / In development"**. The scenes follow the narration: 1. **Title.** "The rest is running code. This is work in progress." 2. **What runs.** Thin slices from DocType to Seata, plus HRM, STMS and SAML SSO (Videos 19 to 21). Each video lists its limits; this one collects them. 3. **Storage.** Running: JSONB, an append-only ledger, a separate BMS schema, Seata. In development: a schema per module, the TCC fence table, a production Seata store. 4. **Identity and extensibility.** Running: Keycloak, SAML SSO, SCIM, Wasm. In development: row-level permissions, Single Logout, SCIM Bulk, Tier 1 isolated JVMs. 5. **Data and AI.** Running: Rust CDC, MCP with personal data masked. In development: streaming CDC, Apache Doris, autonomous agents, vector RAG. 6. **Business modules.** HRM needs income tax, payouts and a month-end job. STMS needs a real LLM, real maps and a driver app. Audit hash chains and field encryption have not started. 7. **Close.** Each step runs for real before it gets a video; the README holds the roadmap. The most useful feedback now is critique of the DocType model and overlays. ## How it works This section describes the **target design**. Each subsection names what is running, what is in development, and the running code that the in-development piece will extend. ```mermaid flowchart LR subgraph RUN["Running today (Videos 1–17, 19–21)"] J[JSONB DocTypes + projection index] L[Append-only ledger] S[Seata AT + TCC] K[Keycloak, SAML SSO, SCIM] W[Tier 0 apps, Tier 2 Wasm] C[Rust CDC + Sync-Aware LSN] M[MCP + PII masking] H[HRM slice] T[STMS slice, simulated LLM] end subgraph DEV["In development (not built)"] J2[Schema per module, hot columns] L2[Audit hash chain, field encryption] S2[Kernel as AT branch, TCC fence, DB/Raft store] K2[Row-level permission conditions, Single Logout, SCIM Bulk/ETag] W2[Tier 1 isolated JVM, Wasm instruction counting] C2[Streaming CDC, Apache Doris, NOC] M2[Autonomous agents, pgvector RAG, OAuth for MCP] H2[Income tax, payout, month-end job, self-service slips] T2[Real LLM, maps and traffic, forecasting, driver app] end J --> J2 L --> L2 S --> S2 K --> K2 W --> W2 C --> C2 M --> M2 H --> H2 T --> T2 ``` ![Figure 1: Running versus In development](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-18-figure-1.png) *Architecture roadmap: core kernel features and business apps running today versus roadmap capabilities in development.* ### Storage - **Running:** every DocType in one JSONB table with online expression indexes for `projected: true` fields ([Video 2](/astra-cms/en/doctype)); an append-only ledger with monthly partitions ([Video 12](/astra-cms/en/ledger)); BMS data in its own `bms` schema with its own role ([Video 17](/astra-cms/en/seata)). - **In development:** use-case-01 puts each module in its own PostgreSQL schema (`schema_oms`, `schema_ops`, `schema_hrm`, …) with row-level security, and adds "hot-field materialisation" for documents whose status changes many times a day (parcel status, vehicle position): frequently updated fields move to physical columns so that PostgreSQL can update them without rewriting the JSONB document. - **In development:** an audit hash chain over every change and field-level encryption declared in the DocType YAML (`encrypt: field`). The narration says these have not started. The ledger's append-only guarantee today is a database trigger. A hash chain would add tamper evidence on top of this tamper resistance: *`astra-doctype/src/main/resources/db/migration/V3__ledger.sql`* (running) ```sql CREATE FUNCTION astra_ledger_append_only() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'astra_ledger is append-only: post a reversal instead of %', TG_OP USING ERRCODE = 'insufficient_privilege'; END $$; CREATE TRIGGER astra_ledger_no_update_delete BEFORE UPDATE OR DELETE ON astra_ledger FOR EACH ROW EXECUTE FUNCTION astra_ledger_append_only(); ``` **Why this order.** The trigger blocks UPDATE and DELETE but not the table owner's DROP or ALTER (Video 12's "To be accurate"). A hash chain detects tampering that slips past access control; it is listed in the README's next steps. ### Transactions - **Running:** Seata 2.6 AT branch on the BMS credit limit, TCC branch on Redis pickup capacity, and the kernel's own transaction committed last ([Video 17](/astra-cms/en/seata)). - **In development:** the kernel as a Seata AT branch itself (today it is the "last resource"), Seata's TCC fence table for idempotency and suspension (today each TCC participant handles that itself), and a DB or Raft store for Seata Server (today it uses the file store). ### Identity and permissions - **Running:** Keycloak OIDC with per-tenant roles and DocType permissions, segregation of duties ([Video 11](/astra-cms/en/keycloak)); SAML SSO and per-tenant SCIM 2.0 ([Video 21](/astra-cms/en/sso)). - **In development:** row-level permission conditions (for example "an accountant sees only her branch's documents"), SAML Single Logout, SCIM Bulk and ETag, and full SCIM filters (today only `userName eq` and `displayName eq`). ### Extensibility tiers - **Running:** Tier 0 apps compiled into the image (Express, HRM, STMS, CMS) and the Tier 2 Wasm sandbox for tenant formulas ([Video 14](/astra-cms/en/wasm)). - **In development:** Tier 1, an isolated JVM class loader for hot-swapping plugins without a pod restart. use-case-01 intends it for localisations such as Vietnamese e-invoicing, VAT, payment gateways and 3PL connectors. Also in development: instruction counting ("fuel") for Wasm, so the CPU limit is not only a wall-clock deadline. Today the Wasm CPU control is a deadline, stated in the sandbox's own documentation: *`astra-plugin-host/src/main/java/io/astra/pluginhost/WasmSandbox.java`* (running) ```java /** * Tier 2 sandbox on Chicory, a pure-Java WebAssembly runtime: no native code, no JNI, nothing the * module can reach outside its own linear memory. Each call gets a fresh instance (no state survives * between calls or leaks between tenants), memory is capped at {@code astra.scripts.max-memory-pages} * (64 KiB each), host imports are refused, and the call runs on its own virtual thread that is * interrupted at the deadline. Chicory has no instruction metering, so the deadline is the CPU control. */ ``` ### Data, analytics and AI - **Running:** a Rust CDC worker decoding `pgoutput` into a CARE replica, with `X-Tx-LSN` / `X-Required-LSN` read-your-writes ([Video 16](/astra-cms/en/cdc)); an MCP server with PII masking and app-contributed tools ([Video 15](/astra-cms/en/mcp)). - **In development:** the streaming replication protocol (today the worker polls the slot), pushing changes into Apache Doris for the NOC module, and NOC dashboards. use-case-01 describes Doris tables exposed as "virtual DocTypes" so that the same permissions and generated UI apply to analytics. - **In development:** autonomous agents (beyond tools called by a client), pgvector-based retrieval for CARE, and OAuth discovery for MCP clients (today the client uses a static bearer token). The CDC worker's current read loop is a poll, which is what the streaming protocol would replace: *`astra-cdc-rust/src/main.rs`* (running) ```rust loop { let cur: String = client.query_one("SELECT pg_current_wal_lsn()::text", &[]).await?.get(0); let rows = client .query( "SELECT lsn::text, data FROM pg_logical_slot_peek_binary_changes($1, $2::text::pg_lsn, NULL, \ 'proto_version', '1', 'publication_names', $3)", &[&cfg.slot, &cur, &cfg.publication], ) .await?; // … ``` ### Business modules - **HRM, running** ([Video 19](/astra-cms/en/hrm)): attendance, Payroll Entry, salary slips on virtual threads, a Wasm KPI bonus, one accrual voucher per run. **In development:** personal income tax, the employer insurance share, the salary payout entry, a month-end background job with leader election, employee self-service slips, and parcel counts fed by CDC from OPS and STMS instead of typed with attendance. - **STMS, running** ([Video 20](/astra-cms/en/stms)): Timefold routing in the kernel, replanning, MCP tools, a dispatch copilot whose **LLM is simulated**. **In development:** a real LLM provider, real maps and traffic, load forecasting (Tier 1), a driver app. - **NOC and the full CARE design:** **In development.** CARE today is the ticket triage and CDC replica slice of [Video 6](/astra-cms/en/care). The STMS copilot shows the pattern most roadmap items follow: the interface is in the code, the real implementation is not. The interface's documentation says so: *`astra-apps/stms/src/main/java/io/astra/apps/stms/copilot/LlmClient.java`* (running interface; only a mock implements it) ```java /** * The language model behind the dispatch copilot: turns a dispatcher's instruction into tool calls. Implementations * are CDI beans; {@code astra.stms.llm} selects one by {@link #id()}. Only {@link MockLlmClient} exists today; a real * provider plugs in here with the same contract. */ public interface LlmClient { ``` ![Figure 2: Interfaces exist, implementations do not](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-18-figure-2.png) *SPI contracts and extension points exist across modules; target enterprise domains will implement them incrementally.* ### Smaller gaps named in other videos These are not in the Video 18 table but are stated in the scripts' "To be accurate" notes. All are **In development**: | Area | Gap | Where it is stated | | --- | --- | --- | | Schema evolution | Removing a field makes older documents that still carry it impossible to edit | [Video 9](/astra-cms/en/developer) | | Rolling restart | No preStop hook or drain; requests that land as an old pod stops can fail | [Video 10](/astra-cms/en/k8s) | | Overlays | Overlays add or modify fields; they cannot add hooks or permissions, or remove an app's fields | [Video 13](/astra-cms/en/overlay) | | MCP | PII masking applies to the MCP channel, not to Desk | [Video 15](/astra-cms/en/mcp) | ## Real-world example *Illustrative scenario: the company, people and values are made up; the mechanism and the rules it applies are the real ones shown above.* A mid-size 3PL in Vietnam with branches in Hanoi and Ho Chi Minh City is considering Astra for order intake, parcel operations, COD settlement, payroll and dispatch. Its CTO maps the requirements to the status above: | Requirement | Status | What that means for a pilot | | --- | --- | --- | | Per-branch tenants that cannot see each other | Running | Pilot can rely on RLS and the fail-closed guard (Videos 7, 8) | | Order → waybill → revenue entry in one transaction | Running | Pilot the lifecycle and ledger (Videos 4, 12) | | Hanoi needs an extra VAT field, Saigon does not | Running | Overlay, no code change (Video 13) | | COD maker/checker | Running | Segregation of duties (Videos 11, 12) | | Accountants see only their branch's documents inside one tenant | In development | Needs row-level permission conditions; split tenants or wait | | Monthly payroll with personal income tax and bank file | In development | HRM slice computes accruals only; keep the current payroll system | | Daily route planning for 20–50 vehicles on real roads | In development | STMS slice uses straight-line distance and has been measured only at about 20 stops | | Vietnamese chat assistant for dispatchers | In development | The copilot's LLM is simulated; no model is wired in | | Tamper-evident audit for regulators | In development | Append-only ledger runs; hash chain has not started | | Real-time NOC dashboards | In development | No Doris pipeline yet | **Output of the exercise:** a pilot scoped to OMS, OPS, BMS and tenant overlays, with payroll and dispatch kept on existing tools, and a list of five roadmap items to track. That is the decision Video 18 is meant to support. ![Figure 3: A CTO's pilot scope](https://raw.githubusercontent.com/ZeroHostCMS/astra-cms/main/docs/articles/figures/video-18-figure-3.png) *Recommended pilot scope: validating tenancy, doc lifecycle, append-only ledgers, and developer ergonomics.* ## For technical leaders - **Read the status, not the architecture diagram.** The README states that everything outside "Project Status" describes target architecture and that performance figures there are design targets, not measurements. - **Where to invest evaluation time.** The narration asks for critique of the DocType model and overlays. Those are the decisions that are hardest to change later; most roadmap items attach behind existing interfaces (`LlmClient`, the ledger SPI, the sandbox, the CDC worker). - **Risk of the gaps.** The ones that change data or security guarantees (row-level permission conditions, audit hash chain, field encryption, safe schema evolution) matter more for a pilot than the ones that add features (Doris, driver app). - **What to measure in a pilot.** Request latency on hooks that do batch work (payroll runs inside a request), solver CPU per tenant (STMS), CDC lag under your write rate, and rolling-restart errors (Video 10 measured 2 of 47 requests failing during a restart without drain). - **When not to adopt yet.** If the first workload depends on an In-development item (tax-compliant payroll, real-road routing, an LLM assistant), Astra is not ready for it. ## Try it yourself There is nothing to run for the In-development items. Everything in the "Running" lists can be run from the repository. Prerequisites: JDK 25, Docker with about 8 GB of RAM, `kubectl`, `k3d`, `jq`, Bash 5. ```bash make k3d-up && make demo-seed # the whole stack, two tenants make demo-express SCENES="9 10" PAUSE=0 # Keycloak permissions and SoD, then the ledger make demo-express SCENES="11 12 13" PAUSE=0 # overlays, Wasm, MCP make demo-express SCENES="14 15" PAUSE=0 # Rust CDC + LSN, Seata AT + TCC make demo-hrm # HRM slice (Video 19) make demo-stms # STMS slice (Video 20), copilot LLM simulated make demo-sso # SAML SSO and SCIM (Video 21); see its script for Keycloak setup ``` Video 8 (the kernel refuses to start as a superuser) needs the Docker Compose stack: `make infra-up && make build && make demo-express SCENES=8`. Each scene's expected output is described in its article and in [demo-video-series.en.md](/astra-cms/en/video-scripts). ## To be accurate - Every item marked **In development** is not built. Descriptions of it come from [use-case-01.md](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/use-case-01.md) and the [README](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/README.md#roadmap) and may change as the work is done. - The video's table covers the main gaps, not every limit. The full list for each mechanism is in that video's "To be accurate" section in [demo-video-series.en.md](/astra-cms/en/video-scripts). - The "Running" items are thin slices: enough to prove the mechanism, not the full scope of the use case. - This article does not give dates or an order of delivery for the In-development items; the README's phase list is the authority on sequencing. ## Figures - [Figure 1 · Running versus In development](#how-it-works) - [Figure 2 · Interfaces exist, implementations do not](#business-modules) - [Figure 3 · A CTO's pilot scope](#real-world-example) ## Related - Previous: [Video 17 · Seata: one submit, three resources](/astra-cms/en/seata). Next: [Video 19 · HRM: attendance and payroll](/astra-cms/en/hrm). - Module slices this roadmap builds on: [Video 19 · HRM](/astra-cms/en/hrm), [Video 20 · STMS](/astra-cms/en/stms), [Video 21 · SAML SSO and SCIM](/astra-cms/en/sso). - Target design: [use-case-01.md](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/use-case-01.md) (Vietnamese; modules carry status notes where a slice runs), [README roadmap](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/README.md#roadmap), [README project status](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/README.md#project-status), design reasoning in [explain.en.md](/astra-cms/en/why-astra). - Running seams quoted above: [`V3__ledger.sql`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/astra-doctype/src/main/resources/db/migration/V3__ledger.sql), [`WasmSandbox`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/astra-plugin-host/src/main/java/io/astra/pluginhost/WasmSandbox.java), [`astra-cdc-rust/src/main.rs`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/astra-cdc-rust/src/main.rs), [`LlmClient`](https://github.com/ZeroHostCMS/astra-cms/blob/main/docs/astra-apps/stms/src/main/java/io/astra/apps/stms/copilot/LlmClient.java).