diff --git a/AGENTS.md b/AGENTS.md
index 3ecf2812d..f82b28f7a 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -54,6 +54,7 @@ Prefer plain ASCII characters in Markdown unless a typographic character is genu
- Tag minor-version availability inline: `` for new surface, `` for behavior changes to existing surface.
- Derive the version from the core release the change ships in, stripping prerelease suffixes (`5.1.0-beta.1` → `v5.1.0`).
+- **Determine that release from the core repo's git tags, not from the feature branch's `package.json`.** A branch reading `5.2.0-beta.3` says which release was open when the branch started, not which one the change lands in — if a release is cut before the feature merges, the badge is silently wrong. Check `git tag --sort=-creatordate` for the newest release, and `git tag --contains ` for whether the change is in one; a merged-but-untagged feature ships in the _next_ version, which may be a minor bump. Re-check on every refresh pass of a long-lived docs PR, because a release cut between passes invalidates a badge that was correct when written.
- Each minor release gets a file under `release-notes//` (e.g. `release-notes/v5-lincoln/5.1.md`); the sidebar picks it up automatically.
- Absolute links from `release-notes/` (or `learn/`) into current reference docs use the versioned path `/reference/v5/...` — the reference plugin maps the current version to the `v5` URL path.
- When documenting a change from a core/pro PR, cross-link the feature PR and the docs PR in both descriptions.
diff --git a/reference/cli/authentication.md b/reference/cli/authentication.md
index d211f20dc..951bc032e 100644
--- a/reference/cli/authentication.md
+++ b/reference/cli/authentication.md
@@ -44,11 +44,12 @@ For remote Operations API commands, the CLI uses the first complete authenticati
4. Legacy `CLI_TARGET_USERNAME` and `CLI_TARGET_PASSWORD` environment variables
5. `HARPER_CLI_OPERATION_TOKEN` and `HARPER_CLI_REFRESH_TOKEN` environment variables, or their legacy `CLI_TARGET_` equivalents — see [Token credentials for CI/CD](#token-credentials-for-cicd)
6. A token saved by `harper login`
-7. `username=` and `password=` operation parameters (legacy fallback)
+7. A [workload identity token](#workload-identity-oidc) exchanged with the runtime's OIDC provider (v5.3.0)
+8. `username=` and `password=` operation parameters (legacy fallback)
Credentials are resolved as a pair and are never combined across sources. An incomplete pair supplied with dedicated authentication parameters or in the target URL causes the command to fail. An incomplete environment-variable pair is skipped with a warning so that a saved login token can still be used.
-Entries 5 and 6 authenticate with a bearer token and apply to **remote targets only**. A local operation goes over the domain socket, which the server already trusts, so token environment variables are deliberately ignored there — a token minted for one instance would otherwise be attached to every local `harper` command in that shell and rejected.
+Entries 5 through 7 authenticate with a bearer token and apply to **remote targets only**. A local operation goes over the domain socket, which the server already trusts, so token environment variables are deliberately ignored there — a token minted for one instance would otherwise be attached to every local `harper` command in that shell and rejected.
Before v5.2.0, `username=` and `password=` operation parameters took precedence over environment variables and saved login tokens. This could authenticate an operation as the wrong user when those fields were part of the operation payload, such as the user being created by `add_user`.
@@ -208,7 +209,7 @@ Expose the two values to the deploy step and no other credentials are needed:
**Refresh behavior.** The CLI mints an operation token from the refresh token when none is supplied, and again whenever the supplied one has expired. A token refreshed from an environment variable is held in memory for that invocation only — nothing is written to `~/.harperdb/credentials.json`, because there is no file entry for an environment-supplied credential. If the refresh token itself is rejected, the command reports that and exits non-zero rather than falling back to another identity.
-**A blank token variable is an error, not a fallback.** If a namespace is set but empty — the usual shape of a misconfigured CI secret — the CLI says so and falls back to saved login credentials. It does not silently run as whoever last logged in on that machine.
+**A blank token variable is reported, then skipped.** If a namespace is set but empty — the usual shape of a misconfigured CI secret — the CLI warns and continues down the precedence list, so the run proceeds under the saved `harper login` token if that machine has one. The warning is the only thing separating this from silently deploying as whoever last logged in, so treat it as a failure signal in CI rather than assuming a blank secret stops the run.
**Lifetimes.** Operation tokens expire after `authentication.operationTokenTimeout` (default `1d`) and refresh tokens after `authentication.refreshTokenTimeout` (default `30d`). The pipeline needs a new refresh token when that window closes.
@@ -218,6 +219,44 @@ Expose the two values to the deploy step and no other credentials are needed:
Create a **dedicated CI user** and run `harper login --for-ci` as that user. That scopes the pipeline's permissions to what it actually needs, and lets you revoke its access without disturbing anyone else.
:::
+##### Workload identity (OIDC)
+
+
+
+On a runner that can prove its own identity, the CLI needs **no stored credential at all**. It asks the runtime for an identity token addressed to your instance and trades it for a one-hour operation token. Nothing durable is stored in your CI provider, and there is no 30-day token to rotate.
+
+Configure the instance to trust the workflow once with [`add_oidc_trust`](../operations-api/operations.md#add_oidc_trust), then grant the token permission in the workflow:
+
+```yaml
+permissions:
+ id-token: write
+ contents: read
+environment: production
+steps:
+ - run: harper deploy by_ref=true restart=true replicated=true
+ env:
+ HARPER_CLI_TARGET: ${{ vars.HARPER_CLI_TARGET }} # a var, not a secret
+```
+
+`HARPER_CLI_TARGET` is the only variable the step needs, and it is not sensitive — hence `vars` rather than `secrets`.
+
+**This ranks below every configured credential**, and above only the legacy `username=`/`password=` payload fallback. Adding `id-token: write` to a workflow that still sets `HARPER_CLI_REFRESH_TOKEN` does not change which identity deploys; the stored token keeps winning. That is deliberate — enabling a new capability should not silently re-point an existing pipeline at a different user. Remove the secret when you want the exchange to take over.
+
+Note the one case where the exchange _does_ take over: because it outranks the legacy fallback, a command that passes `username=` and `password=` as operation parameters on a runner with no configured credential authenticates as the trust policy's user, not as the pair in the payload. That is the intended reading of those fields — for `add_user` they describe the user being created — but it means a script relying on them as credentials changes identity the moment a policy matches.
+
+The exchange is attempted only when the runtime actually offers an identity. GitHub Actions sets `ACTIONS_ID_TOKEN_REQUEST_URL` and `ACTIONS_ID_TOKEN_REQUEST_TOKEN` together on a job that declares `id-token: write`; without both, the CLI falls through to its other credential sources rather than reporting a failure. An unrecognized runtime falls through the same way.
+
+On success the CLI prints, to stderr, which policy authenticated it and as whom:
+
+```
+Requesting a GitHub Actions identity token for https://my-instance.harperdb.io:9925/...
+Authenticated as 'ci-deploy' via OIDC trust policy 'my-app-prod'.
+```
+
+If Harper rejects the token, the CLI reports that and carries on down the precedence list rather than aborting. Usually nothing is left, so the operation returns 401 — but if the command also passes `username=` and `password=`, the legacy fallback applies them and the operation **succeeds as that user instead**. A policy mismatch can therefore look like a working deploy under the wrong identity, so don't leave a payload credential pair on a command you expect the exchange to authenticate.
+
+The server deliberately does not report which check failed — see [`exchange_oidc_token`](../operations-api/operations.md#exchange_oidc_token) — so diagnose with `list_oidc_trust` and the instance's `oidc-trust` log.
+
#### Method 3: Dedicated Authentication Parameters
diff --git a/reference/cli/operations-api-commands.md b/reference/cli/operations-api-commands.md
index 23a7f5f70..ba064a275 100644
--- a/reference/cli/operations-api-commands.md
+++ b/reference/cli/operations-api-commands.md
@@ -34,87 +34,91 @@ The following operations are available through the CLI. Operations that require
This is just a brief overview of all operations available as CLI commands. Review the respective operation documentation for more information on available arguments and expected behavior. Keep in mind that all operations options are converted to CLI arguments in the same way (using `snake_case`).
:::
-| Operation | Description | Category | Available Since |
-| -------------------------------- | ------------------------------------- | ---------------------------------------------------------------------- | --------------- |
-| `describe_table` | Describe table structure and metadata | [Database](../operations-api/operations.md#databases--tables) | v4.3.0 |
-| `describe_all` | Describe all databases and tables | [Database](../operations-api/operations.md#databases--tables) | v4.3.0 |
-| `describe_database` | Describe database structure | [Database](../operations-api/operations.md#databases--tables) | v4.3.0 |
-| `create_database` | Create a new database | [Database](../operations-api/operations.md#databases--tables) | v4.3.0 |
-| `drop_database` | Delete a database | [Database](../operations-api/operations.md#databases--tables) | v4.3.0 |
-| `create_table` | Create a new table | [Database](../operations-api/operations.md#databases--tables) | v4.3.0 |
-| `drop_table` | Delete a table | [Database](../operations-api/operations.md#databases--tables) | v4.3.0 |
-| `create_attribute` | Create a table attribute | [Database](../operations-api/operations.md#databases--tables) | v4.3.0 |
-| `drop_attribute` | Delete a table attribute | [Database](../operations-api/operations.md#databases--tables) | v4.3.0 |
-| `search_by_id` | Search records by ID | [Data](../operations-api/operations.md#nosql-operations) | v4.3.0 |
-| `search_by_value` | Search records by attribute value | [Data](../operations-api/operations.md#nosql-operations) | v4.3.0 |
-| `insert` | Insert new records | [Data](../operations-api/operations.md#nosql-operations) | v4.4.9 |
-| `update` | Update existing records | [Data](../operations-api/operations.md#nosql-operations) | v4.4.9 |
-| `upsert` | Insert or update records | [Data](../operations-api/operations.md#nosql-operations) | v4.4.9 |
-| `delete` | Delete records | [Data](../operations-api/operations.md#nosql-operations) | v4.3.0 |
-| `sql` | Execute SQL queries | [Data](../operations-api/operations.md#nosql-operations) | v4.3.0 |
-| `csv_file_load` | Load data from CSV file | [Data](../operations-api/operations.md#nosql-operations) | v4.3.0 |
-| `csv_url_load` | Load data from CSV URL | [Data](../operations-api/operations.md#nosql-operations) | v4.3.0 |
-| `list_users` | List all users | [Security](../operations-api/operations.md#certificate-management) | v4.3.0 |
-| `add_user` | Create a new user | [Security](../operations-api/operations.md#certificate-management) | v4.3.0 |
-| `alter_user` | Modify user properties | [Security](../operations-api/operations.md#certificate-management) | v4.3.0 |
-| `drop_user` | Delete a user | [Security](../operations-api/operations.md#certificate-management) | v4.3.0 |
-| `list_roles` | List all roles | [Security](../operations-api/operations.md#certificate-management) | v4.3.0 |
-| `drop_role` | Delete a role | [Security](../operations-api/operations.md#certificate-management) | v4.3.0 |
-| `create_csr` | Create certificate signing request | [Security](../operations-api/operations.md#certificate-management) | v4.4.0 |
-| `sign_certificate` | Sign a certificate | [Security](../operations-api/operations.md#certificate-management) | v4.4.0 |
-| `list_certificates` | List SSL/TLS certificates | [Security](../operations-api/operations.md#certificate-management) | v4.4.0 |
-| `add_certificate` | Add SSL/TLS certificate | [Security](../operations-api/operations.md#certificate-management) | v4.4.0 |
-| `remove_certificate` | Remove SSL/TLS certificate | [Security](../operations-api/operations.md#certificate-management) | v4.4.0 |
-| `add_ssh_key` | Add SSH key | [Security](../operations-api/operations.md#certificate-management) | v4.4.0 |
-| `get_ssh_key` | Get SSH key | [Security](../operations-api/operations.md#certificate-management) | v4.7.2 |
-| `update_ssh_key` | Update SSH key | [Security](../operations-api/operations.md#certificate-management) | v4.4.0 |
-| `delete_ssh_key` | Delete SSH key | [Security](../operations-api/operations.md#certificate-management) | v4.4.0 |
-| `list_ssh_keys` | List all SSH keys | [Security](../operations-api/operations.md#certificate-management) | v4.4.0 |
-| `set_ssh_known_hosts` | Set SSH known hosts | [Security](../operations-api/operations.md#certificate-management) | v4.4.0 |
-| `get_ssh_known_hosts` | Get SSH known hosts | [Security](../operations-api/operations.md#certificate-management) | v4.4.0 |
-| `cluster_get_routes` | Get cluster routing information | [Clustering](../operations-api/operations.md#replication--clustering) | v4.3.0 |
-| `cluster_network` | Get cluster network status | [Clustering](../operations-api/operations.md#replication--clustering) | v4.3.0 |
-| `cluster_status` | Get cluster status | [Clustering](../operations-api/operations.md#replication--clustering) | v4.3.0 |
-| `remove_node` | Remove node from cluster | [Clustering](../operations-api/operations.md#replication--clustering) | v4.3.0 |
-| `add_component` | Add a component | [Components](../operations-api/operations.md#components) | v4.3.0 |
-| `deploy_component` | Deploy a component | [Components](../operations-api/operations.md#components) | v4.3.0 |
-| `deploy` (alias) | Alias for `deploy_component` | [Components](../operations-api/operations.md#components) | v4.3.0 |
-| `package_component` | Package a component | [Components](../operations-api/operations.md#components) | v4.3.0 |
-| `package` (alias) | Alias for `package_component` | [Components](../operations-api/operations.md#components) | v4.3.0 |
-| `drop_component` | Remove a component | [Components](../operations-api/operations.md#components) | v4.3.0 |
-| `get_components` | List all components | [Components](../operations-api/operations.md#components) | v4.3.0 |
-| `get_component_file` | Get component file contents | [Components](../operations-api/operations.md#components) | v4.3.0 |
-| `set_component_file` | Set component file contents | [Components](../operations-api/operations.md#components) | v4.3.0 |
-| `install_node_modules` | Install Node.js dependencies | [Components](../operations-api/operations.md#components) | v4.3.0 |
-| `set_configuration` | Update configuration settings | [Configuration](../operations-api/operations.md#configuration) | v4.3.0 |
-| `get_configuration` | Get current configuration | [Configuration](../operations-api/operations.md#configuration) | v4.3.0 |
-| `create_authentication_tokens` | Create authentication tokens | [Authentication](../operations-api/operations.md#token-authentication) | v4.3.0 |
-| `refresh_operation_token` | Refresh operation token | [Authentication](../operations-api/operations.md#token-authentication) | v4.3.0 |
-| `restart_service` | Restart Harper service | [System](../operations-api/operations.md#registration--licensing) | v4.3.0 |
-| `restart` | Restart Harper instance | [System](../operations-api/operations.md#registration--licensing) | v4.3.0 |
-| `system_information` | Get system information | [System](../operations-api/operations.md#registration--licensing) | v4.3.0 |
-| `registration_info` | Get registration information | [Licensing](../operations-api/operations.md#registration--licensing) | v4.3.0 |
-| `get_fingerprint` | Get instance fingerprint | [Licensing](../operations-api/operations.md#registration--licensing) | v4.3.0 |
-| `set_license` | Set license key | [Licensing](../operations-api/operations.md#registration--licensing) | v4.3.0 |
-| `get_usage_licenses` | Get usage and license info | [Licensing](../operations-api/operations.md#registration--licensing) | v4.7.3 |
-| `create_backup` | Create a managed database backup | [Backups](../backups/operations.md) | v5.2.0 |
-| `list_backups` | List managed backups | [Backups](../backups/operations.md) | v5.2.0 |
-| `verify_backup` | Verify a managed backup's integrity | [Backups](../backups/operations.md) | v5.2.0 |
-| `delete_backup` | Delete a managed backup | [Backups](../backups/operations.md) | v5.2.0 |
-| `purge_backups` | Delete all but the newest backups | [Backups](../backups/operations.md) | v5.2.0 |
-| `restore_backup` | Restore a database from a backup | [Backups](../backups/operations.md) | v5.2.0 |
-| `get_backup` | Download a full database snapshot | [Backups](../backups/operations.md) | v5.2.0 |
-| `get_job` | Get job status | [Jobs](../operations-api/operations.md#jobs) | v4.3.0 |
-| `search_jobs_by_start_date` | Search jobs by start date | [Jobs](../operations-api/operations.md#jobs) | v4.3.0 |
-| `read_log` | Read application logs | [Logging](../operations-api/operations.md#logs) | v4.3.0 |
-| `read_transaction_log` | Read transaction logs | [Logging](../operations-api/operations.md#logs) | v4.3.0 |
-| `read_audit_log` | Read audit logs | [Logging](../operations-api/operations.md#logs) | v4.3.0 |
-| `delete_transaction_logs_before` | Delete old transaction logs | [Logging](../operations-api/operations.md#logs) | v4.3.0 |
-| `purge_stream` | Purge streaming data | [Maintenance](../operations-api/operations.md#jobs) | v4.3.0 |
-| `delete_records_before` | Delete old records | [Maintenance](../operations-api/operations.md#jobs) | v4.3.0 |
-| `get_status` | Get custom status information | [Status](../operations-api/operations.md#registration--licensing) | v4.6.0 |
-| `set_status` | Set custom status information | [Status](../operations-api/operations.md#registration--licensing) | v4.6.0 |
-| `clear_status` | Clear custom status information | [Status](../operations-api/operations.md#registration--licensing) | v4.6.0 |
+| Operation | Description | Category | Available Since |
+| -------------------------------- | ----------------------------------------------------------- | ---------------------------------------------------------------------- | --------------- |
+| `describe_table` | Describe table structure and metadata | [Database](../operations-api/operations.md#databases--tables) | v4.3.0 |
+| `describe_all` | Describe all databases and tables | [Database](../operations-api/operations.md#databases--tables) | v4.3.0 |
+| `describe_database` | Describe database structure | [Database](../operations-api/operations.md#databases--tables) | v4.3.0 |
+| `create_database` | Create a new database | [Database](../operations-api/operations.md#databases--tables) | v4.3.0 |
+| `drop_database` | Delete a database | [Database](../operations-api/operations.md#databases--tables) | v4.3.0 |
+| `create_table` | Create a new table | [Database](../operations-api/operations.md#databases--tables) | v4.3.0 |
+| `drop_table` | Delete a table | [Database](../operations-api/operations.md#databases--tables) | v4.3.0 |
+| `create_attribute` | Create a table attribute | [Database](../operations-api/operations.md#databases--tables) | v4.3.0 |
+| `drop_attribute` | Delete a table attribute | [Database](../operations-api/operations.md#databases--tables) | v4.3.0 |
+| `search_by_id` | Search records by ID | [Data](../operations-api/operations.md#nosql-operations) | v4.3.0 |
+| `search_by_value` | Search records by attribute value | [Data](../operations-api/operations.md#nosql-operations) | v4.3.0 |
+| `insert` | Insert new records | [Data](../operations-api/operations.md#nosql-operations) | v4.4.9 |
+| `update` | Update existing records | [Data](../operations-api/operations.md#nosql-operations) | v4.4.9 |
+| `upsert` | Insert or update records | [Data](../operations-api/operations.md#nosql-operations) | v4.4.9 |
+| `delete` | Delete records | [Data](../operations-api/operations.md#nosql-operations) | v4.3.0 |
+| `sql` | Execute SQL queries | [Data](../operations-api/operations.md#nosql-operations) | v4.3.0 |
+| `csv_file_load` | Load data from CSV file | [Data](../operations-api/operations.md#nosql-operations) | v4.3.0 |
+| `csv_url_load` | Load data from CSV URL | [Data](../operations-api/operations.md#nosql-operations) | v4.3.0 |
+| `list_users` | List all users | [Users & Roles](../operations-api/operations.md#users--roles) | v4.3.0 |
+| `add_user` | Create a new user | [Users & Roles](../operations-api/operations.md#users--roles) | v4.3.0 |
+| `alter_user` | Modify user properties | [Users & Roles](../operations-api/operations.md#users--roles) | v4.3.0 |
+| `drop_user` | Delete a user | [Users & Roles](../operations-api/operations.md#users--roles) | v4.3.0 |
+| `list_roles` | List all roles | [Users & Roles](../operations-api/operations.md#users--roles) | v4.3.0 |
+| `drop_role` | Delete a role | [Users & Roles](../operations-api/operations.md#users--roles) | v4.3.0 |
+| `create_csr` | Create certificate signing request | [Security](../operations-api/operations.md#certificate-management) | v4.4.0 |
+| `sign_certificate` | Sign a certificate | [Security](../operations-api/operations.md#certificate-management) | v4.4.0 |
+| `list_certificates` | List SSL/TLS certificates | [Security](../operations-api/operations.md#certificate-management) | v4.4.0 |
+| `add_certificate` | Add SSL/TLS certificate | [Security](../operations-api/operations.md#certificate-management) | v4.4.0 |
+| `remove_certificate` | Remove SSL/TLS certificate | [Security](../operations-api/operations.md#certificate-management) | v4.4.0 |
+| `add_ssh_key` | Add SSH key | [Components](../operations-api/operations.md#components) | v4.4.0 |
+| `get_ssh_key` | Get SSH key | [Components](../operations-api/operations.md#components) | v4.7.2 |
+| `update_ssh_key` | Update SSH key | [Components](../operations-api/operations.md#components) | v4.4.0 |
+| `delete_ssh_key` | Delete SSH key | [Components](../operations-api/operations.md#components) | v4.4.0 |
+| `list_ssh_keys` | List all SSH keys | [Components](../operations-api/operations.md#components) | v4.4.0 |
+| `set_ssh_known_hosts` | Set SSH known hosts | [Components](../operations-api/operations.md#components) | v4.4.0 |
+| `get_ssh_known_hosts` | Get SSH known hosts | [Components](../operations-api/operations.md#components) | v4.4.0 |
+| `cluster_get_routes` | Get cluster routing information | [Clustering](../operations-api/operations.md#replication--clustering) | v4.3.0 |
+| `cluster_network` | Get cluster network status | [Clustering](../operations-api/operations.md#replication--clustering) | v4.3.0 |
+| `cluster_status` | Get cluster status | [Clustering](../operations-api/operations.md#replication--clustering) | v4.3.0 |
+| `remove_node` | Remove node from cluster | [Clustering](../operations-api/operations.md#replication--clustering) | v4.3.0 |
+| `add_component` | Add a component | [Components](../operations-api/operations.md#components) | v4.3.0 |
+| `deploy_component` | Deploy a component | [Components](../operations-api/operations.md#components) | v4.3.0 |
+| `deploy` (alias) | Alias for `deploy_component` | [Components](../operations-api/operations.md#components) | v4.3.0 |
+| `stage` (alias) | Stage a component without going live | [Components](../operations-api/operations.md#components) | v5.3.0 |
+| `activate` (alias) | Activate a previously-staged deploy | [Components](../operations-api/operations.md#components) | v5.3.0 |
+| `revert_component` | Put a component's retained previous version back in service | [Components](../operations-api/operations.md#components) | v5.3.0 |
+| `revert` (alias) | Alias for `revert_component` | [Components](../operations-api/operations.md#components) | v5.3.0 |
+| `package_component` | Package a component | [Components](../operations-api/operations.md#components) | v4.3.0 |
+| `package` (alias) | Alias for `package_component` | [Components](../operations-api/operations.md#components) | v4.3.0 |
+| `drop_component` | Remove a component | [Components](../operations-api/operations.md#components) | v4.3.0 |
+| `get_components` | List all components | [Components](../operations-api/operations.md#components) | v4.3.0 |
+| `get_component_file` | Get component file contents | [Components](../operations-api/operations.md#components) | v4.3.0 |
+| `set_component_file` | Set component file contents | [Components](../operations-api/operations.md#components) | v4.3.0 |
+| `install_node_modules` | Install Node.js dependencies | [Components](../operations-api/operations.md#components) | v4.3.0 |
+| `set_configuration` | Update configuration settings | [Configuration](../operations-api/operations.md#configuration) | v4.3.0 |
+| `get_configuration` | Get current configuration | [Configuration](../operations-api/operations.md#configuration) | v4.3.0 |
+| `create_authentication_tokens` | Create authentication tokens | [Authentication](../operations-api/operations.md#token-authentication) | v4.3.0 |
+| `refresh_operation_token` | Refresh operation token | [Authentication](../operations-api/operations.md#token-authentication) | v4.3.0 |
+| `restart_service` | Restart Harper service | [System](../operations-api/operations.md#registration--licensing) | v4.3.0 |
+| `restart` | Restart Harper instance | [System](../operations-api/operations.md#registration--licensing) | v4.3.0 |
+| `system_information` | Get system information | [System](../operations-api/operations.md#registration--licensing) | v4.3.0 |
+| `registration_info` | Get registration information | [Licensing](../operations-api/operations.md#registration--licensing) | v4.3.0 |
+| `get_fingerprint` | Get instance fingerprint | [Licensing](../operations-api/operations.md#registration--licensing) | v4.3.0 |
+| `set_license` | Set license key | [Licensing](../operations-api/operations.md#registration--licensing) | v4.3.0 |
+| `get_usage_licenses` | Get usage and license info | [Licensing](../operations-api/operations.md#registration--licensing) | v4.7.3 |
+| `create_backup` | Create a managed database backup | [Backups](../backups/operations.md) | v5.2.0 |
+| `list_backups` | List managed backups | [Backups](../backups/operations.md) | v5.2.0 |
+| `verify_backup` | Verify a managed backup's integrity | [Backups](../backups/operations.md) | v5.2.0 |
+| `delete_backup` | Delete a managed backup | [Backups](../backups/operations.md) | v5.2.0 |
+| `purge_backups` | Delete all but the newest backups | [Backups](../backups/operations.md) | v5.2.0 |
+| `restore_backup` | Restore a database from a backup | [Backups](../backups/operations.md) | v5.2.0 |
+| `get_backup` | Download a full database snapshot | [Backups](../backups/operations.md) | v5.2.0 |
+| `get_job` | Get job status | [Jobs](../operations-api/operations.md#jobs) | v4.3.0 |
+| `search_jobs_by_start_date` | Search jobs by start date | [Jobs](../operations-api/operations.md#jobs) | v4.3.0 |
+| `read_log` | Read application logs | [Logging](../operations-api/operations.md#logs) | v4.3.0 |
+| `read_transaction_log` | Read transaction logs | [Logging](../operations-api/operations.md#logs) | v4.3.0 |
+| `read_audit_log` | Read audit logs | [Logging](../operations-api/operations.md#logs) | v4.3.0 |
+| `delete_transaction_logs_before` | Delete old transaction logs | [Logging](../operations-api/operations.md#logs) | v4.3.0 |
+| `purge_stream` | Purge streaming data | [Maintenance](../operations-api/operations.md#jobs) | v4.3.0 |
+| `delete_records_before` | Delete old records | [Maintenance](../operations-api/operations.md#jobs) | v4.3.0 |
+| `get_status` | Get custom status information | [Status](../operations-api/operations.md#registration--licensing) | v4.6.0 |
+| `set_status` | Set custom status information | [Status](../operations-api/operations.md#registration--licensing) | v4.6.0 |
+| `clear_status` | Clear custom status information | [Status](../operations-api/operations.md#registration--licensing) | v4.6.0 |
### Command Aliases
@@ -122,6 +126,9 @@ The following aliases are available for convenience:
- `deploy` → `deploy_component`
- `package` → `package_component`
+- `stage` → `deploy_component` with `activate=false` (build + upload the component cluster-wide without going live; prints a `deployment_id`)
+- `activate` → `deploy_component` with a `deployment_id` (take a previously staged deployment live; requires `deployment_id`)
+- `revert` → `revert_component`
For detailed parameter information for each operation, see the [Operations API documentation](../operations-api/operations.md).
@@ -219,6 +226,32 @@ harper get_components
harper deploy project=my-app package=https://github.com/user/repo
```
+**Stage a component without going live** (builds + uploads across the cluster and prints a `deployment_id`):
+
+```bash
+harper stage project=my-app
+```
+
+**Activate a previously-staged deployment** (takes it live; requires the `deployment_id` from `stage`):
+
+```bash
+harper activate project=my-app deployment_id=a3f8c2d1...
+```
+
+**Put a component's retained previous version back in service** (requires the deployment you want live again; `list_deployments` reports it):
+
+```bash
+harper revert project=my-app to_deployment_id=a3f8c2d1...
+```
+
+Naming the target makes the command safe to re-run: if that version is already live, it reports so and changes nothing.
+
+**Mint a deploy key for a private repository** (Harper generates the ed25519 keypair server-side and returns only the public half — register it with your git host):
+
+```bash
+harper add_ssh_key name=my-key generate=true host=my-key.github.com hostname=github.com
+```
+
:::tip
For more information on components and applications, see the [Components Reference](../components/overview.md).
:::
diff --git a/reference/components/applications.md b/reference/components/applications.md
index a001e3450..59da64dcc 100644
--- a/reference/components/applications.md
+++ b/reference/components/applications.md
@@ -136,6 +136,86 @@ Harper generates a `package.json` from component configurations and uses a form
For SSH-based private repos, use the [Add SSH Key](#add_ssh_key) operation to register keys first.
+### Deploying by Reference
+
+
+
+Omitting `package` uploads a snapshot of your working directory. The result is an anonymous artifact: nothing records _which_ commit it came from, so reproducing it later — or stepping back to a previous release — means finding those exact files again.
+
+Deploying by **reference** sends a pinned git reference instead, and the cluster fetches that exact commit. Redeploying the same reference deploys the same source revision, and rolling back is deploying an older one.
+
+A pinned SHA fixes the _source_, not the built artifact. The cluster installs and builds from that source on each node, so unpinned dependency ranges, a mutable registry artifact, install scripts, or a different toolchain can still produce different bytes — or a failure — from the same commit. Commit your lockfile if you need the build itself to be reproducible.
+
+`harper deploy by_ref=true` builds that reference from the local git repository, so you don't assemble the URL yourself:
+
+```sh
+harper deploy by_ref=true restart=true replicated=true
+```
+
+This resolves the repository's `origin` remote and the current commit, then deploys `package=git+https://github.com//.git#`.
+
+**Parameters**:
+
+- `by_ref` - Build the package reference from the local repository.
+- `ref` _(optional)_ - Deploy a specific commit, tag, or branch instead of `HEAD`. Implies `by_ref`.
+- `credential` _(optional)_ - Git host whose stored credential authenticates the clone, e.g. `github.com`. Omit for public repositories.
+
+```sh
+# Deploy a specific tag
+harper deploy ref=v1.2.0 restart=true replicated=true
+
+# Roll back by deploying an older commit
+harper deploy ref=9f8c2a1 restart=true replicated=true
+```
+
+**A reference is pinned to a SHA, not to the name you typed.** Tags and branches are resolved to a full commit SHA before the deploy is sent — from your local checkout when it has the ref, and from the remote when it doesn't, which is the usual case in a shallow CI clone. If neither can name a commit the deploy stops rather than sending the name for peers to resolve. This matters on a cluster: peers resolve the package independently, so a tag that moves mid-deploy — or a branch that advances — could otherwise leave nodes running different code.
+
+**Commit and push first.** The cluster clones from the remote, so it only sees commits that have been pushed. `by_ref` warns when the working tree is dirty, since uncommitted changes won't be part of the deploy.
+
+#### Private repositories
+
+Pass `credential=` for a private repository. The CLI attaches a `credentials` reference naming a secret that the cluster resolves in memory at clone time, so no token travels in the operation body or lands on disk:
+
+```sh
+harper deploy by_ref=true credential=github.com restart=true replicated=true
+```
+
+Provision that credential once with [`harper deploy setup=true`](#provisioning-a-deploy-credential). See [Private-source deploy credentials](../security/secrets.md#private-source-deploy-credentials) for how the secret is named and resolved, and [`add_ssh_key`](#add_ssh_key) for the SSH-key alternative.
+
+:::note
+Deploying by reference means the **cluster** installs and builds the component from source. If your application needs a build step that can't run on the node, keep shipping the built output as a payload deploy instead.
+:::
+
+### Provisioning a Deploy Credential
+
+
+
+`harper deploy setup=true` provisions the credential a private deploy needs. It's interactive, and runs once per component and source. It calls `get_secrets_public_key` and `set_secret`, both of which require **super_user**, so run it with an administrative credential rather than the CI identity it provisions for:
+
+```sh
+harper deploy setup=true
+```
+
+It asks which private source needs a credential (a GitHub repository or an npm registry), sources a token, and then:
+
+1. Fetches the cluster's public key with `get_secrets_public_key`.
+2. **Encrypts the token locally** into an `enc:v1:` envelope.
+3. Stores only the ciphertext with `set_secret`, in the component-scoped tier.
+4. Grants this component permission to resolve it with `grant_secret`.
+5. Prints the `credentials` reference for the deploy to use.
+
+The plaintext never leaves your machine: the operations API, its logs, and replication only ever carry the envelope, and the cluster decrypts it in memory at deploy time. This requires a cluster with secrets custody (Harper Pro / Fabric) — see [Client-side encryption](../security/secrets.md#client-side-encryption-encrypt-before-it-leaves-the-client).
+
+**Prefer a fine-grained PAT.** For a GitHub repository the prompt offers, and defaults to, pasting a fine-grained personal access token with **Contents: Read-only on that one repository**. If you have the `gh` CLI authenticated it also offers its session token, which is one keypress cheaper but typically carries `repo`, `read:org`, `gist`, and `workflow` scopes across your whole account; choosing it prints a warning. What this flow seals is durable and replayed on every cold deploy and rollback, so it is worth being the narrowest credential that does the job.
+
+The secret is stored **scoped to the component**, never in the global `processEnv` tier that every component and child process can read. If a global secret already exists at the derived name, it is converted to the scoped tier — the name is derived from the component, so a global secret there was never serving anything the scoped one doesn't. Existing grants on the row are preserved.
+
+Because the stored token is durable, later deploys — including re-fetching an older reference — reuse it without re-entering anything.
+
+:::note
+Rolling back to the **immediately previous** version needs no credential at all: [`revert_component`](../operations-api/operations.md#revert_component) puts the retained previous build back in service without re-fetching from the source, and updates the stored `package:` reference to match so a rebuilt node installs the version the cluster is running. It takes a required `to_deployment_id`, so re-running it is safe.
+:::
+
## Dependency Management
Harper uses `npm` and `package.json` for dependency management.
diff --git a/reference/operations-api/operations.md b/reference/operations-api/operations.md
index db0d3df03..c35fc2e54 100644
--- a/reference/operations-api/operations.md
+++ b/reference/operations-api/operations.md
@@ -532,10 +532,14 @@ Operations for JWT token creation and refresh.
Detailed documentation: [JWT Authentication](../security/jwt-authentication.md)
-| Operation | Description | Role Required |
-| ------------------------------ | ------------------------------------------------------- | ---------------------- |
-| `create_authentication_tokens` | Creates an operation token and refresh token for a user | none (unauthenticated) |
-| `refresh_operation_token` | Creates a new operation token from a refresh token | any |
+| Operation | Description | Role Required |
+| ------------------------------ | ---------------------------------------------------------- | ---------------------- |
+| `create_authentication_tokens` | Creates an operation token and refresh token for a user | none (unauthenticated) |
+| `refresh_operation_token` | Creates a new operation token from a refresh token | any |
+| `exchange_oidc_token` | Trades a CI workload identity token for an operation token | none (unauthenticated) |
+| `add_oidc_trust` | Creates or replaces an OIDC trust policy | super_user |
+| `list_oidc_trust` | Lists all OIDC trust policies, including disabled ones | super_user |
+| `drop_oidc_trust` | Deletes an OIDC trust policy | super_user |
### `create_authentication_tokens`
@@ -560,6 +564,188 @@ Creates a new operation token from an existing refresh token.
}
```
+### OIDC Trusted Publishing
+
+
+
+A CI runner can authenticate to Harper with **no stored credential**. It presents an identity token minted by its own provider; if that token verifies against a stored **trust policy**, Harper returns a one-hour operation token for the user the policy names. This is the same exchange npm, PyPI, and AWS STS `AssumeRoleWithWebIdentity` use.
+
+The alternative is a `HARPER_CLI_REFRESH_TOKEN` secret: a 30-day credential, one per user, that expires on a schedule nobody tracks. A trust policy replaces it with a rule you configure once, and revoke with `drop_oidc_trust`.
+
+```yaml
+permissions:
+ id-token: write
+ contents: read
+environment: production
+steps:
+ - run: harper deploy by_ref=true
+ env:
+ HARPER_CLI_TARGET: ${{ vars.HARPER_CLI_TARGET }} # a var, not a secret
+```
+
+No secret at all — `HARPER_CLI_TARGET` is not sensitive. See [CLI Authentication](../cli/authentication.md#workload-identity-oidc) for the client half and where the exchange sits in credential precedence.
+
+Policies live in the replicated `system.hdb_oidc_trust` table, so configuring one on any node applies cluster-wide.
+
+#### `add_oidc_trust`
+
+Creates or replaces a trust policy. **super_user only** — a policy lets an external system authenticate as a Harper user, so granting one is equivalent to handing out a credential.
+
+```json
+{
+ "operation": "add_oidc_trust",
+ "id": "my-app-prod",
+ "issuer": "https://token.actions.githubusercontent.com",
+ "audience": "https://my-instance.harperdb.io:9925/",
+ "user": "ci-deploy",
+ "claims": {
+ "repository_id": "67890",
+ "workflow_ref": "HarperFast/my-app/.github/workflows/deploy.yml@refs/heads/main",
+ "environment": "production"
+ }
+}
+```
+
+| Parameter | Description |
+| ------------- | -------------------------------------------------------------------------------------------------------------------------- |
+| `id` | **Required.** Policy identifier, 1–128 characters of letters, numbers, `_`, `-`, and `.`. |
+| `issuer` | **Required.** The token issuer (`iss`) this policy trusts. |
+| `audience` | **Required.** The audience the token must be addressed to. Should identify **this instance**; enforced for GitHub Actions. |
+| `claims` | **Required.** The claim constraints a token must satisfy. At least one, and specific enough for the issuer's profile. |
+| `user` | **Required.** The Harper user a matching run authenticates as. Must already exist and be active. |
+| `operations` | Narrow the minted token to these operations, 1–100 unique names. Omit for the user's full role — see below. |
+| `enabled` | Defaults to `true`. A disabled policy is kept but never matched. |
+| `description` | Optional free text, up to 1024 characters. |
+
+This **replaces** the policy rather than merging into it. A partial update is how an over-broad policy gets created by accident, and the point of `claims` is that every constraint in it was written deliberately.
+
+##### Narrowing what the token may do (`operations`)
+
+The user the policy names is the privilege boundary: a matching run gets that user's role. `operations` narrows it further, so one CI user can back several policies that each do less than the role allows:
+
+```json
+{
+ "operation": "add_oidc_trust",
+ "id": "my-app-prod",
+ "issuer": "https://token.actions.githubusercontent.com",
+ "audience": "https://my-instance.harperdb.io:9925/",
+ "user": "ci-deploy",
+ "operations": ["deploy_component", "get_deployment", "restart_service"],
+ "claims": { "repository_id": "67890", "environment": "production" }
+}
+```
+
+It is **narrowing only** — never widening. An operation the role forbids stays forbidden, so the scope cannot be used to grant something the user does not already have. Omit `operations` and the token carries the user's full role.
+
+Names are validated when the policy is written, against the same registry `add_role` and `alter_role` use, so a typo is rejected there rather than failing later inside CI with nothing to point at. One consequence: an operation a component registered at runtime with `server.registerOperation` is **not** recognized, because that registry is process-local, so a policy naming one is rejected. It fails closed — a rejected policy, never a widened one — and the same gap applies to `add_role` and `alter_role`.
+
+:::warning
+**The scope covers the Operations API and SQL, not the application data path.** It is enforced at the `verifyPerms` / `verifyPermsAST` gate, so a scoped token still carries the role's full table-level CRUD when it reaches an application's REST or GraphQL resources, which authorize through `checkPermission` instead.
+
+So `operations` bounds what a CI credential can _administer_, not what data it can read or write. If that matters, point the policy's `user` at a role that is itself least-privilege for the data the token can reach, rather than relying on the scope alone.
+:::
+
+A scoped token also cannot trade itself for a browser session: `create_authentication_tokens` with `purpose: "login"` is refused, because a session carries no operation scope and would silently restore the user's full role.
+
+`user` is resolved at write time. A policy naming a user that does not exist, or one that is inactive, is rejected — otherwise it would fail only at exchange time, inside CI, with nothing to point at. If the named user is a **super_user**, the policy is still created but the response carries a `warning`: any run matching it gains full administrative access.
+
+**Claim constraints** are exact string matches. A value may be a string, or an array of strings meaning any-of:
+
+```json
+{ "claims": { "repository": "HarperFast/my-app", "ref": ["refs/heads/main", "refs/heads/release"] } }
+```
+
+A constrained claim that is **absent** from the token fails rather than passes, so a policy cannot be weakened by an issuer that stops emitting a claim.
+
+**The audience should identify this instance.** For GitHub Actions, Harper rejects the provider's shared default — anything shaped like `https://github.com/` — because that value is shared by every repository under the owner, so accepting it would make a token minted by any of them valid here.
+
+That check is a guard against the one known-dangerous value, not a proof of correctness: Harper does not compare the audience against its own identity, so an arbitrary or mistyped value is accepted at write time and instead fails to match at exchange time, when the CLI derives the audience from its target URL. Use the instance URL your CI targets. For an issuer with no registered profile the audience is not checked for specificity at all, and the required `sub` pin is what binds the policy to one principal.
+
+##### Policy specificity for GitHub Actions
+
+For `https://token.actions.githubusercontent.com`, a policy must satisfy all three of these, each closing a distinct way a policy can be accidentally broad:
+
+| Requirement | Satisfied by one of | Left open otherwise |
+| ---------------------- | ------------------------------------------------------------------------ | ----------------------------------------------- |
+| **Pin the repository** | `repository_id`, `repository` | Any repository |
+| **Pin the workflow** | `workflow_ref`, `workflow_path`, `job_workflow_ref`, `job_workflow_path` | Any workflow in that repository |
+| **Gate the ref** | `workflow_ref`, `ref`, `environment` | Any branch that can be pushed to the repository |
+
+The ref gate is the one worth understanding, and it is stricter than npm's model. Pinning repository and workflow without also pinning a ref is not safe: anyone who can push a branch can add the trusted workflow to that branch and mint a token. npm accepts that shape and relies on environment protection instead.
+
+Consequences worth planning around:
+
+- **`repository_id` is preferred over `repository`** because it is immutable — it survives a repository rename, and is immune to org-name recycling.
+- **A tag-triggered release cannot pin `workflow_ref`**, since the tag is unknown when the policy is written. Pin `workflow_path` instead — Harper derives it from `workflow_ref` by removing the ref — and gate on `environment`.
+- **`ref_type: tag` is deliberately not accepted as a ref gate.** Anyone with push access can create a tag.
+- **`sub` is not accepted as a pin.** It varies by trigger, and its format changed for repositories created after 2026-07-15 (immutable subjects embed owner and repository ids), so a policy pinning it would have to handle two shapes indefinitely.
+- **`job_workflow_ref` pins the workflow but does not gate the ref.** In a reusable workflow it names the reusable workflow that ran, not the caller that invoked it, and its `@ref` suffix is that workflow's own branch — constant however it is called. Accepting it as a ref gate would admit any branch of any repository that references the reusable workflow. Pin the workflow with it if you like, then gate the ref with `workflow_ref`, `ref`, or `environment`.
+- **`pull_request_target` runs are denied** unless the policy explicitly constrains `event_name`. Such a run executes the base repository's workflow, with its secrets, while a fork controls the checked-out code. A plain `pull_request` run from a fork cannot mint at all, since it gets no `id-token: write`.
+
+##### Other issuers
+
+An issuer with no registered profile gets a strict generic profile: the policy must pin **`sub`**. That is the one claim every OIDC issuer defines as identifying a single principal, and it makes workload identity work with no provider-specific code — a Kubernetes service-account token (`system:serviceaccount::`), a GCP service account, and a SPIFFE SVID all carry a stable canonical subject.
+
+GitHub Actions needs its own profile precisely because its `sub` is the one claim you should _not_ pin.
+
+#### `exchange_oidc_token`
+
+Trades an identity token for a Harper operation token. **Unauthenticated by design** — this operation _is_ the authentication, the way `create_authentication_tokens` is against a password. The CLI calls it for you; you would call it directly only from a client that mints its own requests.
+
+```json
+{
+ "operation": "exchange_oidc_token",
+ "token": "eyJhbGciOi..."
+}
+```
+
+Response:
+
+```json
+{
+ "operation_token": "eyJhbGciOi...",
+ "expires_in": 3600,
+ "username": "ci-deploy",
+ "policy": "my-app-prod"
+}
+```
+
+The operation token is valid for **one hour** — long enough to cover a slow deploy, short enough to bound the exposure if it leaks. That is a reduced window, not safety: within the hour it is a live credential carrying the policy's identity, so treat it like any other secret and keep it out of logs and step outputs. No refresh token is issued; a subsequent run performs a new exchange.
+
+:::note
+**Every rejection returns the same message.** The endpoint is unauthenticated, so a caller told which check failed could enumerate a policy one claim at a time. The specific reason is written to the `oidc-trust` logger, which is where to look when a workflow that should match does not.
+:::
+
+**An identity token can be exchanged once.** Harper records a SHA-256 fingerprint of each spent token in `system.hdb_oidc_token_use`, expiring with the token itself, so the table stays proportional to in-flight tokens and never holds a credential. The record is written _before_ the operation token is minted: if minting then fails the identity token is burned, costing a CI re-run, where the reverse order would leave a spendable token behind.
+
+That replay check is replicated, but replication is asynchronous, so two simultaneous replays against **different nodes** can both succeed. This is not a privilege escalation — whoever holds the token could obtain one operation token regardless — and what it does stop is the realistic case: a token that leaks after a legitimate run and is reused inside its window.
+
+Exchanges are recorded in the authentication audit stream alongside Basic, Bearer, and mTLS events, for failures as well as successes — a run repeatedly failing to authenticate is what an audit trail is for. Enable it with `logging.auditAuthEvents.logSuccessful` and `logging.auditAuthEvents.logFailed`.
+
+#### `list_oidc_trust`
+
+Lists every policy, **including disabled ones**, sorted by `id`. **super_user only** — the policy set names exactly which repository and workflow are worth compromising.
+
+```json
+{ "operation": "list_oidc_trust" }
+```
+
+Returns `{ "policies": [ ... ] }`. Each entry carries `id`, `issuer`, `audience`, `claims`, `user`, `operations` (`null` when unscoped), `enabled`, `description`, `updated_by`, and timestamps.
+
+#### `drop_oidc_trust`
+
+Stops every workflow that matched the policy from exchanging again. **super_user only.** Fails with `404` if no policy has that `id`.
+
+```json
+{ "operation": "drop_oidc_trust", "id": "my-app-prod" }
+```
+
+:::caution
+**This does not revoke operation tokens already issued.** The minted token is a stateless JWT valid until its one-hour expiry, so a token obtained moments before the policy was dropped keeps authorizing for the rest of that hour.
+
+Dropping the policy is therefore containment against _future_ runs. If you are responding to a suspected compromise rather than doing routine cleanup, also deactivate or re-role the user the policy named (`alter_user`), which is what stops a token that is already in someone's hands.
+:::
+
---
## Components
@@ -572,6 +758,7 @@ Detailed documentation: [Components Overview](../components/overview.md)
| --------------------------- | ----------------------------------------------------------------------- | ------------- |
| `add_component` | Creates a new component project from a template | super_user |
| `deploy_component` | Deploys a component via payload (tar) or package reference (NPM/GitHub) | super_user |
+| `revert_component` | Puts a component's retained previous version back in service | super_user |
| `package_component` | Packages a component project into a base64-encoded tar | super_user |
| `drop_component` | Deletes a component or a file within a component | super_user |
| `get_components` | Lists all component files and config | super_user |
@@ -591,17 +778,68 @@ Detailed documentation: [Components Overview](../components/overview.md)
### `deploy_component`
+
+
Deploys a component. The `package` option accepts any valid NPM reference including GitHub repos (`HarperDB/app#semver:v1.0.0`), tarballs, or NPM packages. The `payload` option accepts a base64-encoded tar string from `package_component`. Supports `"replicated": true` and `"restart": true` or `"restart": "rolling"`.
+Across a cluster, `deploy_component` runs in two phases separated by an **all-nodes staging barrier**:
+
+1. **Stage** — the incoming version is downloaded/packed, extracted, and `npm install`ed into a hidden staging directory on **every** node, without touching the live component.
+2. **Activate** — only after every node reports a successful stage does any node atomically swap the staged copy into the live path.
+
+The barrier is what the two phases buy you: if a node can't fetch the package or fails `npm install`, it fails during staging and the live component is left untouched on **every** node, rather than leaving part of the cluster half-updated. That is the class of failure — by far the most common one — that a two-phase deploy eliminates.
+
+It is not, however, all-or-nothing at go-live. Activation still happens per node, so a swap that fails on one node after others have already gone live leaves the cluster running mixed versions until you resolve it, and there is deliberately no automatic rollback — see [activation failures](#activation-failures) below. The request and response shape are unchanged; the two phases are internal.
+
+:::note
+Two-phase deploy requires operation replication **and** replication of the `system` database, because the staging barrier is coordinated through a replicated deployment row.
+
+A deploy on a cluster where `system` is excluded from replication silently takes the legacy one-shot path instead: no staging barrier, and no retained previous version for [`revert_component`](#revert_component) to roll back to. The staged-phase parameters are not silently downgraded that way — `activate: false` and `deployment_id` are **rejected** with an explanatory error rather than going live unexpectedly, as is `two_phase: true` itself.
+:::
+
+:::note
+Deploying a **brand-new** component without a restart (`"restart": false`, or omitting `restart`) marks a restart as required — `get_status` reports `restartRequired: true`, and requests to the new component's routes return an actionable 404 explaining that a restart is needed. A never-loaded component can't serve its routes until Harper restarts, so this makes that state visible instead of silent. Each node reports this for itself, since whether the component was already active can differ per node. Redeploying a component that is **already** live does not set the flag: that component's own file watcher requests a restart only if the update actually needs one.
+:::
+
Additional parameters:
- `urlPath` — the HTTP URL path the component is mounted at (e.g. `"/api/v2"`). Must not contain `..` or `.` path segments. Persisted on the component's root-config entry; see [HTTP middleware routing](../http/overview.md#middleware-routing).
- `host` — the virtual hostname the component is served on (e.g. `"api.example.com"`). Must be a bare hostname or IPv6 literal — no scheme, port, path, or brackets. Persisted alongside `urlPath`.
- `install_allow_scripts` — set to `true` to allow npm pre/post install scripts (disabled by default)
+- `activate` — set to `false` to **stage only** and stop before go-live. The build is prepared and verified on every node and the response returns a `deployment_id` in a `staged` state; nothing goes live. Activate it later by calling `deploy_component` again with that `deployment_id` (see below). Useful for pre-staging a release and flipping it live in a separate, fast step.
+- `deployment_id` — activate a previously-staged deployment (from an `activate: false` call). Normally nothing is re-fetched or re-installed: the build staged earlier is swapped live cluster-wide. If a node's staged tree is missing or incomplete by then — a restart or disk repair between staging and activation — that node re-sources the payload and rebuilds before swapping, so activation can take noticeably longer there and can fail on a package whose source or credential is no longer reachable. `project` is still required. For a `package` deploy you do not need to repeat `package` here: the identifier and credential references recorded when it was staged are recovered from the deployment and persisted to root config at activation, on every node — so a later restart or a newly joined peer reinstalls the version you activated.
+- `ignore_replication_errors` — treat replication/peer failures as non-fatal (best-effort deploy to a partially-available cluster). This also opts out of the stage barrier. Applies to both a full deploy and a `deployment_id` activate.
+- `deployment_timeout` — per-deploy budget (ms) for peers to receive the replicated deployment row; defaults to 120000.
+- `two_phase` — set to `false` to force the legacy single-phase (in-place) deploy instead of stage-then-activate.
- `credentials` — credentials for installing a component from a private npm registry or private git repository (see below)
`urlPath` and `host` both require `package` and are rejected on a payload-only deploy. To mount a payload-deployed component, add `host`/`urlPath` to its entry in the root `harper-config.yaml` instead.
+#### Deploy modes
+
+`activate`, `deployment_id`, `two_phase`, and `replicated` are not independent knobs — a request that asks for a staged phase without the machinery to support it is rejected rather than quietly doing something else. The valid combinations:
+
+| Request | Result |
+| -------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
+| No mode parameters, `system` replicated | Two-phase stage → barrier → activate |
+| No mode parameters, `system` **not** replicated | Legacy one-shot deploy, no retained previous version |
+| `two_phase: false` | Legacy one-shot deploy, no retained previous version |
+| `replicated: false` | Legacy one-shot deploy on this node only, no retained previous version |
+| `ignore_replication_errors: true` | Stage barrier **not enforced** — a node that fails to stage no longer blocks activation |
+| `activate: false` | Stage only; returns a `staged` `deployment_id` |
+| `deployment_id` | Activate that staged deployment cluster-wide |
+| `activate: false` or `deployment_id`, with `two_phase: false`, `replicated: false`, or `system` not replicated | **Rejected** |
+| `two_phase: true` with `replicated: false` or `system` not replicated | **Rejected** |
+| `revert_on_failure` (any value) | **Rejected** — see [activation failures](#activation-failures) |
+
+`revert_on_failure` was part of an earlier draft of this operation and is now refused outright rather than accepted and ignored, so a caller that was relying on it finds out.
+
+#### Activation failures
+
+If the activate phase fails on some nodes after others have already gone live, the deploy reports the split nodes and the deployment stays in an `activating` state rather than being rolled back automatically. Recover by rolling forward (stage and activate a known-good version) or by rolling back explicitly with [`revert_component`](#revert_component).
+
+There is deliberately no automatic rollback. Once a node is past the activation barrier, a peer reporting failure does not prove that peer did not activate — it can complete its swap and then fail, or die before replying — so automatically reverting "the failed nodes" risks rolling an untouched node an extra version back and leaving the cluster split three ways instead of converging it. A human deciding to roll forward or back is the only step that reliably converges the cluster.
+
#### Deploy credentials (`credentials`)
When a component is installed from a private source, `credentials` supplies the authentication. It is an array of entries; each entry is one of two kinds, identified by its key:
@@ -650,7 +888,7 @@ Private git repository (token resolved from an existing secret):
`credentials` replaces the earlier `registryAuth` field (renamed while the feature was in alpha, before it grew to carry git-host credentials). `registryAuth` is now rejected with an error directing you to `credentials`.
:::
-The response includes a `deployment_id` that can be used to query the deployment record:
+A normal deploy (stage + activate):
```json
{
@@ -662,31 +900,111 @@ The response includes a `deployment_id` that can be used to query the deployment
}
```
-Response:
+Response — a rolling restart is driven by a separate replicated job, so its id comes back as `restartJobId`:
```json
{
"deployment_id": "a3f8c2d1...",
- "message": "Component deployed successfully"
+ "restartJobId": "b7d41e09...",
+ "message": "Successfully deployed: my-app, restarting Harper"
+}
+```
+
+Without a restart (`"restart": false`, or omitted) the response carries no `restartJobId` and the message is just `Successfully deployed: my-app`.
+
+Stage now, activate later:
+
+**Stage request** (`activate: false`):
+
+```json
+{ "operation": "deploy_component", "project": "my-app", "package": "my-org/my-app#semver:v1.2.3", "activate": false }
+```
+
+**Stage response** (nothing is live yet; note the `staged` marker and the `deployment_id`):
+
+```json
+{ "deployment_id": "a3f8c2d1...", "project": "my-app", "staged": true, "message": "Staged component: my-app" }
+```
+
+**Activation request** (take the staged build live by passing its `deployment_id`):
+
+```json
+{ "operation": "deploy_component", "project": "my-app", "deployment_id": "a3f8c2d1...", "restart": "rolling" }
+```
+
+### `revert_component`
+
+
+
+Puts a component's **retained previous version** back in service across the cluster. This is a fast rollback that resolves no package, decrypts no secret, downloads no artifact and runs no install — every node already has the bytes, and the swap is a single atomic directory rename per node.
+
+This is the rollback for the bad release you just shipped: deploy a new version, run your own health checks against it, and put the old one back if you are not happy — even when the cluster otherwise looks healthy.
+
+:::caution
+**Only a two-phase activation retains a previous version.** The retained copy is created by the activate phase, so `revert_component` can only roll back to a version that went live that way.
+
+A version deployed with `two_phase: false`, or deployed on a cluster where the `system` database is excluded from replication, leaves nothing to revert to — and the call fails, no matter how many times that component has been deployed. If rollback matters, confirm the deploy took the two-phase path rather than assuming repeated deploys have built up a rollback target.
+:::
+
+`to_deployment_id` is **required**, and names the deployment you expect to be live once the call returns:
+
+```json
+{
+ "operation": "revert_component",
+ "project": "my-app",
+ "to_deployment_id": "a3f8c2d1-...",
+ "restart": "rolling"
}
```
+Naming the target is what makes the operation safe to retry. If that version is **already live**, the call succeeds without changing anything — so a client that loses the response and retries cannot flip the rejected release back in. If it matches the retained previous version, the swap happens and the version it displaced becomes the new retained previous, so an explicitly targeted revert of a revert rolls forward again.
+
+| Parameter | Description |
+| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
+| `project` | **Required.** The component to revert. |
+| `to_deployment_id` | **Required.** The deployment you expect to be live afterwards. `list_deployments` reports it, and `deploy_component` returns it. |
+| `restart` | `true` to restart immediately, or `"rolling"` for a rolling restart. **Optional** — omitted, the files are swapped but Harper is not restarted. |
+| `ignore_replication_errors` | Treat peer failures as non-fatal. |
+| `deployment_timeout` | Per-operation budget (ms) for peers. |
+| `force` | Permit the operation on a protected core component name. Does **not** relax the `to_deployment_id` checks. |
+
+`restart` being optional matters more here than on a deploy: a reverted component whose code is already loaded keeps serving the version you just rolled away from until something restarts it. Pass `restart: "rolling"` unless you are deliberately batching the restart yourself.
+
+The response reports `reverted` (`false` when the target was already live), `to_deployment_id`, and `from_deployment_id` — the version taken out of service, which is also recorded as `rollback_of` on the new `hdb_deployment` row for the audit trail.
+
+**Only the immediately previous version is retained**, so `revert_component` reaches back exactly one activation. It fails with "no previous version is retained" whenever there is no retained copy — a component deployed only once, or one whose deploys took the one-shot path described above — and refuses a `to_deployment_id` that is neither live nor the retained previous, naming what the component can actually be reverted to. To return to an older version, redeploy it with `deploy_component` — that is a deploy, not a revert.
+
+Reverting also rewrites the component's stored `package:` reference in `harperdb-config.yaml` and its entry in the boot-time application lock, as part of the same operation. So a revert is a config-level rollback too: a node provisioned _after_ the revert — a newly joined peer, or an existing node whose components directory is rebuilt — installs the version the cluster is actually running, not the one you reverted away from. Reverting away from a `package` deploy to a payload-deployed version removes the package reference entirely, for the same reason.
+
### Deployment Operations
-Harper records every `deploy_component` call in the `system.hdb_deployment` table, capturing the full lifecycle of a deployment including phase transitions (prepare → load → replicate → restart → success/failed), per-node outcomes, and a bounded event log of install output.
+Harper records every `deploy_component` call in the `system.hdb_deployment` table, capturing the full lifecycle of a deployment including phase transitions (`stage` → `activate` → `restart` → `success`/`failed`, or `prepare` → `replicate` → `restart` on the legacy single-phase path), per-node outcomes, and a bounded event log of install output.
+
+**Staged-build retention.** Deployments staged with `activate: false` leave their built files on disk until they are activated. Harper keeps only the most recent staged builds per component (default 5, configurable via the `deployment_stagingRetention_maxCount` configuration option); older not-yet-activated staged builds are evicted automatically when a new stage lands. Activating a `deployment_id` that has aged out of this window fails with "no staged build found."
+
+**Payload retention.** The `hdb_deployment` records themselves are always retained as the audit trail — retention only ever reclaims the stored tarball (`payload_blob`), never the row. Two configuration options bound it, and they answer different questions:
+
+| Option | Default | Effect |
+| -------------------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------- |
+| `deployment_payloadRetention_maxSize` | 10 MiB | Reclaims **this** deploy's tarball right after it succeeds, if the tarball was larger than this. Bounds any single payload. |
+| `deployment_payloadRetention_maxCount` | 1 | Keeps at most this many stored tarballs **per project**, newest first, dropping the rest after a successful deploy. Bounds total. |
+
+The default of `maxCount: 1` means only the current version's tarball is kept. It is deliberately conservative: retained payloads share the instance's disk with your own data, so several copies of a large application payload can quietly consume quota. Raise it if you want a wider window of deployments whose payload is still downloadable; set it to `0` to keep none.
+
+Pruning is automatic and best-effort — it never fails a deploy — and is skipped when a peer failed, since the older payloads are still the retry artifact in that case. A deployment whose payload has been reclaimed (automatically, or explicitly via [`delete_deployment_payload`](#delete_deployment_payload)) reports `payload_blob_present: false` and can no longer serve [`get_deployment_payload`](#get_deployment_payload); everything else about the record stays intact.
### `list_deployments`
Returns a list of deployment records, newest first. All filter parameters are optional.
-| Parameter | Type | Description |
-| --------- | ------ | ------------------------------------------------ |
-| `project` | string | Filter to a specific component project |
-| `status` | string | Filter by status: `pending`, `success`, `failed` |
-| `since` | number | Start of time range (Unix timestamp ms) |
-| `until` | number | End of time range (Unix timestamp ms) |
-| `limit` | number | Maximum number of results (default: 100) |
-| `offset` | number | Pagination offset |
+| Parameter | Type | Description |
+| --------- | ------ | ---------------------------------------- |
+| `project` | string | Filter to a specific component project |
+| `status` | string | Filter by status (see below) |
+| `since` | number | Start of time range (Unix timestamp ms) |
+| `until` | number | End of time range (Unix timestamp ms) |
+| `limit` | number | Maximum number of results (default: 100) |
+| `offset` | number | Pagination offset |
```json
{
@@ -699,6 +1017,12 @@ Returns a list of deployment records, newest first. All filter parameters are op
Response includes a `deployments` array and a `total` count. The `payload_blob` field is stripped from list responses for size; use `get_deployment_payload` to retrieve the tarball.
+Deployment statuses fall into three groups:
+
+- **Terminal** — `success`, `failed`, `rolled_back`. The deploy is over. Only these count as terminal internally, which is what gates `get_deployment_payload` and makes a payload eligible for retention pruning.
+- **Resting** — `staged`. An `activate: false` stage-and-stop, waiting to be activated or to age out of the staging-retention window. It is finished but not terminal, so its payload is deliberately still held: it is the source the pending activation needs.
+- **In flight** — `pending`, `extracting`, `installing`, `staging`, `loading`, `replicating`, `activating`, `reverting`, `restarting`. The phase the deployment is currently in; a record only stays in one of these while the deploy is running.
+
### `get_deployment`
Returns a single deployment record by `deployment_id`. When called on an in-progress deployment via a request that accepts `text/event-stream`, the response streams live phase events and install output as Server-Sent Events, replaying the buffered event log then tailing until the deployment reaches a terminal status.
@@ -712,22 +1036,22 @@ Returns a single deployment record by `deployment_id`. When called on an in-prog
The deployment record includes:
-| Field | Description |
-| -------------------- | ----------------------------------------------------------------------- |
-| `deployment_id` | Unique identifier (content hash) |
-| `project` | Component project name |
-| `package_identifier` | Package reference or `payload` for tar uploads |
-| `status` | `pending`, `success`, `failed`, or `rolled_back` |
-| `phase` | Current lifecycle phase: `prepare`, `load`, `replicate`, `restart` |
-| `event_log` | Bounded log of install output and phase transitions (up to 200 entries) |
-| `peer_results` | Per-node outcome map for replicated deployments |
-| `payload_hash` | SHA-256 hash of the deployment tarball |
-| `payload_size` | Byte size of the deployment tarball |
-| `started_at` | Timestamp when deployment began |
-| `completed_at` | Timestamp when deployment finished |
-| `user` | User who initiated the deployment |
-| `rollback_of` | `deployment_id` of the deployment this rolls back, if applicable |
-| `error` | Error message for failed deployments |
+| Field | Description |
+| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `deployment_id` | Unique identifier (content hash) |
+| `project` | Component project name |
+| `package_identifier` | Package reference or `payload` for tar uploads |
+| `status` | Any of the values listed under [`list_deployments`](#list_deployments) — `pending`, `extracting`, `installing`, `staging`, `staged`, `loading`, `replicating`, `activating`, `reverting`, `restarting`, `success`, `failed`, or `rolled_back` |
+| `phase` | Current lifecycle phase: `stage`, `load`, `activate`, `restart` (or legacy `prepare`, `replicate`) |
+| `event_log` | Bounded log of install output and phase transitions (up to 200 entries) |
+| `peer_results` | Per-node outcome map for replicated deployments |
+| `payload_hash` | SHA-256 hash of the deployment tarball |
+| `payload_size` | Byte size of the deployment tarball |
+| `started_at` | Timestamp when deployment began |
+| `completed_at` | Timestamp when deployment finished |
+| `user` | User who initiated the deployment |
+| `rollback_of` | `deployment_id` of the deployment this rolls back, if applicable |
+| `error` | Error message for failed deployments |
### `get_deployment_payload`
@@ -769,7 +1093,19 @@ The deployment must be in a terminal status (`success`, `failed`, or `rolled_bac
### `add_ssh_key`
-Adds an SSH key (must be ed25519) for authenticating deployments from private repositories.
+Adds an SSH key (must be ed25519) for authenticating deployments from private repositories. Supply the private key with `key`, or omit it and pass `generate: true` to have Harper mint the keypair itself.
+
+`list_ssh_keys` and the logs never return key material.
+
+The stored private key is encrypted at rest and crosses the cluster as ciphertext **when secret custody is configured**. Custody is present by default — the file tier generates a cluster keypair on first boot — so this is the normal case.
+
+:::warning
+On a node with **no** secret custody registered, `add_ssh_key` stores and replicates the private key in **plaintext**. It logs a WARN saying so and the operation still succeeds, because SSH keys predate custody and must keep working on a node that has none.
+
+That means encryption at rest is a property of your configuration, not a guarantee of the operation. If you are relying on it — and `generate: true` in particular reads as though the key can never be exposed — verify `secretCustody` is configured on every node in the cluster, and check the logs for that warning after adding a key. See [Secrets](../security/secrets.md).
+:::
+
+Adding an existing key:
```json
{
@@ -781,6 +1117,39 @@ Adds an SSH key (must be ed25519) for authenticating deployments from private re
}
```
+#### Server-side key generation (`generate`)
+
+
+
+With `generate: true`, Harper mints an ed25519 keypair on the node handling the request and returns only the **public** half. The private key is created inside the cluster and never travels from a client, so it can't be captured in a shell history, CI log, or request body on the way in:
+
+```json
+{
+ "operation": "add_ssh_key",
+ "name": "my-key",
+ "generate": true,
+ "host": "my-key.github.com",
+ "hostname": "github.com"
+}
+```
+
+Response:
+
+```json
+{
+ "message": "Added ssh key: my-key",
+ "public_key": "ssh-ed25519 AAAAC3Nza... harper:my-key"
+}
+```
+
+Register that `public_key` with your git host (e.g. as a GitHub deploy key) to authorize the deploy. The generated key is commented `harper:` so it's identifiable in the host's key list.
+
+`key` and `generate` are mutually exclusive — sending both is rejected. Generation happens in-process, so it requires no `ssh-keygen` binary on the host and the minted private key is never written to a temporary file on its way into storage.
+
+:::note
+`public_key` is returned **only** on the generating call — that response is the one time the public half is handed back. Harper stores the private key (sealed, subject to the custody caveat above) and the host config; it does not retain the public key for later retrieval, and `update_ssh_key` requires a key you supply (it can't mint one). So capture `public_key` from this response — if you lose it, `delete_ssh_key` then `add_ssh_key` with `generate: true` again to mint a fresh pair, and re-register the new public key with your git host.
+:::
+
---
## Secrets
diff --git a/reference/security/secrets.md b/reference/security/secrets.md
index 2e2a0a842..073f74526 100644
--- a/reference/security/secrets.md
+++ b/reference/security/secrets.md
@@ -289,6 +289,8 @@ function encryptSecret(plaintext, publicKeyPem, kid) {
`deploy_component` accepts a `credentials` array so a component installed from a private **npm registry** or private **git repository** can authenticate. A provided token is ingested into the secrets store (as a reference, encrypted) rather than travelling in the operation body, persisting as a plaintext `.npmrc`, or being written to disk for git — so package-reference deploys survive rollback, reboot, and new peers joining. Ingested tokens are stored under a derived name (`deploy..` or `deploy..git.`) granted to the component. See [`deploy_component`](../operations-api/operations.md#deploy_component).
+To provision one of these without handing the cluster a plaintext token at all, `harper deploy setup=true` (v5.3.0+) runs the [client-side encryption](#client-side-encryption-encrypt-before-it-leaves-the-client) flow above for you: it fetches the public key, seals the token locally into an `enc:v1:` envelope, stores only the ciphertext under that same derived name, and prints the `credentials` reference for the deploy to use. See [Provisioning a Deploy Credential](../components/applications.md#provisioning-a-deploy-credential).
+
## Threat model
**Protects against:** theft of on-disk config/`.env` files, the editor/operations read surface, secrets appearing in operations logs and replication payloads, and an operator observing traffic at the TLS-terminating layer. Client-side encryption additionally keeps plaintext off the operations API entirely.
diff --git a/release-notes/v5-lincoln/5.3.md b/release-notes/v5-lincoln/5.3.md
new file mode 100644
index 000000000..ed76597e6
--- /dev/null
+++ b/release-notes/v5-lincoln/5.3.md
@@ -0,0 +1,55 @@
+---
+title: '5.3'
+---
+
+# 5.3 Release Notes
+
+### Patch Releases
+
+All patch release notes for 5.3.x are available on the [releases page](https://github.com/HarperFast/harper/releases?q=v5.3&expanded=true).
+
+## Deployment
+
+### Two-Phase Cluster Deploys
+
+`deploy_component` now runs across a cluster in two phases separated by an all-nodes staging barrier. The incoming version is downloaded, extracted, and `npm install`ed into a hidden staging directory on **every** node before any node touches its live component; only once every node reports a successful stage does the swap happen. A node that can't fetch the package or fails its install now fails during staging, leaving the live component untouched everywhere, instead of leaving part of the cluster half-updated. The request and response shape are unchanged.
+
+Activation is still per node, so this is a staging barrier rather than an all-or-nothing go-live: a swap that fails on one node after others have gone live leaves the cluster on mixed versions, reported as an `activating` deployment for a human to roll forward or back. There is deliberately no automatic rollback — past the barrier, a peer reporting failure does not prove it did not activate, so reverting "the failed nodes" can split the cluster three ways instead of converging it.
+
+Two-phase deploy requires operation replication and replication of the `system` database. A cluster with `system` excluded from replication silently takes the legacy one-shot path, with no staging barrier and no rollback target; the staged-phase parameters are rejected outright rather than downgraded. See [`deploy_component`](/reference/v5/operations-api/operations#deploy_component).
+
+### Stage Now, Activate Later
+
+`deploy_component` accepts `activate: false` to prepare and verify a release on every node without going live, returning a `staged` deployment ID. A later call passing that `deployment_id` swaps it in cluster-wide, turning go-live into a fast, separate step — normally with no re-fetch and no re-install, though a node whose staged tree went missing in between rebuilds it first. Harper retains the most recent staged builds per component (default 5, `deployment_stagingRetention_maxCount`); a `deployment_id` that has aged out fails rather than silently rebuilding. `harper stage` and `harper activate` are the CLI verbs. See [Deploy modes](/reference/v5/operations-api/operations#deploy-modes).
+
+### Component Rollback
+
+The new `revert_component` operation puts a component's retained previous version back in service across the cluster. A two-phase activation retains the version it displaced, so a rollback normally resolves no package, decrypts no secret, downloads no artifact and runs no install — every node already has the bytes. Retention accompanies an activation that records its activation spec, which is the normal deploy path; a one-shot deploy retains nothing, so confirm the deploy took the two-phase path rather than assuming a rollback target exists. It rewrites the stored `package:` reference and the boot-time application lock in the same operation, so a newly joined peer or a rebuilt components directory installs the version the cluster is actually running.
+
+`to_deployment_id` is required and names the deployment you expect to be live afterwards, which makes the operation idempotent: a client that loses the response and retries cannot flip a rejected release back in. Only the immediately previous version is retained, and only a two-phase activation creates that retained copy. `harper revert` is the CLI verb. See [`revert_component`](/reference/v5/operations-api/operations#revert_component).
+
+### Deploying by Git Reference
+
+`harper deploy by_ref=true` builds a package reference from the local git repository, so a deploy records which commit it came from instead of shipping an anonymous snapshot of the working directory. Redeploying the same reference deploys the same source revision, and rolling back is deploying an older one.
+
+A pinned SHA fixes the source revision rather than the built artifact, since the cluster still builds from source on each node. Tags and branches are resolved to a full commit SHA before the deploy is sent — from the local checkout when it has the ref, and from the remote when it doesn't, as a shallow CI clone usually doesn't. A ref that can't be resolved either way stops the deploy rather than being sent by name, because peers resolve the package independently and a name that moves mid-deploy would leave nodes on different code. The CLI warns when the working tree is dirty and when the commit isn't on any remote branch. Under GitHub Actions on a `pull_request` run it deploys the pull request's head commit, not the merge commit the runner checks out. See [Deploying by Reference](/reference/v5/components/applications#deploying-by-reference).
+
+### Sealed Deploy Credentials
+
+`harper deploy setup=true` provisions the credential a private-source deploy needs, sealing the token locally before it leaves the machine: the CLI fetches the instance's public key, encrypts the token into an `enc:v1:` envelope, and stores only the ciphertext. The plaintext never reaches the operations API, its logs, or replication. It handles the two private-source kinds it supports — a private GitHub repository and a private npm registry — and prints the `credentials` entry the deploy should use. For a git-by-reference deploy that entry is attached by `harper deploy credential=true`, which derives the host from the package so the credential always matches the clone it authenticates; an npm-registry credential is attached by passing the printed entry, which names the `registry` rather than a host. See [Provisioning a Deploy Credential](/reference/v5/components/applications#provisioning-a-deploy-credential).
+
+## Security
+
+### OIDC Trusted Publishing
+
+A CI runner can now authenticate to Harper with no stored credential. It presents an identity token minted by its own provider, and if that token verifies against a trust policy configured on the instance, Harper returns a one-hour operation token for the user the policy names — the same exchange npm, PyPI, and AWS STS `AssumeRoleWithWebIdentity` use. This replaces a 30-day refresh-token secret with a rule you configure once and revoke with `drop_oidc_trust`.
+
+Policies are managed with `add_oidc_trust`, `list_oidc_trust`, and `drop_oidc_trust` (all super_user), and live in the replicated `system.hdb_oidc_trust` table. A policy pins the issuer, an instance-specific audience, and a set of claim constraints. For GitHub Actions those constraints must pin the repository, pin the workflow, and gate the ref — a policy naming only a repository and workflow is refused, because anyone who can push a branch could otherwise add the trusted workflow to it and mint a token. Issuers with no registered profile must pin `sub`, which makes Kubernetes service accounts, GCP service accounts, and SPIFFE identities work with no provider-specific configuration.
+
+A policy can also carry an `operations` list, narrowing the minted token to a subset of its user's role on the Operations API and SQL paths — narrowing only, and not covering an application's REST/GraphQL data path, so the policy's user should still be a least-privilege role for the data it can reach.
+
+Identity tokens are single-use, though the replay record replicates asynchronously, so two simultaneous replays against different nodes can both succeed. Exchanges are recorded in the authentication audit stream when `logging.auditAuthEvents.logSuccessful` / `logFailed` is enabled — they are off by default, so turn them on before you need the trail. Every rejection returns the same message, with the specific reason written to the `oidc-trust` log. In the CLI the exchange ranks below every configured credential, so enabling it does not silently re-point a pipeline that still has a token secret set. See [OIDC Trusted Publishing](/reference/v5/operations-api/operations#oidc-trusted-publishing) and [Workload identity](/reference/v5/cli/authentication#workload-identity-oidc).
+
+### Server-Side SSH Key Generation
+
+`add_ssh_key` accepts `generate: true` to have Harper mint an ed25519 keypair itself and return only the public half, so a deploy key's private half is never carried in a request body, shell history, or CI log. Generation happens in process, requiring no `ssh-keygen` binary on the host. The public key is returned only on the generating call — Harper does not retain it — and is commented `harper:` so it is identifiable in the git host's key list. See [Server-side key generation](/reference/v5/operations-api/operations#server-side-key-generation-generate).