#!/usr/bin/env python3 """ERPClaw v3 — Unified router for 365+ actions across 14 domains. Routes --action to the correct domain script via os.execvp(). Three dispatch tiers: 1. ALIASES — action name remapping before forwarding 2. ACTION_MAP — static 315-action map for core domains 3. MODULE_ACTIONS — dynamic lookup in erpclaw_module_action table for installed expansion modules (~/.openclaw/erpclaw/modules/) Usage: python3 db_query.py --action [--flags ...] Output: JSON to stdout (passed through from domain script) """ import json import os import sqlite3 import sys import time from uuid import uuid4 BASE_DIR = os.path.dirname(os.path.abspath(__file__)) # Install paths resolve through the single ERPCLAW_HOME point of truth # (ADR-0017). The router is a pure os.execvp dispatcher that deliberately avoids # importing erpclaw_lib (the bundled-lib path below is only inserted when the # dev-tree lib exists), so these reproduce the resolver's logic inline rather # than importing it — equivalent to erpclaw_lib.paths.modules_dir()/db_default(). # With ERPCLAW_HOME unset they equal today's ~/.openclaw/erpclaw literals exactly. _ERPCLAW_HOME = os.path.expanduser(os.environ.get("ERPCLAW_HOME", "~/.openclaw/erpclaw")) MODULES_DIR = os.path.join(_ERPCLAW_HOME, "modules") DB_PATH = os.path.join(_ERPCLAW_HOME, "data.sqlite") BUNDLED_LIB = os.path.join(BASE_DIR, "erpclaw-setup", "lib") if os.path.isdir(os.path.join(BUNDLED_LIB, "erpclaw_lib")): sys.path.insert(0, BUNDLED_LIB) def _chmod_db_files_action() -> None: """Lock down DB file perms to 600 on every action invocation. Idempotent. Covers data.sqlite + WAL + SHM. Cheap (~30µs). """ for suffix in ("", "-wal", "-shm"): path = DB_PATH + suffix try: if os.path.exists(path): os.chmod(path, 0o600) except OSError: pass _chmod_db_files_action() # Session ID for grouping action calls within one test scenario (set via env var) _SESSION_ID = os.environ.get("ERPCLAW_TEST_SESSION") def _log_action_call(action_name, routed_to, route_tier): """Log an action call to action_call_log for L2 test verification. Only logs when ERPCLAW_TEST_SESSION env var is set (test mode). Silently ignores errors to never break normal operation. """ if not _SESSION_ID: return try: conn = sqlite3.connect(DB_PATH) conn.execute( "INSERT INTO action_call_log (id, action_name, routed_to, route_tier, session_id) " "VALUES (?, ?, ?, ?, ?)", (str(uuid4()), action_name, routed_to, route_tier, _SESSION_ID), ) conn.commit() conn.close() except Exception: pass # Never break normal operation # Action → domain mapping (365 core entries + aliases + 10 module mgmt) # Collisions resolved: status→setup, recurring-template→journals, # update-invoice-outstanding→selling. Aliases added for alternate domains. ACTION_MAP = { # === Setup (42 actions) === "initialize-database": "erpclaw-setup", "setup-company": "erpclaw-setup", "update-company": "erpclaw-setup", "get-company": "erpclaw-setup", "list-companies": "erpclaw-setup", "add-currency": "erpclaw-setup", "list-currencies": "erpclaw-setup", "add-exchange-rate": "erpclaw-setup", "get-exchange-rate": "erpclaw-setup", "list-exchange-rates": "erpclaw-setup", "add-payment-terms": "erpclaw-setup", "list-payment-terms": "erpclaw-setup", "add-uom": "erpclaw-setup", "list-uoms": "erpclaw-setup", "add-uom-conversion": "erpclaw-setup", "seed-defaults": "erpclaw-setup", "get-audit-log": "erpclaw-setup", "get-schema-version": "erpclaw-setup", "update-regional-settings": "erpclaw-setup", "backup-database": "erpclaw-setup", "list-backups": "erpclaw-setup", "verify-backup": "erpclaw-setup", "restore-database": "erpclaw-setup", "cleanup-backups": "erpclaw-setup", "fetch-exchange-rates": "erpclaw-setup", "status": "erpclaw-setup", "tutorial": "erpclaw-setup", "add-user": "erpclaw-setup", "update-user": "erpclaw-setup", "list-users": "erpclaw-setup", "get-user": "erpclaw-setup", "add-role": "erpclaw-setup", "list-roles": "erpclaw-setup", "assign-role": "erpclaw-setup", "revoke-role": "erpclaw-setup", "set-password": "erpclaw-setup", "seed-permissions": "erpclaw-setup", "set-credential": "erpclaw-setup", "get-credential": "erpclaw-setup", "list-credentials": "erpclaw-setup", "delete-credential": "erpclaw-setup", "migrate-credentials": "erpclaw-setup", "import-master-key-from-backup": "erpclaw-setup", "link-telegram-user": "erpclaw-setup", "unlink-telegram-user": "erpclaw-setup", "check-telegram-permission": "erpclaw-setup", "onboarding-step": "erpclaw-setup", # === Meta (4 actions) === "check-installation": "erpclaw-meta", "install-guide": "erpclaw-meta", "seed-demo-data": "erpclaw-meta", # setup-web-dashboard moved to erpclaw-os-engine addon as os-setup-web-dashboard # (2026-05-04 split per CLAWHUB_FIX_C_PLAN; bare name now returns missing-addon error) "setup-web-dashboard": "erpclaw-meta", # routes to erpclaw-meta which returns helpful error # === General Ledger (28 actions) === "setup-chart-of-accounts": "erpclaw-gl", "add-account": "erpclaw-gl", "update-account": "erpclaw-gl", "list-accounts": "erpclaw-gl", "get-account": "erpclaw-gl", "freeze-account": "erpclaw-gl", "unfreeze-account": "erpclaw-gl", "post-gl-entries": "erpclaw-gl", "reverse-gl-entries": "erpclaw-gl", "list-gl-entries": "erpclaw-gl", "add-fiscal-year": "erpclaw-gl", "list-fiscal-years": "erpclaw-gl", "validate-period-close": "erpclaw-gl", "close-fiscal-year": "erpclaw-gl", "reopen-fiscal-year": "erpclaw-gl", "add-cost-center": "erpclaw-gl", "list-cost-centers": "erpclaw-gl", "add-budget": "erpclaw-gl", "list-budgets": "erpclaw-gl", "seed-naming-series": "erpclaw-gl", "next-series": "erpclaw-gl", "check-gl-integrity": "erpclaw-gl", "get-account-balance": "erpclaw-gl", "revalue-foreign-balances": "erpclaw-gl", "import-chart-of-accounts": "erpclaw-gl", "import-opening-balances": "erpclaw-gl", "gl-status": "erpclaw-gl", # === Journal Entries (17 actions) === "add-journal-entry": "erpclaw-journals", "update-journal-entry": "erpclaw-journals", "get-journal-entry": "erpclaw-journals", "list-journal-entries": "erpclaw-journals", "submit-journal-entry": "erpclaw-journals", "cancel-journal-entry": "erpclaw-journals", "amend-journal-entry": "erpclaw-journals", "delete-journal-entry": "erpclaw-journals", "duplicate-journal-entry": "erpclaw-journals", "create-intercompany-je": "erpclaw-journals", "add-recurring-template": "erpclaw-journals", "update-recurring-template": "erpclaw-journals", "list-recurring-templates": "erpclaw-journals", "get-recurring-template": "erpclaw-journals", "process-recurring": "erpclaw-journals", "delete-recurring-template": "erpclaw-journals", "journals-status": "erpclaw-journals", # === Payments (15 actions) === "add-payment": "erpclaw-payments", "update-payment": "erpclaw-payments", "get-payment": "erpclaw-payments", "list-payments": "erpclaw-payments", "submit-payment": "erpclaw-payments", "cancel-payment": "erpclaw-payments", "delete-payment": "erpclaw-payments", "create-payment-ledger-entry": "erpclaw-payments", "write-off-invoice": "erpclaw-payments", "get-outstanding": "erpclaw-payments", "get-unallocated-payments": "erpclaw-payments", "allocate-payment": "erpclaw-payments", "reconcile-payments": "erpclaw-payments", "bank-reconciliation": "erpclaw-payments", "payments-status": "erpclaw-payments", # === Tax (19 actions) === "add-tax-template": "erpclaw-tax", "update-tax-template": "erpclaw-tax", "get-tax-template": "erpclaw-tax", "list-tax-templates": "erpclaw-tax", "delete-tax-template": "erpclaw-tax", "resolve-tax-template": "erpclaw-tax", "calculate-tax": "erpclaw-tax", "add-tax-category": "erpclaw-tax", "list-tax-categories": "erpclaw-tax", "add-tax-rule": "erpclaw-tax", "list-tax-rules": "erpclaw-tax", "add-item-tax-template": "erpclaw-tax", "add-tax-withholding-category": "erpclaw-tax", "get-withholding-details": "erpclaw-tax", "record-withholding-entry": "erpclaw-tax", "record-1099-payment": "erpclaw-tax", "generate-1099-data": "erpclaw-tax", "tax-status": "erpclaw-tax", # === Financial Reports (21 actions) === "trial-balance": "erpclaw-reports", "profit-and-loss": "erpclaw-reports", "balance-sheet": "erpclaw-reports", "cash-flow": "erpclaw-reports", "general-ledger": "erpclaw-reports", "ar-aging": "erpclaw-reports", "ap-aging": "erpclaw-reports", "budget-vs-actual": "erpclaw-reports", "budget-variance": "erpclaw-reports", "party-ledger": "erpclaw-reports", "tax-summary": "erpclaw-reports", "payment-summary": "erpclaw-reports", "gl-summary": "erpclaw-reports", "comparative-pl": "erpclaw-reports", "check-overdue": "erpclaw-reports", "add-elimination-rule": "erpclaw-reports", "list-elimination-rules": "erpclaw-reports", "run-elimination": "erpclaw-reports", "list-elimination-entries": "erpclaw-reports", "reports-status": "erpclaw-reports", # === Selling / Order-to-Cash (42 actions) === "add-customer": "erpclaw-selling", "update-customer": "erpclaw-selling", "get-customer": "erpclaw-selling", "list-customers": "erpclaw-selling", "add-quotation": "erpclaw-selling", "update-quotation": "erpclaw-selling", "get-quotation": "erpclaw-selling", "list-quotations": "erpclaw-selling", "submit-quotation": "erpclaw-selling", "convert-quotation-to-so": "erpclaw-selling", "add-sales-order": "erpclaw-selling", "update-sales-order": "erpclaw-selling", "get-sales-order": "erpclaw-selling", "list-sales-orders": "erpclaw-selling", "submit-sales-order": "erpclaw-selling", "cancel-sales-order": "erpclaw-selling", "create-delivery-note": "erpclaw-selling", "get-delivery-note": "erpclaw-selling", "list-delivery-notes": "erpclaw-selling", "submit-delivery-note": "erpclaw-selling", "cancel-delivery-note": "erpclaw-selling", "create-sales-invoice": "erpclaw-selling", "update-sales-invoice": "erpclaw-selling", "get-sales-invoice": "erpclaw-selling", "list-sales-invoices": "erpclaw-selling", "submit-sales-invoice": "erpclaw-selling", "cancel-sales-invoice": "erpclaw-selling", "create-credit-note": "erpclaw-selling", "list-credit-notes": "erpclaw-selling", "update-invoice-outstanding": "erpclaw-selling", "add-sales-partner": "erpclaw-selling", "list-sales-partners": "erpclaw-selling", "add-recurring-invoice-template": "erpclaw-selling", "update-recurring-invoice-template": "erpclaw-selling", "list-recurring-invoice-templates": "erpclaw-selling", "generate-recurring-invoices": "erpclaw-selling", "import-customers": "erpclaw-selling", "add-intercompany-account-map": "erpclaw-selling", "list-intercompany-account-maps": "erpclaw-selling", "create-intercompany-invoice": "erpclaw-selling", "list-intercompany-invoices": "erpclaw-selling", "cancel-intercompany-invoice": "erpclaw-selling", "selling-status": "erpclaw-selling", "close-sales-order": "erpclaw-selling", "amend-sales-order": "erpclaw-selling", "get-amendment-history": "erpclaw-selling", "add-blanket-order": "erpclaw-selling", "submit-blanket-order": "erpclaw-selling", "get-blanket-order": "erpclaw-selling", "list-blanket-orders": "erpclaw-selling", "create-so-from-blanket": "erpclaw-selling", "create-drop-ship-order": "erpclaw-selling", "add-packing-slip": "erpclaw-selling", "get-packing-slip": "erpclaw-selling", "list-packing-slips": "erpclaw-selling", # === Buying / Procure-to-Pay (36 actions) === "add-supplier": "erpclaw-buying", "update-supplier": "erpclaw-buying", "get-supplier": "erpclaw-buying", "list-suppliers": "erpclaw-buying", "add-material-request": "erpclaw-buying", "submit-material-request": "erpclaw-buying", "list-material-requests": "erpclaw-buying", "get-material-request": "erpclaw-buying", "create-po-from-material-request": "erpclaw-buying", "add-rfq": "erpclaw-buying", "submit-rfq": "erpclaw-buying", "list-rfqs": "erpclaw-buying", "add-supplier-quotation": "erpclaw-buying", "list-supplier-quotations": "erpclaw-buying", "compare-supplier-quotations": "erpclaw-buying", "add-purchase-order": "erpclaw-buying", "update-purchase-order": "erpclaw-buying", "get-purchase-order": "erpclaw-buying", "list-purchase-orders": "erpclaw-buying", "submit-purchase-order": "erpclaw-buying", "cancel-purchase-order": "erpclaw-buying", "create-purchase-receipt": "erpclaw-buying", "get-purchase-receipt": "erpclaw-buying", "list-purchase-receipts": "erpclaw-buying", "submit-purchase-receipt": "erpclaw-buying", "cancel-purchase-receipt": "erpclaw-buying", "create-purchase-invoice": "erpclaw-buying", "update-purchase-invoice": "erpclaw-buying", "get-purchase-invoice": "erpclaw-buying", "list-purchase-invoices": "erpclaw-buying", "submit-purchase-invoice": "erpclaw-buying", "cancel-purchase-invoice": "erpclaw-buying", "create-debit-note": "erpclaw-buying", "update-purchase-outstanding": "erpclaw-buying", "add-landed-cost-voucher": "erpclaw-buying", "list-landed-cost-vouchers": "erpclaw-buying", "get-landed-cost-voucher": "erpclaw-buying", "cancel-landed-cost-voucher": "erpclaw-buying", "import-suppliers": "erpclaw-buying", "buying-status": "erpclaw-buying", "close-purchase-order": "erpclaw-buying", "update-receipt-tolerance": "erpclaw-buying", "update-three-way-match-policy": "erpclaw-buying", "add-blanket-po": "erpclaw-buying", "submit-blanket-po": "erpclaw-buying", "get-blanket-po": "erpclaw-buying", "list-blanket-pos": "erpclaw-buying", "create-po-from-blanket": "erpclaw-buying", "create-po-from-so": "erpclaw-buying", "add-recurring-bill-template": "erpclaw-buying", "list-recurring-bill-templates": "erpclaw-buying", "generate-recurring-bills": "erpclaw-buying", "set-item-purchase-uom": "erpclaw-buying", # === Inventory (38 actions) === "add-item": "erpclaw-inventory", "update-item": "erpclaw-inventory", "get-item": "erpclaw-inventory", "list-items": "erpclaw-inventory", "resolve-item": "erpclaw-inventory", "add-item-group": "erpclaw-inventory", "list-item-groups": "erpclaw-inventory", "add-warehouse": "erpclaw-inventory", "update-warehouse": "erpclaw-inventory", "list-warehouses": "erpclaw-inventory", "add-stock-entry": "erpclaw-inventory", "add-repack-stock-entry": "erpclaw-inventory", "add-material-consumption": "erpclaw-inventory", "get-stock-entry": "erpclaw-inventory", "list-stock-entries": "erpclaw-inventory", "submit-stock-entry": "erpclaw-inventory", "cancel-stock-entry": "erpclaw-inventory", # RETIRED (M103, 2026-08-13) but routable on purpose: both answer with a # steer to the stock-entry flow and write nothing. See the retirement block # in erpclaw-inventory/db_query.py. "create-stock-ledger-entries": "erpclaw-inventory", "reverse-stock-ledger-entries": "erpclaw-inventory", "get-stock-balance": "erpclaw-inventory", "stock-balance": "erpclaw-inventory", "stock-balance-report": "erpclaw-inventory", "stock-ledger-report": "erpclaw-inventory", "add-batch": "erpclaw-inventory", "list-batches": "erpclaw-inventory", "add-serial-number": "erpclaw-inventory", "list-serial-numbers": "erpclaw-inventory", "add-price-list": "erpclaw-inventory", "add-item-price": "erpclaw-inventory", "get-item-price": "erpclaw-inventory", "add-pricing-rule": "erpclaw-inventory", "add-stock-reconciliation": "erpclaw-inventory", "submit-stock-reconciliation": "erpclaw-inventory", "revalue-stock": "erpclaw-inventory", "list-stock-revaluations": "erpclaw-inventory", "get-stock-revaluation": "erpclaw-inventory", "cancel-stock-revaluation": "erpclaw-inventory", "check-reorder": "erpclaw-inventory", "import-items": "erpclaw-inventory", "inventory-status": "erpclaw-inventory", "get-projected-qty": "erpclaw-inventory", "add-item-attribute": "erpclaw-inventory", "create-item-variant": "erpclaw-inventory", "generate-item-variants": "erpclaw-inventory", "list-item-variants": "erpclaw-inventory", "add-item-supplier": "erpclaw-inventory", "list-item-suppliers": "erpclaw-inventory", # Wave 2 M5: putaway + pick list + persisted hard reservation (ADR-0026) "add-putaway-rule": "erpclaw-inventory", "list-putaway-rules": "erpclaw-inventory", "update-putaway-rule": "erpclaw-inventory", "delete-putaway-rule": "erpclaw-inventory", "apply-putaway-on-receipt": "erpclaw-inventory", "create-pick-list": "erpclaw-inventory", "add-pick-list-item": "erpclaw-inventory", "submit-pick-list": "erpclaw-inventory", "mark-picked": "erpclaw-inventory", "complete-pick-list": "erpclaw-inventory", "cancel-pick-list": "erpclaw-inventory", "add-reservation": "erpclaw-inventory", "release-reservation": "erpclaw-inventory", "list-reservations": "erpclaw-inventory", # Wave 2 S7: item-global alternatives / substitutes "add-item-alternative": "erpclaw-inventory", "list-item-alternatives": "erpclaw-inventory", "get-best-alternative-for-item": "erpclaw-inventory", "remove-item-alternative": "erpclaw-inventory", # === Billing & Metering (22 actions) === "add-meter": "erpclaw-billing", "update-meter": "erpclaw-billing", "get-meter": "erpclaw-billing", "list-meters": "erpclaw-billing", "add-meter-reading": "erpclaw-billing", "list-meter-readings": "erpclaw-billing", "add-usage-event": "erpclaw-billing", "add-usage-events-batch": "erpclaw-billing", "add-rate-plan": "erpclaw-billing", "update-rate-plan": "erpclaw-billing", "get-rate-plan": "erpclaw-billing", "list-rate-plans": "erpclaw-billing", "rate-consumption": "erpclaw-billing", "create-billing-period": "erpclaw-billing", "run-billing": "erpclaw-billing", "generate-invoices": "erpclaw-billing", "sync-billing-period-status": "erpclaw-billing", "link-billing-period-invoice": "erpclaw-billing", "unlink-billing-period-invoice": "erpclaw-billing", "add-billing-adjustment": "erpclaw-billing", "list-billing-periods": "erpclaw-billing", "get-billing-period": "erpclaw-billing", "add-prepaid-credit": "erpclaw-billing", "get-prepaid-balance": "erpclaw-billing", "list-billing-runs": "erpclaw-billing", "get-billing-run": "erpclaw-billing", "resume-billing-run": "erpclaw-billing", "billing-status": "erpclaw-billing", # === Advanced Accounting — Revenue Recognition / ASC 606 (17 actions) === "add-revenue-contract": "erpclaw-accounting-adv", "update-revenue-contract": "erpclaw-accounting-adv", "get-revenue-contract": "erpclaw-accounting-adv", "list-revenue-contracts": "erpclaw-accounting-adv", "add-performance-obligation": "erpclaw-accounting-adv", "list-performance-obligations": "erpclaw-accounting-adv", "satisfy-performance-obligation": "erpclaw-accounting-adv", "update-performance-obligation": "erpclaw-accounting-adv", "add-variable-consideration": "erpclaw-accounting-adv", "list-variable-considerations": "erpclaw-accounting-adv", "modify-contract": "erpclaw-accounting-adv", "calculate-revenue-schedule": "erpclaw-accounting-adv", "generate-revenue-entries": "erpclaw-accounting-adv", "update-schedule-amounts": "erpclaw-accounting-adv", "recognize-schedule-entry": "erpclaw-accounting-adv", "revenue-waterfall-report": "erpclaw-accounting-adv", "revenue-recognition-summary": "erpclaw-accounting-adv", # === Advanced Accounting — Lease Accounting / ASC 842 (12 actions) === "add-lease": "erpclaw-accounting-adv", "update-lease": "erpclaw-accounting-adv", "get-lease": "erpclaw-accounting-adv", "list-leases": "erpclaw-accounting-adv", "classify-lease": "erpclaw-accounting-adv", "calculate-rou-asset": "erpclaw-accounting-adv", "calculate-lease-liability": "erpclaw-accounting-adv", "generate-amortization-schedule": "erpclaw-accounting-adv", "record-lease-payment": "erpclaw-accounting-adv", "lease-maturity-report": "erpclaw-accounting-adv", "lease-disclosure-report": "erpclaw-accounting-adv", "lease-summary": "erpclaw-accounting-adv", # === Advanced Accounting — Intercompany Transactions (10 actions) === "add-ic-transaction": "erpclaw-accounting-adv", "update-ic-transaction": "erpclaw-accounting-adv", "get-ic-transaction": "erpclaw-accounting-adv", "list-ic-transactions": "erpclaw-accounting-adv", "approve-ic-transaction": "erpclaw-accounting-adv", "post-ic-transaction": "erpclaw-accounting-adv", "add-transfer-price-rule": "erpclaw-accounting-adv", "list-transfer-price-rules": "erpclaw-accounting-adv", "ic-reconciliation-report": "erpclaw-accounting-adv", "ic-elimination-report": "erpclaw-accounting-adv", # === Advanced Accounting — Multi-Entity Consolidation (8 actions) === "add-consolidation-group": "erpclaw-accounting-adv", "list-consolidation-groups": "erpclaw-accounting-adv", "add-group-entity": "erpclaw-accounting-adv", "run-consolidation": "erpclaw-accounting-adv", "generate-elimination-entries": "erpclaw-accounting-adv", "add-currency-translation": "erpclaw-accounting-adv", "consolidation-trial-balance-report": "erpclaw-accounting-adv", "consolidation-summary": "erpclaw-accounting-adv", # M114: surface + correct the pre-M95 elimination-duplication surplus. "list-elimination-surplus": "erpclaw-accounting-adv", "remove-elimination-surplus": "erpclaw-accounting-adv", # === Advanced Accounting — Reports (1 action) === "standards-compliance-dashboard": "erpclaw-accounting-adv", # === HR — Employee Management (28 actions) === "add-employee": "erpclaw-hr", "update-employee": "erpclaw-hr", "get-employee": "erpclaw-hr", "list-employees": "erpclaw-hr", "add-department": "erpclaw-hr", "list-departments": "erpclaw-hr", "add-designation": "erpclaw-hr", "list-designations": "erpclaw-hr", "add-leave-type": "erpclaw-hr", "list-leave-types": "erpclaw-hr", "add-leave-allocation": "erpclaw-hr", "get-leave-balance": "erpclaw-hr", "add-leave-application": "erpclaw-hr", "approve-leave": "erpclaw-hr", "reject-leave": "erpclaw-hr", "list-leave-applications": "erpclaw-hr", "mark-attendance": "erpclaw-hr", "bulk-mark-attendance": "erpclaw-hr", "list-attendance": "erpclaw-hr", "add-holiday-list": "erpclaw-hr", "add-expense-claim": "erpclaw-hr", "submit-expense-claim": "erpclaw-hr", "approve-expense-claim": "erpclaw-hr", "reject-expense-claim": "erpclaw-hr", "update-expense-claim-status": "erpclaw-hr", "list-expense-claims": "erpclaw-hr", "record-lifecycle-event": "erpclaw-hr", # hr-status routed via ALIASES (-> erpclaw-hr 'status'); a bare ACTION_MAP # entry forwarded the literal 'hr-status', which erpclaw-hr rejects. "add-shift-type": "erpclaw-hr", "list-shift-types": "erpclaw-hr", "update-shift-type": "erpclaw-hr", "assign-shift": "erpclaw-hr", "list-shift-assignments": "erpclaw-hr", "add-regularization-rule": "erpclaw-hr", "apply-attendance-regularization": "erpclaw-hr", "add-employee-document": "erpclaw-hr", "list-employee-documents": "erpclaw-hr", "get-employee-document": "erpclaw-hr", "check-expiring-documents": "erpclaw-hr", # === Payroll — US Payroll Processing (22 actions) === "add-salary-component": "erpclaw-payroll", "list-salary-components": "erpclaw-payroll", "add-salary-structure": "erpclaw-payroll", "get-salary-structure": "erpclaw-payroll", "list-salary-structures": "erpclaw-payroll", "add-salary-assignment": "erpclaw-payroll", "list-salary-assignments": "erpclaw-payroll", "add-income-tax-slab": "erpclaw-payroll", "update-fica-config": "erpclaw-payroll", "update-futa-suta-config": "erpclaw-payroll", "create-payroll-run": "erpclaw-payroll", "generate-salary-slips": "erpclaw-payroll", "get-salary-slip": "erpclaw-payroll", "list-salary-slips": "erpclaw-payroll", "submit-payroll-run": "erpclaw-payroll", "cancel-payroll-run": "erpclaw-payroll", "generate-w2-data": "erpclaw-payroll", "add-garnishment": "erpclaw-payroll", "update-garnishment": "erpclaw-payroll", "list-garnishments": "erpclaw-payroll", "get-garnishment": "erpclaw-payroll", # payroll-status routed via ALIASES (-> erpclaw-payroll 'status'); a bare # ACTION_MAP entry forwarded the literal 'payroll-status', which is rejected. "add-state-tax-slab": "erpclaw-payroll", "update-employee-state-config": "erpclaw-payroll", "add-overtime-policy": "erpclaw-payroll", "calculate-overtime": "erpclaw-payroll", "calculate-retro-pay": "erpclaw-payroll", "add-employee-bank-account": "erpclaw-payroll", "list-employee-bank-accounts": "erpclaw-payroll", "generate-nacha-file": "erpclaw-payroll", # === ERPClaw OS — runtime actions kept in foundation (after 2026-05-04 split) === # validate-module, list-articles, build-table-registry, schema-* stay live. "validate-module": "erpclaw-os", "list-articles": "erpclaw-os", "build-table-registry": "erpclaw-os", "schema-plan": "erpclaw-os", "schema-apply": "erpclaw-os", "schema-rollback": "erpclaw-os", "schema-drift": "erpclaw-os", # === ERPClaw OS — moved to erpclaw-os-engine addon (renamed with os- prefix) === # These bare names route to foundation's erpclaw-os/db_query.py which # returns a structured missing-addon error JSON with the new os-prefixed # name + install command. New callers should use the os- prefixed name. "generate-module": "erpclaw-os", "configure-module": "erpclaw-os", "list-industries": "erpclaw-os", "classify-operation": "erpclaw-os", "deploy-module": "erpclaw-os", "deploy-audit-log": "erpclaw-os", "install-suite": "erpclaw-os", "run-audit": "erpclaw-os", "compliance-weather-status": "erpclaw-os", "semantic-check": "erpclaw-os", "semantic-rules-list": "erpclaw-os", "log-improvement": "erpclaw-os", "list-improvements": "erpclaw-os", "review-improvement": "erpclaw-os", "dgm-run-variant": "erpclaw-os", "dgm-list-variants": "erpclaw-os", "dgm-select-best": "erpclaw-os", "detect-gaps": "erpclaw-os", "detect-schema-divergence": "erpclaw-os", "detect-stubs": "erpclaw-os", "suggest-modules": "erpclaw-os", "heartbeat-analyze": "erpclaw-os", "heartbeat-report": "erpclaw-os", "heartbeat-suggest": "erpclaw-os", "add-feature-to-module": "erpclaw-os", "check-feature-completeness": "erpclaw-os", "list-feature-matrix": "erpclaw-os", "research-business-rule": "erpclaw-os", "get-implementation-guide": "erpclaw-os", # === M31 router union (2026-07-02) === # These 27 actions were defined + tested in their domain sub-script ACTIONS # dicts but were missing from this map, so the NL router returned # "Unknown action" for shipped, SKILL-documented features. Derived # mechanically as (union of every foundation sub-script ACTIONS dict) minus # (ACTION_MAP ∪ ALIASES ∪ MODULE_ACTIONS ∪ ONBOARDING_ACTIONS); zero # cross-domain collisions. Each resolves to the single domain whose ACTIONS # dict owns it. The L0 dispatchability gate # (testing/unit/constitution/test_router_dispatchability.py) now guards # against this class of drift: defined ⇒ routable. # Setup — account-type / voucher-type / custom-field registries, advance # account config, schema migrate, registry completeness check. "add-account-type": "erpclaw-setup", "deactivate-account-type": "erpclaw-setup", "list-account-types": "erpclaw-setup", "add-voucher-type": "erpclaw-setup", "deactivate-voucher-type": "erpclaw-setup", "list-voucher-types": "erpclaw-setup", "add-custom-field": "erpclaw-setup", "remove-custom-field": "erpclaw-setup", "list-custom-fields": "erpclaw-setup", "set-custom-field-value": "erpclaw-setup", "get-custom-field-values": "erpclaw-setup", "set-advance-account": "erpclaw-setup", "migrate": "erpclaw-setup", "validate-registry-completeness": "erpclaw-setup", # GL — accounting dimensions CRUD. "add-dimension": "erpclaw-gl", "update-dimension": "erpclaw-gl", "deactivate-dimension": "erpclaw-gl", "list-dimensions": "erpclaw-gl", # Selling — credit control + dunning. "check-credit-limit": "erpclaw-selling", "place-customer-on-hold": "erpclaw-selling", "add-dunning-level": "erpclaw-selling", "run-dunning-cycle": "erpclaw-selling", "list-dunning-runs": "erpclaw-selling", # Payments — advance handling. "apply-advance-to-invoice": "erpclaw-payments", "list-open-advances": "erpclaw-payments", # Reports — multi-dimensional reporting. "multi-dim-trial-balance": "erpclaw-reports", "dimension-balance-report": "erpclaw-reports", } # Aliases: actions that need to be forwarded with a different --action name # Format: "router-action-name": ("domain", "original-action-name") ALIASES = { # Domain-specific status aliases "gl-status": ("erpclaw-gl", "status"), "journals-status": ("erpclaw-journals", "status"), "payments-status": ("erpclaw-payments", "status"), "tax-status": ("erpclaw-tax", "status"), "reports-status": ("erpclaw-reports", "status"), "selling-status": ("erpclaw-selling", "status"), "buying-status": ("erpclaw-buying", "status"), "inventory-status": ("erpclaw-inventory", "status"), "billing-status": ("erpclaw-billing", "status"), "accounting-adv-status": ("erpclaw-accounting-adv", "status"), # hr / payroll status: were bare ACTION_MAP entries that forwarded the # literal name (rejected by the sub-script, whose action is `status`). Moved # here to match the 10 sibling domain-status aliases so they actually route. "hr-status": ("erpclaw-hr", "status"), "payroll-status": ("erpclaw-payroll", "status"), # Selling recurring template aliases (journals owns the base names) "add-recurring-invoice-template": ("erpclaw-selling", "add-recurring-template"), "update-recurring-invoice-template": ("erpclaw-selling", "update-recurring-template"), "list-recurring-invoice-templates": ("erpclaw-selling", "list-recurring-templates"), # Buying outstanding alias (selling owns the base name) "update-purchase-outstanding": ("erpclaw-buying", "update-invoice-outstanding"), # Common LLM guesses (wrong names → correct names) "create-payment": ("erpclaw-payments", "add-payment"), "create-purchase-order": ("erpclaw-buying", "add-purchase-order"), "create-customer": ("erpclaw-selling", "add-customer"), "create-supplier": ("erpclaw-buying", "add-supplier"), "create-employee": ("erpclaw-hr", "add-employee"), "create-item": ("erpclaw-inventory", "add-item"), "add-invoice": ("erpclaw-selling", "create-sales-invoice"), "create-invoice": ("erpclaw-selling", "create-sales-invoice"), "add-sales-invoice": ("erpclaw-selling", "create-sales-invoice"), } # --------------------------------------------------------------------------- # Module management actions — forwarded to module_manager.py / onboarding.py # --------------------------------------------------------------------------- MODULE_ACTIONS = { "install-module", "remove-module", "update-modules", "list-modules", "available-modules", "module-status", "search-modules", "rebuild-action-cache", "list-all-actions", "regenerate-skill-md", "update-foundation", "rollback-foundation", "verify-trust-root", } # Actions that touch foundation install state; sync hook MUST skip these # to avoid re-entering update during their own execution. SYNC_RECURSION_GUARD = frozenset({ "update-foundation", "rollback-foundation", "verify-trust-root", "install-module", "remove-module", "update-modules", "schema-apply", "schema-rollback", }) ONBOARDING_ACTIONS = { "list-profiles", "onboard", } # High-impact actions that require explicit per-invocation confirmation. # Foundation router gates these before dispatch via --user-confirmed flag. # Categories: financial mutations, RBAC + credential lifecycle, restores, # schema migrations, payroll, module install/remove. Read-only actions # (list-*, get-*, reports) are NOT gated. DANGEROUS_ACTIONS = frozenset({ # GL + fiscal-period mutations "post-gl-entries", "reverse-gl-entries", "close-fiscal-year", "reopen-fiscal-year", "freeze-account", "unfreeze-account", # Journal lifecycle "submit-journal-entry", "cancel-journal-entry", "delete-journal-entry", "delete-recurring-template", # Payments "submit-payment", "cancel-payment", "delete-payment", # Bad-debt write-off (Wave G F17). Posts GL and permanently forgives a # receivable — the transaction-class definition exactly (ADR-0018 dec. 1: # everything gated that is not one of the five ratified destructive actions # is transaction-class, so the agent may pass the flag on a clear request # without re-asking). Gating them closes a real asymmetry: submitting the # invoice is gated and cancelling it — the only undo — is gated, while # forgiving the same debt was not. `legal-write-off-invoice` is listed with # it because the two are one operation across the delegation hop. "write-off-invoice", "legal-write-off-invoice", # Tax "delete-tax-template", # Reports / consolidation that mutate. `run-elimination` left this set when # it was retired (M63-C): it writes nothing now, and a confirmation prompt in # front of a steer message is friction with no decision behind it. "run-consolidation", # M114: deletes consolidation-layer elimination rows (audited, report-only # until --confirm) — financial-mutation class, gated like its siblings. "remove-elimination-surplus", # Selling lifecycle "submit-quotation", "submit-sales-order", "cancel-sales-order", "submit-delivery-note", "cancel-delivery-note", "submit-sales-invoice", "cancel-sales-invoice", "cancel-intercompany-invoice", "submit-blanket-order", # Buying lifecycle "submit-material-request", "submit-rfq", "submit-purchase-order", "cancel-purchase-order", "submit-purchase-receipt", "cancel-purchase-receipt", "submit-purchase-invoice", "cancel-purchase-invoice", "submit-blanket-po", # Inventory mutations "submit-stock-entry", "cancel-stock-entry", "submit-stock-reconciliation", "cancel-stock-revaluation", # Wave 2 M5: pick-list lifecycle that creates/consumes/releases hard # reservations (and complete-pick-list generates a delivery note). "submit-pick-list", "complete-pick-list", "cancel-pick-list", # Intercompany approvals "approve-ic-transaction", # HR approvals "approve-leave", "reject-leave", "approve-expense-claim", "reject-expense-claim", # Payroll "create-payroll-run", "submit-payroll-run", "cancel-payroll-run", "generate-w2-data", "generate-nacha-file", # Setup destructive "restore-database", # Deletes backup FILES per the retention policy (7 daily / 4 weekly / # 12 monthly) — irreversible, gated like its restore sibling (M36 R-c) "cleanup-backups", # RBAC + identity changes "set-password", "add-role", "assign-role", "revoke-role", "seed-permissions", "update-user", # Credential management "set-credential", "delete-credential", "migrate-credentials", "import-master-key-from-backup", # Module lifecycle "install-module", "remove-module", "update-modules", "update-foundation", "rollback-foundation", # Schema migrations "schema-apply", "schema-rollback", # Foundation migration runner (DDL-executing; gated like its schema-* siblings # — ADR-0028 / BDFL checkpoint-② condition c, M31 H1) "migrate", # Initialize-database --force "initialize-database", }) def _is_user_confirmed() -> bool: """Confirmation requires the explicit per-invocation flag. Environment-variable forms intentionally not honored: a process-wide bypass would let agents/cron/CI globally enable financial mutations without a fresh check. Per-invocation only. """ return "--user-confirmed" in sys.argv def _gate_dangerous_action(action: str) -> None: """Block dispatch of high-impact actions without --user-confirmed. The gate runs in the foundation router BEFORE dispatch so CLI, cron, agent, and addon-driven invocations all pass through the same check. """ if action not in DANGEROUS_ACTIONS: return # initialize-database is dangerous only when --force is also passed if action == "initialize-database" and "--force" not in sys.argv: return if _is_user_confirmed(): return print(json.dumps({ "status": "error", "error": "user_confirmation_required", "action": action, "message": ( f"Action '{action}' is a high-impact action. " f"Re-invoke with --user-confirmed to proceed." ), })) sys.exit(2) def find_action(): """Extract --action value from sys.argv.""" for i, arg in enumerate(sys.argv): if arg == "--action" and i + 1 < len(sys.argv): return sys.argv[i + 1] return None def _maybe_check_drift_reminder(action): """Surface a one-line reminder if the installed foundation version differs from the published manifest. Read-only; never modifies files. The user invokes `update-foundation --user-confirmed` explicitly to reconcile. At most one reminder per 24-hour window per install. Best-effort: silent on any failure; never blocks dispatch. """ if action in SYNC_RECURSION_GUARD: return if "--no-reconcile-check" in sys.argv: return skip_marker = os.path.expanduser("~/.openclaw/erpclaw/.skip_reconcile") if os.path.isfile(skip_marker): return # Skip when SKILL.md is tracked by an enclosing git repo (developer checkout) install_root = os.path.dirname(BASE_DIR) skill_md = os.path.join(install_root, "SKILL.md") if os.path.isfile(skill_md): try: import subprocess as _sp r = _sp.run(["git", "-C", install_root, "ls-files", "--error-unmatch", "SKILL.md"], capture_output=True, timeout=5) if r.returncode == 0: return except (Exception,): pass last_check = os.path.expanduser("~/.openclaw/erpclaw/.last_drift_check") try: if os.path.isfile(last_check): if time.time() - os.path.getmtime(last_check) < 86400: return except OSError: return try: cache = os.path.expanduser("~/.openclaw/erpclaw/registry_cache.json") if not os.path.isfile(cache): return with open(cache) as f: data = json.load(f) published = data.get("modules", {}).get("erpclaw", {}).get("version") local = None skill_md = os.path.join(install_root, "SKILL.md") if os.path.isfile(skill_md): with open(skill_md) as f: for line in f: if line.startswith("version:"): local = line.split(":", 1)[1].strip() break if published and local and published != local: print( f"erpclaw: published manifest is at {published}, installed foundation is {local}. " f"Run 'erpclaw update-foundation --user-confirmed' to reconcile.", file=sys.stderr, ) try: os.makedirs(os.path.dirname(last_check), exist_ok=True) with open(last_check, "w") as f: f.write("") except OSError: pass except Exception: pass def _strip_router_flags(args: list[str]) -> list[str]: """Remove router-only flags before forwarding to domain scripts. --user-confirmed is consumed by the foundation gate and must not pass through to domain script argparse. """ return [a for a in args if a != "--user-confirmed"] def forward(domain, action_override=None): """Forward execution to the domain script via os.execvp.""" script = os.path.join(BASE_DIR, domain, "db_query.py") if not os.path.isfile(script): print(json.dumps({ "status": "error", "error": f"Domain script not found: {domain}/db_query.py" })) sys.exit(1) args = _strip_router_flags(list(sys.argv[1:])) # If there's an action override (alias), replace the action name in args if action_override: for i, arg in enumerate(args): if arg == "--action" and i + 1 < len(args): args[i + 1] = action_override break os.execvp(sys.executable, [sys.executable, script] + args) def forward_script(script_path): """Forward execution to a standalone script (module_manager, onboarding).""" if not os.path.isfile(script_path): print(json.dumps({ "status": "error", "error": f"Script not found: {script_path}" })) sys.exit(1) args = _strip_router_flags(list(sys.argv[1:])) os.execvp(sys.executable, [sys.executable, script_path] + args) def forward_module(module_name, action_override=None): """Forward execution to an installed module's db_query.py.""" script = os.path.join(MODULES_DIR, module_name, "scripts", "db_query.py") if not os.path.isfile(script): print(json.dumps({ "status": "error", "error": f"Module script not found: {module_name}/scripts/db_query.py", "hint": f"Try: --action module-status --module-name {module_name}" })) sys.exit(1) args = _strip_router_flags(list(sys.argv[1:])) if action_override: for i, arg in enumerate(args): if arg == "--action" and i + 1 < len(args): args[i + 1] = action_override break os.execvp(sys.executable, [sys.executable, script] + args) def _suggest_module_for_action(action): """Check module registry for which uninstalled module might provide this action. Scans module_registry.json tags and naming conventions to suggest a module. Returns module name or None. """ # First check: does the action name have a known prefix? PREFIX_MAP = { "health-": "healthclaw", "dental-": "healthclaw-dental", "vet-": "healthclaw-vet", "mental-": "healthclaw-mental", "homehealth-": "healthclaw-homehealth", "retail-": "retailclaw", "construction-": "constructclaw", "agri-": "agricultureclaw", "auto-": "automotiveclaw", "food-": "foodclaw", "hotel-": "hospitalityclaw", "legal-": "legalclaw", "nonprofit-": "nonprofitclaw", "edu-": "educlaw", "prop-": "propertyclaw", "india-": "erpclaw-region-in", "canada-": "erpclaw-region-ca", "uk-": "erpclaw-region-uk", "eu-": "erpclaw-region-eu", "stripe-": "erpclaw-integrations-stripe", "shopify-": "erpclaw-integrations-shopify", "os-": "erpclaw-os-engine", } for prefix, module in PREFIX_MAP.items(): if action.startswith(prefix): return module return None def lookup_module_for_action(action): """Query erpclaw_module_action table to find which module owns this action. Returns the module_name if found, None otherwise. Uses a direct sqlite3 connection (not shared lib) to avoid import overhead. """ if not os.path.isfile(DB_PATH): return None try: conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row row = conn.execute( """SELECT ma.module_name FROM erpclaw_module_action ma JOIN erpclaw_module m ON m.name = ma.module_name WHERE ma.action_name = ? AND m.install_status = 'installed' AND m.is_active = 1 LIMIT 1""", (action,) ).fetchone() conn.close() if row: return row["module_name"] except (sqlite3.OperationalError, sqlite3.DatabaseError): # Table doesn't exist yet or DB issue — fall through pass return None def main(): action = find_action() if not action: print(json.dumps({ "status": "error", "error": "Missing --action flag. Usage: python3 db_query.py --action [flags]" })) sys.exit(1) # Gate dangerous actions BEFORE any dispatch path _gate_dangerous_action(action) # Surface a drift reminder if installed version differs from manifest. # Read-only; never modifies files. The user invokes update-foundation # explicitly to apply. _maybe_check_drift_reminder(action) # Tier 0: Module management actions → module_manager.py if action in MODULE_ACTIONS: _log_action_call(action, "module_manager", 0) forward_script(os.path.join(BASE_DIR, "module_manager.py")) return # Tier 0: Onboarding actions → onboarding.py if action in ONBOARDING_ACTIONS: _log_action_call(action, "onboarding", 0) forward_script(os.path.join(BASE_DIR, "onboarding.py")) return # Tier 1: Check aliases (need to override action name) if action in ALIASES: domain, original_action = ALIASES[action] _log_action_call(action, domain, 1) forward(domain, action_override=original_action) return # Tier 2: Check static core action map domain = ACTION_MAP.get(action) if domain: _log_action_call(action, domain, 2) forward(domain) return # Tier 3: Dynamic lookup — check installed modules module_name = lookup_module_for_action(action) if module_name: _log_action_call(action, module_name, 3) forward_module(module_name) return # Unknown action — check if any module provides it suggestion = _suggest_module_for_action(action) if suggestion: print(json.dumps({ "status": "error", "error": f"Unknown action: {action}", "hint": f"This action is provided by module '{suggestion}'. " f"Install it with: --action install-module --module-name {suggestion}", "suggested_module": suggestion, })) else: print(json.dumps({ "status": "error", "error": f"Unknown action: {action}", "hint": "Run --action available-modules --search to find modules, " "or --action list-all-actions to see available actions", })) sys.exit(1) if __name__ == "__main__": main()