velophex 0.2.0__tar.gz → 0.5.3__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 (58) hide show
  1. velophex-0.5.3/PKG-INFO +680 -0
  2. velophex-0.5.3/README.md +652 -0
  3. {velophex-0.2.0 → velophex-0.5.3}/pyproject.toml +14 -4
  4. {velophex-0.2.0 → velophex-0.5.3}/src/velophex/__init__.py +3 -2
  5. velophex-0.5.3/src/velophex/_cert_store.py +207 -0
  6. {velophex-0.2.0 → velophex-0.5.3}/src/velophex/_json.py +15 -2
  7. velophex-0.5.3/src/velophex/_redaction.py +60 -0
  8. {velophex-0.2.0 → velophex-0.5.3}/src/velophex/agents/__init__.py +15 -2
  9. {velophex-0.2.0 → velophex-0.5.3}/src/velophex/agents/_loop.py +93 -23
  10. velophex-0.5.3/src/velophex/agents/_prompt.py +80 -0
  11. {velophex-0.2.0 → velophex-0.5.3}/src/velophex/agents/_tools.py +78 -8
  12. {velophex-0.2.0 → velophex-0.5.3}/src/velophex/agents/testing.py +81 -4
  13. velophex-0.5.3/src/velophex/cli/design_time.py +243 -0
  14. velophex-0.5.3/src/velophex/cli/main.py +1209 -0
  15. {velophex-0.2.0 → velophex-0.5.3}/src/velophex/cli/publish.py +71 -1
  16. velophex-0.5.3/src/velophex/cli/signin.py +530 -0
  17. {velophex-0.2.0 → velophex-0.5.3}/src/velophex/cli/sync.py +58 -1
  18. {velophex-0.2.0 → velophex-0.5.3}/src/velophex/cli/templates.py +48 -8
  19. {velophex-0.2.0 → velophex-0.5.3}/src/velophex/contracts/agents.py +18 -0
  20. {velophex-0.2.0 → velophex-0.5.3}/src/velophex/contracts/diagnostics.py +9 -0
  21. {velophex-0.2.0 → velophex-0.5.3}/src/velophex/contracts/executor.py +221 -1
  22. {velophex-0.2.0 → velophex-0.5.3}/src/velophex/contracts/package.py +63 -0
  23. {velophex-0.2.0 → velophex-0.5.3}/src/velophex/contracts/project.py +166 -20
  24. {velophex-0.2.0 → velophex-0.5.3}/src/velophex/contracts/runtimes.py +39 -0
  25. {velophex-0.2.0 → velophex-0.5.3}/src/velophex/packaging.py +5 -1
  26. {velophex-0.2.0 → velophex-0.5.3}/src/velophex/runtime/__init__.py +326 -6
  27. velophex-0.5.3/src/velophex/runtime/_local.py +197 -0
  28. {velophex-0.2.0 → velophex-0.5.3}/src/velophex/runtime/_session.py +44 -11
  29. velophex-0.5.3/src/velophex/runtime/client.py +1940 -0
  30. velophex-0.5.3/src/velophex/runtime/data_hub.py +451 -0
  31. velophex-0.5.3/src/velophex/runtime/models.py +764 -0
  32. velophex-0.5.3/src/velophex/signing.py +897 -0
  33. velophex-0.5.3/src/velophex/webhooks.py +111 -0
  34. {velophex-0.2.0 → velophex-0.5.3}/src/velophex/worker/framing.py +17 -3
  35. {velophex-0.2.0 → velophex-0.5.3}/src/velophex/worker/loader.py +3 -3
  36. {velophex-0.2.0 → velophex-0.5.3}/src/velophex/worker/outbox.py +4 -3
  37. {velophex-0.2.0 → velophex-0.5.3}/src/velophex/worker/session.py +136 -23
  38. velophex-0.2.0/PKG-INFO +0 -313
  39. velophex-0.2.0/README.md +0 -285
  40. velophex-0.2.0/src/velophex/cli/main.py +0 -548
  41. velophex-0.2.0/src/velophex/runtime/client.py +0 -957
  42. velophex-0.2.0/src/velophex/runtime/models.py +0 -326
  43. velophex-0.2.0/src/velophex/signing.py +0 -425
  44. {velophex-0.2.0 → velophex-0.5.3}/.gitignore +0 -0
  45. {velophex-0.2.0 → velophex-0.5.3}/LICENSE +0 -0
  46. {velophex-0.2.0 → velophex-0.5.3}/agent-runner/README.md +0 -0
  47. {velophex-0.2.0 → velophex-0.5.3}/src/velophex/agents/_errors.py +0 -0
  48. {velophex-0.2.0 → velophex-0.5.3}/src/velophex/agents/_tracing.py +0 -0
  49. {velophex-0.2.0 → velophex-0.5.3}/src/velophex/cli/__init__.py +0 -0
  50. {velophex-0.2.0 → velophex-0.5.3}/src/velophex/contracts/__init__.py +0 -0
  51. {velophex-0.2.0 → velophex-0.5.3}/src/velophex/contracts/_model.py +0 -0
  52. {velophex-0.2.0 → velophex-0.5.3}/src/velophex/contracts/signin.py +0 -0
  53. {velophex-0.2.0 → velophex-0.5.3}/src/velophex/contracts/validation.py +0 -0
  54. {velophex-0.2.0 → velophex-0.5.3}/src/velophex/entrypoints.py +0 -0
  55. {velophex-0.2.0 → velophex-0.5.3}/src/velophex/errors.py +0 -0
  56. {velophex-0.2.0 → velophex-0.5.3}/src/velophex/py.typed +0 -0
  57. {velophex-0.2.0 → velophex-0.5.3}/src/velophex/worker/__init__.py +0 -0
  58. {velophex-0.2.0 → velophex-0.5.3}/src/velophex/worker/__main__.py +0 -0
