lambda-erp 0.8.29__tar.gz → 0.8.31__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/PKG-INFO +15 -15
  2. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/README.md +14 -13
  3. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/api/attachments.py +14 -4
  4. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/api/auth.py +143 -33
  5. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/api/bootstrap.py +46 -42
  6. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/api/chat.py +198 -259
  7. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/api/demo_limits.py +1 -1
  8. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/api/main.py +4 -1
  9. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/api/oauth.py +121 -23
  10. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/api/providers.py +20 -41
  11. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/api/routers/admin.py +2 -2
  12. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/api/routers/analytics.py +351 -43
  13. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/api/routers/setup.py +16 -8
  14. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/frontend/src/api/client.ts +2 -2
  15. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/database.py +16 -0
  16. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/pyproject.toml +1 -2
  17. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/terraform/README.md +11 -14
  18. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/.gitignore +0 -0
  19. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/LICENSE +0 -0
  20. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/api/__init__.py +0 -0
  21. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/api/deps.py +0 -0
  22. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/api/errors.py +0 -0
  23. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/api/list_values.py +0 -0
  24. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/api/pdf.py +0 -0
  25. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/api/remarks_md.py +0 -0
  26. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/api/routers/__init__.py +0 -0
  27. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/api/routers/accounting.py +0 -0
  28. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/api/routers/actions.py +0 -0
  29. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/api/routers/availability.py +0 -0
  30. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/api/routers/bank_reconciliation.py +0 -0
  31. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/api/routers/bank_statements.py +0 -0
  32. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/api/routers/chat_api.py +0 -0
  33. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/api/routers/documents.py +0 -0
  34. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/api/routers/masters.py +0 -0
  35. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/api/routers/mcp.py +0 -0
  36. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/api/routers/proposals.py +0 -0
  37. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/api/routers/reports.py +0 -0
  38. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/api/services.py +0 -0
  39. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/api/templates/document.html +0 -0
  40. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/api/templates/proposal.html +0 -0
  41. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/api/tool_permissions.py +0 -0
  42. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/docs/agents/README.md +0 -0
  43. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/frontend/LICENSE +0 -0
  44. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/frontend/README.md +0 -0
  45. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/__init__.py +0 -0
  46. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/accounting/__init__.py +0 -0
  47. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/accounting/bank_account.py +0 -0
  48. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/accounting/bank_reconciliation.py +0 -0
  49. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/accounting/bank_statement_import.py +0 -0
  50. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/accounting/bank_transaction.py +0 -0
  51. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/accounting/budget.py +0 -0
  52. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/accounting/camt.py +0 -0
  53. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/accounting/chart_of_accounts.py +0 -0
  54. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/accounting/general_ledger.py +0 -0
  55. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/accounting/journal_entry.py +0 -0
  56. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/accounting/payment_entry.py +0 -0
  57. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/accounting/pos_invoice.py +0 -0
  58. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/accounting/purchase_invoice.py +0 -0
  59. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/accounting/revaluation.py +0 -0
  60. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/accounting/sales_invoice.py +0 -0
  61. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/accounting/setup/__init__.py +0 -0
  62. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/accounting/setup/engine.py +0 -0
  63. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/accounting/setup/pack.py +0 -0
  64. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/accounting/setup/packs/__init__.py +0 -0
  65. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/accounting/setup/packs/ch.py +0 -0
  66. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/accounting/setup/packs/de_common.py +0 -0
  67. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/accounting/setup/packs/de_skr03.py +0 -0
  68. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/accounting/setup/packs/de_skr04.py +0 -0
  69. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/accounting/setup/packs/generic.py +0 -0
  70. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/accounting/setup/profiles.py +0 -0
  71. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/accounting/setup/spine.py +0 -0
  72. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/accounting/subscription.py +0 -0
  73. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/assets/__init__.py +0 -0
  74. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/assets/asset.py +0 -0
  75. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/assets/reservation.py +0 -0
  76. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/buying/__init__.py +0 -0
  77. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/buying/purchase_order.py +0 -0
  78. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/controllers/__init__.py +0 -0
  79. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/controllers/currency.py +0 -0
  80. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/controllers/defaults.py +0 -0
  81. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/controllers/pricing_rule.py +0 -0
  82. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/controllers/taxes_and_totals.py +0 -0
  83. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/exceptions.py +0 -0
  84. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/hooks.py +0 -0
  85. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/model.py +0 -0
  86. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/selling/__init__.py +0 -0
  87. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/selling/proposal.py +0 -0
  88. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/selling/quotation.py +0 -0
  89. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/selling/sales_order.py +0 -0
  90. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/simulation.py +0 -0
  91. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/stock/__init__.py +0 -0
  92. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/stock/delivery_note.py +0 -0
  93. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/stock/purchase_receipt.py +0 -0
  94. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/stock/stock_entry.py +0 -0
  95. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/stock/stock_ledger.py +0 -0
  96. {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/utils.py +0 -0
@@ -1,12 +1,11 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lambda-erp
3
- Version: 0.8.29
3
+ Version: 0.8.31
4
4
  Summary: Core ERP logic - accounting, sales, purchasing, inventory
5
5
  Author: TORUS INVESTMENTS AG
6
6
  License-Expression: Apache-2.0
7
7
  License-File: LICENSE
8
8
  Requires-Python: >=3.10
9
- Requires-Dist: anthropic>=0.40
10
9
  Requires-Dist: bcrypt>=5
11
10
  Requires-Dist: defusedxml>=0.7
12
11
  Requires-Dist: fastapi>=0.115
@@ -87,8 +86,8 @@ Each of those is hours of skilled work today. With a capable LLM in the loop, th
87
86
  │ - Document forms │ │ - Document CRUD API │
88
87
  │ - Reports / Analytics │ │ - Report endpoints │
89
88
  │ - Chat (WebSocket) │ │ - Auth (JWT cookie) │
90
- │ - Client-side JS runtime │ │ - WebSocket chat gateway │
91
- │ (Web Worker) for charts │ └────────────┬───────────────┘
89
+ │ - Declarative report │ │ - WebSocket chat gateway │
90
+ │ interpreter for charts │ └────────────┬───────────────┘
92
91
  └────────────────────────────┘ │
93
92
  ▼
94
93
  ┌──────────────────────────────────────────┐
@@ -96,8 +95,8 @@ Each of those is hours of skilled work today. With a capable LLM in the loop, th
96
95
  │ - GPT-5.6 drives the reasoning loop │
97
96
  │ - Tool-use: document CRUD, search, │
98
97
  │ reports, aggregations, analytics │
99
- │ - Delegates JS generation to Anthropic │
100
- │ code-specialist sub-agent │
98
+ │ - Delegates report specs to a separate │
99
+ │ GPT-5.6 Terra specialist call │
101
100
  └──────────────────┬───────────────────────┘
102
101
  │
103
102
  ▼
@@ -117,9 +116,9 @@ Each of those is hours of skilled work today. With a capable LLM in the loop, th
117
116
  - **Extensions are first-class to the AI.** A customer deployment registers its own doctypes and master types through plugin seams (`register_doctype`, `register_master`) and the chat discovers them automatically - tool schemas and the system prompt are built per request from the live registries, and fields are introspected from the live table, so there is nothing to teach per type. A CRM lead added by a plugin is searchable in chat from day one, with zero prompt or tool edits.
118
117
  - **One shape for every document.** Invoices, sales orders, stock entries, payments - all share a single `Document` base class and the same three-state lifecycle (Draft → Submitted → Cancelled) with `on_submit`/`on_cancel` hooks. The LLM learns the pattern once and drives every doctype the same way. Leading open-source and commercial ERPs have per-model action verbs spread across 150+ core models; each one is a separate tool the model has to get right.
119
118
  - **Metadata-driven UI, shared with the LLM.** A single React form component renders every doctype from `frontend/src/lib/doctypes.ts`. The schema the model reasons over and the schema the user sees are literally the same file. Adding a field is two lines - one in the Python class, one in the config - not a new module with hand-written views and inheritance overlays.
120
- - **Two-model orchestration.** A planner model handles reasoning and tool-use. When it needs to generate code for a custom report, it delegates to a code-specialist sub-agent. This keeps each model doing what it's best at and keeps latency down on simple turns.
119
+ - **Two-model orchestration.** A planner model handles reasoning and tool-use. When it needs a custom report, it delegates a bounded declarative report specification to a report-specialist model. No generated report JavaScript is executed in the browser.
121
120
  - **Semantic datasets, not free SQL.** The LLM can't write raw SQL; it calls whitelisted semantic datasets (`sales_invoices`, `purchase_invoices`, `ar_open_items`, `stock_balances`, etc.) with whitelisted filters and group-bys. This makes the system auditable without sacrificing flexibility.
122
- - **Client-side analytics runtime.** Custom report JS executes in a sandboxed Web Worker in the user's browser. The server never runs untrusted JS. Charts are persisted as portable specs, not screenshots.
121
+ - **Client-side analytics runtime.** A bounded declarative interpreter groups, aggregates, sorts, and charts whitelisted semantic datasets. Report drafts contain portable JSON specifications, never executable code.
123
122
  - **Double-entry invariant enforced.** Every submitted document that touches the GL must balance to zero. The engine adds round-off entries for rounding gaps and refuses to post imbalanced vouchers.
124
123
  - **One deployment per customer, simple to operate.** Lambda ERP is built to be self-hosted by a single company for its own books - not as a multi-tenant SaaS. One FastAPI process, one database, one VPS is enough. If you want a hosted offering, we'll ship a dedicated instance per customer.
125
124
 
@@ -133,7 +132,7 @@ Each of those is hours of skilled work today. With a capable LLM in the loop, th
133
132
  | Frontend | React + Vite + TypeScript + Tailwind + Recharts |
134
133
  | Chat transport | WebSocket |
135
134
  | LLM orchestrator | OpenAI (configurable) |
136
- | Code specialist | Anthropic (configurable) |
135
+ | Report specialist | OpenAI GPT-5.6 Terra (configurable) |
137
136
  | Auth | JWT httponly cookie, three roles + demo |
138
137
 
139
138
  ---
@@ -186,7 +185,7 @@ The result is booked in one step — chart of accounts, sensible default account
186
185
  Four things had to be true for this to work, and they all became true in the last ~18 months:
187
186
 
188
187
  1. **LLMs can reliably call tools.** A year ago, models would hallucinate tool calls, mangle JSON, or drift after 2–3 steps. Today's frontier models can run an 8-step reasoning loop over a real tool inventory without falling off.
189
- 2. **Costs collapsed.** Generating a custom report via a code-specialist sub-agent is cents of compute. Even keeping a human reviewer fully in the loop, the marginal cost of "one more report" or "one more dashboard" drops by orders of magnitude — which means companies actually ask for them, instead of living with the defaults.
188
+ 2. **Costs collapsed.** Generating a custom report via a report-specialist model is cents of compute. Even keeping a human reviewer fully in the loop, the marginal cost of "one more report" or "one more dashboard" drops by orders of magnitude — which means companies actually ask for them, instead of living with the defaults.
190
189
  3. **Structured output + function calling are first-class.** We can constrain the LLM's outputs to valid tool-call schemas, safe SQL parameters, and typed JSON - which is what makes an AI-native ERP even conceivable as a safe thing to run.
191
190
  4. **Greenfield is finally cheaper than retrofit.** Twenty-year ERP codebases have hundreds of bespoke models and thousands of hand-written forms - teaching an LLM to drive that reliably means curating a custom tool layer over every quirk. Starting from scratch around one Document lifecycle and a metadata-driven UI is now cheaper than retrofitting an existing platform.
192
191
 
@@ -217,7 +216,7 @@ You need Docker and Compose v2. The canonical installs:
217
216
  Then, from the repo root:
218
217
 
219
218
  ```bash
220
- cp .env.example .env # add your OPENAI_API_KEY and ANTHROPIC_API_KEY (for custom analytics)
219
+ cp .env.example .env # add your OPENAI_API_KEY
221
220
  docker compose up --build
222
221
  ```
223
222
 
@@ -262,14 +261,15 @@ Open `http://localhost:5173`. Vite proxies `/api/*` to the backend.
262
261
 
263
262
  ```
264
263
  OPENAI_API_KEY=sk-...
265
- ANTHROPIC_API_KEY=sk-ant-... # optional, used for the code-specialist sub-agent
266
- ANTHROPIC_CODE_MODEL=claude-opus-4-7 # optional, default shown
264
+ LAMBDA_ERP_REPORT_MODEL=gpt-5.6-terra # optional specialist override; default shown
267
265
  LAMBDA_ERP_ADMIN_EMAIL=admin@example.com # optional, seeds the admin at boot
268
266
  LAMBDA_ERP_ADMIN_PASSWORD=... # optional, required with the line above
269
267
  LAMBDA_ERP_ADMIN_NAME=Administrator # optional, display name for the seeded admin
270
268
  ```
271
269
 
272
- Chat needs `OPENAI_API_KEY`. Custom-report code generation uses `ANTHROPIC_API_KEY` when set; otherwise it falls back and the chat will tell you it can't generate reports.
270
+ Chat and custom-report specification generation share `OPENAI_API_KEY`. Both
271
+ the orchestrator and report specialist default to GPT-5.6 Terra; the specialist
272
+ can be overridden with `LAMBDA_ERP_REPORT_MODEL`.
273
273
 
274
274
  **Seeded admin.** By default the first person to register becomes the admin. Set `LAMBDA_ERP_ADMIN_EMAIL` + `LAMBDA_ERP_ADMIN_PASSWORD` to instead provision that admin automatically at startup — useful when the database is recreated on every deploy, so a redeployed instance can't be claimed by whoever visits first. It's create-if-missing and idempotent (an existing account with that email is promoted to an enabled admin; its password is left untouched), and the password is only ever read from the environment.
275
275
 
@@ -487,4 +487,4 @@ Release notes live in [CHANGELOG.md](./CHANGELOG.md). Releases are tagged
487
487
 
488
488
  ## Trademarks and affiliations
489
489
 
490
- Lambda ERP and [lambda.dev](https://lambda.dev/) are product and trade names of **TORUS INVESTMENTS AG**. It is not affiliated with, endorsed by, or sponsored by OpenAI, Anthropic, SAP, Oracle, Microsoft, or any other company named in this repository. SAP, Business One, S/4HANA, Oracle, NetSuite, Microsoft, Dynamics, OpenAI, GPT, Anthropic, and Claude are trademarks of their respective owners and are referenced here only for descriptive and comparative purposes (nominative fair use). We interoperate with OpenAI and Anthropic APIs as a customer like anyone else; you supply your own API keys.
490
+ Lambda ERP and [lambda.dev](https://lambda.dev/) are product and trade names of **TORUS INVESTMENTS AG**. It is not affiliated with, endorsed by, or sponsored by OpenAI, Anthropic, SAP, Oracle, Microsoft, or any other company named in this repository. SAP, Business One, S/4HANA, Oracle, NetSuite, Microsoft, Dynamics, OpenAI, GPT, Anthropic, and Claude are trademarks of their respective owners and are referenced here only for descriptive and comparative purposes (nominative fair use). The ERP uses the OpenAI API as a customer; you supply your own API key.
@@ -58,8 +58,8 @@ Each of those is hours of skilled work today. With a capable LLM in the loop, th
58
58
  │ - Document forms │ │ - Document CRUD API │
59
59
  │ - Reports / Analytics │ │ - Report endpoints │
60
60
  │ - Chat (WebSocket) │ │ - Auth (JWT cookie) │
61
- │ - Client-side JS runtime │ │ - WebSocket chat gateway │
62
- │ (Web Worker) for charts │ └────────────┬───────────────┘
61
+ │ - Declarative report │ │ - WebSocket chat gateway │
62
+ │ interpreter for charts │ └────────────┬───────────────┘
63
63
  └────────────────────────────┘ │
64
64
  ▼
65
65
  ┌──────────────────────────────────────────┐
@@ -67,8 +67,8 @@ Each of those is hours of skilled work today. With a capable LLM in the loop, th
67
67
  │ - GPT-5.6 drives the reasoning loop │
68
68
  │ - Tool-use: document CRUD, search, │
69
69
  │ reports, aggregations, analytics │
70
- │ - Delegates JS generation to Anthropic │
71
- │ code-specialist sub-agent │
70
+ │ - Delegates report specs to a separate │
71
+ │ GPT-5.6 Terra specialist call │
72
72
  └──────────────────┬───────────────────────┘
73
73
  │
74
74
  ▼
@@ -88,9 +88,9 @@ Each of those is hours of skilled work today. With a capable LLM in the loop, th
88
88
  - **Extensions are first-class to the AI.** A customer deployment registers its own doctypes and master types through plugin seams (`register_doctype`, `register_master`) and the chat discovers them automatically - tool schemas and the system prompt are built per request from the live registries, and fields are introspected from the live table, so there is nothing to teach per type. A CRM lead added by a plugin is searchable in chat from day one, with zero prompt or tool edits.
89
89
  - **One shape for every document.** Invoices, sales orders, stock entries, payments - all share a single `Document` base class and the same three-state lifecycle (Draft → Submitted → Cancelled) with `on_submit`/`on_cancel` hooks. The LLM learns the pattern once and drives every doctype the same way. Leading open-source and commercial ERPs have per-model action verbs spread across 150+ core models; each one is a separate tool the model has to get right.
90
90
  - **Metadata-driven UI, shared with the LLM.** A single React form component renders every doctype from `frontend/src/lib/doctypes.ts`. The schema the model reasons over and the schema the user sees are literally the same file. Adding a field is two lines - one in the Python class, one in the config - not a new module with hand-written views and inheritance overlays.
91
- - **Two-model orchestration.** A planner model handles reasoning and tool-use. When it needs to generate code for a custom report, it delegates to a code-specialist sub-agent. This keeps each model doing what it's best at and keeps latency down on simple turns.
91
+ - **Two-model orchestration.** A planner model handles reasoning and tool-use. When it needs a custom report, it delegates a bounded declarative report specification to a report-specialist model. No generated report JavaScript is executed in the browser.
92
92
  - **Semantic datasets, not free SQL.** The LLM can't write raw SQL; it calls whitelisted semantic datasets (`sales_invoices`, `purchase_invoices`, `ar_open_items`, `stock_balances`, etc.) with whitelisted filters and group-bys. This makes the system auditable without sacrificing flexibility.
93
- - **Client-side analytics runtime.** Custom report JS executes in a sandboxed Web Worker in the user's browser. The server never runs untrusted JS. Charts are persisted as portable specs, not screenshots.
93
+ - **Client-side analytics runtime.** A bounded declarative interpreter groups, aggregates, sorts, and charts whitelisted semantic datasets. Report drafts contain portable JSON specifications, never executable code.
94
94
  - **Double-entry invariant enforced.** Every submitted document that touches the GL must balance to zero. The engine adds round-off entries for rounding gaps and refuses to post imbalanced vouchers.
95
95
  - **One deployment per customer, simple to operate.** Lambda ERP is built to be self-hosted by a single company for its own books - not as a multi-tenant SaaS. One FastAPI process, one database, one VPS is enough. If you want a hosted offering, we'll ship a dedicated instance per customer.
96
96
 
@@ -104,7 +104,7 @@ Each of those is hours of skilled work today. With a capable LLM in the loop, th
104
104
  | Frontend | React + Vite + TypeScript + Tailwind + Recharts |
105
105
  | Chat transport | WebSocket |
106
106
  | LLM orchestrator | OpenAI (configurable) |
107
- | Code specialist | Anthropic (configurable) |
107
+ | Report specialist | OpenAI GPT-5.6 Terra (configurable) |
108
108
  | Auth | JWT httponly cookie, three roles + demo |
109
109
 
110
110
  ---
@@ -157,7 +157,7 @@ The result is booked in one step — chart of accounts, sensible default account
157
157
  Four things had to be true for this to work, and they all became true in the last ~18 months:
158
158
 
159
159
  1. **LLMs can reliably call tools.** A year ago, models would hallucinate tool calls, mangle JSON, or drift after 2–3 steps. Today's frontier models can run an 8-step reasoning loop over a real tool inventory without falling off.
160
- 2. **Costs collapsed.** Generating a custom report via a code-specialist sub-agent is cents of compute. Even keeping a human reviewer fully in the loop, the marginal cost of "one more report" or "one more dashboard" drops by orders of magnitude — which means companies actually ask for them, instead of living with the defaults.
160
+ 2. **Costs collapsed.** Generating a custom report via a report-specialist model is cents of compute. Even keeping a human reviewer fully in the loop, the marginal cost of "one more report" or "one more dashboard" drops by orders of magnitude — which means companies actually ask for them, instead of living with the defaults.
161
161
  3. **Structured output + function calling are first-class.** We can constrain the LLM's outputs to valid tool-call schemas, safe SQL parameters, and typed JSON - which is what makes an AI-native ERP even conceivable as a safe thing to run.
162
162
  4. **Greenfield is finally cheaper than retrofit.** Twenty-year ERP codebases have hundreds of bespoke models and thousands of hand-written forms - teaching an LLM to drive that reliably means curating a custom tool layer over every quirk. Starting from scratch around one Document lifecycle and a metadata-driven UI is now cheaper than retrofitting an existing platform.
163
163
 
@@ -188,7 +188,7 @@ You need Docker and Compose v2. The canonical installs:
188
188
  Then, from the repo root:
189
189
 
190
190
  ```bash
191
- cp .env.example .env # add your OPENAI_API_KEY and ANTHROPIC_API_KEY (for custom analytics)
191
+ cp .env.example .env # add your OPENAI_API_KEY
192
192
  docker compose up --build
193
193
  ```
194
194
 
@@ -233,14 +233,15 @@ Open `http://localhost:5173`. Vite proxies `/api/*` to the backend.
233
233
 
234
234
  ```
235
235
  OPENAI_API_KEY=sk-...
236
- ANTHROPIC_API_KEY=sk-ant-... # optional, used for the code-specialist sub-agent
237
- ANTHROPIC_CODE_MODEL=claude-opus-4-7 # optional, default shown
236
+ LAMBDA_ERP_REPORT_MODEL=gpt-5.6-terra # optional specialist override; default shown
238
237
  LAMBDA_ERP_ADMIN_EMAIL=admin@example.com # optional, seeds the admin at boot
239
238
  LAMBDA_ERP_ADMIN_PASSWORD=... # optional, required with the line above
240
239
  LAMBDA_ERP_ADMIN_NAME=Administrator # optional, display name for the seeded admin
241
240
  ```
242
241
 
243
- Chat needs `OPENAI_API_KEY`. Custom-report code generation uses `ANTHROPIC_API_KEY` when set; otherwise it falls back and the chat will tell you it can't generate reports.
242
+ Chat and custom-report specification generation share `OPENAI_API_KEY`. Both
243
+ the orchestrator and report specialist default to GPT-5.6 Terra; the specialist
244
+ can be overridden with `LAMBDA_ERP_REPORT_MODEL`.
244
245
 
245
246
  **Seeded admin.** By default the first person to register becomes the admin. Set `LAMBDA_ERP_ADMIN_EMAIL` + `LAMBDA_ERP_ADMIN_PASSWORD` to instead provision that admin automatically at startup — useful when the database is recreated on every deploy, so a redeployed instance can't be claimed by whoever visits first. It's create-if-missing and idempotent (an existing account with that email is promoted to an enabled admin; its password is left untouched), and the password is only ever read from the environment.
246
247
 
@@ -458,4 +459,4 @@ Release notes live in [CHANGELOG.md](./CHANGELOG.md). Releases are tagged
458
459
 
459
460
  ## Trademarks and affiliations
460
461
 
461
- Lambda ERP and [lambda.dev](https://lambda.dev/) are product and trade names of **TORUS INVESTMENTS AG**. It is not affiliated with, endorsed by, or sponsored by OpenAI, Anthropic, SAP, Oracle, Microsoft, or any other company named in this repository. SAP, Business One, S/4HANA, Oracle, NetSuite, Microsoft, Dynamics, OpenAI, GPT, Anthropic, and Claude are trademarks of their respective owners and are referenced here only for descriptive and comparative purposes (nominative fair use). We interoperate with OpenAI and Anthropic APIs as a customer like anyone else; you supply your own API keys.
462
+ Lambda ERP and [lambda.dev](https://lambda.dev/) are product and trade names of **TORUS INVESTMENTS AG**. It is not affiliated with, endorsed by, or sponsored by OpenAI, Anthropic, SAP, Oracle, Microsoft, or any other company named in this repository. SAP, Business One, S/4HANA, Oracle, NetSuite, Microsoft, Dynamics, OpenAI, GPT, Anthropic, and Claude are trademarks of their respective owners and are referenced here only for descriptive and comparative purposes (nominative fair use). The ERP uses the OpenAI API as a customer; you supply your own API key.
@@ -238,6 +238,12 @@ async def upload_attachment(
238
238
  user: dict = Depends(require_role("viewer")),
239
239
  ):
240
240
  """Upload a chat attachment. Returns metadata the client uses to attach it to a message."""
241
+ db = get_db()
242
+ sessions = db.sql('SELECT user_id FROM "Chat Session" WHERE id = ?', [session_id])
243
+ if not sessions or sessions[0].get("user_id") != user.get("name"):
244
+ # Keep foreign session IDs indistinguishable from nonexistent ones.
245
+ raise HTTPException(status_code=404, detail="Chat session not found")
246
+
241
247
  mime = (file.content_type or "application/octet-stream").lower()
242
248
  if mime not in ALLOWED_MIME_TYPES:
243
249
  # Browsers/OSes often mislabel Office files (e.g. octet-stream) — fall
@@ -253,9 +259,14 @@ async def upload_attachment(
253
259
  "and structured bank files (XML/ZIP)."),
254
260
  )
255
261
 
256
- data = await file.read()
257
- if len(data) > MAX_ATTACHMENT_SIZE:
258
- raise HTTPException(status_code=413, detail=f"File too large. Maximum {MAX_ATTACHMENT_SIZE // (1024 * 1024)} MB.")
262
+ chunks: list[bytes] = []
263
+ size = 0
264
+ while chunk := await file.read(1024 * 1024):
265
+ size += len(chunk)
266
+ if size > MAX_ATTACHMENT_SIZE:
267
+ raise HTTPException(status_code=413, detail=f"File too large. Maximum {MAX_ATTACHMENT_SIZE // (1024 * 1024)} MB.")
268
+ chunks.append(chunk)
269
+ data = b"".join(chunks)
259
270
  if not data:
260
271
  raise HTTPException(status_code=400, detail="Empty file.")
261
272
 
@@ -275,7 +286,6 @@ async def upload_attachment(
275
286
  ),
276
287
  )
277
288
 
278
- db = get_db()
279
289
  # Sanity-cap the number of attachments per session
280
290
  cnt = db.sql('SELECT COUNT(*) as c FROM "Chat Attachment" WHERE session_id = ?', [session_id])
281
291
  if cnt and cnt[0]["c"] >= MAX_ATTACHMENTS_PER_SESSION:
@@ -196,6 +196,87 @@ class SetPasswordRequest(BaseModel):
196
196
  ROLE_HIERARCHY = {"admin": 3, "manager": 2, "public_manager": 2, "viewer": 1}
197
197
  ASSIGNABLE_ROLES = {"admin", "manager", "viewer"}
198
198
 
199
+ SESSION_CREDENTIAL = "session"
200
+ API_KEY_CREDENTIAL = "api_key"
201
+ PUBLIC_DEMO_CREDENTIAL = "public_demo"
202
+
203
+
204
+ def make_auth_principal(
205
+ user: dict,
206
+ credential_type: str,
207
+ *,
208
+ name: str | None = None,
209
+ api_key_id: str | None = None,
210
+ ) -> dict:
211
+ """Return the request-scoped identity used by authorization checks.
212
+
213
+ ``User`` and ``Api Key`` rows remain the persistence model. A principal is
214
+ deliberately ephemeral: it records both *who* the owner is and *how* this
215
+ request authenticated, so a role-capped API key cannot be mistaken for an
216
+ interactive browser session at credential-management endpoints.
217
+ """
218
+ principal = dict(user)
219
+ principal["user_id"] = user["name"]
220
+ principal["name"] = name or user["name"]
221
+ principal["credential_type"] = credential_type
222
+ if api_key_id:
223
+ principal["api_key_id"] = api_key_id
224
+ return principal
225
+
226
+
227
+ def refresh_auth_principal(principal: dict | None) -> dict | None:
228
+ """Re-resolve an authenticated principal from live database state.
229
+
230
+ Used at long-lived boundaries (notably chat WebSockets and tool dispatch)
231
+ so disabling/demoting a user or revoking a key takes effect without waiting
232
+ for the connection to close. Legacy in-process callers without an explicit
233
+ credential type are returned unchanged; every real HTTP/WS auth path sets it.
234
+ """
235
+ if not principal:
236
+ return None
237
+ credential_type = principal.get("credential_type")
238
+ if not credential_type:
239
+ return principal
240
+
241
+ db = get_db()
242
+ if credential_type == API_KEY_CREDENTIAL:
243
+ key_id = principal.get("api_key_id")
244
+ rows = db.sql(
245
+ 'SELECT id, owner, role, session_owner, revoked FROM "Api Key" WHERE id = ?',
246
+ [key_id],
247
+ )
248
+ if not rows or rows[0].get("revoked"):
249
+ return None
250
+ key = dict(rows[0])
251
+ owner = db.get_value(
252
+ "User", key["owner"], ["name", "email", "full_name", "role", "enabled"]
253
+ )
254
+ if not owner or not owner.get("enabled"):
255
+ return None
256
+ owner = dict(owner)
257
+ effective_role = (
258
+ key["role"] if _role_rank(key["role"]) <= _role_rank(owner["role"])
259
+ else owner["role"]
260
+ )
261
+ owner["role"] = effective_role
262
+ return make_auth_principal(
263
+ owner,
264
+ API_KEY_CREDENTIAL,
265
+ name=principal.get("name") or key.get("session_owner") or owner["name"],
266
+ api_key_id=key["id"],
267
+ )
268
+
269
+ user_id = principal.get("user_id") or principal.get("name")
270
+ user = db.get_value(
271
+ "User", user_id, ["name", "email", "full_name", "role", "enabled"]
272
+ )
273
+ if not user or not user.get("enabled"):
274
+ return None
275
+ user = dict(user)
276
+ if credential_type == PUBLIC_DEMO_CREDENTIAL and user.get("role") != "public_manager":
277
+ return None
278
+ return make_auth_principal(user, credential_type)
279
+
199
280
 
200
281
  def get_current_user(request: Request) -> dict:
201
282
  """FastAPI dependency: resolve the caller for every cookie-gated REST route.
@@ -223,26 +304,23 @@ def get_current_user(request: Request) -> dict:
223
304
  if user_name:
224
305
  user = db.get_value("User", user_name, ["name", "email", "full_name", "role", "enabled"])
225
306
  if user and user.get("enabled"):
226
- return dict(user)
307
+ return make_auth_principal(dict(user), SESSION_CREDENTIAL)
227
308
 
228
309
  bearer = _bearer_token(request)
229
310
  if bearer is not None:
230
311
  if not _setting_enabled(db, "rest_api_enabled"):
231
312
  raise HTTPException(status_code=401, detail="REST API key access is disabled")
232
313
  key, owner, effective_role = _lookup_api_key(db, bearer)
233
- return {
234
- "name": owner["name"],
235
- "email": owner.get("email"),
236
- "full_name": owner.get("full_name"),
237
- "role": effective_role,
238
- "enabled": owner.get("enabled"),
239
- "api_key_id": key["id"],
240
- }
314
+ owner = dict(owner)
315
+ owner["role"] = effective_role
316
+ return make_auth_principal(
317
+ owner, API_KEY_CREDENTIAL, api_key_id=key["id"]
318
+ )
241
319
 
242
320
  # Fall back to public manager (demo mode)
243
321
  pub = db.sql('SELECT name, email, full_name, role, enabled FROM "User" WHERE role = \'public_manager\' AND enabled = 1')
244
322
  if pub:
245
- return dict(pub[0])
323
+ return make_auth_principal(dict(pub[0]), PUBLIC_DEMO_CREDENTIAL)
246
324
 
247
325
  raise HTTPException(status_code=401, detail="Not authenticated")
248
326
 
@@ -272,6 +350,34 @@ require_manager = require_role("manager")
272
350
  require_viewer = require_role("viewer")
273
351
 
274
352
 
353
+ def require_interactive_user(user: dict = Depends(get_current_user)) -> dict:
354
+ """Require a real browser session for account/credential management."""
355
+ if user.get("credential_type") != SESSION_CREDENTIAL:
356
+ raise HTTPException(
357
+ status_code=403,
358
+ detail="This account-security operation requires an interactive sign-in.",
359
+ )
360
+ return user
361
+
362
+
363
+ def require_interactive_role(minimum_role: str):
364
+ """Require both a browser session and the requested live account role."""
365
+ min_level = ROLE_HIERARCHY[minimum_role]
366
+
367
+ def checker(user: dict = Depends(require_interactive_user)) -> dict:
368
+ if ROLE_HIERARCHY.get(user.get("role"), 0) < min_level:
369
+ raise HTTPException(
370
+ status_code=403,
371
+ detail=f"Requires {minimum_role} role or higher",
372
+ )
373
+ return user
374
+
375
+ return checker
376
+
377
+
378
+ require_interactive_admin = require_interactive_role("admin")
379
+
380
+
275
381
  def require_non_public_manager(user: dict = Depends(get_current_user)) -> dict:
276
382
  user_level = ROLE_HIERARCHY.get(user["role"], 0)
277
383
  if user_level < ROLE_HIERARCHY["manager"]:
@@ -404,7 +510,7 @@ def me(user: dict = Depends(get_current_user)):
404
510
 
405
511
 
406
512
  @router.post("/change-password")
407
- def change_password(data: ChangePasswordRequest, user: dict = Depends(get_current_user)):
513
+ def change_password(data: ChangePasswordRequest, user: dict = Depends(require_interactive_user)):
408
514
  """Change the signed-in user's own password (verifies the current one)."""
409
515
  # The public_manager is a shared demo account — no real password to change.
410
516
  if user["role"] == "public_manager":
@@ -426,7 +532,7 @@ def change_password(data: ChangePasswordRequest, user: dict = Depends(get_curren
426
532
 
427
533
 
428
534
  @router.post("/set-password")
429
- def set_password(data: SetPasswordRequest, user: dict = Depends(get_current_user)):
535
+ def set_password(data: SetPasswordRequest, user: dict = Depends(require_interactive_user)):
430
536
  """Set a first password for an account that has none — e.g. a user created
431
537
  via social login who wants an email+password fallback. Requires an
432
538
  authenticated session; refuses if a usable password already exists (that
@@ -451,7 +557,7 @@ def set_password(data: SetPasswordRequest, user: dict = Depends(get_current_user
451
557
 
452
558
 
453
559
  @router.post("/invite")
454
- def create_invite(data: InviteRequest, user: dict = Depends(require_admin)):
560
+ def create_invite(data: InviteRequest, user: dict = Depends(require_interactive_admin)):
455
561
  db = get_db()
456
562
 
457
563
  validate_assignable_role(data.role)
@@ -474,14 +580,14 @@ def create_invite(data: InviteRequest, user: dict = Depends(require_admin)):
474
580
 
475
581
 
476
582
  @router.get("/users")
477
- def list_users(user: dict = Depends(require_admin)):
583
+ def list_users(user: dict = Depends(require_interactive_admin)):
478
584
  db = get_db()
479
585
  rows = db.sql('SELECT name, email, full_name, role, enabled, creation FROM "User" ORDER BY creation')
480
586
  return [dict(r) for r in rows]
481
587
 
482
588
 
483
589
  @router.put("/users/{user_name}/role")
484
- def change_role(user_name: str, data: ChangeRoleRequest, user: dict = Depends(require_admin)):
590
+ def change_role(user_name: str, data: ChangeRoleRequest, user: dict = Depends(require_interactive_admin)):
485
591
  validate_assignable_role(data.role)
486
592
 
487
593
  db = get_db()
@@ -498,7 +604,7 @@ def change_role(user_name: str, data: ChangeRoleRequest, user: dict = Depends(re
498
604
 
499
605
 
500
606
  @router.delete("/users/{user_name}")
501
- def disable_user(user_name: str, user: dict = Depends(require_admin)):
607
+ def disable_user(user_name: str, user: dict = Depends(require_interactive_admin)):
502
608
  if user_name == user["name"]:
503
609
  raise HTTPException(status_code=409, detail="Cannot disable yourself")
504
610
 
@@ -511,14 +617,14 @@ def disable_user(user_name: str, user: dict = Depends(require_admin)):
511
617
 
512
618
 
513
619
  @router.get("/invites")
514
- def list_invites(user: dict = Depends(require_admin)):
620
+ def list_invites(user: dict = Depends(require_interactive_admin)):
515
621
  db = get_db()
516
622
  rows = db.sql('SELECT token, email, role, used, creation FROM "Invite" ORDER BY creation DESC')
517
623
  return [dict(r) for r in rows]
518
624
 
519
625
 
520
626
  @router.delete("/invites/{token}")
521
- def revoke_invite(token: str, user: dict = Depends(require_admin)):
627
+ def revoke_invite(token: str, user: dict = Depends(require_interactive_admin)):
522
628
  """Revoke a pending (unused) invite so its link no longer works."""
523
629
  db = get_db()
524
630
  rows = db.sql('SELECT token, used FROM "Invite" WHERE token = ?', [token])
@@ -532,7 +638,7 @@ def revoke_invite(token: str, user: dict = Depends(require_admin)):
532
638
 
533
639
 
534
640
  @router.post("/public-manager")
535
- def create_public_manager(user: dict = Depends(require_admin)):
641
+ def create_public_manager(user: dict = Depends(require_interactive_admin)):
536
642
  """Create (or re-enable) the public manager account for demo mode.
537
643
 
538
644
  Also seeds the chat-replay artefacts the "Enter Live Demo" script
@@ -580,7 +686,7 @@ def create_public_manager(user: dict = Depends(require_admin)):
580
686
 
581
687
 
582
688
  @router.delete("/public-manager")
583
- def remove_public_manager(user: dict = Depends(require_admin)):
689
+ def remove_public_manager(user: dict = Depends(require_interactive_admin)):
584
690
  """Disable the public manager account."""
585
691
  db = get_db()
586
692
  existing = db.sql('SELECT name FROM "User" WHERE role = \'public_manager\'')
@@ -690,7 +796,7 @@ def ensure_seed_admin() -> dict | None:
690
796
 
691
797
 
692
798
  @router.get("/settings")
693
- def get_settings(user: dict = Depends(get_current_user)):
799
+ def get_settings(user: dict = Depends(require_interactive_user)):
694
800
  db = get_db()
695
801
  rows = db.sql('SELECT key, value FROM "Settings"')
696
802
  settings = {r["key"]: r["value"] for r in rows}
@@ -702,7 +808,7 @@ def get_settings(user: dict = Depends(get_current_user)):
702
808
 
703
809
 
704
810
  @router.put("/settings")
705
- def update_settings(data: dict, user: dict = Depends(require_admin)):
811
+ def update_settings(data: dict, user: dict = Depends(require_interactive_admin)):
706
812
  db = get_db()
707
813
  for key, value in data.items():
708
814
  existing = db.sql('SELECT key FROM "Settings" WHERE key = ?', [key])
@@ -857,13 +963,17 @@ def get_api_caller(request: Request) -> dict:
857
963
  raise HTTPException(status_code=401, detail="Missing or malformed Authorization header")
858
964
  key, owner, effective_role = _lookup_api_key(db, token)
859
965
 
860
- return {
861
- "name": key["session_owner"],
862
- "full_name": f"{owner.get('full_name') or key['owner']} (API)",
863
- "role": effective_role,
864
- "api_key_id": key["id"],
865
- "api_key_user": key["owner"],
866
- }
966
+ owner = dict(owner)
967
+ owner["role"] = effective_role
968
+ principal = make_auth_principal(
969
+ owner,
970
+ API_KEY_CREDENTIAL,
971
+ name=key["session_owner"],
972
+ api_key_id=key["id"],
973
+ )
974
+ principal["full_name"] = f"{owner.get('full_name') or key['owner']} (API)"
975
+ principal["api_key_user"] = key["owner"]
976
+ return principal
867
977
 
868
978
 
869
979
  class ApiKeyCreate(BaseModel):
@@ -904,7 +1014,7 @@ def _require_key_access(key: dict, user: dict) -> None:
904
1014
 
905
1015
 
906
1016
  @router.get("/api-keys")
907
- def list_api_keys(user: dict = Depends(require_viewer)):
1017
+ def list_api_keys(user: dict = Depends(require_interactive_user)):
908
1018
  """List API keys (metadata only — the token is never returned).
909
1019
 
910
1020
  Users see their own keys; admins see everyone's. Each key carries its
@@ -930,7 +1040,7 @@ def list_api_keys(user: dict = Depends(require_viewer)):
930
1040
 
931
1041
 
932
1042
  @router.post("/api-keys")
933
- def create_api_key(data: ApiKeyCreate, user: dict = Depends(require_viewer)):
1043
+ def create_api_key(data: ApiKeyCreate, user: dict = Depends(require_interactive_user)):
934
1044
  """Create an API key for YOURSELF. Returns the full token exactly once.
935
1045
 
936
1046
  Self-service: any logged-in (non-demo) user can mint keys, because a key
@@ -975,7 +1085,7 @@ def create_api_key(data: ApiKeyCreate, user: dict = Depends(require_viewer)):
975
1085
 
976
1086
 
977
1087
  @router.post("/api-keys/{key_id}/revoke")
978
- def revoke_api_key(key_id: str, user: dict = Depends(require_viewer)):
1088
+ def revoke_api_key(key_id: str, user: dict = Depends(require_interactive_user)):
979
1089
  """Revoke (soft-disable) an API key. Owner or admin."""
980
1090
  db = get_db()
981
1091
  key = _get_key_or_404(db, key_id)
@@ -986,7 +1096,7 @@ def revoke_api_key(key_id: str, user: dict = Depends(require_viewer)):
986
1096
 
987
1097
 
988
1098
  @router.delete("/api-keys/{key_id}")
989
- def delete_api_key(key_id: str, user: dict = Depends(require_viewer)):
1099
+ def delete_api_key(key_id: str, user: dict = Depends(require_interactive_user)):
990
1100
  """Hard-delete a REVOKED API key. Owner or admin.
991
1101
 
992
1102
  Two-step by design: a live key must be revoked first (409 otherwise), so a