@@ -0,0 +1,680 @@
1
+ Metadata-Version: 2.5
2
+ Name: velophex
3
+ Version: 0.5.3
4
+ Summary: VeloPhex Python SDK: author, run, pack and publish Python automations for the VeloPhex platform.
5
+ Project-URL: Homepage, https://velophex.com
6
+ Author: VeloPhex
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Operating System :: Microsoft :: Windows
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Typing :: Typed
16
+ Requires-Python: ==3.12.*
17
+ Requires-Dist: httpx<0.29,>=0.27
18
+ Requires-Dist: pydantic<3,>=2.9
19
+ Requires-Dist: typer<0.16,>=0.12
20
+ Provides-Extra: agents
21
+ Requires-Dist: jsonschema<5,>=4.23; extra == 'agents'
22
+ Requires-Dist: langchain-core<2,>=1.6.6; extra == 'agents'
23
+ Requires-Dist: langchain-openai<2,>=1.6.7; extra == 'agents'
24
+ Requires-Dist: langgraph<1.3,>=1.2.12; extra == 'agents'
25
+ Provides-Extra: signing
26
+ Requires-Dist: cryptography<51,>=50; extra == 'signing'
27
+ Description-Content-Type: text/markdown
28
+
29
+ # VeloPhex.Python — the `velophex` SDK
30
+
31
+ The Python half of the VeloPhex hybrid platform: the library a Python automation is written
32
+ against, the worker the Runner executes it with, and the CLI that packs and publishes it.
33
+ New to VeloPhex? Start at `docs/START-HERE.md` at the root of the repository.
34
+
35
+ A Python automation is an ordinary [uv](https://docs.astral.sh/uv/) project with a
36
+ `project.json` and one or more functions marked `@velophex.entrypoint`. `velophex pack` turns
37
+ it into the same signed `.nupkg` a Studio workflow produces, so the Orchestrator stores, places
38
+ and starts both the same way.
39
+
40
+ ```python
41
+ import velophex
42
+ from velophex import runtime
43
+
44
+
45
+ @velophex.entrypoint(id="main", display_name="Total an invoice")
46
+ def run(pdf_path: str, pages: int = 1) -> dict[str, float]:
47
+ """Total one invoice."""
48
+ if not pdf_path.endswith(".pdf"):
49
+ raise velophex.BusinessError("Only PDF invoices are accepted.", code="INVOICE_REJECTED")
50
+
51
+ api_key = runtime.secret("ErpApiKey")
52
+ return {"total": 42.5}
53
+ ```
54
+
55
+ Files go through Orchestrator storage buckets, the twins of the workflow storage activities:
56
+ `runtime.upload_file`, `download_file`, `list_files` (prefix, `*.pdf;*.xlsx` patterns, recursive),
57
+ `delete_file`, `read_text` and `write_text`. A bucket is named and must exist; none is created implicitly.
58
+
59
+ ## Verifying this component
60
+
61
+ ```powershell
62
+ cd VeloPhex.Python
63
+ uv sync # creates .venv and writes uv.lock (Python 3.12)
64
+ uv run pytest # the whole suite
65
+ uv run ruff check # lint
66
+ uv run ruff format --check # formatting
67
+ ```
68
+
69
+ `uv` picks the interpreter from `requires-python` (`>=3.12,<3.13`, ADR-P008). The contract
70
+ conformance tests read `VeloPhex.Platform.Contracts` from the sibling folder; if the components
71
+ are checked out apart, point at it:
72
+
73
+ ```powershell
74
+ $env:VELOPHEX_CONTRACTS_DIR = "<path>\VeloPhex.Platform.Contracts"
75
+ uv run pytest
76
+ ```
77
+
78
+ Tests that need it skip with a message rather than failing.
79
+
80
+ ## What is where
81
+
82
+ | Path | What it is |
83
+ |---|---|
84
+ | `src/velophex/_json.py` | JSON that is byte-identical to `PlatformJson` in .NET. The escaping, the layout and the number literals are all part of the contract |
85
+ | `src/velophex/entrypoints.py` | `@entrypoint`; derives input/output JSON Schema from type hints with pydantic `TypeAdapter` |
86
+ | `src/velophex/errors.py` | `BusinessError` (a `Business` fault, never retried) and `VelophexError` |
87
+ | `src/velophex/contracts/` | `project.json`, package manifest v2, executor protocol messages, runtime kinds, the lexical rules — mirrors `VeloPhex.Platform.Contracts` |
88
+ | `src/velophex/worker/` | `python -m velophex.worker`: framing, the session state machine, the `logging` bridge and the handler loader |
89
+ | `src/velophex/runtime/` | `stop_requested()`, `context()` and the httpx client for `/api/v1/runtime/*` |
90
+ | `src/velophex/webhooks.py` | `verify()`: checks a webhook delivery's `VeloPhex-Signature` |
91
+ | `src/velophex/packaging.py` | The deterministic `.nupkg` builder, the twin of the engine's `AutomationPackageBuilder` |
92
+ | `src/velophex/cli/` | `velophex init \| run \| pack \| publish \| login \| logout` (typer); `signin.py` is the browser sign-in and the stored session |
93
+ | `src/velophex/agents/` | `velophex.agents.run`: the agent tool-calling loop (extra `velophex[agents]`); `testing.py` has the fake gateway and fake tools |
94
+ | `agent-runner/` | The `Agent.Runner` package every agent runs; see its README for how CI builds and signs it |
95
+ | `tests/data/sample_project/` | A Python project whose `project.json` was written by the **.NET** serializer |
96
+ | `tests/data/golden/` | The package the **.NET** `AutomationPackageBuilder` produced from that project |
97
+
98
+ ## Quickstart: your first local run
99
+
100
+ You need [uv](https://docs.astral.sh/uv/) and nothing else; uv fetches CPython 3.12 itself.
101
+ `velophex` is on PyPI, so four commands take you from an empty folder to a run:
102
+
103
+ ```powershell
104
+ uvx --python 3.12 velophex init Hello.World
105
+ cd Hello.World
106
+ uv sync
107
+ uv run velophex run main
108
+ ```
109
+
110
+ The last command logs `INFO hello_world.main: Hello from VeloPhex` and prints
111
+ `{"message": "Hello from VeloPhex"}`. These are the steps `velophex init` prints. The generated
112
+ handler, `src/hello_world/main.py`, takes one input, `message`, with a default. `uv sync` writes
113
+ `uv.lock`, which every package must carry.
114
+
115
+ Inputs go in a file or inline:
116
+
117
+ ```powershell
118
+ echo '{"message": "Hi"}' > inputs.json
119
+ uv run velophex run main --inputs-file inputs.json
120
+ ```
121
+
122
+ `--inputs '{"message": "Hi"}'` works too where the shell passes the quotes through (PowerShell
123
+ 7.3+, bash). Windows PowerShell 5.1 strips them, so use a file there. Exit codes: `0` success,
124
+ `1` failed, `2` unusable inputs, `3` a business rule. An error the SDK expects prints one line;
125
+ `--verbose` adds debug logging and the traceback.
126
+
127
+ PyPI may lag the SDK in this repository: the version PyPI serves is the one `uvx` installs, and
128
+ uploading a new one needs the owner's approval (see "Building and publishing the SDK"). To use
129
+ the SDK from a checkout, see "From a checkout" below.
130
+
131
+ ### More entry points
132
+
133
+ Decorate another function in a module beside `main.py`, and run it by its id:
134
+
135
+ ```python
136
+ @velophex.entrypoint(id="report")
137
+ def report(month: str) -> dict[str, int]:
138
+ return {"rows": 0}
139
+ ```
140
+
141
+ ```powershell
142
+ uv run velophex run report --inputs '{"month": "2026-09"}'
143
+ ```
144
+
145
+ The first run of an id `project.json` does not have adds it, with its schemas, and says so.
146
+ `velophex pack` does the same for every new handler before it builds.
147
+
148
+ ### Local runs: assets and secrets
149
+
150
+ A local run has no Orchestrator, so `runtime.asset`, `runtime.secret` and `runtime.credential`
151
+ have nothing to ask. Give them a file instead:
152
+
153
+ ```json
154
+ {
155
+ "ErpBaseUrl": "https://erp.example.com",
156
+ "BatchSize": 50,
157
+ "DryRun": true,
158
+ "ErpApiKey": { "secret": "s3cret" },
159
+ "Erp": { "userName": "robot", "password": "p@ss" }
160
+ }
161
+ ```
162
+
163
+ ```powershell
164
+ uv run velophex run main --assets-file local-assets.json
165
+ ```
166
+
167
+ A string is a Text asset, a number an Integer, `true`/`false` a Boolean, `{"secret": ...}` a
168
+ Secret, and `{"userName": ..., "password": ...}` a Credential. `runtime.secret("Erp.password")`
169
+ reads a credential's password, as it does on a Robot. `init` adds `local-assets.json` to
170
+ `.gitignore`, because it holds secrets. Queues, storage buckets, Data Hub and child jobs still
171
+ need an Orchestrator and fail with a one-line error.
172
+
173
+ ### Local runs against an Orchestrator
174
+
175
+ `--orchestrator` makes the local run a design-time run of a real Orchestrator, the Python twin of
176
+ Studio's F5. Nothing is published: the handler runs here and reaches assets, queues, storage and
177
+ Data Hub through the Runtime API, acting as you in the workspace.
178
+
179
+ ```powershell
180
+ uv run velophex login --orchestrator https://orchestrator.example.com # once; see "Signing in"
181
+ uv run velophex run main --orchestrator https://orchestrator.example.com --workspace Finance
182
+ ```
183
+
184
+ `--tenant` and `--workspace` default to `VELOPHEX_TENANT_ID` and `VELOPHEX_WORKSPACE`, then to the
185
+ tenant approved at `velophex login`; the workspace is an id or a name, and may be left out when you
186
+ can start jobs in only one. The token comes from `VELOPHEX_API_KEY` or `--api-key-stdin` when given,
187
+ else from the `velophex login` session, never an option. You need `jobs.create` in the
188
+ workspace and the tenant's design-time runs on (Settings > Security). The run ends with its outcome
189
+ (Succeeded, Failed, or Canceled on Ctrl+C) and is then visible as a design-time job, recorded with
190
+ the `python` runtime (Orchestrators with Contracts 0.8.73 or later; older ones record a workflow run).
191
+
192
+ ### From a checkout
193
+
194
+ Contributors, or anyone ahead of PyPI, build the wheel and point uv at it:
195
+
196
+ ```powershell
197
+ <checkout>\VeloPhex.Python\build\Build.ps1 # -> VeloPhex.Python\artifacts\dist
198
+ $env:UV_FIND_LINKS = "<checkout>\VeloPhex.Python\artifacts\dist"
199
+ uvx --python 3.12 --from velophex velophex init Hello.World
200
+ ```
201
+
202
+ `init` finds the SDK wheel through `UV_FIND_LINKS` and copies it into `wheels/`. The project
203
+ then restores on a Robot with no package index at all (see "How a Robot gets the SDK"). From
204
+ PyPI, `init` has no wheel file to copy. It warns that a Robot restores the SDK from its package
205
+ index (`Python:IndexUrl`, PyPI by default), and that is fine for any Robot that can reach one.
206
+
207
+ ## Signing in
208
+
209
+ The intended setup is `pip install velophex` (or `uv tool install velophex`), then one sign-in:
210
+
211
+ ```powershell
212
+ velophex login --orchestrator https://staging.velophex.com --tenant velo-test/default
213
+ ```
214
+
215
+ The system browser opens the Orchestrator's approval page; approve it and the CLI is signed in.
216
+ `publish` and `run --orchestrator` then need no URL, tenant or token. `--orchestrator` also takes
217
+ a tenant page pasted from the browser (`https://staging.velophex.com/velo-test/default/orchestrator_`),
218
+ which names the tenant too. `--tenant` (an id, a name, a slug or `org/tenant`) only preselects the
219
+ tenant on the approval page; the session is bound to the one you approve, and `login` prints it.
220
+ After the first time, `velophex login` with no arguments signs in again to the same Orchestrator
221
+ and tenant. `--no-browser` prints the approval page's address instead of opening it.
222
+
223
+ | | |
224
+ |---|---|
225
+ | Flow | OAuth 2.0 Authorization Code + PKCE (S256) on a loopback redirect, client `velophex-cli`, the same as Studio and the Assistant (ADR-P014). The browser returns to `http://127.0.0.1:{48620-48639}/callback` within 5 minutes. |
226
+ | Session | A `vpx_pat_…` access token (the SDK token preset, bound to the approved tenant) renewed with a rotating refresh token; the sign-in lasts 30 days. |
227
+ | Storage | One file per user, one session per Orchestrator: `%LOCALAPPDATA%\VeloPhex\cli\sessions.dat` encrypted with DPAPI for the current Windows user; `~/.config/velophex/cli/sessions.dat` (mode 0600) elsewhere. Never printed. |
228
+ | Precedence | `--api-key-stdin` or `VELOPHEX_API_KEY` (CI), then the stored session (renewed within a minute of its expiry), else an error that says to run `velophex login`. |
229
+
230
+ `velophex logout [--orchestrator URL]` revokes the sign-in on the Orchestrator and deletes it here;
231
+ without `--orchestrator` it signs out of the last Orchestrator signed in to.
232
+
233
+ ## Publishing to an Orchestrator
234
+
235
+ From the project folder, signed in (`velophex login`) with `packages.create` in the tenant:
236
+
237
+ ```powershell
238
+ uv run velophex pack # -> artifacts\Hello.World.1.0.0.nupkg
239
+ $env:VELOPHEX_SIGNING_PASSWORD = "..." # or --sign-password-stdin, or be prompted
240
+ uv run velophex publish artifacts\Hello.World.1.0.0.nupkg --sign signer.pfx
241
+ ```
242
+
243
+ In CI, where nobody can approve a browser sign-in, use a personal access token instead:
244
+ `$env:VELOPHEX_API_KEY = "vpx_pat_..."` (or `--api-key-stdin`) with `--orchestrator` and
245
+ `--tenant <tenantId>` (or `VELOPHEX_ORCHESTRATOR_URL` and `VELOPHEX_TENANT_ID`).
246
+
247
+ #### Signing
248
+
249
+ A production Orchestrator ships with `Packages:RequireSignedPackages` on and refuses an unsigned
250
+ package, or one whose signer it does not trust. `pack` and `publish` both sign, with either:
251
+
252
+ | Option | Signs with |
253
+ |---|---|
254
+ | `--sign signer.pfx` | A PKCS#12 file. Its password comes from `VELOPHEX_SIGNING_PASSWORD`, stdin (`--sign-password-stdin`) or a hidden prompt, never from an option. |
255
+ | `--sign-thumbprint <thumbprint>` | A certificate in the Windows `CurrentUser\My` store, by SHA-256 or SHA-1 thumbprint. Windows signs with its key (CNG), so a non-exportable key works, such as the one Studio creates. |
256
+
257
+ Signing needs the `signing` extra (`uv add "velophex[signing]"`). The signature is the detached
258
+ `velophex/signature.json` Studio writes (ADR-P003, ADR-P012). Signing prints the signer's SHA-256
259
+ thumbprint. `publish --sign` signs the package in place; a package already signed by that
260
+ certificate is published as it is, and one signed by another certificate is refused.
261
+
262
+ Before uploading, `publish` reads `GET /api/v1/tenants/{tenantId}/package-signing` (`packages.view`)
263
+ and stops, without uploading, when the tenant would refuse the package:
264
+
265
+ | Situation | `publish` says |
266
+ |---|---|
267
+ | Signing required, package unsigned | This tenant only accepts signed packages. Sign it with --sign <file.pfx> or --sign-thumbprint <thumbprint>, then publish again. |
268
+ | Signed by a certificate the tenant does not trust | This tenant doesn't trust the signing certificate. An admin adds its pin in the Orchestrator under Settings > Package feeds: `spki-sha256:…` |
269
+
270
+ An Orchestrator without the endpoint, or one `publish` cannot read it from, skips the check, and
271
+ the upload's own refusal decides. A tenant trusts the deployment's signers
272
+ (`Packages:TrustedSignerThumbprints`) and its own `trustedSignerPins` (ADR-P025). Robots refuse a
273
+ package the same way unless its signer is in their trusted signers (`configure --trusted-signers`,
274
+ `VeloPhex.Robot/docs/deployment/ROBOT-DEPLOYMENT.md`) or the machine accepts tenant signers.
275
+ Signers are pinned by thumbprint or public key, never by certificate authority. On an evaluation
276
+ stack with signing off, no signing option is needed.
277
+
278
+ The tenant id is the GUID in the tenant's feed URL (`/api/v1/tenants/<tenantId>/feed/v3/index.json`,
279
+ `VeloPhex.Orchestrator/docs/package-feed.md`).
280
+
281
+ ### How a Robot gets the SDK
282
+
283
+ A Robot restores the package's environment with `uv sync --frozen`, which installs from exactly
284
+ the sources `uv.lock` records and ignores any index or `--find-links` given at sync time. A lock
285
+ made against a wheel *folder* records that folder's absolute path, and it only restores where
286
+ the folder exists. So `init` **vendors** the SDK instead: it copies this SDK's own wheel into
287
+ the project's `wheels/` folder (from `--sdk-wheel`, or found in the `UV_FIND_LINKS` folders) and
288
+ writes
289
+
290
+ ```toml
291
+ [tool.uv.sources]
292
+ velophex = { path = "wheels/velophex-<version>-py3-none-any.whl" }
293
+ ```
294
+
295
+ `uv.lock` then records the wheel by a path relative to the project and by its SHA-256, and
296
+ `velophex pack` carries `wheels/` in the package like any other project file (hashed into the
297
+ manifest and signed). The Robot's `uv sync --frozen` restores the SDK from the unpacked package
298
+ itself, with no index and no Robot configuration, and it never asks any index for a package
299
+ named `velophex` - which is what closes the dependency-confusion gap (PY-02). The SDK's own dependencies (pydantic, httpx, typer) come from the Robot's index
300
+ (`Python:IndexUrl`, PyPI by default) or, when it reaches none, from the wheelhouse the Robot's
301
+ Full installer ships. That wheelhouse holds exactly the versions this SDK's `uv.lock` pins, so
302
+ `init` also writes them as `[tool.uv] constraint-dependencies`, and `package = false` so the
303
+ project is never built (the worker puts `src/` on `sys.path` itself) and needs no build backend.
304
+
305
+ Commit `wheels/` with the project. To move a project to a newer SDK, replace the wheel, update
306
+ the path in `pyproject.toml` and run `uv lock`. When `init` finds no wheel it falls back to a
307
+ plain `velophex` dependency and warns that a Robot cannot restore the project until the SDK is
308
+ on an index it can reach.
309
+
310
+ Why not ship the wheel with the Robot instead: under `--frozen` the lock's recorded path wins,
311
+ so a wheel in the Robot's payload would only be used if every developer machine had it at the
312
+ same absolute path. Verified with uv 0.12.15; vendoring needs no Robot change at all.
313
+
314
+ Every other dependency must ship a wheel too. A Robot restores with `uv sync --no-build`, because
315
+ building a package from source runs that package's own build code as the Robot service, so a
316
+ locked dependency with only an sdist fails the restore on the Robot, naming it. `velophex pack`
317
+ reads `uv.lock` and warns about each such package before you publish: one whose lock entry has an
318
+ sdist but no `py3-none-any`, `cp312-*-win_amd64` or `abi3` `win_amd64` wheel. Lock a version that
319
+ ships a wheel, or vendor the wheel in `wheels/` as above. The escape hatch is per machine, not per
320
+ package: `Python:AllowSourceBuilds` in the Robot's configuration (default `false`) lets that Robot
321
+ build from source; turn it on only where every index the Robot reaches is trusted
322
+ (`VeloPhex.Robot/docs/deployment/ROBOT-DEPLOYMENT.md`, "Wheel-only restores").
323
+
324
+ ## Building and publishing the SDK
325
+
326
+ ```powershell
327
+ build\Build.ps1 # wheel + sdist into artifacts\dist, built twice and compared byte for byte, plus SHA256SUMS
328
+ build\Publish.ps1 # dry run: checks SHA256SUMS and runs `uv publish --dry-run`; uploads nothing
329
+ ```
330
+
331
+ The version has one source, `__version__` in `src/velophex/__init__.py`; hatchling reads it.
332
+ Publishing is an outward action that needs the owner's approval: `Publish.ps1 -Publish`
333
+ uploads (`velophex` is on PyPI since 0.2.0), and refuses a public index if `pyproject.toml`
334
+ carries the `Private :: Do Not Upload` classifier (PyPI rejects that classifier too). Credentials come
335
+ from `UV_PUBLISH_TOKEN` or trusted publishing, never from a parameter.
336
+
337
+ The token is never a command-line option (it would land in shell history and the process
338
+ list); `--api-key` is refused with an error that says where to put it instead.
339
+
340
+ `pack` refreshes `project.json` from the decorated handlers before it builds (`--no-sync` to
341
+ skip), requires `uv.lock`, writes the manifest with the SHA-512 of every content file, and
342
+ computes `requiredRuntimes` as this project's runtime plus every `automationDependencies`
343
+ runtime — which is what the Orchestrator matches against a runner's reported runtimes.
344
+
345
+ `publish` is a `PUT {publicBaseUrl}/api/v1/tenants/{tenantId}/feed/v3/package` with the
346
+ package as multipart and `X-NuGet-ApiKey: vpx_pat_…`. A `409` is success only when the
347
+ feed answers `package_binary_duplicate` with a `sha256` equal to the local package's: these
348
+ exact bytes are already published. Any other `409` - different bytes under a published version -
349
+ fails, naming the package and version: a published version never changes, so increment the
350
+ version in `project.json` and pack again. Without a path, `publish` takes the one package in
351
+ `--output` (`artifacts`) and refuses to guess when there are several. The URL and tenant come from
352
+ the options, then `VELOPHEX_ORCHESTRATOR_URL` and `VELOPHEX_TENANT_ID`, then the `velophex login`
353
+ session; the token from `VELOPHEX_API_KEY` or stdin (`--api-key-stdin`), then the session.
354
+
355
+ ## How the worker runs an automation
356
+
357
+ The Runner prepares the environment with `uv sync --frozen --no-dev` and then starts
358
+ `<env>\Scripts\python.exe -m velophex.worker` (ADR-P008). Parent and worker speak the executor
359
+ protocol (ADR-P005) over the process's stdin and stdout:
360
+
361
+ 1. The worker takes the session key out of `VELOPHEX_EXECUTOR_SESSION_KEY` and **removes the
362
+ variable**, before any automation code is imported. It ignores `VELOPHEX_EXECUTOR_FEED_CREDENTIALS`
363
+ (executor protocol §1): a Python run restores no NuGet packages, and the Runner never sets it for one.
364
+ 2. The frame pipe moves off standard output to a private, non-inheritable descriptor, and fd 1,
365
+ the Win32 standard output handle and `sys.stdout` all point at `stderr`. A `print()`, a child
366
+ process (`os.system`, `subprocess`) or a `WriteFile` on the standard handle therefore cannot
367
+ corrupt the frame stream, and a detached grandchild cannot keep the pipe open after the worker exits.
368
+ The parent's pipe moves off standard input the same way, and fd 0, the Win32 standard input
369
+ handle and `sys.stdin` become the null device: an automation or a child process that reads
370
+ standard input gets end-of-file instead of consuming the parent's frames.
371
+ 3. `hello` → `hello-ack` (runtime `python`, capabilities `should-stop` and `heartbeat`: the
372
+ environment is already prepared, and there is no Python debugger over this protocol yet).
373
+ From then until the terminal message a `heartbeat` goes out every 15 seconds from a thread of
374
+ its own (0.2.2, contracts 0.8.50); a parent ends a worker it has not heard from for 60 seconds.
375
+ 4. `start` names a package root and an entry point id; `project.json` turns the id into
376
+ `module:function`, the module is imported and the inputs are validated against the
377
+ handler's signature before it is called.
378
+ 5. While it runs, `logging` records become `log` messages and the parent's `stop` sets
379
+ `velophex.runtime.stop_requested()`.
380
+ 6. Exactly one terminal message: `result`, `canceled` or `fault`.
381
+
382
+ Fault mapping, which decides retries:
383
+
384
+ | Raised | Fault |
385
+ |---|---|
386
+ | `velophex.BusinessError` | `Business` / the error's `code`, never retried |
387
+ | inputs that do not fit the handler | `Application` / `INVALID_INPUT`, not retried |
388
+ | a package or handler that will not load | `Application` / `INVALID_PACKAGE` |
389
+ | anything else | `Application` / `UNHANDLED_EXCEPTION` |
390
+ | a return value with no JSON form (`datetime`, a set, NaN) | `Application` / `RESULT_NOT_SERIALIZABLE`, not retried |
391
+ | outputs over the frame limit (16 MiB) | `Application` / `RESULT_TOO_LARGE`, not retried |
392
+
393
+ The last two are checked before the terminal frame is written, so the parent always gets a
394
+ terminal message rather than a crash it would have to report (and retry) as
395
+ `EXECUTOR_CRASHED`. Both are entries in the shared `FaultCodes` table (Contracts 0.1.0-preview.4), so the workflow executor uses the same spellings.
396
+
397
+ A stop is cooperative: the flag is set and the handler is expected to notice. Just before the
398
+ grace period ends (a second before, or halfway through a grace under two seconds), an `async`
399
+ handler's task is cancelled, so it unwinds through `finally` before the parent kills the
400
+ process tree at the deadline; a grace of 0 means "no grace" and cancels it at once. A
401
+ synchronous handler cannot be interrupted safely from outside, so the parent's kill is the
402
+ answer, and the worker logs a warning saying so. A handler that returns *after* a stop was
403
+ asked for ends the run as `canceled`, not `result`, with the reason `Timeout` for a timeout
404
+ stop and `User` for every other.
405
+
406
+ What the worker does when the channel itself goes wrong (0.2.2, contracts 0.8.50):
407
+
408
+ - A frame the parent sends during the run that fails the protocol ends the run: the terminal
409
+ message is `Infrastructure` / `EXECUTOR_PROTOCOL_VIOLATION` and the exit code is 2, as for the
410
+ workflow executor.
411
+ - A `sub-result` for a request nobody waits for (one that timed out, or a repeated answer) is
412
+ logged at Warning and ignored.
413
+ - `invoke_automation` raises in the caller when the request cannot be sent (a value with no JSON
414
+ form, a parent that is gone), and gives up after `timeout_seconds` plus 60 seconds.
415
+ - A frame write that fails breaks the writer: nothing more is written on that channel.
416
+
417
+ Runtime API calls that `POST` carry an `Idempotency-Key` and are sent again, with the same key,
418
+ after a dropped connection or a 502/503/504, so a retried queue completion or child job start
419
+ has one effect.
420
+
421
+ ## Flow messages
422
+
423
+ `velophex.runtime.send_flow_message(name, correlation_key=None, variables=None, *, token)`
424
+ continues a flow from Python (0.2.1): it resolves every flow waiting for the message, starts the
425
+ flows that begin with it, or buffers it for `flows.messageTtl`. It calls
426
+ `POST /api/v1/tenants/{t}/workspaces/{ws}/flows/messages` (`VeloPhex.Orchestrator/docs/flows/message-api.md`)
427
+ in the job's tenant and workspace.
428
+
429
+ ```python
430
+ from velophex import runtime
431
+
432
+ result = runtime.send_flow_message("invoice-paid", "INV-1042", {"amount": 1250}, token=runtime.secret("FlowsToken"))
433
+ result.outcome # "correlated", "started" or "buffered"
434
+ result.instance_ids # the flow instances it resolved or started
435
+ result.buffered_until # when a buffered message expires
436
+ ```
437
+
438
+ - `token` is an access token with `pulse.message` (a personal access token or an application
439
+ token), kept in a secret asset. The job's execution token cannot send flow messages.
440
+ - The `Idempotency-Key` defaults to the job id and the call site, so a retried attempt delivers
441
+ nothing twice; pass `idempotency_key=` to choose your own.
442
+ - A refusal raises `RuntimeApiError` with the problem's `code` (`too_many_matches` for more
443
+ than 100 matches: send the message with a correlation key).
444
+ - Outside a run, `velophex.runtime.client.send_flow_message(orchestrator_url, token, tenant_id,
445
+ workspace_id, name, idempotency_key=...)` takes everything explicitly.
446
+
447
+ ## Data Hub
448
+
449
+ Typed business records of the job's workspace (0.3.0, runtime capability `data-hub/1`;
450
+ `VeloPhex.Orchestrator/docs/data-hub.md`). An entity is addressed by its API name and its
451
+ fields by theirs, so an automation runs unchanged in another workspace with the same entity.
452
+ The tested quickstart (an `Invoice` entity, `velophex run --orchestrator`, the expected output) and
453
+ the filter, value-type, limit and error references are in that page.
454
+
455
+ ```python
456
+ from decimal import Decimal
457
+
458
+ from velophex import runtime
459
+ from velophex.runtime import where
460
+
461
+ result = runtime.upsert_record("Invoice", "invoiceNumber", {"invoiceNumber": "INV-23", "amount": Decimal("1204.50")})
462
+ result.created # True the first time, False after
463
+
464
+ for invoice in runtime.iter_records("Invoice", where("amount").gte(Decimal("1000")), sort="amount desc"):
465
+ print(invoice["invoiceNumber"], invoice["amount"]) # amount is a Decimal
466
+
467
+ runtime.update_record("Invoice", result.record.id, {"status": "paid"}, revision=result.record.revision)
468
+ ```
469
+
470
+ | Call | Does |
471
+ |---|---|
472
+ | `list_entities()`, `entity(name)`, `entity_json_schema(name)` | The workspace's entities, one entity's fields, the JSON Schema of a record |
473
+ | `query_records(entity, filter, *, match, sort, select, page_size, cursor, include_total)` | One page (`next_cursor` for the next); `page_size` up to 1,000 |
474
+ | `iter_records(entity, filter, *, ..., limit=10_000)` | Every match, page after page; `DataHubResultTooLargeError` past `limit` or 64 MiB, `limit=None` lifts it |
475
+ | `get_record(entity, id)` | One record |
476
+ | `create_record(entity, data, *, idempotency_key=None)` | A new record; the same key and data within 24 hours return the first one |
477
+ | `upsert_record(entity, key_field, data)` | Create, or update the record with that unique key; `.created` says which |
478
+ | `update_record(entity, id, values, *, revision=None)` | Only the fields given change; `None` clears one; `revision` refuses a stale write |
479
+ | `delete_record(entity, id)` | Only where the entity's Settings allow automations to delete |
480
+
481
+ - **Filters** are `where("field").op(value)` combined with `&` and `|` (`eq ne in_ not_in gt gte lt
482
+ lte contains starts_with is_null is_not_null`), a dict AST, or the JSON the web app's "Copy filter as
483
+ JSON" gives. `match={"status": "open"}` adds equalities. Text compares case-insensitively.
484
+ - **Sort** is the one string every surface uses: `"amount desc, invoiceNumber"`. Only indexed and
485
+ system fields sort.
486
+ - **Values** are typed from the entity's schema: decimal `Decimal` (sent as a string, never a float),
487
+ date `date`, datetime an aware UTC `datetime` (a naive one is refused), number `int`. A record's
488
+ system fields are attributes (`id`, `revision`, `created_at`, ...); its values are `record["name"]`.
489
+ - **Refusals** are `DataHubError` subclasses by code, each with `cause`, `fix` and `docs_url`:
490
+ `EntityNotFoundError`, `RecordNotFoundError`, `RecordValidationError` (`.errors` by `data.<field>`),
491
+ `UniqueViolationError` (`.field`), `RevisionConflictError`, `SchemaChangedError`,
492
+ `FilterInvalidError` (`.pointer`), `CursorInvalidError`, `FieldNotIndexedError`, `QueryTooExpensiveError`,
493
+ `KeyFieldNotUniqueError`, `IdempotencyKeyReusedError`, `DataHubLimitReachedError`,
494
+ `DeleteDisabledError`, `DataHubAccessDeniedError`, `DataHubDisabledError` (the tenant has it
495
+ off) and `DataHubUnsupportedError` (the Orchestrator predates it).
496
+ - A filter on a field that is not indexed works on small entities and raises a `DataHubWarning`
497
+ (also logged); above 50,000 records it is refused with `FieldNotIndexedError`. Index the field.
498
+
499
+ ## Integration connections
500
+
501
+ Vendor credentials the Orchestrator holds for the workspace (0.4.0, runtime capability
502
+ `integration-connections/1`, ADR-P026). The code names a connection by its binding, a connector
503
+ key and the connection's name, and declares it in `project.json`; the package never carries a
504
+ connection id, so the same package uses each workspace's own `Sales` connection.
505
+
506
+ ```json
507
+ "integrationConnections": [{ "connector": "salesforce", "name": "Sales" }]
508
+ ```
509
+
510
+ ```python
511
+ import httpx
512
+
513
+ from velophex import runtime
514
+
515
+ sales = runtime.connection("salesforce", "Sales")
516
+ limits = httpx.get(f"{sales.instance_url}/services/data/v61.0/limits", headers=sales.auth_headers())
517
+ ```
518
+
519
+ | Call | Does |
520
+ |---|---|
521
+ | `connection(connector, name)` | A handle: `.token`, `.instance_url`, `.auth_headers()`, `.auth_kind`; it leases again at 80% of each lease's lifetime |
522
+ | `lease(connector, name)` | The raw lease (`IntegrationConnectionLease`): at most 15 minutes, never past the job's attempt |
523
+
524
+ - **Secrets.** An OAuth or JWT connection's lease is a freshly minted access token. An api-key,
525
+ basic or custom-header connection's lease is the stored secret itself, which cannot be revoked
526
+ once released. Every leased secret is masked in what the worker writes; keep it out of outputs.
527
+ - **Where a name resolves.** A job searches its own workspace's shared connections; an attended or
528
+ Studio run then also searches the personal workspace of the user it runs for.
529
+ - **Refusals** are `IntegrationConnectionError` subclasses by code, each with `cause`, `fix` and
530
+ `docs_url`: `IntegrationConnectionNotFoundError` (`.searched_workspaces`),
531
+ `IntegrationConnectionBrokenError` (`.reason`, `.fix_url`), `IntegrationConnectionDisabledError`,
532
+ `IntegrationConnectionNotAllowedError` (the connection is for server-side use only),
533
+ `IntegrationsDisabledError` (the tenant has Integrations off) and
534
+ `IntegrationConnectionsUnavailableError` (the Orchestrator does not offer it).
535
+ - **`velophex pack`** warns about a literal `connection("...", "...")` or `lease(...)` in the code that
536
+ `project.json` does not declare.
537
+ - **Local runs** read `"connections"` from `--assets-file`, by connector and then name (a string is
538
+ an access token, an object has the lease's members), or the variable
539
+ `VELOPHEX_CONNECTION_<CONNECTOR>_<NAME>_TOKEN` (upper case, every other character `_`):
540
+
541
+ ```json
542
+ { "connections": { "salesforce": { "Sales": { "accessToken": "00D...", "instanceUrl": "https://acme.my.salesforce.com" } } } }
543
+ ```
544
+
545
+ ### Starting from an event
546
+
547
+ An entry point a trigger starts gets the event (0.5.0, contracts 0.8.78, ADR-P028):
548
+
549
+ - **Standard inputs.** Declare any of `VeloPhexEventConnector`, `VeloPhexEvent`,
550
+ `VeloPhexEventObjectType`, `VeloPhexEventObjectId` and `VeloPhexEventPayload` (the payload as
551
+ JSON text) as `str` parameters, optional with a default. They are filled from the event and a
552
+ trigger mapping cannot target them.
553
+ - **`runtime.context_event()`** returns the `EventContext` (`connector`, `source`, `event`,
554
+ `object_type`, `object_id`, `payload`, `occurred_at`, `trigger_id`, `sample`), or `None` on a
555
+ manual start. An event over 256 KiB keeps only `{"velophextruncated": true, "velophexrecordid": ...}`
556
+ as its payload (`event.truncated`); fetch the record by that id.
557
+ - **Locally**, `velophex run main --event event.json` runs with a sample event: the standard inputs
558
+ the entry point declares are filled from it, and `context_event()` returns it with `sample` set.
559
+ With `--orchestrator` the sample is also sent with the design-time run.
560
+
561
+ ```json
562
+ { "connector": "http", "source": "http-poll", "event": "record.changed", "objectId": "ord_1001", "payload": { "id": "ord_1001", "status": "open" } }
563
+ ```
564
+
565
+ ## Priority
566
+
567
+ Jobs and queue items share the Orchestrator's 0–100 priority scale, higher first (0.2.3).
568
+ `runtime.add_queue_item(..., priority=)` and `runtime.start_job(..., priority=)` take a
569
+ `runtime.Priority` band or any `int`; the band is sent as its number.
570
+
571
+ ```python
572
+ from velophex import runtime
573
+
574
+ runtime.add_queue_item("Invoices", {"invoice": "INV-1"}, priority=runtime.Priority.HIGH) # 75
575
+ runtime.start_job(automation_name="Reconcile", priority=90)
576
+ ```
577
+
578
+ `Priority` is `LOW` 25, `NORMAL` 50, `HIGH` 75 and `CRITICAL` 100, the Orchestrator's
579
+ `PriorityBands` (a test reads the C# table). `QueueItem.priority_band` and
580
+ `RuntimeJob.priority_band` name the band when the number is one, otherwise `None`.
581
+
582
+ ## Verifying a webhook
583
+
584
+ `velophex.webhooks.verify(body, headers, secret, tolerance=300)` checks a delivery's
585
+ `VeloPhex-Signature` exactly as the Orchestrator signs it (`VeloPhex.Orchestrator/docs/webhooks.md`)
586
+ and returns the signed time in Unix seconds (0.2.3). It needs no run, so any receiver can use it.
587
+
588
+ ```python
589
+ from velophex import webhooks
590
+
591
+
592
+ @app.post("/hooks/velophex")
593
+ async def receive(request: Request):
594
+ try:
595
+ webhooks.verify(await request.body(), request.headers, secret)
596
+ except webhooks.WebhookSignatureError:
597
+ return Response(status_code=401)
598
+ ...
599
+ ```
600
+
601
+ - Pass the raw body before any JSON parsing; re-serialised JSON does not match.
602
+ - `headers` is the request's headers (any case) or the header's value. A header with several
603
+ `v1` values (a secret rotation) verifies when one matches.
604
+ - A timestamp more than `tolerance` seconds from this clock raises, so a captured delivery cannot
605
+ be replayed later. To act exactly once, also remember the CloudEvent's `id`.
606
+ - `tests/test_webhooks.py` and the Orchestrator's `WebhookSignatureVectorTests` pin the same
607
+ vector: change one and you must change the other.
608
+
609
+ ## What "the same package as .NET" means
610
+
611
+ `tests/test_pack_parity.py` compares what `velophex pack` builds against
612
+ `tests/data/golden/Sample.Automation.1.2.0.nupkg`, which the engine's
613
+ `AutomationPackageBuilder` produced from `tests/data/sample_project`. Parity is asserted on
614
+ the archive's *content*: the same entry names in the same order, the same fixed
615
+ `2000-01-01T00:00:00` timestamp, and the same bytes in every entry — `content/project.json`,
616
+ `velophex/package-manifest.json`, the `.nuspec` and `[Content_Types].xml`.
617
+
618
+ The *compressed* bytes are deliberately not compared. A .NET `ZipArchive` and Python's `zlib`
619
+ do not emit the same deflate stream, and nothing in the platform depends on it: what is hashed
620
+ and signed is the manifest and the files the manifest lists, never the archive's compression.
621
+
622
+ Regenerate the golden only for an intentional packaging change, by running the .NET builder
623
+ over the same fixture directory.
624
+
625
+ ## The JSON, and why it is not `json.dumps`
626
+
627
+ The package manifest carries the SHA-512 of `content/project.json`, so a manifest written here
628
+ and a manifest written by Studio only verify against each other when both writers agree byte
629
+ for byte. `System.Text.Json` and Python's `json` do not agree, in three ways that all matter
630
+ and are all pinned by tests:
631
+
632
+ * The default .NET encoder escapes `"`, `&`, `'`, `+`, `<`, `>`, `` ` `` and everything
633
+ outside printable ASCII as `\uXXXX` with **upper-case** hex. So `">=3.12,<3.13"` is written
634
+ `">=3.12,<3.13"`.
635
+ * A `JsonElement` — an entry point's JSON Schema, say — is re-emitted with its **original
636
+ number literals**: `1.0` stays `1.0`, `1e2` stays `1e2`.
637
+ * A `DateTimeOffset` goes through a typed fast path that never reaches the encoder, so the
638
+ `+` of its offset stays a `+` where the same text in a plain string property would become
639
+ `+`. The executor frame vectors pin exactly this.
640
+
641
+ ## Agents: `velophex[agents]`
642
+
643
+ `velophex.agents.run(definition, inputs)` runs an agent definition (`agent-definition` v1,
644
+ `VeloPhex.Platform.Contracts/schemas/agent-definition.v1.schema.json`) and returns its validated
645
+ output. The extra brings LangGraph, `langchain-openai` and `jsonschema` from PyPI; LangGraph is
646
+ pinned to one minor and only `velophex/agents/_loop.py` imports it.
647
+
648
+ * **Model calls** go to the Orchestrator's LLM gateway, `POST <runtime>/ai/chat/completions`, in
649
+ the OpenAI chat completions shape, non-streaming, with the job token and the agent's binding
650
+ (`agent:default`) as the model. Outside a job, `VELOPHEX_AI_BASE_URL`, `VELOPHEX_AI_API_KEY`
651
+ and `VELOPHEX_AI_MODEL` name any OpenAI-compatible endpoint; nothing is traced then.
652
+ * **Tools:** `process` runs a published automation **in this job** (`velophex.runtime.invoke_automation`,
653
+ executor `sub-invoke`: same machine, same lease, no extra robot slot); `mcp`, `http`,
654
+ `vector_search` and `action_task` go through the Runtime API. Every call carries an
655
+ idempotency key, and tool and retrieved content reaches the model fenced as untrusted data.
656
+ * **Approvals** (`approval: before_call`) are bounded waits in the job: at most 900 s, and never
657
+ past the run's own timeout. A rejected or expired approval means the call is not made.
658
+ * **Limits:** 12 model calls, 50 000 tokens and 15 minutes per run, temperature 0.2, unless the
659
+ definition says otherwise. A limit ends the run with `agent_limit_exceeded`; an answer that
660
+ fails the output schema gets one repair turn, then `agent_output_invalid`.
661
+ * **Faults keep their code.** A gateway refusal raises `AiBudgetExhaustedError`,
662
+ `AiPolicyDeniedError`, `AiProviderUnavailableError` or `AiRequestInvalidError` (all
663
+ `RuntimeApiError`), and the worker reports it under that code with the error envelope (cause,
664
+ fix, request id, retry scope, docs URL), not as `UNHANDLED_EXCEPTION`. A run that fails after a
665
+ tool call succeeded reports `effectsCommitted` and is never retried automatically; an unknown
666
+ tool outcome is `tool_outcome_unknown`, reconciled by a person.
667
+ * **Testing:** `velophex.agents.testing.FakeChatServer` (a scripted gateway on 127.0.0.1),
668
+ `FakeTools` and `RecordingTracer`; `tests/test_agents.py` shows the patterns.
669
+
670
+ `velophex run` and the worker shape outputs with the same rule (`normalize_outputs`): `None` is
671
+ `{}`, a scalar or list is `{"value": ...}`, and a value with no JSON form is
672
+ `RESULT_NOT_SERIALIZABLE` in both.
673
+
674
+ ## Contract changes
675
+
676
+ `project.json`, the package manifest, the runner protocol and the executor protocol are
677
+ platform contracts. They change only through the `velophex-contract-change` skill, which
678
+ updates the engine, Studio, the Activities, the Orchestrator, the Runner and this SDK together.
679
+ Nothing in `src/velophex/contracts/` may drift on its own; the conformance tests against
680
+ `VeloPhex.Platform.Contracts/schemas/` are there to make drift fail loudly.